Files
pi-ku/frontend/repomix-output.xml
T

1359 lines
38 KiB
XML

This file is a merged representation of a subset of the codebase, containing specifically included files, combined into a single document by Repomix.
<file_summary>
This section contains a summary of this file.
<purpose>
This file contains a packed representation of a subset of the repository's contents that is considered the most important context.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>
<file_format>
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
- File path as an attribute
- Full contents of the file
</file_format>
<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
</usage_guidelines>
<notes>
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Only files matching these patterns are included: **/*.ts, **/*.tsx
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)
</notes>
</file_summary>
<directory_structure>
src/
api/
apiClient.ts
components/
ui/
ComposeCanvas.tsx
DateDisplay.tsx
FormField.tsx
Logo.tsx
RouteGuards.tsx
SplashScreen.tsx
config/
endpoints.ts
routes.ts
hooks/
useAuth.ts
pages/
Activate.tsx
Drawer.tsx
Editor.tsx
Home.tsx
Login.tsx
Register.tsx
VerifyEmail.tsx
store/
useAuthStore.ts
utils/
crypto.ts
App.tsx
main.tsx
vite.config.ts
</directory_structure>
<files>
This section contains the contents of the repository's files.
<file path="src/api/apiClient.ts">
import axios from "axios";
import { endpoints } from "../config/endpoints";
import { useAuthStore } from "../store/useAuthStore";
// publicApi for endpoints that don't need authentication (login, refresh, register)
export const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
});
// api for all authenticated requests
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
});
// auto-attach access token to authenticated requests
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 errors by attempting a silent refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and we haven't tried refreshing yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Attempt silent refresh
const { data } = await publicApi.post(endpoints.REFRESH);
const newAccessToken = data.access;
// Update store
const { user, setAuth } = useAuthStore.getState();
if (user) {
setAuth(newAccessToken, user);
}
// Retry the original request with the new token
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh failed, perform logout to clear tokens
console.error("Session expired, logging out...");
useAuthStore.getState().clearAuth();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
},
);
</file>
<file path="src/components/ui/ComposeCanvas.tsx">
import * as fabric from "fabric";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
const PAD = 36;
export type CanvasTools = {
addImage: (url: string) => void;
};
export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
const textboxRef = useRef<fabric.Textbox | null>(null);
useEffect(() => {
let isMounted = true;
let canvas: fabric.Canvas | null = null;
const init = async () => {
// lazy populate
await document.fonts.ready;
const waitForLayout = (): Promise<number> => {
return new Promise((resolve) => {
const check = () => {
const wrapperWidth = wrapperRef.current?.clientWidth || 0;
if (wrapperWidth > 0) resolve(wrapperWidth);
else requestAnimationFrame(check);
};
check();
});
};
const finalWidth = await waitForLayout();
if (!isMounted || !canvasRef.current || !wrapperRef.current) return;
const initialHeight = Math.max(
wrapperRef.current.clientHeight || 900,
600,
);
// init canvas
canvas = new fabric.Canvas(canvasRef.current, {
width: finalWidth,
height: initialHeight,
selection: false,
preserveObjectStacking: true,
allowTouchScrolling: true, // for mobile
});
fabricRef.current = canvas;
// transparent background
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
// the core textbox
const textbox = new fabric.Textbox("Take a deep breath...", {
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: finalWidth - PAD * 2,
fontSize: 16,
fontWeight: 500,
fontFamily: "Playfair Display Variable",
fill: "#000",
lineHeight: 1.5,
editable: true,
hasControls: false,
hasBorders: false,
objectCaching: false, // for font crispness
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
});
textboxRef.current = textbox;
canvas.add(textbox);
// automatically adjust height
textbox.on("changed", () => {
if (!canvas || !wrapperRef.current) return;
const neededHeight = textbox.top + textbox.height + PAD;
if (neededHeight > canvas.height) {
const newH = neededHeight + PAD;
canvas.setDimensions({ height: newH });
wrapperRef.current.style.height = `${newH}px`;
}
});
// auto focus
setTimeout(() => {
if (!isMounted) return;
canvas?.setActiveObject(textbox);
textbox.enterEditing();
canvas?.renderAll();
// Accessibility fix for Fabric.js hidden textarea
// searching globally in case it is appended to body
const hiddenTextareas = document.querySelectorAll(
'textarea[data-fabric="textarea"]',
);
hiddenTextareas.forEach((ta) => {
if (!ta.getAttribute("aria-label")) {
ta.setAttribute("aria-label", "Canvas text input");
}
});
}, 100);
canvas.on("mouse:down", (opt) => {
if (!opt.target || opt.target === textbox) {
canvas?.setActiveObject(textbox);
textbox.enterEditing();
canvas?.renderAll();
}
});
};
init();
return () => {
isMounted = false;
canvas?.dispose();
fabricRef.current = null;
textboxRef.current = null;
};
}, []);
useImperativeHandle(ref, () => ({
addImage: (url: string) => {
if (!fabricRef.current) return;
fabric.FabricImage.fromURL(url).then((img) => {
img.scaleToWidth(300);
img.set({
left: PAD,
top: PAD,
});
fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img);
fabricRef.current?.requestRenderAll();
URL.revokeObjectURL(url); // cleanup browser upload
});
},
}));
return (
<div
ref={wrapperRef}
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
style={{ minHeight: "900px" }}
>
<canvas
ref={canvasRef}
className="absolute top-0 left-0"
style={{ background: "transparent" }}
/>
</div>
);
});
ComposeCanvas.displayName = "ComposeCanvas";
</file>
<file path="src/components/ui/DateDisplay.tsx">
interface DateDisplayProps {
date?: Date;
className?: string;
}
export default function DateDisplay({
date = new Date(),
className = "",
}: DateDisplayProps) {
const formattedDate = date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
return (
<div
className={`text-right flex flex-col gap-2 min-w-[140px] ${className}`}
>
<span className="text-[10px] uppercase tracking-[0.4em] text-accent font-bold">
Date
</span>
<span className="text-sm font-serif text-secondary-content italic whitespace-nowrap">
{formattedDate}
</span>
</div>
);
}
</file>
<file path="src/components/ui/FormField.tsx">
import type { UseFormRegisterReturn } from "react-hook-form";
interface FormFieldProps {
label: string;
type?: string;
placeholder?: string;
registration: UseFormRegisterReturn;
error?: string;
}
export default function FormField({
label,
type = "text",
placeholder,
registration,
error,
}: FormFieldProps) {
return (
<div className="form-control">
<label
htmlFor={registration.name}
className="field-label font-display text-base-content/90 font-medium"
>
{label}
</label>
<input
{...registration}
id={registration.name}
type={type}
placeholder={placeholder}
className={`input input-bordered focus:input-primary ${
error ? "input-error" : ""
}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
);
}
</file>
<file path="src/components/Logo.tsx">
import { DotIcon } from "@phosphor-icons/react";
import "@fontsource/knewave/400.css";
export default function Logo() {
return (
<span
role="img"
aria-label="Pi Ku"
className="inline-flex items-baseline justify-center leading-none select-none"
style={{ fontFamily: "'Knewave', serif" }}
>
<span className="text-2xl font-light text-accent">Pi</span>
<DotIcon
weight="fill"
size={12}
className="text-accent translate-y-[0.3em] -mx-px"
/>
<span className="text-2xl font-light text-accent">Ku</span>
<DotIcon
weight="fill"
size={12}
className="text-accent translate-y-[0.3em] -mx-px"
/>
</span>
);
}
</file>
<file path="src/components/RouteGuards.tsx">
import { Navigate, useLocation } from "react-router-dom";
import { ROUTES } from "../config/routes";
import { useAuth } from "../hooks/useAuth";
import SplashScreen from "./SplashScreen";
/**
* Post-login routes.
* Redirects to /login if not already authenticated.
*/
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isInitializing } = useAuth();
const location = useLocation();
if (isInitializing) return <SplashScreen />;
if (!isAuthenticated) {
// Save the intended location to redirect back after login
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
/**
* Pre-login flows.
* Redirects to /drawer if already authenticated.
*/
export function PublicRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isInitializing } = useAuth();
if (isInitializing) return <SplashScreen />;
if (isAuthenticated) {
return <Navigate to={ROUTES.DRAWER} replace />;
}
return <>{children}</>;
}
</file>
<file path="src/components/SplashScreen.tsx">
import Logo from "./Logo";
export default function SplashScreen() {
return (
<div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
<div className="flex flex-col items-center gap-6 animate-pulse">
<Logo />
<div className="flex flex-col items-center gap-2">
<span className="loading loading-ring loading-lg text-primary" />
<p className="text-xs uppercase tracking-widest opacity-40 font-display">
Initializing Identity
</p>
</div>
</div>
</div>
);
}
</file>
<file path="src/config/endpoints.ts">
export const endpoints = {
LOGIN: "/api/auth/login/",
REGISTER: "/api/auth/register/",
VERIFY_EMAIL: "/api/auth/verify-email/",
ACTIVATE: "/api/auth/activate/:uidb64/:token/",
ME: "/api/auth/me/",
REFRESH: "/api/auth/refresh/",
LOGOUT: "/api/auth/logout/",
};
// simple utility to handle path params
export const replacePathParams = (
url: string,
params: Record<string, string>,
): string => {
let result = url;
Object.entries(params).forEach(([key, value]) => {
result = result.replace(`:${key}`, value);
});
return result;
};
</file>
<file path="src/config/routes.ts">
export const ROUTES = {
HOME: "/",
ONBOARD: "/onboard",
VERIFY_EMAIL: "/verify-email",
ACTIVATE: "/activate/:uidb64/:token",
LOGIN: "/login",
DRAWER: "/drawer",
WRITE: "/quill",
READ: "/read",
};
</file>
<file path="src/hooks/useAuth.ts">
import { useCallback } from "react";
import { api, publicApi } from "../api/apiClient";
import { endpoints } from "../config/endpoints";
import { type UserProfile, useAuthStore } from "../store/useAuthStore";
export const useAuth = () => {
const { accessToken, user, isInitializing, setAuth, clearAuth } =
useAuthStore();
const isAuthenticated = !!accessToken;
const login = (access: string, profile: UserProfile) => {
setAuth(access, profile);
};
const logout = async () => {
try {
await api.post(endpoints.LOGOUT);
} finally {
clearAuth();
}
};
const initialize = useCallback(async () => {
const { accessToken, user, setAuth, clearAuth, setInitializing } =
useAuthStore.getState();
// If session in memory, don't trigger refresh/me again
if (accessToken && user) {
setInitializing(false);
return;
}
try {
// try refresh
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
// fetch user profile with the new access token
const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${refreshData.access}` },
});
// update auth details in memory
setAuth(refreshData.access, userData);
} catch (err) {
console.error("Initialization failed:", err);
clearAuth();
}
}, []);
return {
isAuthenticated,
user,
isInitializing,
login,
logout,
initialize,
};
};
</file>
<file path="src/pages/Activate.tsx">
import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { publicApi } from "../api/apiClient";
import Logo from "../components/Logo";
import { endpoints, replacePathParams } from "../config/endpoints";
import { ROUTES } from "../config/routes";
export default function Activate() {
const { uidb64, token } = useParams();
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading",
);
const hasCalled = useRef(false);
const navigate = useNavigate();
useEffect(() => {
if (!uidb64 || !token || hasCalled.current) return;
// prevent double api calls
hasCalled.current = true;
const activateAccount = async () => {
try {
const url = replacePathParams(endpoints.ACTIVATE, {
uidb64,
token,
});
await publicApi.get(url);
setStatus("success");
} catch (err) {
console.error("Activation error:", err);
setStatus("error");
}
};
activateAccount();
}, [uidb64, token]);
return (
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom">
{status === "loading" && (
<div className="flex flex-col items-center gap-4 py-8">
<span className="loading loading-spinner loading-lg text-primary" />
<p className="text-sm opacity-70">Activating your account...</p>
</div>
)}
{status === "success" && (
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
<div className="bg-success/10 p-4 rounded-full">
<CheckCircleIcon
size={64}
weight="duotone"
className="text-success"
/>
</div>
<h2 className="font-display text-xl text-success">
Account Activated!
</h2>
<p className="opacity-70 mb-8 leading-relaxed">
Welcome to <Logo />
<br />
Your identity is now verified and ready for timeless letters.
</p>
<button
type="button"
className="btn btn-primary w-full shadow-lg"
onClick={() => navigate(ROUTES.DRAWER)}
>
Open Drawer
</button>
</div>
)}
{status === "error" && (
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
<div className="bg-error/10 p-4 rounded-full">
<XCircleIcon size={64} weight="duotone" className="text-error" />
</div>
<h2 className="font-display text-xl text-error">Activation Failed</h2>
<p className="opacity-70 mb-8 leading-relaxed">
The link might be expired or already used. Please try registering
again.
</p>
<button
type="button"
className="btn btn-ghost w-full"
onClick={() => navigate(ROUTES.ONBOARD)}
>
Back to Registration
</button>
</div>
)}
</div>
);
}
</file>
<file path="src/pages/Drawer.tsx">
import { useAuth } from "../hooks/useAuth";
export default function Drawer() {
const { user, logout } = useAuth();
if (!user) return null;
return (
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col gap-6 fade-zoom">
<div className="space-y-2">
<h1 className="font-display text-2xl font-bold text-primary">
Your Drawer
</h1>
<p className="text-sm opacity-70">Welcome back, {user.full_name}</p>
</div>
<div className="divider opacity-10" />
<button
type="button"
onClick={logout}
className="btn btn-link btn-xs opacity-40 hover:opacity-100 no-underline transition-all"
>
Sign Out
</button>
</div>
);
}
</file>
<file path="src/pages/Editor.tsx">
import { ImageIcon, LockIcon, TrayIcon } from "@phosphor-icons/react";
import { useRef, useState } from "react";
import {
type CanvasTools,
ComposeCanvas,
} from "../components/ui/ComposeCanvas";
import DateDisplay from "../components/ui/DateDisplay";
export default function Editor() {
const [recipient, setRecipient] = useState("");
const canvasRef = useRef<CanvasTools>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; // pick one file at a time
if (file) {
const url = URL.createObjectURL(file);
canvasRef.current?.addImage(url);
}
};
return (
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 py-12 bg-base-300">
<div className="max-w-[720px] mx-auto px-1 md:px-0">
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
<div className="flex flex-col gap-2 flex-1">
<label
htmlFor="recipient"
className="text-[10px] uppercase tracking-[0.4em] text-secondary-content font-bold"
>
Recipient
</label>
<input
id="recipient"
type="text"
placeholder="Someone dear..."
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
className="bg-transparent border-none outline-none text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full"
/>
</div>
<DateDisplay />
</div>
<div
id="writer-toolbar"
className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
>
<div className="flex gap-4">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon size={18} weight="bold" />
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
className="hidden"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-ghost btn-sm text-[10px] tracking-[0.2em] uppercase font-bold text-base-content/60 hover:text-base-content"
title="Keep in your private drawer"
>
<TrayIcon size={18} weight="bold" />
<span className="hidden md:inline">Keep</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2" />
<button
type="button"
className="btn btn-primary btn-sm rounded-full px-6"
>
<LockIcon size={14} weight="fill" className="mr-1" />
Seal
</button>
</div>
</div>
<ComposeCanvas ref={canvasRef} />
</div>
</section>
);
}
</file>
<file path="src/pages/Home.tsx">
import Logo from "../components/Logo";
export default function Home() {
return (
<div>
<Logo />
</div>
);
}
</file>
<file path="src/pages/Login.tsx">
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { api, publicApi } from "../api/apiClient";
import Logo from "../components/Logo";
import FormField from "../components/ui/FormField";
import { endpoints } from "../config/endpoints";
import { ROUTES } from "../config/routes";
import { useAuth } from "../hooks/useAuth";
const loginSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(1, "Password is required"),
});
type LoginInputs = z.infer<typeof loginSchema>;
export default function Login() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const { login } = useAuth();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginInputs>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginInputs) => {
setIsLoading(true);
setApiError(null);
try {
// 1. Authenticate
const { data: authData } = await publicApi.post(endpoints.LOGIN, data);
// 2. Fetch User Profile with the fresh token
// We pass the header explicitly to avoid any race conditions with interceptors
const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${authData.access}` },
});
// 3. Update store using the hook method
await login(authData.access, userData);
navigate(ROUTES.DRAWER);
} catch (err) {
console.error("Login error:", err);
let message = "Invalid email or password";
if (axios.isAxiosError(err)) {
message =
err.response?.data?.detail || err.response?.data?.message || message;
}
setApiError(message);
} finally {
setIsLoading(false);
}
};
return (
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
Sign in to <Logo />
</h1>
{apiError && (
<div className="alert alert-error text-xs py-2 rounded-md">
<span>{apiError}</span>
</div>
)}
<FormField
label="Email"
type="email"
placeholder="you@email.com"
registration={register("email")}
error={errors.email?.message}
/>
<FormField
label="Password"
type="password"
placeholder="••••••••"
registration={register("password")}
error={errors.password?.message}
/>
<div className="card-actions mt-4">
<button
type="submit"
disabled={isLoading}
aria-label="Sign In"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Sign In"
)}
</button>
</div>
<div className="text-center text-sm font-medium text-base-content/70">
Don't have an account?{" "}
<button
type="button"
onClick={() => navigate(ROUTES.ONBOARD)}
className="link link-primary no-underline hover:underline font-bold"
>
Register
</button>
</div>
</form>
</div>
);
}
</file>
<file path="src/pages/Register.tsx">
import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "@phosphor-icons/react";
import axios from "axios";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { publicApi } from "../api/apiClient";
import Logo from "../components/Logo";
import FormField from "../components/ui/FormField";
import { endpoints } from "../config/endpoints";
import { ROUTES } from "../config/routes";
// validation logic
const registerSchema = z
.object({
full_name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirm_password: z.string(),
})
.refine((data) => data.password === data.confirm_password, {
message: "Passwords don't match",
path: ["confirm_password"],
});
type RegisterInputs = z.infer<typeof registerSchema>;
export default function Register() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterInputs>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterInputs) => {
setIsLoading(true);
setApiError(null);
try {
await publicApi.post(endpoints.REGISTER, {
full_name: data.full_name,
email: data.email,
password: data.password,
});
navigate(ROUTES.VERIFY_EMAIL);
} catch (err) {
console.error("Registration error:", err);
let message = "Registration failed. Please try again.";
if (axios.isAxiosError(err)) {
message = err.response?.data?.message || message;
}
setApiError(message);
} finally {
setIsLoading(false);
}
};
return (
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<h1 className="card-title font-display text-2xl font-bold justify-center text-primary tracking-tight">
Create a <Logo /> Account
</h1>
{apiError && (
<div className="alert alert-error text-xs py-2 rounded-md">
<span>{apiError}</span>
</div>
)}
<FormField
label="Full Name"
placeholder="Word Smith"
registration={register("full_name")}
error={errors.full_name?.message}
/>
<FormField
label="Email"
type="email"
placeholder="you@email.com"
registration={register("email")}
error={errors.email?.message}
/>
<FormField
label="Password"
type="password"
placeholder="••••••••"
registration={register("password")}
error={errors.password?.message}
/>
<FormField
label="Confirm Password"
type="password"
placeholder="••••••••"
registration={register("confirm_password")}
error={errors.confirm_password?.message}
/>
{/* Warning */}
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
<p className="text-sm font-semibold">
Choose a password you won't forget. <br />
<span className="underline decoration-2">There is no reset.</span>{" "}
If you lose it, your letters cannot be recovered.
</p>
</div>
<div className="card-actions mt-4">
<button
type="submit"
disabled={isLoading}
aria-label="Register"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Register"
)}
</button>
</div>
</form>
</div>
);
}
</file>
<file path="src/pages/VerifyEmail.tsx">
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
import Logo from "../components/Logo";
export default function VerifyEmail() {
return (
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
<div className="auth-icon-container">
<EnvelopeSimpleOpenIcon
size={32}
weight="duotone"
className="text-primary"
/>
</div>
<div className="space-y-2">
<h2 className="font-display text-xl text-primary">Check Your Email</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans">
We've sent an activation link to your inbox. <br />
Please click it to verify your <Logo /> account.
</p>
</div>
<div className="divider opacity-10"></div>
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed text-left opacity-70">
<p>
Didn't receive it? Check your spam folder or wait for a few minutes.
The link will expire in 24 hours.
</p>
</div>
<button
type="button"
className="text-xs italic opacity-40 cursor-pointer underline"
onClick={() => window.close()}
onKeyDown={(e) => e.key === "Enter" && window.close()}
>
You can close this window now.
</button>
</div>
);
}
</file>
<file path="src/store/useAuthStore.ts">
import { create } from "zustand";
export interface UserProfile {
public_id: string;
email: string;
full_name: string;
}
interface AuthState {
accessToken: string | null;
user: UserProfile | null;
isInitializing: boolean;
setAuth: (accessToken: string, user: UserProfile) => void;
clearAuth: () => void;
setInitializing: (v: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
user: null,
isInitializing: true,
setAuth: (accessToken, user) =>
set({ accessToken, user, isInitializing: false }),
clearAuth: () =>
set({ accessToken: null, user: null, isInitializing: false }),
setInitializing: (isInitializing) => set({ isInitializing }),
}));
</file>
<file path="src/utils/crypto.ts">
/**
* 0 knowledge cryptography. No Server involved in encryption/decryption
*/
const ITERATIONS = 100000;
const KEY_ALGO = { name: "AES-GCM", length: 256 };
/**
* Derives a Master Encryption Key from a password and email (salt).
*/
export async function deriveMasterKey(
password: string,
email: string,
): Promise<CryptoKey> {
const encoder = new TextEncoder();
const passwordKey = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: encoder.encode(email.toLowerCase()),
iterations: ITERATIONS,
hash: "SHA-256",
},
passwordKey,
KEY_ALGO,
false,
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
);
}
/**
* Encrypts a letter using Envelope Encryption.
*/
export async function encryptLetter(plaintext: string, masterKey: CryptoKey) {
const encoder = new TextEncoder();
// Generate random Data Encryption Key (DEK)
const dek = await crypto.subtle.generateKey(KEY_ALGO, true, [
"encrypt",
"decrypt",
]);
// Encrypt the content with the DEK
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
dek,
encoder.encode(plaintext),
);
// encrpyt the DEK using the Master Key for the self access
const keyIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedKey = await crypto.subtle.wrapKey("raw", dek, masterKey, {
name: "AES-GCM",
iv: keyIv,
});
// for recipients (link share), export DEK in raw format
const rawKey = await crypto.subtle.exportKey("raw", dek);
// conversion to base64 for transit
const toBase64 = (buf: Uint8Array) =>
btoa(buf.reduce((acc, b) => acc + String.fromCharCode(b), ""));
return {
// This goes to the server
encryptedPayload: {
ciphertext: toBase64(new Uint8Array(ciphertext)),
iv: toBase64(new Uint8Array(iv)),
wrappedKey: toBase64(new Uint8Array(wrappedKey)),
keyIv: toBase64(new Uint8Array(keyIv)),
},
// This goes into the url for the recipient
sharingKey: toBase64(new Uint8Array(rawKey)),
};
}
</file>
<file path="src/App.tsx">
import { useEffect } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
import SplashScreen from "./components/SplashScreen";
import { ROUTES } from "./config/routes";
import { useAuth } from "./hooks/useAuth";
import Activate from "./pages/Activate";
import Drawer from "./pages/Drawer";
import Editor from "./pages/Editor";
// Pages
import Home from "./pages/Home";
import Login from "./pages/Login";
import Register from "./pages/Register";
import VerifyEmail from "./pages/VerifyEmail";
export default function App() {
const { initialize, isInitializing } = useAuth();
useEffect(() => {
initialize();
}, [initialize]);
if (isInitializing) {
return <SplashScreen />;
}
return (
<BrowserRouter>
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
<Routes>
<Route path={ROUTES.HOME} element={<Home />} />
<Route
path={ROUTES.ONBOARD}
element={
<PublicRoute>
<Register />
</PublicRoute>
}
/>
<Route
path={ROUTES.LOGIN}
element={
<PublicRoute>
<Login />
</PublicRoute>
}
/>
<Route
path={ROUTES.VERIFY_EMAIL}
element={
<PublicRoute>
<VerifyEmail />
</PublicRoute>
}
/>
<Route
path={ROUTES.ACTIVATE}
element={
<PublicRoute>
<Activate />
</PublicRoute>
}
/>
<Route
path={ROUTES.DRAWER}
element={
<ProtectedRoute>
<Drawer />
</ProtectedRoute>
}
/>
<Route
path={ROUTES.WRITE}
element={
<ProtectedRoute>
<Editor />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
</Routes>
</main>
</BrowserRouter>
);
}
</file>
<file path="src/main.tsx">
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
import "@fontsource-variable/jost/wght.css";
import "@fontsource-variable/playfair-display/wght.css";
import App from "./App.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}
</file>
<file path="vite.config.ts">
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, "../", "");
return {
envDir: "../",
plugins: [react(), tailwindcss()],
server: {
port: Number(env.FRONTEND_PORT),
host: env.FRONTEND_DOMAIN,
},
}
});
</file>
</files>