init elas atelier #1

Merged
nvrl merged 82 commits from ela into main 2026-05-18 13:55:42 +02:00
13 changed files with 722 additions and 44 deletions
Showing only changes of commit 244dc076cb - Show all commits
+2
View File
@@ -11,6 +11,7 @@ pub enum AppError {
Unauthorized, Unauthorized,
NotFound(String), NotFound(String),
BadRequest(String), BadRequest(String),
TooManyRequests(String),
/// (public_message, internal_details) — details are logged but not returned. /// (public_message, internal_details) — details are logged but not returned.
Internal(String, Option<String>), Internal(String, Option<String>),
} }
@@ -21,6 +22,7 @@ impl IntoResponse for AppError {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
AppError::Internal(msg, details) => { AppError::Internal(msg, details) => {
if let Some(d) = details { if let Some(d) = details {
error!("Internal error: {} — {}", msg, d); error!("Internal error: {} — {}", msg, d);
+237
View File
@@ -0,0 +1,237 @@
use axum::{
Json,
extract::{Path, State},
http::HeaderMap,
response::IntoResponse,
};
use chrono::Utc;
use std::{
collections::hash_map::DefaultHasher,
fs,
hash::{Hash, Hasher},
sync::Arc,
};
use tracing::{error, info, warn};
use crate::{
AppState,
auth::check_auth,
error::AppError,
models::{ContactResponse, ContactSubmission, Message},
};
const MIN_FILL_TIME_MS: i64 = 3_000;
const MAX_FORM_AGE_MS: i64 = 24 * 60 * 60 * 1000;
const RATE_LIMIT_WINDOW_MS: i64 = 60 * 60 * 1000;
const RATE_LIMIT_MAX: usize = 5;
const MAX_NAME: usize = 200;
const MAX_EMAIL: usize = 200;
const MAX_SUBJECT: usize = 300;
const MAX_BODY: usize = 10_000;
fn client_ip(headers: &HeaderMap) -> String {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok()) {
if let Some(first) = xff.split(',').next() {
let s = first.trim();
if !s.is_empty() {
return s.to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) {
let s = real_ip.trim();
if !s.is_empty() {
return s.to_string();
}
}
"unknown".to_string()
}
fn hash_ip(ip: &str) -> String {
let mut h = DefaultHasher::new();
ip.hash(&mut h);
format!("{:x}", h.finish())
}
pub async fn submit_contact(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(sub): Json<ContactSubmission>,
) -> Result<Json<ContactResponse>, AppError> {
let now_ms = Utc::now().timestamp_millis();
let ip = client_ip(&headers);
let ip_hash = hash_ip(&ip);
{
let mut map = state.contact_rate_limit.lock().await;
let entry = map.entry(ip.clone()).or_default();
entry.retain(|t| now_ms - *t < RATE_LIMIT_WINDOW_MS);
if entry.len() >= RATE_LIMIT_MAX {
warn!("Contact rate limit hit for ip hash {}", ip_hash);
return Err(AppError::TooManyRequests(
"Too many submissions. Try again later.".into(),
));
}
entry.push(now_ms);
}
if sub
.website
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
info!("Contact honeypot triggered from ip hash {}", ip_hash);
return Ok(Json(ContactResponse { ok: true }));
}
let elapsed = now_ms - sub.started_at;
if elapsed < MIN_FILL_TIME_MS {
return Err(AppError::BadRequest(
"Submission too fast — please take a moment and try again.".into(),
));
}
if elapsed < 0 || elapsed > MAX_FORM_AGE_MS {
return Err(AppError::BadRequest(
"Form expired — refresh the page and try again.".into(),
));
}
let body = sub.message.trim().to_string();
if body.is_empty() {
return Err(AppError::BadRequest("Message cannot be empty.".into()));
}
if body.chars().count() > MAX_BODY {
return Err(AppError::BadRequest(format!(
"Message too long (max {} characters).",
MAX_BODY
)));
}
let name = sub
.name
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let email = sub
.email
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let subject = sub
.subject
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) {
return Err(AppError::BadRequest("Name is too long.".into()));
}
if email.as_ref().is_some_and(|s| s.chars().count() > MAX_EMAIL) {
return Err(AppError::BadRequest("Email is too long.".into()));
}
if subject
.as_ref()
.is_some_and(|s| s.chars().count() > MAX_SUBJECT)
{
return Err(AppError::BadRequest("Subject is too long.".into()));
}
if let Some(ref e) = email {
if !e.contains('@') || !e.contains('.') {
return Err(AppError::BadRequest("Email looks invalid.".into()));
}
}
let received_at = now_ms;
let id = Utc::now()
.timestamp_nanos_opt()
.unwrap_or(received_at * 1_000_000)
.to_string();
let msg = Message {
id: id.clone(),
name,
email,
subject,
body,
received_at,
ip_hash: Some(ip_hash),
};
let messages_dir = state.data_dir.join("messages");
fs::create_dir_all(&messages_dir).map_err(|e| {
error!("Failed to create messages dir: {}", e);
AppError::Internal("Storage error".into(), Some(e.to_string()))
})?;
let path = messages_dir.join(format!("{}.json", id));
let json = serde_json::to_string_pretty(&msg).map_err(|e| {
AppError::Internal("Serialization error".into(), Some(e.to_string()))
})?;
fs::write(&path, json).map_err(|e| {
error!("Failed to write message {}: {}", id, e);
AppError::Internal("Storage error".into(), Some(e.to_string()))
})?;
info!("Stored contact message {}", id);
Ok(Json(ContactResponse { ok: true }))
}
pub async fn list_messages(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = check_auth(&headers, &state.admin_token) {
return e.into_response();
}
let messages_dir = state.data_dir.join("messages");
if !messages_dir.exists() {
return Json(Vec::<Message>::new()).into_response();
}
let entries = match fs::read_dir(&messages_dir) {
Ok(e) => e,
Err(e) => {
error!("Failed to read messages dir: {}", e);
return AppError::Internal("Read error".into(), Some(e.to_string()))
.into_response();
}
};
let mut messages: Vec<Message> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
error!("Failed to read {:?}: {}", path, e);
continue;
}
};
match serde_json::from_str::<Message>(&content) {
Ok(m) => messages.push(m),
Err(e) => error!("Malformed message {:?}: {}", path, e),
}
}
messages.sort_by(|a, b| b.received_at.cmp(&a.received_at));
Json(messages).into_response()
}
pub async fn delete_message(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Json<ContactResponse>, AppError> {
check_auth(&headers, &state.admin_token)?;
if id.contains('/') || id.contains('\\') || id.contains("..") || id.is_empty() {
return Err(AppError::BadRequest("Invalid id.".into()));
}
let path = state.data_dir.join("messages").join(format!("{}.json", id));
if !path.exists() {
return Err(AppError::NotFound("Message not found.".into()));
}
fs::remove_file(&path).map_err(|e| {
AppError::Internal("Delete failed".into(), Some(e.to_string()))
})?;
Ok(Json(ContactResponse { ok: true }))
}
+1
View File
@@ -1,4 +1,5 @@
pub mod auth; pub mod auth;
pub mod config; pub mod config;
pub mod contact;
pub mod posts; pub mod posts;
pub mod upload; pub mod upload;
+9 -1
View File
@@ -9,7 +9,7 @@ use axum::{
http::{HeaderValue, header}, http::{HeaderValue, header},
routing::{delete, get, post}, routing::{delete, get, post},
}; };
use std::{env, fs, path::PathBuf, sync::Arc}; use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower_http::{ use tower_http::{
cors::{AllowOrigin, CorsLayer}, cors::{AllowOrigin, CorsLayer},
@@ -22,6 +22,7 @@ pub struct AppState {
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub cookie_secure: bool, pub cookie_secure: bool,
pub post_lock: Mutex<()>, pub post_lock: Mutex<()>,
pub contact_rate_limit: Mutex<HashMap<String, Vec<i64>>>,
} }
#[tokio::main] #[tokio::main]
@@ -61,6 +62,7 @@ async fn main() {
data_dir, data_dir,
cookie_secure, cookie_secure,
post_lock: Mutex::new(()), post_lock: Mutex::new(()),
contact_rate_limit: Mutex::new(HashMap::new()),
}); });
// CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of // CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of
@@ -107,6 +109,12 @@ async fn main() {
delete(handlers::upload::delete_upload), delete(handlers::upload::delete_upload),
) )
.route("/api/upload", post(handlers::upload::upload_file)) .route("/api/upload", post(handlers::upload::upload_file))
.route("/api/contact", post(handlers::contact::submit_contact))
.route("/api/messages", get(handlers::contact::list_messages))
.route(
"/api/messages/{id}",
delete(handlers::contact::delete_message),
)
.route("/healthz", get(|| async { "ok" })) .route("/healthz", get(|| async { "ok" }))
.nest_service("/uploads", ServeDir::new(uploads_dir)) .nest_service("/uploads", ServeDir::new(uploads_dir))
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)) .layer(DefaultBodyLimit::max(50 * 1024 * 1024))
+34
View File
@@ -138,6 +138,40 @@ pub struct CreatePostRequest {
pub content: String, pub content: String,
} }
#[derive(Deserialize)]
pub struct ContactSubmission {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub subject: Option<String>,
pub message: String,
#[serde(default)]
pub website: Option<String>,
pub started_at: i64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Message {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
pub body: String,
pub received_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_hash: Option<String>,
}
#[derive(Serialize)]
pub struct ContactResponse {
pub ok: bool,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct ErrorResponse { pub struct ErrorResponse {
pub error: String, pub error: String,
@@ -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>
);
}
+12 -1
View File
@@ -167,7 +167,18 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
)} )}
</div> </div>
<div class="text-[var(--overlay0)] text-xs italic"> <div class="text-[var(--overlay0)] text-xs italic">
&copy; {year} · {siteConfig.title} &copy;
{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> </div>
</footer> </footer>
</body> </body>
+8 -1
View File
@@ -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> { async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -67,3 +67,10 @@ export const uploadAsset = (file: File) => {
}; };
export const deleteAsset = (name: string) => export const deleteAsset = (name: string) =>
apiFetch<void>(`/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' }); 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' });
+19
View File
@@ -35,3 +35,22 @@ export interface Asset {
name: string; name: string;
url: 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;
}
+9
View File
@@ -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>
+78 -41
View File
@@ -1,5 +1,6 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import ContactForm from '../components/react/ContactForm';
interface ContactLink { interface ContactLink {
kind: string; kind: string;
@@ -32,18 +33,6 @@ try {
const links: ContactLink[] = siteConfig.contact_links ?? []; const links: ContactLink[] = siteConfig.contact_links ?? [];
const intro = siteConfig.contact_intro ?? ''; 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> = { const KIND_LABEL: Record<string, string> = {
email: 'Email', email: 'Email',
mastodon: 'Mastodon', mastodon: 'Mastodon',
@@ -52,6 +41,19 @@ const KIND_LABEL: Record<string, string> = {
instagram: 'Instagram', instagram: 'Instagram',
url: 'Link', 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."> <Layout title="Contact" description="Get in touch.">
@@ -74,43 +76,78 @@ const KIND_LABEL: Record<string, string> = {
</div> </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 && ( {links.length > 0 && (
<ul class="space-y-3"> <ul class="space-y-3 mb-12">
{links.map((link) => ( {links.map((link) => {
<li> const isEmail = link.kind === 'email';
<a const obf = isEmail ? obfuscateEmail(link.value.trim()) : null;
href={hrefFor(link)} return (
{...(isExternal(link) ? { target: '_blank', rel: 'noopener noreferrer me' } : {})} <li>
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" <a
style="border-radius: 1px" href={obf ? '#' : link.value}
> {...(!isEmail ? { target: '_blank', rel: 'noopener noreferrer me' } : {})}
<div class="min-w-0"> {...(obf ? { 'data-mail': '', 'data-mail-u': obf.u64, 'data-mail-h': obf.h64 } : {})}
<div class="font-display italic text-xs uppercase tracking-[0.25em] text-[var(--subtext0)] mb-1"> 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"
{KIND_LABEL[link.kind] ?? link.kind} 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>
<div class="font-display italic text-xl md:text-2xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors truncate"> <div
{link.label} 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> </a>
<div class="font-mono text-xs text-[var(--subtext0)] hidden md:block truncate max-w-[40%]" title={link.value}> </li>
{link.value} );
</div> })}
</a>
</li>
))}
</ul> </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"> <div class="section-rule mt-16">
<span class="ornament">✦</span> <span class="ornament">✦</span>
</div> </div>
</section> </section>
</Layout> </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>
+4
View File
@@ -91,6 +91,10 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
New work New work
</a> </a>
<AssetsButton client:load className="btn-ghost" iconSize={12} /> <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"> <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> <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 Settings