responsive
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
101
frontend/src/components/LetterPDF.tsx
Normal file
101
frontend/src/components/LetterPDF.tsx
Normal 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(/&/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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
293
frontend/src/pages/Inbox.tsx
Normal file
293
frontend/src/pages/Inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(/&/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 (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user