initial commit
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Copy this file to .env and fill in the values before running.
|
||||
|
||||
# Password for the letter writer (full access: read + write)
|
||||
WRITER_PASSWORD=your-writer-password-here
|
||||
|
||||
# Password for the reader / partner (read-only access)
|
||||
READER_PASSWORD=your-reader-password-here
|
||||
|
||||
# A long random string used to sign JWT tokens.
|
||||
# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
JWT_SECRET=change-this-to-a-random-secret
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# TypeScript build cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Vite cache
|
||||
.vite/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:22-alpine AS build-frontend
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build backend
|
||||
FROM node:22-alpine AS build-backend
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install
|
||||
COPY backend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:22-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Install only production deps for backend
|
||||
COPY backend/package*.json ./backend/
|
||||
RUN cd backend && npm install --omit=dev
|
||||
|
||||
# Copy compiled backend
|
||||
COPY --from=build-backend /app/backend/dist ./backend/dist
|
||||
|
||||
# Copy built frontend into location the backend will serve
|
||||
COPY --from=build-frontend /app/frontend/dist ./frontend/dist
|
||||
|
||||
# Data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
3273
backend/package-lock.json
generated
Normal file
3273
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
backend/package.json
Normal file
26
backend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "hibb-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=../.env src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/jwt": "^8.0.1",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"drizzle-orm": "^0.31.2",
|
||||
"fastify": "^4.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"drizzle-kit": "^0.22.8",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
40
backend/src/db/index.ts
Normal file
40
backend/src/db/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { letters } from "./schema";
|
||||
|
||||
const DB_PATH = process.env.DB_PATH ?? "./data/letters.db";
|
||||
|
||||
// Ensure the parent directory exists before libsql tries to open the file
|
||||
fs.mkdirSync(path.dirname(path.resolve(DB_PATH)), { recursive: true });
|
||||
|
||||
// libsql expects a file: URI; handle both relative and absolute paths
|
||||
const dbUrl = DB_PATH.startsWith("/")
|
||||
? `file://${DB_PATH}`
|
||||
: `file:${DB_PATH}`;
|
||||
|
||||
const client = createClient({ url: dbUrl });
|
||||
|
||||
export const db = drizzle(client, { schema: { letters } });
|
||||
|
||||
export async function runMigrations() {
|
||||
await client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS letters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
letter_date TEXT,
|
||||
read_at TEXT,
|
||||
deliver_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
// Additive migrations for existing databases
|
||||
for (const col of [
|
||||
"ALTER TABLE letters ADD COLUMN letter_date TEXT",
|
||||
"ALTER TABLE letters ADD COLUMN read_at TEXT",
|
||||
]) {
|
||||
try { await client.execute(col); } catch { /* already exists */ }
|
||||
}
|
||||
}
|
||||
17
backend/src/db/schema.ts
Normal file
17
backend/src/db/schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const letters = sqliteTable("letters", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title"),
|
||||
content: text("content").notNull(),
|
||||
letterDate: text("letter_date"),
|
||||
readAt: text("read_at"),
|
||||
deliverAt: text("deliver_at"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(datetime('now'))`),
|
||||
});
|
||||
|
||||
export type Letter = typeof letters.$inferSelect;
|
||||
export type NewLetter = typeof letters.$inferInsert;
|
||||
39
backend/src/index.ts
Normal file
39
backend/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import Fastify from "fastify";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import fastifyCors from "@fastify/cors";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { runMigrations } from "./db";
|
||||
import { authRoutes } from "./routes/auth";
|
||||
import { letterRoutes } from "./routes/letters";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
async function main() {
|
||||
await runMigrations();
|
||||
|
||||
await app.register(fastifyCors, { origin: true });
|
||||
await app.register(fastifyJwt, {
|
||||
secret: process.env.JWT_SECRET ?? "change-me-in-production",
|
||||
});
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.register(letterRoutes);
|
||||
|
||||
// Serve the React SPA in production
|
||||
const distPath = path.join(__dirname, "../../frontend/dist");
|
||||
if (fs.existsSync(distPath)) {
|
||||
await app.register(fastifyStatic, { root: distPath, prefix: "/" });
|
||||
app.setNotFoundHandler((_req, reply) => {
|
||||
reply.sendFile("index.html");
|
||||
});
|
||||
}
|
||||
|
||||
await app.listen({ port: 3000, host: "0.0.0.0" });
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
9
backend/src/middleware/auth.ts
Normal file
9
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
|
||||
export async function verifyJwt(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
}
|
||||
38
backend/src/routes/auth.ts
Normal file
38
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
type Role = "writer" | "reader";
|
||||
|
||||
async function checkPassword(input: string, stored: string): Promise<boolean> {
|
||||
if (!stored) return false;
|
||||
return stored.startsWith("$2") ? bcrypt.compare(input, stored) : input === stored;
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: { password: string } }>("/api/auth/login", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["password"],
|
||||
properties: { password: { type: "string" } },
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const { password } = request.body;
|
||||
|
||||
let role: Role | null = null;
|
||||
if (await checkPassword(password, process.env.WRITER_PASSWORD ?? "")) {
|
||||
role = "writer";
|
||||
} else if (await checkPassword(password, process.env.READER_PASSWORD ?? "")) {
|
||||
role = "reader";
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return reply.code(401).send({ error: "Invalid password" });
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ role }, { expiresIn: "30d" });
|
||||
return { token, role };
|
||||
},
|
||||
});
|
||||
}
|
||||
181
backend/src/routes/letters.ts
Normal file
181
backend/src/routes/letters.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { letters, NewLetter } from "../db/schema";
|
||||
import { verifyJwt } from "../middleware/auth";
|
||||
|
||||
const utcDate = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Effective date for a letter: prefer client-sent letter_date, fall back to UTC date of created_at
|
||||
const effectiveDate = sql<string>`COALESCE(${letters.letterDate}, DATE(${letters.createdAt}))`;
|
||||
|
||||
export async function letterRoutes(app: FastifyInstance) {
|
||||
app.addHook("onRequest", verifyJwt);
|
||||
|
||||
// GET /api/letters
|
||||
app.get("/api/letters", async () => {
|
||||
return db
|
||||
.select({
|
||||
id: letters.id,
|
||||
letterDate: letters.letterDate,
|
||||
createdAt: letters.createdAt,
|
||||
deliverAt: letters.deliverAt,
|
||||
readAt: letters.readAt,
|
||||
})
|
||||
.from(letters)
|
||||
.where(sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`)
|
||||
.orderBy(sql`COALESCE(${letters.letterDate}, DATE(${letters.createdAt})) DESC`);
|
||||
});
|
||||
|
||||
// GET /api/letters/calendar
|
||||
app.get("/api/letters/calendar", async () => {
|
||||
const rows = await db
|
||||
.select({ id: letters.id, letterDate: letters.letterDate, createdAt: letters.createdAt })
|
||||
.from(letters)
|
||||
.where(sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
date: r.letterDate ?? r.createdAt.slice(0, 10),
|
||||
}));
|
||||
});
|
||||
|
||||
// GET /api/streak
|
||||
app.get("/api/streak", async () => {
|
||||
const rows = await db
|
||||
.select({ date: effectiveDate })
|
||||
.from(letters)
|
||||
.where(sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`)
|
||||
.orderBy(sql`COALESCE(${letters.letterDate}, DATE(${letters.createdAt})) DESC`);
|
||||
|
||||
const dates: string[] = [...new Set(rows.map((r: { date: string }) => r.date))];
|
||||
|
||||
let current = 0;
|
||||
let longest = 0;
|
||||
let streak = 0;
|
||||
let prev: Date | null = null;
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const d = new Date(dates[i]);
|
||||
streak = prev === null ? 1 : (prev.getTime() - d.getTime()) / 86400000 === 1 ? streak + 1 : 1;
|
||||
if (i === 0) current = streak;
|
||||
longest = Math.max(longest, streak);
|
||||
prev = d;
|
||||
}
|
||||
|
||||
if (dates.length > 0) {
|
||||
const diffFromToday = (new Date(utcDate()).getTime() - new Date(dates[0]).getTime()) / 86400000;
|
||||
if (diffFromToday > 1) current = 0;
|
||||
}
|
||||
|
||||
return { current, longest };
|
||||
});
|
||||
|
||||
// GET /api/letters/:id
|
||||
app.get<{ Params: { id: string } }>("/api/letters/:id", async (req, reply) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(letters)
|
||||
.where(
|
||||
and(
|
||||
eq(letters.id, id),
|
||||
sql`(${letters.deliverAt} IS NULL OR ${letters.deliverAt} <= ${utcDate()})`
|
||||
)
|
||||
);
|
||||
if (!rows[0]) return reply.code(404).send({ error: "Letter not found" });
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
});
|
||||
|
||||
// POST /api/letters
|
||||
app.post<{ Body: NewLetter & { letterDate?: string } }>("/api/letters", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["content"],
|
||||
properties: {
|
||||
content: { type: "string" },
|
||||
letterDate: { type: "string" },
|
||||
deliverAt: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { role } = req.user as { role: string };
|
||||
if (role !== "writer") {
|
||||
return reply.code(403).send({ error: "Readers cannot create letters" });
|
||||
}
|
||||
|
||||
const { content, deliverAt, letterDate } = req.body;
|
||||
|
||||
const checkDate = letterDate ?? utcDate();
|
||||
const dayCheck = sql`COALESCE(${letters.letterDate}, DATE(${letters.createdAt})) = ${checkDate}`;
|
||||
|
||||
const existing = await db.select({ id: letters.id }).from(letters).where(dayCheck);
|
||||
if (existing[0]) {
|
||||
return reply.code(409).send({ error: "Already wrote today", id: existing[0].id });
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(letters)
|
||||
.values({ content, letterDate: letterDate ?? null, deliverAt: deliverAt ?? null })
|
||||
.returning({ id: letters.id });
|
||||
|
||||
return reply.code(201).send({ id: inserted[0].id });
|
||||
},
|
||||
});
|
||||
|
||||
// PUT /api/letters/:id
|
||||
app.put<{ Params: { id: string }; Body: Partial<NewLetter> & { clientDate?: string } }>("/api/letters/:id", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string" },
|
||||
deliverAt: { type: "string", nullable: true },
|
||||
clientDate: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (req, reply) => {
|
||||
const { role } = req.user as { role: string };
|
||||
if (role !== "writer") {
|
||||
return reply.code(403).send({ error: "Readers cannot edit letters" });
|
||||
}
|
||||
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const rows = await db
|
||||
.select({ letterDate: letters.letterDate, createdAt: letters.createdAt })
|
||||
.from(letters)
|
||||
.where(eq(letters.id, id));
|
||||
|
||||
if (!rows[0]) return reply.code(404).send({ error: "Letter not found" });
|
||||
|
||||
const storedDate = rows[0].letterDate ?? rows[0].createdAt.slice(0, 10);
|
||||
const clientToday = req.body.clientDate ?? utcDate();
|
||||
if (storedDate !== clientToday) {
|
||||
return reply.code(403).send({ error: "Can only edit today's letter" });
|
||||
}
|
||||
|
||||
const { content, deliverAt } = req.body;
|
||||
await db
|
||||
.update(letters)
|
||||
.set({
|
||||
...(content !== undefined && { content }),
|
||||
...(deliverAt !== undefined && { deliverAt }),
|
||||
})
|
||||
.where(eq(letters.id, id));
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
}
|
||||
15
backend/tsconfig.json
Normal file
15
backend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- APP_PASSWORD=${APP_PASSWORD}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- DB_PATH=/app/data/letters.db
|
||||
restart: unless-stopped
|
||||
27
frontend/index.html
Normal file
27
frontend/index.html
Normal 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
3351
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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
84
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
frontend/src/api/client.ts
Normal file
83
frontend/src/api/client.ts
Normal 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"),
|
||||
};
|
||||
75
frontend/src/components/CalendarGrid.tsx
Normal file
75
frontend/src/components/CalendarGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
frontend/src/components/RichTextEditor.tsx
Normal file
124
frontend/src/components/RichTextEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/StreakBanner.tsx
Normal file
33
frontend/src/components/StreakBanner.tsx
Normal 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
135
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
165
frontend/src/pages/Archive.tsx
Normal file
165
frontend/src/pages/Archive.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
frontend/src/pages/Editor.tsx
Normal file
210
frontend/src/pages/Editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/pages/Login.tsx
Normal file
60
frontend/src/pages/Login.tsx
Normal 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
17
frontend/tsconfig.json
Normal 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
11
frontend/vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
372
package-lock.json
generated
Normal file
372
package-lock.json
generated
Normal file
@@ -0,0 +1,372 @@
|
||||
{
|
||||
"name": "hibb",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hibb",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"spawn-command": "0.0.2",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-command": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "hibb",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently -n backend,frontend -c magenta,cyan \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"install:all": "npm install --prefix backend && npm install --prefix frontend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user