diff --git a/backend/src/routes/letters.ts b/backend/src/routes/letters.ts
index 1ed83ca..8f0b8ab 100644
--- a/backend/src/routes/letters.ts
+++ b/backend/src/routes/letters.ts
@@ -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(/ /g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/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];
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 531420c..752e041 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
Hi bb ♡
{isWriter &&
Write}
-
Archive
+
{isWriter ? "Archive" : "Inbox"}
@@ -71,7 +72,7 @@ export default function App() {
} />
- } />
+ : } />
} />
>
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index c80b359..92ccfb0 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -26,7 +26,7 @@ async function request
(path: string, options: RequestInit = {}): Promise {
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"),
diff --git a/frontend/src/components/LetterPDF.tsx b/frontend/src/components/LetterPDF.tsx
new file mode 100644
index 0000000..d04c340
--- /dev/null
+++ b/frontend/src/components/LetterPDF.tsx
@@ -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 = /
]+src="([^"]+)"[^>]*>/gi;
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ const toText = (raw: string) =>
+ raw
+ .replace(/
/gi, "\n")
+ .replace(/<\/p>/gi, "\n\n")
+ .replace(/<\/?(h[1-6]|li|blockquote)[^>]*>/gi, "\n")
+ .replace(/<[^>]+>/g, "")
+ .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /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 (
+
+
+
+ {letter.title && {letter.title}}
+ {dateStr}
+
+ {segments.map((seg, i) =>
+ seg.type === "image"
+ ?
+ : {seg.text}
+ )}
+ hibb ♡
+
+
+ );
+}
diff --git a/frontend/src/components/RichTextEditor.tsx b/frontend/src/components/RichTextEditor.tsx
index ca8377a..bbf728c 100644
--- a/frontend/src/components/RichTextEditor.tsx
+++ b/frontend/src/components/RichTextEditor.tsx
@@ -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;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c298748..5bb3ad4 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
+}
diff --git a/frontend/src/pages/Inbox.tsx b/frontend/src/pages/Inbox.tsx
new file mode 100644
index 0000000..c7f76d6
--- /dev/null
+++ b/frontend/src/pages/Inbox.tsx
@@ -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 (
+
+
♡
+
+
+
+ {formatDate(dateStr)}
+
+ {unread && NEW}
+
+ {letter.snippet &&
{letter.snippet}
}
+
+
+ );
+}
+
+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 No letters yet.
;
+ }
+
+ return (
+
+ {unread.length > 0 && (
+
+
+ {unread.map((l) => (
+ onSelect(l)} />
+ ))}
+
+
+ )}
+ {read.length > 0 && (
+
+ {unread.length > 0 && (
+
+ Read
+
+ )}
+
+ {read.map((l) => (
+ onSelect(l)} />
+ ))}
+
+
+ )}
+
+ );
+}
+
+function LetterPanel({
+ letter,
+ loading,
+ onMarkUnread,
+}: {
+ letter: OpenLetter | null;
+ loading: boolean;
+ onMarkUnread: (id: number) => void;
+}) {
+ if (loading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (!letter) {
+ return (
+
+ Select a letter to read it.
+
+ );
+ }
+
+ const localDate = letter.letterDate ?? letter.createdAt.slice(0, 10);
+
+ return (
+
+
+
+ {formatDate(localDate)}
+
+
+ {letter.readAt && (
+
+ )}
+
}
+ fileName={`letter-${localDate}.pdf`}
+ >
+
+
+
+
+
+
+ ♡
+
+
+ );
+}
+
+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 (
+
+
+
+ {monthName}
+
+
+
+
+ );
+}
+
+export default function Inbox() {
+ const [letters, setLetters] = useState([]);
+ const [calendarEntries, setCalendarEntries] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedId, setSelectedId] = useState(null);
+ const [openLetter, setOpenLetter] = useState(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 Loading...
;
+ }
+
+ const unreadCount = letters.filter((l) => !l.readAt).length;
+
+ const inboxHeader = (
+
+
Inbox
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+ );
+
+ // Mobile layout
+ if (!isDesktop) {
+ return (
+
+ {inboxHeader}
+
+
+
+
+
+ );
+ }
+
+ // Desktop two-panel layout
+ return (
+
+
+ {/* Sidebar */}
+
+ {inboxHeader}
+
+
+ {/* Letter content */}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/LetterView.tsx b/frontend/src/pages/LetterView.tsx
index 6f605da..1813184 100644
--- a/frontend/src/pages/LetterView.tsx
+++ b/frontend/src/pages/LetterView.tsx
@@ -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(/
]*>/gi, "[image]")
- .replace(/
/gi, "\n")
- .replace(/<\/p>/gi, "\n\n")
- .replace(/<\/?(h[1-6]|li|blockquote)[^>]*>/gi, "\n")
- .replace(/<[^>]+>/g, "")
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/ /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 (
-
-
-
- {letter.title && {letter.title}}
- {dateStr}
-
- {stripHtml(letter.content)}
- hibb ♡
-
-
- );
+ readAt: string | null;
}
export default function LetterView() {
@@ -89,6 +20,7 @@ export default function LetterView() {
const [letter, setLetter] = useState(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 Loading...
;
}
@@ -126,21 +68,22 @@ export default function LetterView() {
-
- {isToday && getRole() === "writer" && (
+
+ {isReader && letter.readAt && (
+
+ )}
+ {isToday && !isReader && (
)}
}
- fileName={`letter-${letter.createdAt.slice(0, 10)}.pdf`}
+ fileName={`letter-${localDate}.pdf`}
>
- {({ loading: pdfLoading }) => (
-
- )}
+
@@ -150,25 +93,6 @@ export default function LetterView() {
{dateStr}
-
♡