mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
1359 lines
38 KiB
XML
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>
|