responsive

This commit is contained in:
HPL-JesusCastro
2026-05-23 01:21:15 +08:00
parent ae2d6fa158
commit 6446609119
8 changed files with 568 additions and 112 deletions

View File

@@ -14,17 +14,35 @@ export async function letterRoutes(app: FastifyInstance) {
// GET /api/letters
app.get("/api/letters", async () => {
return db
const rows = await db
.select({
id: letters.id,
letterDate: letters.letterDate,
createdAt: letters.createdAt,
deliverAt: letters.deliverAt,
readAt: letters.readAt,
content: letters.content,
})
.from(letters)
.where(sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`)
.orderBy(sql`COALESCE(${letters.letterDate}, DATE(${letters.createdAt})) DESC`);
return rows.map((r) => ({
id: r.id,
letterDate: r.letterDate,
createdAt: r.createdAt,
deliverAt: r.deliverAt,
readAt: r.readAt,
snippet: r.content
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120),
}));
});
// GET /api/letters/calendar
@@ -70,6 +88,19 @@ export async function letterRoutes(app: FastifyInstance) {
return { current, longest };
});
// POST /api/letters/:id/unread — reader marks a letter as unread
app.post<{ Params: { id: string } }>("/api/letters/:id/unread", async (req, reply) => {
const { role } = req.user as { role: string };
if (role !== "reader") {
return reply.code(403).send({ error: "Only readers can mark letters as unread" });
}
const id = parseInt(req.params.id, 10);
const rows = await db.select({ id: letters.id }).from(letters).where(eq(letters.id, id));
if (!rows[0]) return reply.code(404).send({ error: "Letter not found" });
await db.update(letters).set({ readAt: null }).where(eq(letters.id, id));
return { ok: true };
});
// GET /api/letters/:id
app.get<{ Params: { id: string } }>("/api/letters/:id", async (req, reply) => {
const id = parseInt(req.params.id, 10);
@@ -87,10 +118,9 @@ export async function letterRoutes(app: FastifyInstance) {
// Mark as read when the reader opens a letter for the first time
const { role } = req.user as { role: string };
if (role === "reader" && !rows[0].readAt) {
await db
.update(letters)
.set({ readAt: new Date().toISOString() })
.where(eq(letters.id, id));
const readAt = new Date().toISOString();
await db.update(letters).set({ readAt }).where(eq(letters.id, id));
return { ...rows[0], readAt };
}
return rows[0];

View File

@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate, NavLink, useNavigate } from "re
import Login from "./pages/Login";
import Editor from "./pages/Editor";
import Archive from "./pages/Archive";
import Inbox from "./pages/Inbox";
import LetterView from "./pages/LetterView";
import { clearToken, getRole } from "./api/client";
@@ -36,7 +37,7 @@ function Nav() {
<span className="nav-brand">Hi bb </span>
<div className="nav-links">
{isWriter && <NavLink to="/">Write</NavLink>}
<NavLink to="/archive">Archive</NavLink>
<NavLink to="/archive">{isWriter ? "Archive" : "Inbox"}</NavLink>
<button className="theme-toggle" onClick={toggle} title="Toggle dark mode">
{dark ? "☀ Light" : "☾ Dark"}
</button>
@@ -71,7 +72,7 @@ export default function App() {
<Nav />
<Routes>
<Route path="/" element={<WriterRoute />} />
<Route path="/archive" element={<Archive />} />
<Route path="/archive" element={getRole() === "writer" ? <Archive /> : <Inbox />} />
<Route path="/letters/:id" element={<LetterView />} />
</Routes>
</>

View File

@@ -26,7 +26,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(options.body !== undefined ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
@@ -54,12 +54,12 @@ export const api = {
}),
listLetters: () =>
request<{ id: number; letterDate: string | null; createdAt: string; deliverAt: string | null; readAt: string | null }[]>(
request<{ id: number; letterDate: string | null; createdAt: string; deliverAt: string | null; readAt: string | null; snippet: string }[]>(
"/api/letters"
),
getLetter: (id: number) =>
request<{ id: number; content: string; letterDate: string | null; createdAt: string; deliverAt: string | null; readAt: string | null }>(
request<{ id: number; title: string | null; content: string; letterDate: string | null; createdAt: string; deliverAt: string | null; readAt: string | null }>(
`/api/letters/${id}`
),
@@ -75,6 +75,9 @@ export const api = {
body: JSON.stringify(data),
}),
markUnread: (id: number) =>
request<{ ok: boolean }>(`/api/letters/${id}/unread`, { method: "POST" }),
getCalendar: () =>
request<{ id: number; date: string }[]>("/api/letters/calendar"),

View File

@@ -0,0 +1,101 @@
import { Document, Page, Text, View, Image as PdfImage, StyleSheet } from "@react-pdf/renderer";
export interface PdfLetterData {
title?: string | null;
content: string;
letterDate: string | null;
createdAt: string;
}
const pdfStyles = StyleSheet.create({
page: {
padding: 60,
fontFamily: "Helvetica",
backgroundColor: "#fdf8f0",
},
header: {
marginBottom: 32,
borderBottomWidth: 1,
borderBottomColor: "#e8d5c0",
paddingBottom: 16,
},
title: {
fontSize: 22,
marginBottom: 6,
color: "#2c1810",
},
date: {
fontSize: 10,
color: "#6b4f3a",
},
content: {
fontSize: 12,
lineHeight: 1.8,
color: "#2c1810",
},
footer: {
position: "absolute",
bottom: 40,
left: 60,
right: 60,
textAlign: "center",
fontSize: 10,
color: "#c9727a",
},
});
type PdfSegment = { type: "text"; text: string } | { type: "image"; src: string };
function parseSegments(html: string): PdfSegment[] {
const segments: PdfSegment[] = [];
const imgRegex = /<img[^>]+src="([^"]+)"[^>]*>/gi;
let lastIndex = 0;
let match: RegExpExecArray | null;
const toText = (raw: string) =>
raw
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/?(h[1-6]|li|blockquote)[^>]*>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
while ((match = imgRegex.exec(html)) !== null) {
const before = toText(html.slice(lastIndex, match.index));
if (before) segments.push({ type: "text", text: before });
segments.push({ type: "image", src: match[1] });
lastIndex = match.index + match[0].length;
}
const remaining = toText(html.slice(lastIndex));
if (remaining) segments.push({ type: "text", text: remaining });
return segments;
}
export function LetterPDF({ letter }: { letter: PdfLetterData }) {
const localDate = letter.letterDate ?? letter.createdAt.slice(0, 10);
const dateStr = new Date(`${localDate}T00:00:00`).toLocaleDateString("en-US", {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
const segments = parseSegments(letter.content);
return (
<Document>
<Page size="A4" style={pdfStyles.page}>
<View style={pdfStyles.header}>
{letter.title && <Text style={pdfStyles.title}>{letter.title}</Text>}
<Text style={pdfStyles.date}>{dateStr}</Text>
</View>
{segments.map((seg, i) =>
seg.type === "image"
? <PdfImage key={i} src={seg.src} style={{ maxWidth: "100%", marginVertical: 8 }} />
: <Text key={i} style={pdfStyles.content}>{seg.text}</Text>
)}
<Text style={pdfStyles.footer}>hibb </Text>
</Page>
</Document>
);
}

View File

@@ -61,7 +61,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: Props
canvas.height = h;
canvas.getContext("2d")!.drawImage(img, 0, 0, w, h);
const src = canvas.toDataURL("image/webp", 0.82);
const src = canvas.toDataURL("image/jpeg", 0.85);
editor.chain().focus().setImage({ src }).run();
};
img.src = objectUrl;

View File

@@ -133,3 +133,107 @@ button {
background: var(--accent-light);
color: var(--ink);
}
/* Inbox */
.inbox-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.inbox-row {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg);
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.inbox-row[data-unread] {
background: var(--surface);
box-shadow: var(--shadow);
}
.inbox-row[data-selected] {
background: var(--accent-light);
border-color: var(--accent);
}
.inbox-row:hover {
background: var(--surface);
box-shadow: var(--shadow);
}
.inbox-row-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--accent-light);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1rem;
color: var(--heart);
}
.inbox-row-body {
flex: 1;
min-width: 0;
}
.inbox-row-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.inbox-row-date {
font-family: "Lora", serif;
font-size: 0.95rem;
color: var(--ink);
}
.inbox-row-badge {
font-size: 0.65rem;
font-weight: 700;
background: var(--accent);
color: white;
border-radius: 20px;
padding: 2px 8px;
white-space: nowrap;
flex-shrink: 0;
}
.inbox-row-snippet {
font-size: 0.85rem;
color: var(--ink-light);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Letter body (used in LetterView and Inbox desktop panel) */
.letter-body {
font-family: "Lora", serif;
font-size: 1.05rem;
line-height: 1.8;
color: var(--ink);
}
.letter-body p { margin-bottom: 1em; }
.letter-body p:empty::after,
.letter-body p:has(br:only-child)::after { content: "\00a0"; }
.letter-body img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
.letter-body blockquote {
border-left: 3px solid var(--accent);
padding-left: 16px;
color: var(--ink-light);
font-style: italic;
margin: 12px 0;
}

View File

@@ -0,0 +1,293 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PDFDownloadLink } from "@react-pdf/renderer";
import { api } from "../api/client";
import CalendarGrid from "../components/CalendarGrid";
import { LetterPDF } from "../components/LetterPDF";
interface LetterSummary {
id: number;
letterDate: string | null;
createdAt: string;
readAt: string | null;
snippet: string;
}
interface OpenLetter {
id: number;
content: string;
letterDate: string | null;
createdAt: string;
readAt: string | null;
}
interface CalendarEntry { id: number; date: string; }
function useIsDesktop() {
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia("(min-width: 768px)").matches);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return isDesktop;
}
function formatDate(dateStr: string) {
return new Date(`${dateStr}T00:00:00`).toLocaleDateString("en-US", {
weekday: "long", month: "long", day: "numeric", year: "numeric",
});
}
function InboxRow({
letter,
unread,
selected,
onClick,
}: {
letter: LetterSummary;
unread: boolean;
selected?: boolean;
onClick: () => void;
}) {
const dateStr = letter.letterDate ?? letter.createdAt.slice(0, 10);
return (
<div
onClick={onClick}
className="inbox-row"
data-unread={unread ? "true" : undefined}
data-selected={selected ? "true" : undefined}
>
<div className="inbox-row-avatar"></div>
<div className="inbox-row-body">
<div className="inbox-row-header">
<span className="inbox-row-date" style={{ fontWeight: unread ? 700 : 400 }}>
{formatDate(dateStr)}
</span>
{unread && <span className="inbox-row-badge">NEW</span>}
</div>
{letter.snippet && <p className="inbox-row-snippet">{letter.snippet}</p>}
</div>
</div>
);
}
function InboxList({
letters,
selectedId,
onSelect,
}: {
letters: LetterSummary[];
selectedId: number | null;
onSelect: (letter: LetterSummary) => void;
}) {
const unread = letters.filter((l) => !l.readAt);
const read = letters.filter((l) => l.readAt);
if (!letters.length) {
return <p style={{ color: "var(--ink-light)", fontStyle: "italic" }}>No letters yet.</p>;
}
return (
<div>
{unread.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div className="inbox-list">
{unread.map((l) => (
<InboxRow key={l.id} letter={l} unread selected={selectedId === l.id} onClick={() => onSelect(l)} />
))}
</div>
</div>
)}
{read.length > 0 && (
<div>
{unread.length > 0 && (
<p style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--ink-light)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 8, marginTop: 4 }}>
Read
</p>
)}
<div className="inbox-list">
{read.map((l) => (
<InboxRow key={l.id} letter={l} unread={false} selected={selectedId === l.id} onClick={() => onSelect(l)} />
))}
</div>
</div>
)}
</div>
);
}
function LetterPanel({
letter,
loading,
onMarkUnread,
}: {
letter: OpenLetter | null;
loading: boolean;
onMarkUnread: (id: number) => void;
}) {
if (loading) {
return (
<div style={{ padding: 40, color: "var(--ink-light)", fontStyle: "italic" }}>
Loading...
</div>
);
}
if (!letter) {
return (
<div style={{ padding: 40, color: "var(--ink-light)", fontStyle: "italic", textAlign: "center", paddingTop: 80 }}>
Select a letter to read it.
</div>
);
}
const localDate = letter.letterDate ?? letter.createdAt.slice(0, 10);
return (
<div style={{ padding: "32px 40px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 28, flexWrap: "wrap", gap: 10 }}>
<h1 style={{ fontFamily: "Lora, serif", fontSize: "1.6rem", color: "var(--ink)" }}>
{formatDate(localDate)}
</h1>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{letter.readAt && (
<button
className="btn btn-ghost"
style={{ padding: "6px 14px", fontSize: "0.8rem" }}
onClick={() => onMarkUnread(letter.id)}
>
Mark as unread
</button>
)}
<PDFDownloadLink
document={<LetterPDF letter={letter} />}
fileName={`letter-${localDate}.pdf`}
>
<button className="btn btn-secondary" style={{ padding: "6px 14px", fontSize: "0.8rem" }}>
Download PDF
</button>
</PDFDownloadLink>
</div>
</div>
<div className="letter-body" dangerouslySetInnerHTML={{ __html: letter.content }} />
<p style={{ textAlign: "right", color: "var(--heart)", marginTop: 40, fontFamily: "Lora, serif", fontStyle: "italic" }}>
</p>
</div>
);
}
function CalendarSection({ entries }: { entries: CalendarEntry[] }) {
const [viewDate, setViewDate] = useState(new Date());
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const monthName = viewDate.toLocaleDateString("en-US", { month: "long", year: "numeric" });
return (
<div className="card" style={{ padding: 24 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<button className="btn btn-ghost" style={{ padding: "6px 12px" }} onClick={() => setViewDate(new Date(year, month - 1, 1))}></button>
<span style={{ fontFamily: "Lora, serif", fontSize: "1.1rem" }}>{monthName}</span>
<button className="btn btn-ghost" style={{ padding: "6px 12px" }} onClick={() => setViewDate(new Date(year, month + 1, 1))}></button>
</div>
<CalendarGrid year={year} month={month} entries={entries} />
</div>
);
}
export default function Inbox() {
const [letters, setLetters] = useState<LetterSummary[]>([]);
const [calendarEntries, setCalendarEntries] = useState<CalendarEntry[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [openLetter, setOpenLetter] = useState<OpenLetter | null>(null);
const [letterLoading, setLetterLoading] = useState(false);
const isDesktop = useIsDesktop();
const navigate = useNavigate();
useEffect(() => {
Promise.all([api.listLetters(), api.getCalendar()])
.then(([ls, cal]) => { setLetters(ls); setCalendarEntries(cal); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleSelect = (letter: LetterSummary) => {
if (!isDesktop) {
navigate(`/letters/${letter.id}`);
return;
}
setSelectedId(letter.id);
setLetterLoading(true);
api.getLetter(letter.id)
.then((data) => {
setOpenLetter(data);
setLetters((prev) =>
prev.map((l) => l.id === letter.id && !l.readAt ? { ...l, readAt: new Date().toISOString() } : l)
);
})
.catch(() => {})
.finally(() => setLetterLoading(false));
};
const handleMarkUnread = async (id: number) => {
try {
await api.markUnread(id);
setLetters((prev) => prev.map((l) => l.id === id ? { ...l, readAt: null } : l));
setOpenLetter(null);
setSelectedId(null);
} catch (err) {
console.error("Mark as unread failed:", err);
}
};
if (loading) {
return <div className="page" style={{ color: "var(--ink-light)", fontStyle: "italic" }}>Loading...</div>;
}
const unreadCount = letters.filter((l) => !l.readAt).length;
const inboxHeader = (
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 20 }}>
<h2 style={{ fontFamily: "Lora, serif", fontSize: "1.3rem" }}>Inbox</h2>
{unreadCount > 0 && (
<span style={{ background: "var(--accent)", color: "white", borderRadius: 20, padding: "2px 9px", fontSize: "0.75rem", fontWeight: 700 }}>
{unreadCount}
</span>
)}
</div>
);
// Mobile layout
if (!isDesktop) {
return (
<div className="page">
{inboxHeader}
<div style={{ marginBottom: 32 }}>
<InboxList letters={letters} selectedId={null} onSelect={handleSelect} />
</div>
<CalendarSection entries={calendarEntries} />
</div>
);
}
// Desktop two-panel layout
return (
<div style={{ maxWidth: 1100, margin: "0 auto", padding: "24px 16px" }}>
<div className="card" style={{ display: "flex", overflow: "hidden", marginBottom: 24 }}>
{/* Sidebar */}
<div style={{ width: 340, flexShrink: 0, borderRight: "1px solid var(--border)", padding: "20px 16px", overflowY: "auto" }}>
{inboxHeader}
<InboxList letters={letters} selectedId={selectedId} onSelect={handleSelect} />
</div>
{/* Letter content */}
<div style={{ flex: 1, minWidth: 0 }}>
<LetterPanel letter={openLetter} loading={letterLoading} onMarkUnread={handleMarkUnread} />
</div>
</div>
<CalendarSection entries={calendarEntries} />
</div>
);
}

View File

@@ -1,86 +1,17 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getRole } from "../api/client";
import { PDFDownloadLink, Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";
import { api } from "../api/client";
import { getRole, api } from "../api/client";
import { PDFDownloadLink } from "@react-pdf/renderer";
import { LetterPDF } from "../components/LetterPDF";
interface LetterData {
id: number;
title: string | null;
content: string;
letterDate: string | null;
createdAt: string;
deliverAt: string | null;
}
const pdfStyles = StyleSheet.create({
page: {
padding: 60,
fontFamily: "Helvetica",
backgroundColor: "#fdf8f0",
},
header: {
marginBottom: 32,
borderBottomWidth: 1,
borderBottomColor: "#e8d5c0",
paddingBottom: 16,
},
title: {
fontSize: 22,
marginBottom: 6,
color: "#2c1810",
},
date: {
fontSize: 10,
color: "#6b4f3a",
},
content: {
fontSize: 12,
lineHeight: 1.8,
color: "#2c1810",
},
footer: {
position: "absolute",
bottom: 40,
left: 60,
right: 60,
textAlign: "center",
fontSize: 10,
color: "#c9727a",
},
});
function stripHtml(html: string): string {
return html
.replace(/<img[^>]*>/gi, "[image]")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/?(h[1-6]|li|blockquote)[^>]*>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function LetterPDF({ letter }: { letter: LetterData }) {
const dateStr = new Date(letter.createdAt).toLocaleDateString("en-US", {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
return (
<Document>
<Page size="A4" style={pdfStyles.page}>
<View style={pdfStyles.header}>
{letter.title && <Text style={pdfStyles.title}>{letter.title}</Text>}
<Text style={pdfStyles.date}>{dateStr}</Text>
</View>
<Text style={pdfStyles.content}>{stripHtml(letter.content)}</Text>
<Text style={pdfStyles.footer}>hibb </Text>
</Page>
</Document>
);
readAt: string | null;
}
export default function LetterView() {
@@ -89,6 +20,7 @@ export default function LetterView() {
const [letter, setLetter] = useState<LetterData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const isReader = getRole() === "reader";
useEffect(() => {
if (!id) return;
@@ -99,6 +31,16 @@ export default function LetterView() {
.finally(() => setLoading(false));
}, [id]);
const handleMarkUnread = async () => {
if (!letter) return;
try {
await api.markUnread(letter.id);
navigate("/archive");
} catch (err) {
console.error("Mark as unread failed:", err);
}
};
if (loading) {
return <div className="page" style={{ color: "var(--ink-light)", fontStyle: "italic" }}>Loading...</div>;
}
@@ -126,21 +68,22 @@ export default function LetterView() {
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ padding: "6px 14px", fontSize: "0.875rem" }}>
Back
</button>
<div style={{ display: "flex", gap: 10 }}>
{isToday && getRole() === "writer" && (
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{isReader && letter.readAt && (
<button className="btn btn-ghost" style={{ padding: "6px 14px", fontSize: "0.875rem" }} onClick={handleMarkUnread}>
Mark as unread
</button>
)}
{isToday && !isReader && (
<button className="btn btn-secondary" onClick={() => navigate("/")}>
Edit
</button>
)}
<PDFDownloadLink
document={<LetterPDF letter={letter} />}
fileName={`letter-${letter.createdAt.slice(0, 10)}.pdf`}
fileName={`letter-${localDate}.pdf`}
>
{({ loading: pdfLoading }) => (
<button className="btn btn-secondary">
{pdfLoading ? "Preparing PDF..." : "Download PDF"}
</button>
)}
<button className="btn btn-secondary">Download PDF</button>
</PDFDownloadLink>
</div>
</div>
@@ -150,25 +93,6 @@ export default function LetterView() {
{dateStr}
</h1>
<div className="letter-body" dangerouslySetInnerHTML={{ __html: letter.content }} />
<style>{`
.letter-body {
font-family: "Lora", serif;
font-size: 1.05rem;
line-height: 1.8;
color: var(--ink);
}
.letter-body p { margin-bottom: 1em; }
.letter-body p:empty::after,
.letter-body p:has(br:only-child)::after { content: "\\00a0"; }
.letter-body img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
.letter-body blockquote {
border-left: 3px solid var(--accent);
padding-left: 16px;
color: var(--ink-light);
font-style: italic;
margin: 12px 0;
}
`}</style>
<p style={{ textAlign: "right", color: "var(--heart)", marginTop: 40, fontFamily: "Lora, serif", fontStyle: "italic" }}>
</p>