initial commit

This commit is contained in:
HPL-JesusCastro
2026-05-23 00:46:01 +08:00
commit ae2d6fa158
31 changed files with 8699 additions and 0 deletions

View 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(/&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>
);
}
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>
);
}