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

27
frontend/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hi bb</title>
<script>
(function () {
try {
if (localStorage.getItem("hibb_theme") === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
}
} catch (_) {}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;1,400&family=Inter:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3351
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "hibb-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-pdf/renderer": "^3.4.4",
"@tiptap/extension-image": "^2.4.0",
"@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-text-align": "^2.4.0",
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/react": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.3"
}
}

84
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { useState } from "react";
import { BrowserRouter, Routes, Route, Navigate, NavLink, useNavigate } from "react-router-dom";
import Login from "./pages/Login";
import Editor from "./pages/Editor";
import Archive from "./pages/Archive";
import LetterView from "./pages/LetterView";
import { clearToken, getRole } from "./api/client";
function useDarkMode() {
const [dark, setDark] = useState(
() => document.documentElement.getAttribute("data-theme") === "dark"
);
const toggle = () => {
const next = !dark;
setDark(next);
document.documentElement.setAttribute("data-theme", next ? "dark" : "light");
localStorage.setItem("hibb_theme", next ? "dark" : "light");
};
return { dark, toggle };
}
function Nav() {
const navigate = useNavigate();
const isWriter = getRole() === "writer";
const { dark, toggle } = useDarkMode();
const handleLogout = () => {
clearToken();
navigate("/login");
};
return (
<nav className="nav">
<span className="nav-brand">Hi bb </span>
<div className="nav-links">
{isWriter && <NavLink to="/">Write</NavLink>}
<NavLink to="/archive">Archive</NavLink>
<button className="theme-toggle" onClick={toggle} title="Toggle dark mode">
{dark ? "☀ Light" : "☾ Dark"}
</button>
<button className="btn btn-ghost" style={{ padding: "6px 14px", fontSize: "0.85rem" }} onClick={handleLogout}>
Logout
</button>
</div>
</nav>
);
}
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem("hibb_token");
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function WriterRoute() {
return getRole() === "writer" ? <Editor /> : <Navigate to="/archive" replace />;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<RequireAuth>
<>
<Nav />
<Routes>
<Route path="/" element={<WriterRoute />} />
<Route path="/archive" element={<Archive />} />
<Route path="/letters/:id" element={<LetterView />} />
</Routes>
</>
</RequireAuth>
}
/>
</Routes>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,83 @@
const BASE = import.meta.env.DEV ? "" : "";
function getToken() {
return localStorage.getItem("hibb_token");
}
export function setToken(token: string, role: string) {
localStorage.setItem("hibb_token", token);
localStorage.setItem("hibb_role", role);
}
export function getRole(): "writer" | "reader" {
// Default to "writer" if logged in but role wasn't stored (pre-role-system sessions)
const stored = localStorage.getItem("hibb_role");
if (!stored && localStorage.getItem("hibb_token")) return "writer";
return (stored as "writer" | "reader") ?? "reader";
}
export function clearToken() {
localStorage.removeItem("hibb_token");
localStorage.removeItem("hibb_role");
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (res.status === 401) {
clearToken();
window.location.href = "/login";
throw new Error("Unauthorized");
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw Object.assign(new Error(body.error ?? "Request failed"), { status: res.status, body });
}
return res.json();
}
export const api = {
login: (password: string) =>
request<{ token: string; role: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
}),
listLetters: () =>
request<{ id: number; letterDate: string | null; createdAt: string; deliverAt: string | null; readAt: string | null }[]>(
"/api/letters"
),
getLetter: (id: number) =>
request<{ id: number; content: string; letterDate: string | null; createdAt: string; deliverAt: string | null; readAt: string | null }>(
`/api/letters/${id}`
),
createLetter: (data: { content: string; letterDate?: string; deliverAt?: string }) =>
request<{ id: number }>("/api/letters", {
method: "POST",
body: JSON.stringify(data),
}),
updateLetter: (id: number, data: { content?: string; deliverAt?: string; clientDate?: string }) =>
request<{ ok: boolean }>(`/api/letters/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}),
getCalendar: () =>
request<{ id: number; date: string }[]>("/api/letters/calendar"),
getStreak: () =>
request<{ current: number; longest: number }>("/api/streak"),
};

View File

@@ -0,0 +1,75 @@
import { useNavigate } from "react-router-dom";
interface CalendarEntry {
id: number;
date: string; // YYYY-MM-DD
}
interface Props {
year: number;
month: number; // 0-indexed
entries: CalendarEntry[];
}
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
export default function CalendarGrid({ year, month, entries }: Props) {
const navigate = useNavigate();
const entryMap = new Map(entries.map((e) => [e.date, e.id]));
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date().toISOString().slice(0, 10);
const cells: (number | null)[] = [
...Array(firstDay).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
return (
<div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4, marginBottom: 8 }}>
{DAYS.map((d) => (
<div key={d} style={{ textAlign: "center", fontSize: "0.75rem", color: "var(--ink-light)", fontWeight: 600, padding: "4px 0" }}>
{d}
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 4 }}>
{cells.map((day, i) => {
if (!day) return <div key={`empty-${i}`} />;
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const id = entryMap.get(dateStr);
const isToday = dateStr === today;
return (
<div
key={dateStr}
onClick={() => id && navigate(`/letters/${id}`)}
style={{
aspectRatio: "1",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
cursor: id ? "pointer" : "default",
background: isToday ? "var(--surface-alt)" : "transparent",
border: isToday ? "1px solid var(--border)" : "1px solid transparent",
transition: "background 0.15s",
position: "relative",
}}
onMouseEnter={(e) => { if (id) (e.currentTarget as HTMLDivElement).style.background = "var(--accent-light)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = isToday ? "var(--surface-alt)" : "transparent"; }}
>
<span style={{ fontSize: "0.875rem", color: id ? "var(--ink)" : "var(--ink-light)" }}>{day}</span>
{id && (
<span style={{ fontSize: "0.6rem", color: "var(--heart)", marginTop: 2 }}></span>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
import Placeholder from "@tiptap/extension-placeholder";
import { useRef } from "react";
interface Props {
content: string;
onChange: (html: string) => void;
placeholder?: string;
}
const btnStyle = (active?: boolean): React.CSSProperties => ({
padding: "4px 8px",
border: "1px solid var(--border)",
borderRadius: 6,
background: active ? "var(--accent-light)" : "transparent",
color: active ? "var(--accent)" : "var(--ink-light)",
fontSize: "0.8rem",
fontWeight: 600,
cursor: "pointer",
});
export default function RichTextEditor({ content, onChange, placeholder }: Props) {
const fileRef = useRef<HTMLInputElement>(null);
const editor = useEditor({
extensions: [
StarterKit,
Underline,
TextAlign.configure({ types: ["heading", "paragraph"] }),
Image.configure({ inline: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: placeholder ?? "Write your letter here..." }),
],
content,
onUpdate: ({ editor }) => onChange(editor.getHTML()),
});
if (!editor) return null;
const insertImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
const MAX = 1000;
const scale = Math.min(1, MAX / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
canvas.getContext("2d")!.drawImage(img, 0, 0, w, h);
const src = canvas.toDataURL("image/webp", 0.82);
editor.chain().focus().setImage({ src }).run();
};
img.src = objectUrl;
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{/* Toolbar */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, padding: "8px 0", borderBottom: "1px solid var(--border)" }}>
<button style={btnStyle(editor.isActive("bold"))} onClick={() => editor.chain().focus().toggleBold().run()} title="Bold">B</button>
<button style={{ ...btnStyle(editor.isActive("italic")), fontStyle: "italic" }} onClick={() => editor.chain().focus().toggleItalic().run()} title="Italic">I</button>
<button style={{ ...btnStyle(editor.isActive("underline")), textDecoration: "underline" }} onClick={() => editor.chain().focus().toggleUnderline().run()} title="Underline">U</button>
<div style={{ width: 1, background: "var(--border)", margin: "0 4px" }} />
<button style={btnStyle(editor.isActive({ textAlign: "left" }))} onClick={() => editor.chain().focus().setTextAlign("left").run()} title="Align left"></button>
<button style={btnStyle(editor.isActive({ textAlign: "center" }))} onClick={() => editor.chain().focus().setTextAlign("center").run()} title="Center">̈</button>
<button style={btnStyle(editor.isActive({ textAlign: "right" }))} onClick={() => editor.chain().focus().setTextAlign("right").run()} title="Align right"></button>
<div style={{ width: 1, background: "var(--border)", margin: "0 4px" }} />
<button style={btnStyle(editor.isActive("bulletList"))} onClick={() => editor.chain().focus().toggleBulletList().run()} title="Bullet list"> </button>
<button style={btnStyle(editor.isActive("blockquote"))} onClick={() => editor.chain().focus().toggleBlockquote().run()} title="Quote"></button>
<div style={{ width: 1, background: "var(--border)", margin: "0 4px" }} />
<button style={btnStyle()} onClick={() => fileRef.current?.click()} title="Insert image">🖼</button>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={insertImage} />
</div>
<div
style={{ minHeight: 320, cursor: "text" }}
onClick={() => editor.chain().focus().run()}
>
<EditorContent
editor={editor}
style={{
fontFamily: "Lora, serif",
fontSize: "1.05rem",
lineHeight: 1.8,
color: "var(--ink)",
}}
/>
</div>
<style>{`
.tiptap { outline: none; }
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--border);
pointer-events: none;
float: left;
height: 0;
}
.tiptap img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
.tiptap blockquote {
border-left: 3px solid var(--accent);
padding-left: 16px;
color: var(--ink-light);
font-style: italic;
margin: 12px 0;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
export default function StreakBanner() {
const [streak, setStreak] = useState<{ current: number; longest: number } | null>(null);
useEffect(() => {
api.getStreak().then(setStreak).catch(() => {});
}, []);
if (!streak || streak.current === 0) return null;
return (
<div style={{
background: "linear-gradient(135deg, var(--accent-light), var(--surface-alt))",
border: "1px solid var(--accent-light)",
borderRadius: 12,
padding: "12px 20px",
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 24,
}}>
<span style={{ fontSize: "1.5rem" }}>🔥</span>
<div>
<span style={{ fontWeight: 600, color: "var(--accent)" }}>{streak.current}-day streak!</span>
<span style={{ color: "var(--ink-light)", fontSize: "0.875rem", marginLeft: 8 }}>
Longest: {streak.longest} {streak.longest === 1 ? "day" : "days"}
</span>
</div>
</div>
);
}

135
frontend/src/index.css Normal file
View File

@@ -0,0 +1,135 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #e8f4fb;
--surface: #ffffff;
--surface-alt: #d4ecf7;
--ink: #1a2f3e;
--ink-light: #4a6a80;
--accent: #4a9fd4;
--accent-light: #c2e0f4;
--heart: #d4708a;
--border: #b8d9ef;
--shadow: 0 2px 12px rgba(26, 47, 62, 0.08);
--radius: 12px;
}
[data-theme="dark"] {
--bg: #0e1c2a;
--surface: #162536;
--surface-alt: #1c3048;
--ink: #ddeef8;
--ink-light: #7aaec8;
--accent: #5aaee0;
--accent-light: #1a3a52;
--heart: #e08898;
--border: #1f3a54;
--shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
body {
font-family: "Inter", sans-serif;
background: var(--bg);
color: var(--ink);
min-height: 100vh;
transition: background 0.2s, color 0.2s;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
font-family: "Inter", sans-serif;
cursor: pointer;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
border: none;
transition: opacity 0.15s, transform 0.1s;
}
.btn:active { transform: scale(0.98); }
.btn:hover { opacity: 0.88; }
.btn-primary {
background: var(--accent);
color: white;
}
.btn-secondary {
background: var(--surface-alt);
color: var(--ink);
border: 1px solid var(--border);
}
.btn-ghost {
background: transparent;
color: var(--ink-light);
border: 1px solid var(--border);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: background 0.2s, border-color 0.2s;
}
.page {
max-width: 800px;
margin: 0 auto;
padding: 24px 16px;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
transition: background 0.2s, border-color 0.2s;
}
.nav-brand {
font-family: "Lora", serif;
font-size: 1.4rem;
color: var(--accent);
font-style: italic;
}
.nav-links {
display: flex;
gap: 16px;
align-items: center;
}
.nav-links a {
color: var(--ink-light);
font-size: 0.9rem;
}
/* Theme toggle pill */
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
border: 1px solid var(--border);
background: var(--surface-alt);
color: var(--ink-light);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.theme-toggle:hover {
background: var(--accent-light);
color: var(--ink);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import CalendarGrid from "../components/CalendarGrid";
import StreakBanner from "../components/StreakBanner";
interface CalendarEntry { id: number; date: string; }
interface LetterSummary { id: number; letterDate: string | null; createdAt: string; readAt: string | null; }
const PAGE_SIZE = 5;
function formatDate(dateStr: string) {
return new Date(`${dateStr}T00:00:00`).toLocaleDateString("en-US", {
weekday: "long", month: "long", day: "numeric", year: "numeric",
});
}
function LetterRow({ letter, unread }: { letter: LetterSummary; unread: boolean }) {
const dateStr = letter.letterDate ?? letter.createdAt.slice(0, 10);
return (
<a href={`/letters/${letter.id}`} style={{ textDecoration: "none" }}>
<div
className="card"
style={{ padding: "14px 20px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}
>
<span style={{
fontFamily: "Lora, serif",
color: "var(--ink)",
fontWeight: unread ? 700 : 400,
}}>
{formatDate(dateStr)}
</span>
{unread && (
<span style={{
fontSize: "0.7rem",
fontWeight: 600,
background: "var(--accent)",
color: "white",
borderRadius: 20,
padding: "2px 8px",
whiteSpace: "nowrap",
}}>
NEW
</span>
)}
</div>
</a>
);
}
function RecentList() {
const [letters, setLetters] = useState<LetterSummary[]>([]);
const [unreadVisible, setUnreadVisible] = useState(PAGE_SIZE);
const [readVisible, setReadVisible] = useState(PAGE_SIZE);
useEffect(() => {
api.listLetters().then(setLetters).catch(() => {});
}, []);
if (!letters.length) {
return <p style={{ color: "var(--ink-light)", fontStyle: "italic" }}>No letters yet.</p>;
}
const unread = letters.filter((l) => !l.readAt);
const read = letters.filter((l) => l.readAt);
const unreadCount = unread.length;
return (
<div>
{/* Unread section */}
{unread.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
<h3 style={{ fontFamily: "Lora, serif", fontSize: "1rem", color: "var(--ink)" }}>Unread</h3>
<span style={{
background: "var(--accent)",
color: "white",
borderRadius: 20,
padding: "2px 9px",
fontSize: "0.75rem",
fontWeight: 700,
}}>
{unreadCount}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{unread.slice(0, unreadVisible).map((l) => <LetterRow key={l.id} letter={l} unread />)}
</div>
{unread.length > unreadVisible && (
<button
className="btn btn-ghost"
style={{ marginTop: 10, fontSize: "0.85rem", padding: "6px 14px" }}
onClick={() => setUnreadVisible((v) => v + PAGE_SIZE)}
>
Show more ({unread.length - unreadVisible} remaining)
</button>
)}
</div>
)}
{/* Read section */}
{read.length > 0 && (
<div>
{unread.length > 0 && (
<h3 style={{ fontFamily: "Lora, serif", fontSize: "1rem", color: "var(--ink-light)", marginBottom: 12 }}>
Read
</h3>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{read.slice(0, readVisible).map((l) => <LetterRow key={l.id} letter={l} unread={false} />)}
</div>
{read.length > readVisible && (
<button
className="btn btn-ghost"
style={{ marginTop: 10, fontSize: "0.85rem", padding: "6px 14px" }}
onClick={() => setReadVisible((v) => v + PAGE_SIZE)}
>
Show more ({read.length - readVisible} remaining)
</button>
)}
</div>
)}
</div>
);
}
export default function Archive() {
const [entries, setEntries] = useState<CalendarEntry[]>([]);
const [viewDate, setViewDate] = useState(new Date());
useEffect(() => {
api.getCalendar().then(setEntries).catch(() => {});
}, []);
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
const monthName = viewDate.toLocaleDateString("en-US", { month: "long", year: "numeric" });
return (
<div className="page">
<StreakBanner />
<div style={{ marginBottom: 8 }}>
<h2 style={{ fontFamily: "Lora, serif", fontSize: "1.5rem" }}>Archive</h2>
<p style={{ color: "var(--ink-light)", fontSize: "0.875rem" }}>
{entries.length} {entries.length === 1 ? "letter" : "letters"} written
</p>
</div>
<div style={{ marginBottom: 32, marginTop: 24 }}>
<RecentList />
</div>
<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={prevMonth}></button>
<span style={{ fontFamily: "Lora, serif", fontSize: "1.1rem" }}>{monthName}</span>
<button className="btn btn-ghost" style={{ padding: "6px 12px" }} onClick={nextMonth}></button>
</div>
<CalendarGrid year={year} month={month} entries={entries} />
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import RichTextEditor from "../components/RichTextEditor";
import { api } from "../api/client";
function localToday(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function formatDate(dateStr: string) {
return new Date(`${dateStr}T00:00:00`).toLocaleDateString("en-US", {
weekday: "long", year: "numeric", month: "long", day: "numeric",
});
}
type LetterSummary = { id: number; letterDate: string | null; createdAt: string; deliverAt: string | null };
function isContentEmpty(html: string): boolean {
if (!html) return true;
if (html.includes("<img")) return false;
const div = document.createElement("div");
div.innerHTML = html;
return !div.textContent?.trim();
}
export default function Editor() {
const navigate = useNavigate();
const todayStr = localToday();
const [letterDate, setLetterDate] = useState(todayStr);
const [content, setContent] = useState("");
const [deliverAt, setDeliverAt] = useState("");
const [existingId, setExistingId] = useState<number | null>(null);
const [conflictId, setConflictId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [allLetters, setAllLetters] = useState<LetterSummary[]>([]);
const [listLoaded, setListLoaded] = useState(false);
const [editorKey, setEditorKey] = useState(0);
// Load the full letter list once
useEffect(() => {
api.listLetters()
.then((fetched) => setAllLetters(fetched as LetterSummary[]))
.finally(() => setListLoaded(true));
}, []);
// Whenever the selected date or the list changes, look up the letter for that date
const prevLoadedId = useRef<number | null>(null);
useEffect(() => {
if (!listLoaded) return;
setError("");
setConflictId(null);
setLoading(true);
const match = allLetters.find(
(l) => (l.letterDate ?? l.createdAt.slice(0, 10)) === letterDate
);
if (match) {
if (prevLoadedId.current === match.id) {
// Content already loaded for this letter — skip fetch
setLoading(false);
return;
}
prevLoadedId.current = match.id;
setExistingId(match.id);
api.getLetter(match.id)
.then((letter) => {
setContent(letter.content);
setDeliverAt(letter.deliverAt ?? "");
})
.finally(() => setLoading(false));
} else {
prevLoadedId.current = null;
setExistingId(null);
setContent("");
setDeliverAt("");
setEditorKey((k) => k + 1);
setLoading(false);
}
}, [letterDate, allLetters, listLoaded]);
const handleSave = async () => {
if (isContentEmpty(content)) {
setError("The letter cannot be empty.");
return;
}
setSaving(true);
setError("");
setConflictId(null);
try {
if (existingId) {
await api.updateLetter(existingId, { content, deliverAt: deliverAt || undefined, clientDate: letterDate });
} else {
const { id } = await api.createLetter({ content, letterDate, deliverAt: deliverAt || undefined });
prevLoadedId.current = id;
setExistingId(id);
setAllLetters((prev) => [
...prev,
{ id, letterDate, createdAt: new Date().toISOString(), deliverAt: deliverAt || null },
]);
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err: unknown) {
const e = err as { status?: number; body?: { id?: number } };
if (e.status === 409 && e.body?.id) {
setConflictId(e.body.id);
prevLoadedId.current = e.body.id;
setExistingId(e.body.id);
setError(`There's already a letter for ${formatDate(letterDate)} — editing that instead.`);
api.getLetter(e.body.id).then((l) => setContent(l.content));
} else {
setError("Something went wrong. Please try again.");
}
} finally {
setSaving(false);
}
};
const isEditing = existingId !== null;
return (
<div className="page">
<div style={{ marginBottom: 24, display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
<div>
<h2 style={{ fontFamily: "Lora, serif", fontSize: "1.5rem", color: "var(--ink)", marginBottom: 4 }}>
{isEditing
? letterDate === todayStr
? "Today's letter"
: `Letter for ${formatDate(letterDate)}`
: "Write a letter"}
</h2>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<label style={{ fontSize: "0.875rem", color: "var(--ink-light)" }}>Date:</label>
<input
type="date"
value={letterDate}
onChange={(e) => setLetterDate(e.target.value)}
style={{
border: "1px solid var(--border)",
borderRadius: 6,
padding: "4px 10px",
fontSize: "0.875rem",
background: "var(--surface-alt)",
color: "var(--ink)",
}}
/>
</div>
</div>
</div>
<div className="card" style={{ padding: 32, display: "flex", flexDirection: "column", gap: 20 }}>
{loading ? (
<p style={{ color: "var(--ink-light)", fontStyle: "italic", minHeight: 320 }}>Loading...</p>
) : (
<RichTextEditor key={editorKey} content={content} onChange={setContent} placeholder="My dearest..." />
)}
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<label style={{ fontSize: "0.85rem", color: "var(--ink-light)", display: "flex", alignItems: "center", gap: 8 }}>
<span>Deliver on:</span>
<input
type="date"
value={deliverAt}
min={todayStr}
onChange={(e) => setDeliverAt(e.target.value)}
style={{
border: "1px solid var(--border)",
borderRadius: 6,
padding: "4px 8px",
fontSize: "0.875rem",
background: "var(--surface-alt)",
color: "var(--ink)",
}}
/>
</label>
<span style={{ fontSize: "0.75rem", color: "var(--ink-light)", fontStyle: "italic" }}>
Leave blank to publish immediately
</span>
</div>
{error && (
<p style={{ color: "var(--heart)", fontSize: "0.875rem" }}>
{error}{" "}
{conflictId && (
<a href={`/letters/${conflictId}`} style={{ color: "var(--accent)" }}>View it </a>
)}
</p>
)}
<div style={{ display: "flex", gap: 12 }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? "Saving..." : saved ? "Saved ♡" : isEditing ? "Update letter" : "Send letter ♡"}
</button>
{isEditing && (
<button className="btn btn-secondary" onClick={() => navigate(`/letters/${existingId}`)}>
View
</button>
)}
</div>
</div>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api, setToken } from "../api/client";
export default function Login() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const { token, role } = await api.login(password);
setToken(token, role);
navigate("/");
} catch {
setError("Wrong password, my love.");
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg)" }}>
<div className="card" style={{ width: "100%", maxWidth: 380, padding: 40 }}>
<h1 style={{ fontFamily: "Lora, serif", fontSize: "2rem", color: "var(--accent)", textAlign: "center", marginBottom: 8 }}>
hibb
</h1>
<p style={{ color: "var(--ink-light)", textAlign: "center", marginBottom: 32, fontStyle: "italic" }}>
a place for letters
</p>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<input
type="password"
placeholder="Enter the password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
style={{
padding: "12px 16px",
border: "1px solid var(--border)",
borderRadius: 8,
fontSize: "1rem",
background: "var(--surface-alt)",
color: "var(--ink)",
outline: "none",
}}
/>
{error && <p style={{ color: "var(--accent)", fontSize: "0.875rem", textAlign: "center" }}>{error}</p>}
<button type="submit" className="btn btn-primary" disabled={loading} style={{ justifyContent: "center" }}>
{loading ? "Opening..." : "Enter ♡"}
</button>
</form>
</div>
</div>
);
}

17
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
});