initial commit
This commit is contained in:
178
frontend/src/pages/LetterView.tsx
Normal file
178
frontend/src/pages/LetterView.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
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";
|
||||
|
||||
interface LetterData {
|
||||
id: number;
|
||||
title: string | null;
|
||||
content: string;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LetterView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [letter, setLetter] = useState<LetterData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api
|
||||
.getLetter(parseInt(id, 10))
|
||||
.then(setLetter)
|
||||
.catch(() => setError("Letter not found."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="page" style={{ color: "var(--ink-light)", fontStyle: "italic" }}>Loading...</div>;
|
||||
}
|
||||
|
||||
if (error || !letter) {
|
||||
return (
|
||||
<div className="page" style={{ color: "var(--ink-light)", fontStyle: "italic" }}>
|
||||
{error || "Letter not found."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 d = new Date();
|
||||
const todayLocal = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const isToday = localDate === todayLocal;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24, flexWrap: "wrap", gap: 12 }}>
|
||||
<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" && (
|
||||
<button className="btn btn-secondary" onClick={() => navigate("/")}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<PDFDownloadLink
|
||||
document={<LetterPDF letter={letter} />}
|
||||
fileName={`letter-${letter.createdAt.slice(0, 10)}.pdf`}
|
||||
>
|
||||
{({ loading: pdfLoading }) => (
|
||||
<button className="btn btn-secondary">
|
||||
{pdfLoading ? "Preparing PDF..." : "Download PDF"}
|
||||
</button>
|
||||
)}
|
||||
</PDFDownloadLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 40 }}>
|
||||
<h1 style={{ fontFamily: "Lora, serif", fontSize: "1.8rem", color: "var(--ink)", marginBottom: 32 }}>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user