refactor: lint formatting and fixes #6
@@ -3,63 +3,58 @@ import logo from "../assets/logo.svg";
|
|||||||
import "@fontsource/knewave/400.css";
|
import "@fontsource/knewave/400.css";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
scale?: number;
|
scale?: number;
|
||||||
type?: "inline" | "mono" | "logo" | null;
|
type?: "inline" | "mono" | "logo" | null;
|
||||||
ul?: boolean;
|
ul?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Logo({
|
export default function Logo({
|
||||||
scale = 1,
|
scale = 1,
|
||||||
type = null,
|
type = null,
|
||||||
ul = false,
|
ul = false,
|
||||||
}: LogoProps) {
|
}: LogoProps) {
|
||||||
if (type === "inline") {
|
if (type === "inline") {
|
||||||
return (
|
|
||||||
<span className={"text-accent font-display italic "}>
|
|
||||||
pi<span className="text-primary">.</span> ku
|
|
||||||
<span className="text-primary">.</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "mono") {
|
|
||||||
return (
|
|
||||||
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
|
|
||||||
pi. ku.
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "logo") {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={logo}
|
|
||||||
alt="Pi. Ku. logo"
|
|
||||||
className="mx-4"
|
|
||||||
width={scale * 100}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<span className={"text-accent font-display italic "}>
|
||||||
role="img"
|
pi<span className="text-primary">.</span> ku
|
||||||
aria-label="Pi. Ku. logo"
|
<span className="text-primary">.</span>
|
||||||
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
|
</span>
|
||||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
|
||||||
>
|
|
||||||
<span className="text-3xl font-light text-accent">Pi</span>
|
|
||||||
<DotIcon
|
|
||||||
weight="fill"
|
|
||||||
size={12}
|
|
||||||
className="text-primary translate-y-1 -mx-px"
|
|
||||||
/>
|
|
||||||
<span className="text-3xl font-light text-accent"> Ku</span>
|
|
||||||
<DotIcon
|
|
||||||
weight="fill"
|
|
||||||
size={12}
|
|
||||||
className="text-primary translate-y-1 -mx-px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "mono") {
|
||||||
|
return (
|
||||||
|
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
|
||||||
|
pi. ku.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "logo") {
|
||||||
|
return (
|
||||||
|
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label="Pi. Ku. logo"
|
||||||
|
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
|
||||||
|
style={{ fontFamily: "'Knewave', serif", scale }}
|
||||||
|
>
|
||||||
|
<span className="text-3xl font-light text-accent">Pi</span>
|
||||||
|
<DotIcon
|
||||||
|
weight="fill"
|
||||||
|
size={12}
|
||||||
|
className="text-primary translate-y-1 -mx-px"
|
||||||
|
/>
|
||||||
|
<span className="text-3xl font-light text-accent"> Ku</span>
|
||||||
|
<DotIcon
|
||||||
|
weight="fill"
|
||||||
|
size={12}
|
||||||
|
className="text-primary translate-y-1 -mx-px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,73 +6,73 @@ import { type CanvasTools, ComposeCanvas } from "../editor/ComposeCanvas";
|
|||||||
import { EnvelopeReveal } from "../reader/EnvelopeReveal";
|
import { EnvelopeReveal } from "../reader/EnvelopeReveal";
|
||||||
|
|
||||||
export interface WelcomeLetterOverlayProps {
|
export interface WelcomeLetterOverlayProps {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
userName: string;
|
userName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WelcomeLetterOverlay({
|
export function WelcomeLetterOverlay({
|
||||||
onComplete,
|
onComplete,
|
||||||
userName,
|
userName,
|
||||||
}: WelcomeLetterOverlayProps) {
|
}: WelcomeLetterOverlayProps) {
|
||||||
const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">(
|
const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">(
|
||||||
"SEALED",
|
"SEALED",
|
||||||
);
|
);
|
||||||
const canvasRef = useRef<CanvasTools>(null);
|
const canvasRef = useRef<CanvasTools>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (revealState === "REVEALED" && canvasRef.current) {
|
if (revealState === "REVEALED" && canvasRef.current) {
|
||||||
const welcomeContent = getWelcomeLetterContent(userName);
|
const welcomeContent = getWelcomeLetterContent(userName);
|
||||||
canvasRef.current.loadData(welcomeContent);
|
canvasRef.current.loadData(welcomeContent);
|
||||||
}
|
}
|
||||||
}, [revealState, userName]);
|
}, [revealState, userName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden">
|
<div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden">
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
||||||
|
|
||||||
<div className="w-full max-w-4xl z-10 flex flex-col items-center">
|
<div className="w-full max-w-4xl z-10 flex flex-col items-center">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{revealState === "SEALED" && (
|
{revealState === "SEALED" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="envelope"
|
key="envelope"
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
animate={{ scale: 0.8, opacity: 1 }}
|
animate={{ scale: 0.8, opacity: 1 }}
|
||||||
exit={{
|
exit={{
|
||||||
scale: 1,
|
scale: 1,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transition: { duration: 0.5, ease: "easeOut" },
|
transition: { duration: 0.5, ease: "easeOut" },
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 4, delay: 1 }}
|
transition={{ duration: 4, delay: 1 }}
|
||||||
>
|
>
|
||||||
<EnvelopeReveal
|
<EnvelopeReveal
|
||||||
recipient={userName}
|
recipient={userName}
|
||||||
date={formatDate(new Date())}
|
date={formatDate(new Date())}
|
||||||
onRevealComplete={() => setRevealState("REVEALED")}
|
onRevealComplete={() => setRevealState("REVEALED")}
|
||||||
ignite={false}
|
ignite={false}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<div
|
<div
|
||||||
className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`}
|
className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`}
|
||||||
>
|
>
|
||||||
<div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180">
|
<div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180">
|
||||||
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
|
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
|
||||||
<ComposeCanvas ref={canvasRef} readOnly />
|
<ComposeCanvas ref={canvasRef} readOnly />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center mt-12">
|
<div className="flex justify-center mt-12">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="dismiss-welcome-letter-btn"
|
data-testid="dismiss-welcome-letter-btn"
|
||||||
onClick={onComplete}
|
onClick={onComplete}
|
||||||
className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider"
|
className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider"
|
||||||
>
|
>
|
||||||
I'll see you
|
I'll see you
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,39 @@ import { XCircleIcon } from "@phosphor-icons/react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
"data-testid"?: string;
|
"data-testid"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
export function Modal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
"data-testid": testId,
|
"data-testid": testId,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]"
|
className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]"
|
||||||
>
|
>
|
||||||
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
|
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="modal-close-btn"
|
data-testid="modal-close-btn"
|
||||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<XCircleIcon size={18} weight="bold" />
|
<XCircleIcon size={18} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
|
|
||||||
import trainImage from "../assets/screenshots/train.png";
|
import trainImage from "../assets/screenshots/train.png";
|
||||||
|
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
|
||||||
|
|
||||||
export function getWelcomeLetterContent(userName: string): CanvasJSON {
|
export function getWelcomeLetterContent(userName: string): CanvasJSON {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,87 +6,87 @@ import { useKeyStore } from "../store/useKeyStore";
|
|||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
|
||||||
export interface ProcessedLetter extends LetterResponseData {
|
export interface ProcessedLetter extends LetterResponseData {
|
||||||
metadata: LetterMetadata;
|
metadata: LetterMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptLettersMetadata(
|
async function decryptLettersMetadata(
|
||||||
letters: LetterResponseData[],
|
letters: LetterResponseData[],
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<ProcessedLetter[]> {
|
): Promise<ProcessedLetter[]> {
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
letters.map(async (letter) => {
|
letters.map(async (letter) => {
|
||||||
try {
|
try {
|
||||||
const metadata = (await cryptoUtils.decryptMetadata(
|
const metadata = (await cryptoUtils.decryptMetadata(
|
||||||
{
|
{
|
||||||
encrypted_content: letter.encrypted_metadata,
|
encrypted_content: letter.encrypted_metadata,
|
||||||
encrypted_dek: letter.encrypted_dek,
|
encrypted_dek: letter.encrypted_dek,
|
||||||
},
|
},
|
||||||
masterKey,
|
masterKey,
|
||||||
)) as LetterMetadata;
|
)) as LetterMetadata;
|
||||||
|
|
||||||
return { ...letter, metadata };
|
return { ...letter, metadata };
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
...letter,
|
...letter,
|
||||||
metadata: { recipient: "Encrypted Letter" },
|
metadata: { recipient: "Encrypted Letter" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLetters() {
|
export function useLetters() {
|
||||||
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
// to fetch the letters and decryypt the metadata on load
|
// to fetch the letters and decryypt the metadata on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!masterKey) {
|
if (!masterKey) {
|
||||||
setIsAuthRequired(true);
|
setIsAuthRequired(true);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
setIsAuthRequired(false);
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
api
|
|
||||||
.get(endpoints.LETTERS)
|
|
||||||
.then((res) => decryptLettersMetadata(res.data, masterKey))
|
|
||||||
.then((decrypted) => {
|
|
||||||
setLetters(
|
|
||||||
decrypted.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.updated_at).getTime() -
|
|
||||||
new Date(a.updated_at).getTime(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [masterKey]);
|
|
||||||
|
|
||||||
const drawerItems = useMemo(() => {
|
|
||||||
return {
|
|
||||||
drafts: letters.filter((l) => l.status === "DRAFT"),
|
|
||||||
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
|
|
||||||
vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"),
|
|
||||||
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
|
||||||
};
|
|
||||||
}, [letters]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
setIsAuthRequired(false);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.get(endpoints.LETTERS)
|
||||||
|
.then((res) => decryptLettersMetadata(res.data, masterKey))
|
||||||
|
.then((decrypted) => {
|
||||||
|
setLetters(
|
||||||
|
decrypted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updated_at).getTime() -
|
||||||
|
new Date(a.updated_at).getTime(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [masterKey]);
|
||||||
|
|
||||||
|
const drawerItems = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
...drawerItems,
|
drafts: letters.filter((l) => l.status === "DRAFT"),
|
||||||
loading,
|
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
|
||||||
isAuthRequired,
|
vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"),
|
||||||
|
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
||||||
};
|
};
|
||||||
|
}, [letters]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...drawerItems,
|
||||||
|
loading,
|
||||||
|
isAuthRequired,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,99 +7,99 @@ import { endpoints, replacePathParams } from "../config/endpoints";
|
|||||||
import { ROUTES } from "../config/routes";
|
import { ROUTES } from "../config/routes";
|
||||||
|
|
||||||
export default function Activate() {
|
export default function Activate() {
|
||||||
const { uidb64, token } = useParams();
|
const { uidb64, token } = useParams();
|
||||||
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
||||||
"loading",
|
"loading",
|
||||||
);
|
);
|
||||||
const hasCalled = useRef(false);
|
const hasCalled = useRef(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(uidb64 && token) || hasCalled.current) return;
|
if (!(uidb64 && token) || hasCalled.current) return;
|
||||||
hasCalled.current = true;
|
hasCalled.current = true;
|
||||||
|
|
||||||
const activateAccount = async () => {
|
const activateAccount = async () => {
|
||||||
try {
|
try {
|
||||||
const url = replacePathParams(endpoints.ACTIVATE, {
|
const url = replacePathParams(endpoints.ACTIVATE, {
|
||||||
uidb64,
|
uidb64,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
await publicApi.get(url);
|
await publicApi.get(url);
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
activateAccount();
|
activateAccount();
|
||||||
}, [uidb64, token]);
|
}, [uidb64, token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom">
|
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom">
|
||||||
{status === "loading" && (
|
{status === "loading" && (
|
||||||
<div className="flex flex-col items-center gap-4 py-8">
|
<div className="flex flex-col items-center gap-4 py-8">
|
||||||
<span className="loading loading-spinner loading-lg text-primary" />
|
<span className="loading loading-spinner loading-lg text-primary" />
|
||||||
<p className="text-sm opacity-70">Activating your account...</p>
|
<p className="text-sm opacity-70">Activating your account...</p>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "success" && (
|
|
||||||
<div className="flex flex-col items-center gap-6 duration-500">
|
|
||||||
<div className="bg-success/10 p-4 rounded-full">
|
|
||||||
<CheckCircleIcon
|
|
||||||
size={64}
|
|
||||||
weight="duotone"
|
|
||||||
className="text-success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2
|
|
||||||
data-testid="activation-success-header"
|
|
||||||
className="font-display text-xl text-success"
|
|
||||||
>
|
|
||||||
You're in.
|
|
||||||
</h2>
|
|
||||||
<p className="opacity-70 leading-relaxed">
|
|
||||||
Welcome to <Logo scale={1} />
|
|
||||||
<br />
|
|
||||||
Just one more step and you can start writing timeless letters.
|
|
||||||
</p>
|
|
||||||
<div className="divider opacity-10 my-0"></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="start-writing-btn"
|
|
||||||
className="btn btn-primary w-full shadow-lg"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(ROUTES.LOGIN, {
|
|
||||||
state: { firstTime: true },
|
|
||||||
replace: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
I'm ready
|
|
||||||
</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 leading-relaxed">
|
|
||||||
The link might be expired or already used. Please try registering
|
|
||||||
again.
|
|
||||||
</p>
|
|
||||||
<div className="divider opacity-10 my-0"></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost w-full"
|
|
||||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
|
||||||
>
|
|
||||||
Register Again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
|
|
||||||
|
{status === "success" && (
|
||||||
|
<div className="flex flex-col items-center gap-6 duration-500">
|
||||||
|
<div className="bg-success/10 p-4 rounded-full">
|
||||||
|
<CheckCircleIcon
|
||||||
|
size={64}
|
||||||
|
weight="duotone"
|
||||||
|
className="text-success"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
data-testid="activation-success-header"
|
||||||
|
className="font-display text-xl text-success"
|
||||||
|
>
|
||||||
|
You're in.
|
||||||
|
</h2>
|
||||||
|
<p className="opacity-70 leading-relaxed">
|
||||||
|
Welcome to <Logo scale={1} />
|
||||||
|
<br />
|
||||||
|
Just one more step and you can start writing timeless letters.
|
||||||
|
</p>
|
||||||
|
<div className="divider opacity-10 my-0"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="start-writing-btn"
|
||||||
|
className="btn btn-primary w-full shadow-lg"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(ROUTES.LOGIN, {
|
||||||
|
state: { firstTime: true },
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
I'm ready
|
||||||
|
</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 leading-relaxed">
|
||||||
|
The link might be expired or already used. Please try registering
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
<div className="divider opacity-10 my-0"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost w-full"
|
||||||
|
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||||
|
>
|
||||||
|
Register Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+465
-461
@@ -1,27 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
DownloadSimpleIcon,
|
DownloadSimpleIcon,
|
||||||
SpinnerGapIcon,
|
SpinnerGapIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
type NavigateFunction,
|
type NavigateFunction,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import type { LetterResponseData } from "../api/response";
|
import type { LetterResponseData } from "../api/response";
|
||||||
import {
|
import {
|
||||||
type CanvasStyle,
|
type CanvasStyle,
|
||||||
type CanvasTools,
|
type CanvasTools,
|
||||||
ComposeCanvas,
|
ComposeCanvas,
|
||||||
} from "../components/editor/ComposeCanvas";
|
} from "../components/editor/ComposeCanvas";
|
||||||
import { PostSealModal } from "../components/editor/PostSealModal";
|
import { PostSealModal } from "../components/editor/PostSealModal";
|
||||||
import {
|
import {
|
||||||
LetterHead,
|
LetterHead,
|
||||||
ToolBar,
|
ToolBar,
|
||||||
VaultConfirmModal,
|
VaultConfirmModal,
|
||||||
} from "../components/editor/ToolBar";
|
} from "../components/editor/ToolBar";
|
||||||
import DateDisplay from "../components/ui/DateDisplay";
|
import DateDisplay from "../components/ui/DateDisplay";
|
||||||
import { LogModal } from "../components/ui/LogModal";
|
import { LogModal } from "../components/ui/LogModal";
|
||||||
@@ -42,482 +42,486 @@ const ERROR_VISIBLE_MS = 2400;
|
|||||||
const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000;
|
const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000;
|
||||||
|
|
||||||
const toPlaceholderList = [
|
const toPlaceholderList = [
|
||||||
"Someone dear...",
|
"Someone dear...",
|
||||||
"Somewhere near...",
|
"Somewhere near...",
|
||||||
"Something to bear...",
|
"Something to bear...",
|
||||||
];
|
];
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||||
navigateRef.current = navigate;
|
navigateRef.current = navigate;
|
||||||
|
|
||||||
const { public_id } = useParams();
|
const { public_id } = useParams();
|
||||||
const letterIdRef = useRef<string>(public_id ?? "");
|
const letterIdRef = useRef<string>(public_id ?? "");
|
||||||
const justSavedRef = useRef<boolean>(false);
|
const justSavedRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const [decryptionStatus, setDecryptionStatus] = useState<{
|
const [decryptionStatus, setDecryptionStatus] = useState<{
|
||||||
status: "SUCCESS" | "WARN" | "ERROR" | "RESET";
|
status: "SUCCESS" | "WARN" | "ERROR" | "RESET";
|
||||||
message: string;
|
message: string;
|
||||||
log: string;
|
log: string;
|
||||||
}>({ status: "RESET", message: "", log: "" });
|
}>({ status: "RESET", message: "", log: "" });
|
||||||
|
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||||
const [sealedTargetId, setSealedTargetId] = useState<string | null>(null);
|
const [sealedTargetId, setSealedTargetId] = useState<string | null>(null);
|
||||||
const [lastSaved, setLastSaved] = useState<string | null>(null);
|
const [lastSaved, setLastSaved] = useState<string | null>(null);
|
||||||
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
|
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
|
||||||
"DRAFT",
|
"DRAFT",
|
||||||
);
|
);
|
||||||
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
|
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
|
||||||
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
|
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
|
||||||
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
|
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
|
||||||
|
|
||||||
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("IDLE");
|
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("IDLE");
|
||||||
const [logStatus, setLogStatus] = useState<{
|
const [logStatus, setLogStatus] = useState<{
|
||||||
status: "WARN" | "ERROR" | "RESET";
|
status: "WARN" | "ERROR" | "RESET";
|
||||||
message: string;
|
message: string;
|
||||||
}>({
|
}>({
|
||||||
status: "RESET",
|
status: "RESET",
|
||||||
message: "",
|
message: "",
|
||||||
|
});
|
||||||
|
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
|
||||||
|
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [recipient, setRecipient] = useState("");
|
||||||
|
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
|
||||||
|
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||||
|
const [canvasFontStyle, setCanvasFontStyle] = useState<CanvasStyle>({
|
||||||
|
fontColor: "",
|
||||||
|
fontFamily: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
|
const canvasRef = useRef<CanvasTools>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// to continuously rotate placeholder text of the recipient input
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// to load existing letter when public_id param and masterKey is available
|
||||||
|
// NOTE: this has to trigger just once after each save
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(public_id && masterKey)) return;
|
||||||
|
if (justSavedRef.current) {
|
||||||
|
justSavedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decryptAndLoadLetter = async (
|
||||||
|
letterData: LetterResponseData,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
) => {
|
||||||
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
const metadata = await cryptoUtils.decryptMetadata(
|
||||||
|
{
|
||||||
|
encrypted_content: letterData.encrypted_metadata,
|
||||||
|
encrypted_dek: letterData.encrypted_dek,
|
||||||
|
},
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
setRecipient(metadata.recipient || "");
|
||||||
|
|
||||||
|
const decryptedJsonStr = await cryptoUtils.decryptLetter(
|
||||||
|
{
|
||||||
|
encrypted_content: letterData.encrypted_content,
|
||||||
|
encrypted_dek: letterData.encrypted_dek,
|
||||||
|
},
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
const canvasData = JSON.parse(decryptedJsonStr);
|
||||||
|
|
||||||
|
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
|
||||||
|
await decryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
|
letterData.images ?? [],
|
||||||
|
letterData.encrypted_dek,
|
||||||
|
masterKey,
|
||||||
|
cryptoUtils,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPartialFailure) {
|
||||||
|
setDecryptionStatus({
|
||||||
|
status: "WARN",
|
||||||
|
message: "Failed to decrypt some elements. Please check the render.",
|
||||||
|
log: errors.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvasRef.current) {
|
||||||
|
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadExistingLetter = async () => {
|
||||||
|
setIsInitialLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||||
|
const letterData = res.data;
|
||||||
|
|
||||||
|
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
|
||||||
|
setLetterStatus(letterData.status);
|
||||||
|
|
||||||
|
if (letterData.status === "SEALED") {
|
||||||
|
navigateRef.current(PATHS.read(public_id), { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (letterData.encrypted_dek && masterKey) {
|
||||||
|
await decryptAndLoadLetter(letterData, masterKey);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setDecryptionStatus({
|
||||||
|
status: "ERROR",
|
||||||
|
message: "Failed to decrypt letter. Please try again later.",
|
||||||
|
log: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsInitialLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadExistingLetter().then((_) => {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
setCanvasFontStyle(canvasRef.current.getStyle());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
|
}, [public_id, masterKey]);
|
||||||
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
|
|
||||||
null,
|
// to trigger short pulse animation for Last Saved AT element
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastSavedPulseTick === 0) return;
|
||||||
|
setIsSaveDatePulsing(true);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsSaveDatePulsing(false);
|
||||||
|
}, STOP_SAVE_DATE_PULSE_AFTER_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [lastSavedPulseTick]);
|
||||||
|
|
||||||
|
// to fade in and fade out the save status overlay after each save operation
|
||||||
|
// Note: otherwise the fade efect is abrupt due to component's immediate unmount
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveOverlay === "IDLE" || saveOverlay === "SAVING") return;
|
||||||
|
const visibleTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
setShowSaveOverlay(false);
|
||||||
|
},
|
||||||
|
saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
|
||||||
|
);
|
||||||
|
const unmountTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
setSaveOverlay("IDLE");
|
||||||
|
},
|
||||||
|
(saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
|
||||||
|
OVERLAY_FADE_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [recipient, setRecipient] = useState("");
|
return () => {
|
||||||
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
|
clearTimeout(visibleTimer);
|
||||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
clearTimeout(unmountTimer);
|
||||||
const [canvasFontStyle, setCanvasFontStyle] = useState<CanvasStyle>({
|
};
|
||||||
fontColor: "",
|
}, [saveOverlay]);
|
||||||
fontFamily: "",
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file && file.size < MAX_FILE_SIZE) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
canvasRef.current?.addImage(url, file);
|
||||||
|
} else {
|
||||||
|
setLogStatus({
|
||||||
|
status: "WARN",
|
||||||
|
message: "Please upload images with size less than 10MB.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequestData = async (
|
||||||
|
targetId: string,
|
||||||
|
status: string,
|
||||||
|
vaultDate?: Date,
|
||||||
|
): Promise<FormData> => {
|
||||||
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
await cryptoUtils.initialize();
|
||||||
|
|
||||||
|
const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
|
||||||
|
const canvasImages = canvasRef.current?.getImages() || [];
|
||||||
|
|
||||||
|
const { encryptedImageFiles, encryptedCanvasData } =
|
||||||
|
await encryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
|
canvasImages,
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
||||||
|
masterKey!,
|
||||||
|
cryptoUtils,
|
||||||
|
);
|
||||||
|
|
||||||
|
const encrypted_letter = await cryptoUtils.encryptLetter(
|
||||||
|
JSON.stringify(encryptedCanvasData),
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
||||||
|
masterKey!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
||||||
|
{ recipient, tags: [] },
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
||||||
|
masterKey!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
if (status === "VAULT") {
|
||||||
|
const finalDate = vaultDate || unlockDate;
|
||||||
|
formData.append("type", "VAULT");
|
||||||
|
if (finalDate) formData.append("unlock_at", finalDate.toISOString());
|
||||||
|
formData.append("status", "SEALED");
|
||||||
|
} else {
|
||||||
|
formData.append("type", "KEPT");
|
||||||
|
formData.append("status", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append("public_id", targetId);
|
||||||
|
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
||||||
|
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
||||||
|
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
|
||||||
|
|
||||||
|
encryptedImageFiles.forEach((blob, filename) => {
|
||||||
|
formData.append("image_files", blob, filename);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { masterKey } = useKeyStore();
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
const canvasRef = useRef<CanvasTools>(null);
|
const handleSave = async (
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
status: "SEALED" | "DRAFT" | "VAULT",
|
||||||
|
vaultDate?: Date,
|
||||||
|
): Promise<void> => {
|
||||||
|
setSealBtnClicked(false);
|
||||||
|
// use the letter's id if an existing letter or create a new id
|
||||||
|
const targetId = public_id || letterIdRef.current || crypto.randomUUID();
|
||||||
|
|
||||||
// to continuously rotate placeholder text of the recipient input
|
if (saveOverlay === "SAVING" || !masterKey) return;
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
|
|
||||||
}, 4000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
setSaveOverlay("SAVING");
|
||||||
}, []);
|
setShowSaveOverlay(true);
|
||||||
|
|
||||||
// to load existing letter when public_id param and masterKey is available
|
try {
|
||||||
// NOTE: this has to trigger just once after each save
|
const formData = await getRequestData(targetId, status, vaultDate);
|
||||||
useEffect(() => {
|
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
|
||||||
if (!(public_id && masterKey)) return;
|
|
||||||
if (justSavedRef.current) {
|
|
||||||
justSavedRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const decryptAndLoadLetter = async (
|
|
||||||
letterData: LetterResponseData,
|
|
||||||
masterKey: CryptoKey,
|
|
||||||
) => {
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
|
||||||
const metadata = await cryptoUtils.decryptMetadata(
|
|
||||||
{
|
|
||||||
encrypted_content: letterData.encrypted_metadata,
|
|
||||||
encrypted_dek: letterData.encrypted_dek,
|
|
||||||
},
|
|
||||||
masterKey,
|
|
||||||
);
|
|
||||||
setRecipient(metadata.recipient || "");
|
|
||||||
|
|
||||||
const decryptedJsonStr = await cryptoUtils.decryptLetter(
|
justSavedRef.current = true;
|
||||||
{
|
if (!public_id) {
|
||||||
encrypted_content: letterData.encrypted_content,
|
letterIdRef.current = targetId;
|
||||||
encrypted_dek: letterData.encrypted_dek,
|
navigate(PATHS.write(targetId), { replace: true });
|
||||||
},
|
}
|
||||||
masterKey,
|
|
||||||
);
|
|
||||||
const canvasData = JSON.parse(decryptedJsonStr);
|
|
||||||
|
|
||||||
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
|
setLastSaved(formatRelativeDate(new Date()));
|
||||||
await decryptCanvasImages(
|
setLetterStatus(status);
|
||||||
canvasData,
|
setLastSavedPulseTick((prev) => prev + 1);
|
||||||
letterData.images ?? [],
|
|
||||||
letterData.encrypted_dek,
|
|
||||||
masterKey,
|
|
||||||
cryptoUtils,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isPartialFailure) {
|
if (status === "SEALED" || status === "VAULT") {
|
||||||
setDecryptionStatus({
|
setSealedTargetId(targetId);
|
||||||
status: "WARN",
|
}
|
||||||
message: "Failed to decrypt some elements. Please check the render.",
|
setSaveOverlay("SAVED");
|
||||||
log: errors.toString(),
|
setShowSaveOverlay(true);
|
||||||
});
|
} catch {
|
||||||
}
|
setSaveOverlay("ERROR");
|
||||||
|
setShowSaveOverlay(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (canvasRef.current) {
|
return (
|
||||||
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
|
<>
|
||||||
}
|
<Navbar
|
||||||
};
|
child={
|
||||||
|
<div
|
||||||
const loadExistingLetter = async () => {
|
className={`flex items-center gap-2 ${
|
||||||
setIsInitialLoading(true);
|
isSaveDatePulsing ? "animate-pulse" : ""
|
||||||
try {
|
}`}
|
||||||
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
>
|
||||||
const letterData = res.data;
|
<div className="text-xxs text-neutral-content/30 flex-col justify-end leading-none text-right">
|
||||||
|
<span className="uppercase tracking-widest font-bold">
|
||||||
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
|
Last Save
|
||||||
setLetterStatus(letterData.status);
|
</span>
|
||||||
|
<br />
|
||||||
if (letterData.status === "SEALED") {
|
<span className="italic">{lastSaved}</span>
|
||||||
navigateRef.current(PATHS.read(public_id), { replace: true });
|
</div>
|
||||||
return;
|
<ClockIcon
|
||||||
}
|
size={16}
|
||||||
|
weight="bold"
|
||||||
if (letterData.encrypted_dek && masterKey) {
|
className="text-neutral-content/30"
|
||||||
await decryptAndLoadLetter(letterData, masterKey);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setDecryptionStatus({
|
|
||||||
status: "ERROR",
|
|
||||||
message: "Failed to decrypt letter. Please try again later.",
|
|
||||||
log: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsInitialLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadExistingLetter().then((_) => {
|
|
||||||
if (canvasRef.current) {
|
|
||||||
setCanvasFontStyle(canvasRef.current.getStyle());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [public_id, masterKey]);
|
|
||||||
|
|
||||||
// to trigger short pulse animation for Last Saved AT element
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastSavedPulseTick === 0) return;
|
|
||||||
setIsSaveDatePulsing(true);
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsSaveDatePulsing(false);
|
|
||||||
}, STOP_SAVE_DATE_PULSE_AFTER_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [lastSavedPulseTick]);
|
|
||||||
|
|
||||||
// to fade in and fade out the save status overlay after each save operation
|
|
||||||
// Note: otherwise the fade efect is abrupt due to component's immediate unmount
|
|
||||||
useEffect(() => {
|
|
||||||
if (saveOverlay === "IDLE" || saveOverlay === "SAVING") return;
|
|
||||||
const visibleTimer = setTimeout(
|
|
||||||
() => {
|
|
||||||
setShowSaveOverlay(false);
|
|
||||||
},
|
|
||||||
saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
|
|
||||||
);
|
|
||||||
const unmountTimer = setTimeout(
|
|
||||||
() => {
|
|
||||||
setSaveOverlay("IDLE");
|
|
||||||
},
|
|
||||||
(saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
|
|
||||||
OVERLAY_FADE_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(visibleTimer);
|
|
||||||
clearTimeout(unmountTimer);
|
|
||||||
};
|
|
||||||
}, [saveOverlay]);
|
|
||||||
|
|
||||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file && file.size < MAX_FILE_SIZE) {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
canvasRef.current?.addImage(url, file);
|
|
||||||
} else {
|
|
||||||
setLogStatus({
|
|
||||||
status: "WARN",
|
|
||||||
message: "Please upload images with size less than 10MB.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRequestData = async (
|
|
||||||
targetId: string,
|
|
||||||
status: string,
|
|
||||||
vaultDate?: Date,
|
|
||||||
): Promise<FormData> => {
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
|
||||||
await cryptoUtils.initialize();
|
|
||||||
|
|
||||||
const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
|
|
||||||
const canvasImages = canvasRef.current?.getImages() || [];
|
|
||||||
|
|
||||||
const { encryptedImageFiles, encryptedCanvasData } =
|
|
||||||
await encryptCanvasImages(
|
|
||||||
canvasData,
|
|
||||||
canvasImages,
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
|
||||||
masterKey!,
|
|
||||||
cryptoUtils,
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted_letter = await cryptoUtils.encryptLetter(
|
|
||||||
JSON.stringify(encryptedCanvasData),
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
|
||||||
masterKey!,
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
|
||||||
{ recipient, tags: [] },
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
|
||||||
masterKey!,
|
|
||||||
);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
if (status === "VAULT") {
|
|
||||||
const finalDate = vaultDate || unlockDate;
|
|
||||||
formData.append("type", "VAULT");
|
|
||||||
if (finalDate) formData.append("unlock_at", finalDate.toISOString());
|
|
||||||
formData.append("status", "SEALED");
|
|
||||||
} else {
|
|
||||||
formData.append("type", "KEPT");
|
|
||||||
formData.append("status", status);
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append("public_id", targetId);
|
|
||||||
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
|
||||||
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
|
||||||
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
|
|
||||||
|
|
||||||
encryptedImageFiles.forEach((blob, filename) => {
|
|
||||||
formData.append("image_files", blob, filename);
|
|
||||||
});
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (
|
|
||||||
status: "SEALED" | "DRAFT" | "VAULT",
|
|
||||||
vaultDate?: Date,
|
|
||||||
): Promise<void> => {
|
|
||||||
setSealBtnClicked(false);
|
|
||||||
// use the letter's id if an existing letter or create a new id
|
|
||||||
const targetId = public_id || letterIdRef.current || crypto.randomUUID();
|
|
||||||
|
|
||||||
if (saveOverlay === "SAVING" || !masterKey) return;
|
|
||||||
|
|
||||||
setSaveOverlay("SAVING");
|
|
||||||
setShowSaveOverlay(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = await getRequestData(targetId, status, vaultDate);
|
|
||||||
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
|
|
||||||
|
|
||||||
justSavedRef.current = true;
|
|
||||||
if (!public_id) {
|
|
||||||
letterIdRef.current = targetId;
|
|
||||||
navigate(PATHS.write(targetId), { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastSaved(formatRelativeDate(new Date()));
|
|
||||||
setLetterStatus(status);
|
|
||||||
setLastSavedPulseTick((prev) => prev + 1);
|
|
||||||
|
|
||||||
if (status === "SEALED" || status === "VAULT") {
|
|
||||||
setSealedTargetId(targetId);
|
|
||||||
}
|
|
||||||
setSaveOverlay("SAVED");
|
|
||||||
setShowSaveOverlay(true);
|
|
||||||
} catch {
|
|
||||||
setSaveOverlay("ERROR");
|
|
||||||
setShowSaveOverlay(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navbar
|
|
||||||
child={
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 ${isSaveDatePulsing ? "animate-pulse" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-xxs text-neutral-content/30 flex-col justify-end leading-none text-right">
|
|
||||||
<span className="uppercase tracking-widest font-bold">
|
|
||||||
Last Save
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<span className="italic">{lastSaved}</span>
|
|
||||||
</div>
|
|
||||||
<ClockIcon
|
|
||||||
size={16}
|
|
||||||
weight="bold"
|
|
||||||
className="text-neutral-content/30"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-12 bg-base-300 relative">
|
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-12 bg-base-300 relative">
|
||||||
<LogModal
|
<LogModal
|
||||||
status={decryptionStatus.status}
|
status={decryptionStatus.status}
|
||||||
message={decryptionStatus.message}
|
message={decryptionStatus.message}
|
||||||
log={decryptionStatus.log}
|
log={decryptionStatus.log}
|
||||||
onClose={() =>
|
onClose={() =>
|
||||||
setDecryptionStatus({ status: "RESET", message: "", log: "" })
|
setDecryptionStatus({ status: "RESET", message: "", log: "" })
|
||||||
}
|
}
|
||||||
isOpen={decryptionStatus.status !== "RESET"}
|
isOpen={decryptionStatus.status !== "RESET"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isInitialLoading && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<SpinnerGapIcon
|
||||||
|
size={48}
|
||||||
|
weight="bold"
|
||||||
|
className="animate-spin text-primary"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
data-testid="opening-draft-overlay"
|
||||||
|
className="text-xxs uppercase tracking-widester font-bold text-base-content/40"
|
||||||
|
>
|
||||||
|
Opening your draft...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveOverlay !== "IDLE" && (
|
||||||
|
<Modal isOpen={showSaveOverlay}>
|
||||||
|
{saveOverlay === "SAVING" && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
|
||||||
|
showSaveOverlay
|
||||||
|
? "opacity-100 scale-100 translate-y-0"
|
||||||
|
: "opacity-0 scale-95 translate-y-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SpinnerGapIcon
|
||||||
|
size={18}
|
||||||
|
weight="bold"
|
||||||
|
className="animate-spin"
|
||||||
/>
|
/>
|
||||||
|
<span className="font-bold">Securing your letter...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isInitialLoading && (
|
{saveOverlay === "SAVED" && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm">
|
<div
|
||||||
<div className="flex flex-col items-center gap-4">
|
role="alert"
|
||||||
<SpinnerGapIcon
|
data-testid="save-success-toast"
|
||||||
size={48}
|
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
|
||||||
weight="bold"
|
showSaveOverlay
|
||||||
className="animate-spin text-primary"
|
? "opacity-100 scale-100 translate-y-0"
|
||||||
/>
|
: "opacity-0 scale-95 translate-y-1"
|
||||||
<p
|
}`}
|
||||||
data-testid="opening-draft-overlay"
|
>
|
||||||
className="text-xxs uppercase tracking-widester font-bold text-base-content/40"
|
<DownloadSimpleIcon size={18} weight="bold" />
|
||||||
>
|
<span className="font-bold">Your letter is saved!</span>
|
||||||
Opening your draft...
|
</div>
|
||||||
</p>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{saveOverlay !== "IDLE" && (
|
{saveOverlay === "ERROR" && (
|
||||||
<Modal isOpen={showSaveOverlay}>
|
<div
|
||||||
{saveOverlay === "SAVING" && (
|
role="alert"
|
||||||
<div
|
className={`alert alert-error shadow-lg transition-all duration-300 ${
|
||||||
role="alert"
|
showSaveOverlay
|
||||||
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${showSaveOverlay
|
? "opacity-100 scale-100 translate-y-0"
|
||||||
? "opacity-100 scale-100 translate-y-0"
|
: "opacity-0 scale-95 translate-y-1"
|
||||||
: "opacity-0 scale-95 translate-y-1"
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<XIcon size={18} weight="bold" />
|
||||||
<SpinnerGapIcon
|
<span className="font-bold">Failed to save letter</span>
|
||||||
size={18}
|
</div>
|
||||||
weight="bold"
|
)}
|
||||||
className="animate-spin"
|
</Modal>
|
||||||
/>
|
)}
|
||||||
<span className="font-bold">Securing your letter...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{saveOverlay === "SAVED" && (
|
{confirmModal === "VAULT" && (
|
||||||
<div
|
<VaultConfirmModal
|
||||||
role="alert"
|
onSave={handleSave}
|
||||||
data-testid="save-success-toast"
|
setConfirmModal={setConfirmModal}
|
||||||
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${showSaveOverlay
|
setUnlockDate={setUnlockDate}
|
||||||
? "opacity-100 scale-100 translate-y-0"
|
/>
|
||||||
: "opacity-0 scale-95 translate-y-1"
|
)}
|
||||||
}`}
|
{sealedTargetId && (
|
||||||
>
|
<PostSealModal
|
||||||
<DownloadSimpleIcon size={18} weight="bold" />
|
sealedTargetId={sealedTargetId}
|
||||||
<span className="font-bold">Your letter is saved!</span>
|
navigate={navigate}
|
||||||
</div>
|
type={status === "VAULT" ? "VAULT" : "KEPT"}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{saveOverlay === "ERROR" && (
|
<div className="max-w-180 mx-auto px-1 md:px-0">
|
||||||
<div
|
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
|
||||||
role="alert"
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
className={`alert alert-error shadow-lg transition-all duration-300 ${showSaveOverlay
|
<label
|
||||||
? "opacity-100 scale-100 translate-y-0"
|
htmlFor="recipient"
|
||||||
: "opacity-0 scale-95 translate-y-1"
|
className="text-xxs uppercase tracking-widester text-secondary-content font-bold"
|
||||||
}`}
|
>
|
||||||
>
|
Recipient
|
||||||
<XIcon size={18} weight="bold" />
|
</label>
|
||||||
<span className="font-bold">Failed to save letter</span>
|
<input
|
||||||
</div>
|
id="recipient"
|
||||||
)}
|
data-testid="recipient-input"
|
||||||
</Modal>
|
type="text"
|
||||||
)}
|
placeholder={toPlaceholderList[placeholderIndex]}
|
||||||
|
value={recipient}
|
||||||
|
disabled={status !== "DRAFT"}
|
||||||
|
onChange={(e) => setRecipient(e.target.value)}
|
||||||
|
className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DateDisplay />
|
||||||
|
</div>
|
||||||
|
|
||||||
{confirmModal === "VAULT" && (
|
{status === "DRAFT" ? (
|
||||||
<VaultConfirmModal
|
<ToolBar
|
||||||
onSave={handleSave}
|
onAddImage={() => fileInputRef.current?.click()}
|
||||||
setConfirmModal={setConfirmModal}
|
sealBtnClicked={sealBtnClicked}
|
||||||
setUnlockDate={setUnlockDate}
|
setSealBtnClicked={setSealBtnClicked}
|
||||||
/>
|
onSave={handleSave}
|
||||||
)}
|
setConfirmModal={setConfirmModal}
|
||||||
{sealedTargetId && (
|
onFontChange={setCanvasFontStyle}
|
||||||
<PostSealModal
|
latestFontStyle={canvasFontStyle}
|
||||||
sealedTargetId={sealedTargetId}
|
|
||||||
navigate={navigate}
|
|
||||||
type={status === "VAULT" ? "VAULT" : "KEPT"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="max-w-180 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-xxs uppercase tracking-widester text-secondary-content font-bold"
|
|
||||||
>
|
|
||||||
Recipient
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="recipient"
|
|
||||||
data-testid="recipient-input"
|
|
||||||
type="text"
|
|
||||||
placeholder={toPlaceholderList[placeholderIndex]}
|
|
||||||
value={recipient}
|
|
||||||
disabled={status !== "DRAFT"}
|
|
||||||
onChange={(e) => setRecipient(e.target.value)}
|
|
||||||
className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DateDisplay />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status === "DRAFT" ? (
|
|
||||||
<ToolBar
|
|
||||||
onAddImage={() => fileInputRef.current?.click()}
|
|
||||||
sealBtnClicked={sealBtnClicked}
|
|
||||||
setSealBtnClicked={setSealBtnClicked}
|
|
||||||
onSave={handleSave}
|
|
||||||
setConfirmModal={setConfirmModal}
|
|
||||||
onFontChange={setCanvasFontStyle}
|
|
||||||
latestFontStyle={canvasFontStyle}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LetterHead />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ComposeCanvas
|
|
||||||
ref={canvasRef}
|
|
||||||
readOnly={status !== "DRAFT"}
|
|
||||||
style={canvasFontStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<LogModal
|
|
||||||
status={logStatus.status}
|
|
||||||
message={logStatus.message}
|
|
||||||
log={""}
|
|
||||||
onClose={() =>
|
|
||||||
setLogStatus({
|
|
||||||
status: "RESET",
|
|
||||||
message: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
isOpen={logStatus.status !== "RESET"}
|
|
||||||
/>
|
/>
|
||||||
</>
|
) : (
|
||||||
);
|
<LetterHead />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComposeCanvas
|
||||||
|
ref={canvasRef}
|
||||||
|
readOnly={status !== "DRAFT"}
|
||||||
|
style={canvasFontStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<LogModal
|
||||||
|
status={logStatus.status}
|
||||||
|
message={logStatus.message}
|
||||||
|
log={""}
|
||||||
|
onClose={() =>
|
||||||
|
setLogStatus({
|
||||||
|
status: "RESET",
|
||||||
|
message: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isOpen={logStatus.status !== "RESET"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+320
-319
@@ -2,17 +2,17 @@ import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
|||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
type NavigateFunction,
|
type NavigateFunction,
|
||||||
useLocation,
|
useLocation,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import type { LetterImageData, LetterResponseData } from "../api/response";
|
import type { LetterImageData, LetterResponseData } from "../api/response";
|
||||||
import {
|
import {
|
||||||
type CanvasJSON,
|
type CanvasJSON,
|
||||||
type CanvasTools,
|
type CanvasTools,
|
||||||
ComposeCanvas,
|
ComposeCanvas,
|
||||||
} from "../components/editor/ComposeCanvas";
|
} from "../components/editor/ComposeCanvas";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import { BurnModal } from "../components/reader/BurnModal";
|
import { BurnModal } from "../components/reader/BurnModal";
|
||||||
@@ -26,342 +26,343 @@ import { useKeyStore } from "../store/useKeyStore";
|
|||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
import { formatDate } from "../utils/dateFormat";
|
import { formatDate } from "../utils/dateFormat";
|
||||||
import {
|
import {
|
||||||
decryptCanvasImages,
|
decryptCanvasImages,
|
||||||
decryptCanvasImagesWithSharingKey,
|
decryptCanvasImagesWithSharingKey,
|
||||||
} from "../utils/letterLogic";
|
} from "../utils/letterLogic";
|
||||||
|
|
||||||
interface LetterMetadata {
|
interface LetterMetadata {
|
||||||
recipient?: string;
|
recipient?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WAIT_FOR_BURN_MS = 18000;
|
const WAIT_FOR_BURN_MS = 18000;
|
||||||
export default function Reader() {
|
export default function Reader() {
|
||||||
const { public_id } = useParams();
|
const { public_id } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const sharingKey = location.hash.replace("#", "");
|
const sharingKey = location.hash.replace("#", "");
|
||||||
|
|
||||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||||
const canvasRef = useRef<CanvasTools>(null);
|
const canvasRef = useRef<CanvasTools>(null);
|
||||||
|
|
||||||
const [isDecrypting, setIsDecrypting] = useState(true);
|
const [isDecrypting, setIsDecrypting] = useState(true);
|
||||||
const [revealState, setRevealState] = useState<
|
const [revealState, setRevealState] = useState<
|
||||||
"SEALED" | "REVEALED" | "BURNED" | "BURNING"
|
"SEALED" | "REVEALED" | "BURNED" | "BURNING"
|
||||||
>("SEALED");
|
>("SEALED");
|
||||||
const [logTrace, setLogTrace] = useState<{
|
const [logTrace, setLogTrace] = useState<{
|
||||||
type: "WARN" | "ERROR";
|
type: "WARN" | "ERROR";
|
||||||
message: string;
|
message: string;
|
||||||
log: string;
|
log: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [metadata, setMetadata] = useState<LetterMetadata | null>(null);
|
const [metadata, setMetadata] = useState<LetterMetadata | null>(null);
|
||||||
const [decryptedCanvasData, setDecryptedCanvasData] =
|
const [decryptedCanvasData, setDecryptedCanvasData] =
|
||||||
useState<CanvasJSON | null>(null);
|
useState<CanvasJSON | null>(null);
|
||||||
const [showBurnModal, setShowBurnModal] = useState(false);
|
const [showBurnModal, setShowBurnModal] = useState(false);
|
||||||
const [isBurning, setIsBurning] = useState(false);
|
const [isBurning, setIsBurning] = useState(false);
|
||||||
const [ignite, setIgnite] = useState(false);
|
const [ignite, setIgnite] = useState(false);
|
||||||
const [encryptedDek, setEncryptedDek] = useState<string | null>(null);
|
const [encryptedDek, setEncryptedDek] = useState<string | null>(null);
|
||||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||||
|
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
const isAuthor = !!masterKey && !sharingKey;
|
const isAuthor = !!masterKey && !sharingKey;
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
if (!(encryptedDek && masterKey && public_id)) return;
|
||||||
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
||||||
|
try {
|
||||||
|
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
|
||||||
|
} catch {
|
||||||
|
// shouldn't obstruct share if api operation fails (since it's client side share)
|
||||||
|
} finally {
|
||||||
|
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const burnLetter = async () => {
|
||||||
|
if (!public_id || isBurning) return;
|
||||||
|
setIsBurning(true);
|
||||||
|
try {
|
||||||
|
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
||||||
|
status: "BURNED",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// should not obstruct burn if api operation fails
|
||||||
|
// WHY?: it disconnects the UX. if you want to burn the letter, you should be able to burn the letter
|
||||||
|
// TODO: maybe say something like: "the wind is strong today, let's try again"? or maybe something less stupid :3
|
||||||
|
} finally {
|
||||||
|
setIsBurning(false);
|
||||||
|
setShowBurnModal(false);
|
||||||
|
setIgnite(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setRevealState("BURNED");
|
||||||
|
}, WAIT_FOR_BURN_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(sharingKey || masterKey)) {
|
||||||
|
navigateRef.current("/login", {
|
||||||
|
state: { redirectUrl: `/read/${public_id}` },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptImages = async (
|
||||||
|
canvasData: CanvasJSON,
|
||||||
|
images: LetterImageData[],
|
||||||
|
encrypted_dek: string,
|
||||||
|
cryptoUtils: CryptoUtils,
|
||||||
|
) => {
|
||||||
|
if (!images?.length) return;
|
||||||
|
const isShared = !!sharingKey;
|
||||||
|
try {
|
||||||
|
if (isShared) {
|
||||||
|
await decryptCanvasImagesWithSharingKey(
|
||||||
|
canvasData,
|
||||||
|
images,
|
||||||
|
sharingKey,
|
||||||
|
cryptoUtils,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await decryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
|
images,
|
||||||
|
encrypted_dek,
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
|
masterKey!,
|
||||||
|
cryptoUtils,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setLogTrace({
|
||||||
|
message:
|
||||||
|
"Failed to decrypt elements. Images might not render in the letter as intended.",
|
||||||
|
log: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
type: "WARN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptLetterData = async (
|
||||||
|
data: LetterResponseData,
|
||||||
|
cryptoUtils: CryptoUtils,
|
||||||
|
) => {
|
||||||
|
const isShared = !!sharingKey;
|
||||||
|
const {
|
||||||
|
encrypted_content,
|
||||||
|
encrypted_metadata,
|
||||||
|
encrypted_dek,
|
||||||
|
images,
|
||||||
|
updated_at,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
// Decrypt Metadata
|
||||||
|
const decryptedMetadata = isShared
|
||||||
|
? await cryptoUtils.decryptMetadataWithSharingKey(
|
||||||
|
encrypted_metadata,
|
||||||
|
sharingKey,
|
||||||
|
)
|
||||||
|
: await cryptoUtils.decryptMetadata(
|
||||||
|
{ encrypted_content: encrypted_metadata, encrypted_dek },
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
|
masterKey!,
|
||||||
|
);
|
||||||
|
setMetadata({
|
||||||
|
...(decryptedMetadata as LetterMetadata),
|
||||||
|
updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decrypt Content
|
||||||
|
const decryptedContent = isShared
|
||||||
|
? await cryptoUtils.decryptLetterWithSharingKey(
|
||||||
|
encrypted_content,
|
||||||
|
sharingKey,
|
||||||
|
)
|
||||||
|
: await cryptoUtils.decryptLetter(
|
||||||
|
{ encrypted_content, encrypted_dek },
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
|
masterKey!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
||||||
|
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
|
||||||
|
setDecryptedCanvasData(canvasData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAndDecrypt = async () => {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<LetterResponseData> = await api.get(
|
||||||
|
`${endpoints.LETTERS}${public_id}/`,
|
||||||
|
);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (data.status === "BURNED")
|
||||||
|
throw new Error("This letter has been burned.");
|
||||||
|
|
||||||
|
if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek);
|
||||||
|
|
||||||
|
const isDecryptionKeyAvailable = data.encrypted_dek && masterKey;
|
||||||
|
if (!(!!sharingKey || isDecryptionKeyAvailable)) {
|
||||||
|
throw new Error("Auth required: Decryption key is not available");
|
||||||
|
}
|
||||||
|
|
||||||
const handleShare = async () => {
|
|
||||||
if (!(encryptedDek && masterKey && public_id)) return;
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
await decryptLetterData(data, cryptoUtils);
|
||||||
try {
|
} catch (err) {
|
||||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
|
setLogTrace({
|
||||||
} catch {
|
message: `Failed to load letter ☹`,
|
||||||
// shouldn't obstruct share if api operation fails (since it's client side share)
|
log: err instanceof Error ? err.message : "Unknown error",
|
||||||
} finally {
|
type: "ERROR",
|
||||||
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const burnLetter = async () => {
|
loadAndDecrypt().then(() => setIsDecrypting(false));
|
||||||
if (!public_id || isBurning) return;
|
}, [public_id, sharingKey, masterKey]);
|
||||||
setIsBurning(true);
|
|
||||||
try {
|
|
||||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
|
||||||
status: "BURNED",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// should not obstruct burn if api operation fails
|
|
||||||
// WHY?: it disconnects the UX. if you want to burn the letter, you should be able to burn the letter
|
|
||||||
// TODO: maybe say something like: "the wind is strong today, let's try again"? or maybe something less stupid :3
|
|
||||||
} finally {
|
|
||||||
setIsBurning(false);
|
|
||||||
setShowBurnModal(false);
|
|
||||||
setIgnite(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setRevealState("BURNED");
|
|
||||||
}, WAIT_FOR_BURN_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(sharingKey || masterKey)) {
|
if (
|
||||||
navigateRef.current("/login", {
|
!isDecrypting &&
|
||||||
state: { redirectUrl: `/read/${public_id}` },
|
revealState === "REVEALED" &&
|
||||||
});
|
decryptedCanvasData &&
|
||||||
return;
|
canvasRef.current
|
||||||
}
|
) {
|
||||||
|
canvasRef.current.loadData(decryptedCanvasData);
|
||||||
const decryptImages = async (
|
|
||||||
canvasData: CanvasJSON,
|
|
||||||
images: LetterImageData[],
|
|
||||||
encrypted_dek: string,
|
|
||||||
cryptoUtils: CryptoUtils,
|
|
||||||
) => {
|
|
||||||
if (!images?.length) return;
|
|
||||||
const isShared = !!sharingKey;
|
|
||||||
try {
|
|
||||||
if (isShared) {
|
|
||||||
await decryptCanvasImagesWithSharingKey(
|
|
||||||
canvasData,
|
|
||||||
images,
|
|
||||||
sharingKey,
|
|
||||||
cryptoUtils,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await decryptCanvasImages(
|
|
||||||
canvasData,
|
|
||||||
images,
|
|
||||||
encrypted_dek,
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
|
||||||
masterKey!,
|
|
||||||
cryptoUtils,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setLogTrace({
|
|
||||||
message:
|
|
||||||
"Failed to decrypt elements. Images might not render in the letter as intended.",
|
|
||||||
log: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
type: "WARN",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptLetterData = async (
|
|
||||||
data: LetterResponseData,
|
|
||||||
cryptoUtils: CryptoUtils,
|
|
||||||
) => {
|
|
||||||
const isShared = !!sharingKey;
|
|
||||||
const {
|
|
||||||
encrypted_content,
|
|
||||||
encrypted_metadata,
|
|
||||||
encrypted_dek,
|
|
||||||
images,
|
|
||||||
updated_at,
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
// Decrypt Metadata
|
|
||||||
const decryptedMetadata = isShared
|
|
||||||
? await cryptoUtils.decryptMetadataWithSharingKey(
|
|
||||||
encrypted_metadata,
|
|
||||||
sharingKey,
|
|
||||||
)
|
|
||||||
: await cryptoUtils.decryptMetadata(
|
|
||||||
{ encrypted_content: encrypted_metadata, encrypted_dek },
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
|
||||||
masterKey!,
|
|
||||||
);
|
|
||||||
setMetadata({
|
|
||||||
...(decryptedMetadata as LetterMetadata),
|
|
||||||
updated_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Decrypt Content
|
|
||||||
const decryptedContent = isShared
|
|
||||||
? await cryptoUtils.decryptLetterWithSharingKey(
|
|
||||||
encrypted_content,
|
|
||||||
sharingKey,
|
|
||||||
)
|
|
||||||
: await cryptoUtils.decryptLetter(
|
|
||||||
{ encrypted_content, encrypted_dek },
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
|
||||||
masterKey!,
|
|
||||||
);
|
|
||||||
|
|
||||||
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
|
||||||
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
|
|
||||||
setDecryptedCanvasData(canvasData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAndDecrypt = async () => {
|
|
||||||
try {
|
|
||||||
const response: AxiosResponse<LetterResponseData> = await api.get(
|
|
||||||
`${endpoints.LETTERS}${public_id}/`,
|
|
||||||
);
|
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
if (data.status === "BURNED")
|
|
||||||
throw new Error("This letter has been burned.");
|
|
||||||
|
|
||||||
if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek);
|
|
||||||
|
|
||||||
const isDecryptionKeyAvailable = data.encrypted_dek && masterKey;
|
|
||||||
if (!(!!sharingKey || isDecryptionKeyAvailable)) {
|
|
||||||
throw new Error("Auth required: Decryption key is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
|
||||||
await decryptLetterData(data, cryptoUtils);
|
|
||||||
} catch (err) {
|
|
||||||
setLogTrace({
|
|
||||||
message: `Failed to load letter ☹`,
|
|
||||||
log: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
type: "ERROR",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadAndDecrypt().then(() => setIsDecrypting(false));
|
|
||||||
}, [public_id, sharingKey, masterKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isDecrypting &&
|
|
||||||
revealState === "REVEALED" &&
|
|
||||||
decryptedCanvasData &&
|
|
||||||
canvasRef.current
|
|
||||||
) {
|
|
||||||
canvasRef.current.loadData(decryptedCanvasData);
|
|
||||||
}
|
|
||||||
}, [isDecrypting, revealState, decryptedCanvasData]);
|
|
||||||
|
|
||||||
if (isDecrypting) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center h-screen w-screen justify-center bg-base-100 font-sans">
|
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none" />
|
|
||||||
<div className="text-center space-y-6 z-10">
|
|
||||||
<Logo />
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<span className="loading loading-ring loading-md text-primary/40"></span>
|
|
||||||
<p
|
|
||||||
data-testid="decryption-overlay"
|
|
||||||
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse"
|
|
||||||
>
|
|
||||||
Breaking the seal...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logTrace) {
|
|
||||||
return (
|
|
||||||
<LogModal
|
|
||||||
isOpen={!!logTrace}
|
|
||||||
onClose={() => {
|
|
||||||
if (logTrace.type === "ERROR") window.location.href = "/";
|
|
||||||
setLogTrace(null);
|
|
||||||
}}
|
|
||||||
message={logTrace.message}
|
|
||||||
log={logTrace.log}
|
|
||||||
status={logTrace.type}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}, [isDecrypting, revealState, decryptedCanvasData]);
|
||||||
|
|
||||||
|
if (isDecrypting) {
|
||||||
return (
|
return (
|
||||||
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
<div className="flex items-center h-screen w-screen justify-center bg-base-100 font-sans">
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
<div className="fixed inset-0 bg-vig pointer-events-none" />
|
||||||
<div
|
<div className="text-center space-y-6 z-10">
|
||||||
className={`transition-all delay-300 duration-1000 relative ${revealState === "REVEALED"
|
<Logo />
|
||||||
? "opacity-0 w-0 h-0 overflow-hidden invisible"
|
<div className="flex flex-col items-center gap-2">
|
||||||
: "opacity-100"
|
<span className="loading loading-ring loading-md text-primary/40"></span>
|
||||||
}`}
|
<p
|
||||||
|
data-testid="decryption-overlay"
|
||||||
|
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse"
|
||||||
>
|
>
|
||||||
{revealState === "SEALED" && (
|
Breaking the seal...
|
||||||
<div className="h-[80vh] mx-auto flex-col items-center flex justify-center">
|
</p>
|
||||||
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
|
</div>
|
||||||
<EnvelopeReveal
|
</div>
|
||||||
recipient={metadata?.recipient || "Someone dear"}
|
</div>
|
||||||
date={
|
);
|
||||||
metadata?.updated_at
|
}
|
||||||
? formatDate(new Date(metadata.updated_at))
|
|
||||||
: undefined
|
if (logTrace) {
|
||||||
}
|
return (
|
||||||
onRevealComplete={() => setRevealState("REVEALED")}
|
<LogModal
|
||||||
ignite={ignite}
|
isOpen={!!logTrace}
|
||||||
/>
|
onClose={() => {
|
||||||
</div>
|
if (logTrace.type === "ERROR") window.location.href = "/";
|
||||||
</div>
|
setLogTrace(null);
|
||||||
)}
|
}}
|
||||||
|
message={logTrace.message}
|
||||||
|
log={logTrace.log}
|
||||||
|
status={logTrace.type}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
||||||
|
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
||||||
|
<div
|
||||||
|
className={`transition-all delay-300 duration-1000 relative ${
|
||||||
|
revealState === "REVEALED"
|
||||||
|
? "opacity-0 w-0 h-0 overflow-hidden invisible"
|
||||||
|
: "opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{revealState === "SEALED" && (
|
||||||
|
<div className="h-[80vh] mx-auto flex-col items-center flex justify-center">
|
||||||
|
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
|
||||||
|
<EnvelopeReveal
|
||||||
|
recipient={metadata?.recipient || "Someone dear"}
|
||||||
|
date={
|
||||||
|
metadata?.updated_at
|
||||||
|
? formatDate(new Date(metadata.updated_at))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onRevealComplete={() => setRevealState("REVEALED")}
|
||||||
|
ignite={ignite}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ignite && <PostActionOverlay revealState={revealState} />}
|
||||||
|
|
||||||
|
{revealState === "REVEALED" && (
|
||||||
|
<div className="max-w-180 m-8 mx-auto space-y-8 h-full relative inset-0 z-100">
|
||||||
|
<div className="relative group perspective-1000">
|
||||||
|
<div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="bg-paper shadow-warm rounded-sm overflow-hidden animate-[opacity_1s_ease-in-out_1]">
|
||||||
|
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
|
||||||
|
<ComposeCanvas ref={canvasRef} readOnly />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ignite && <PostActionOverlay revealState={revealState} />}
|
{metadata?.recipient && (
|
||||||
|
<p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
|
||||||
{revealState === "REVEALED" && (
|
For {metadata.recipient}
|
||||||
<div className="max-w-180 m-8 mx-auto space-y-8 h-full relative inset-0 z-100">
|
</p>
|
||||||
<div className="relative group perspective-1000">
|
|
||||||
<div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" />
|
|
||||||
|
|
||||||
<div className="bg-paper shadow-warm rounded-sm overflow-hidden animate-[opacity_1s_ease-in-out_1]">
|
|
||||||
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
|
|
||||||
<ComposeCanvas ref={canvasRef} readOnly />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{metadata?.recipient && (
|
|
||||||
<p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
|
|
||||||
For {metadata.recipient}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{shareLink && (
|
{shareLink && (
|
||||||
<ShareModal shareLink={shareLink} setShareLink={setShareLink} />
|
<ShareModal shareLink={shareLink} setShareLink={setShareLink} />
|
||||||
)}
|
)}
|
||||||
{showBurnModal && (
|
{showBurnModal && (
|
||||||
<BurnModal
|
<BurnModal
|
||||||
burnLetter={burnLetter}
|
burnLetter={burnLetter}
|
||||||
isBurning={isBurning}
|
isBurning={isBurning}
|
||||||
setShowBurnModal={setShowBurnModal}
|
setShowBurnModal={setShowBurnModal}
|
||||||
setRevealState={setRevealState}
|
setRevealState={setRevealState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAuthor && revealState !== "BURNED" && (
|
{isAuthor && revealState !== "BURNED" && (
|
||||||
<div className="flex justify-center gap-2 mt-8 z-10 relative">
|
<div className="flex justify-center gap-2 mt-8 z-10 relative">
|
||||||
<button
|
<button
|
||||||
id="share-letter-btn"
|
id="share-letter-btn"
|
||||||
data-testid="share-letter-btn"
|
data-testid="share-letter-btn"
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
|
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
>
|
>
|
||||||
<PaperPlaneTiltIcon size={16} weight="duotone" />
|
<PaperPlaneTiltIcon size={16} weight="duotone" />
|
||||||
<span className="text-md uppercase font-sans tracking-widest">
|
<span className="text-md uppercase font-sans tracking-widest">
|
||||||
Send to someone
|
Send to someone
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="burn-letter-btn"
|
id="burn-letter-btn"
|
||||||
data-testid="burn-letter-btn"
|
data-testid="burn-letter-btn"
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
|
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
|
||||||
onClick={() => setShowBurnModal(true)}
|
onClick={() => setShowBurnModal(true)}
|
||||||
>
|
>
|
||||||
<FlameIcon size={16} weight="duotone" />
|
<FlameIcon size={16} weight="duotone" />
|
||||||
<span className="text-md uppercase font-sans tracking-widest">
|
<span className="text-md uppercase font-sans tracking-widest">
|
||||||
Burn the letter
|
Burn the letter
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
|
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
|
||||||
<p className="text-xs font-sans uppercase tracking-widester">
|
<p className="text-xs font-sans uppercase tracking-widester">
|
||||||
Read. Remember. Release.
|
Read. Remember. Release.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user