refactor: reduce complexity

This commit is contained in:
me
2026-05-08 10:33:11 +05:30
parent 26cf95c78b
commit 7e79c6ca8b
2 changed files with 772 additions and 767 deletions
+454 -452
View File
@@ -1,32 +1,32 @@
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 { 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";
import { Modal } from "../components/ui/Modal"; import { Modal } from "../components/ui/Modal";
import { Navbar } from "../components/ui/Navbar"; import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
@@ -42,480 +42,482 @@ 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 loadExistingLetter = async () => {
setIsInitialLoading(true);
const cryptoUtils = new CryptoUtils();
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) {
return;
}
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);
}
} 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]); const [showSaveOverlay, setShowSaveOverlay] = useState(false);
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
// to trigger short pulse animation for Last Saved AT element null,
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 () => { const [recipient, setRecipient] = useState("");
clearTimeout(visibleTimer); const [unlockDate, setUnlockDate] = useState<Date | null>(null);
clearTimeout(unmountTimer); const [placeholderIndex, setPlaceholderIndex] = useState(0);
}; const [canvasFontStyle, setCanvasFontStyle] = useState<CanvasStyle>({
}, [saveOverlay]); fontColor: "",
fontFamily: "",
});
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const { masterKey } = useKeyStore();
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 handleSave = async ( const canvasRef = useRef<CanvasTools>(null);
status: "SEALED" | "DRAFT" | "VAULT", const fileInputRef = useRef<HTMLInputElement>(null);
vaultDate?: Date,
): Promise<void> => {
setSealBtnClicked(false);
let targetId = public_id || letterIdRef.current; // to continuously rotate placeholder text of the recipient input
if (!targetId) { useEffect(() => {
targetId = crypto.randomUUID(); const interval = setInterval(() => {
} setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
}, 4000);
if (saveOverlay === "SAVING" || !masterKey) return; return () => clearInterval(interval);
}, []);
setSaveOverlay("SAVING"); // to load existing letter when public_id param and masterKey is available
setShowSaveOverlay(true); // 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 cryptoUtils = new CryptoUtils(); const decryptedJsonStr = await cryptoUtils.decryptLetter(
await cryptoUtils.initialize(); {
encrypted_content: letterData.encrypted_content,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
const canvasData = JSON.parse(decryptedJsonStr);
try { const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
const canvasData = (await canvasRef.current?.getData()) || { await decryptCanvasImages(
objects: [], canvasData,
}; letterData.images ?? [],
const canvasImages = canvasRef.current?.getImages() || []; letterData.encrypted_dek,
masterKey,
cryptoUtils,
true,
);
const { encryptedImageFiles, encryptedCanvasData } = if (isPartialFailure) {
await encryptCanvasImages( setDecryptionStatus({
canvasData, status: "WARN",
canvasImages, message: "Failed to decrypt some elements. Please check the render.",
masterKey, log: errors.toString(),
cryptoUtils, });
}
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());
}
});
}, [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,
); );
const encrypted_letter = await cryptoUtils.encryptLetter( return () => {
JSON.stringify(encryptedCanvasData), clearTimeout(visibleTimer);
masterKey, clearTimeout(unmountTimer);
); };
}, [saveOverlay]);
const encrypted_metadata = await cryptoUtils.encryptMetadata( const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
{ recipient, tags: [] }, const file = e.target.files?.[0];
masterKey, if (file && file.size < MAX_FILE_SIZE) {
); const url = URL.createObjectURL(file);
canvasRef.current?.addImage(url, file);
const formData = new FormData(); } else {
if (status === "VAULT") { setLogStatus({
const finalDate = vaultDate || unlockDate; status: "WARN",
formData.append("type", "VAULT"); message: "Please upload images with size less than 10MB.",
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) => { const getRequestData = async (
formData.append("image_files", blob, filename); targetId: string,
}); status: string,
vaultDate?: Date,
): Promise<FormData> => {
const cryptoUtils = new CryptoUtils();
await cryptoUtils.initialize();
await api.put(`${endpoints.LETTERS}${targetId}/`, formData); const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
justSavedRef.current = true; const canvasImages = canvasRef.current?.getImages() || [];
if (!public_id) { const { encryptedImageFiles, encryptedCanvasData } =
letterIdRef.current = targetId; await encryptCanvasImages(
navigate(PATHS.write(targetId), { replace: true }); canvasData,
} canvasImages,
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
cryptoUtils,
);
setLastSaved(formatRelativeDate(new Date())); const encrypted_letter = await cryptoUtils.encryptLetter(
setLetterStatus(status); JSON.stringify(encryptedCanvasData),
setLastSavedPulseTick((prev) => prev + 1); // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
);
if (status === "SEALED" || status === "VAULT") { const encrypted_metadata = await cryptoUtils.encryptMetadata(
setSealedTargetId(targetId); { recipient, tags: [] },
} // biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
setSaveOverlay("SAVED"); masterKey!,
setShowSaveOverlay(true); );
} catch (_error) {
setSaveOverlay("ERROR");
setShowSaveOverlay(true);
}
};
return ( const formData = new FormData();
<> if (status === "VAULT") {
<Navbar const finalDate = vaultDate || unlockDate;
child={ formData.append("type", "VAULT");
<div if (finalDate) formData.append("unlock_at", finalDate.toISOString());
className={`flex items-center gap-2 ${ formData.append("status", "SEALED");
isSaveDatePulsing ? "animate-pulse" : "" } else {
}`} formData.append("type", "KEPT");
> formData.append("status", status);
<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 formData.append("public_id", targetId);
</span> formData.append("encrypted_content", encrypted_letter.encrypted_content);
<br /> formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
<span className="italic">{lastSaved}</span> formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
</div>
<ClockIcon encryptedImageFiles.forEach((blob, filename) => {
size={16} formData.append("image_files", blob, filename);
weight="bold" });
className="text-neutral-content/30"
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>
)}
{saveOverlay === "SAVED" && ( {isInitialLoading && (
<div <div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm">
role="alert" <div className="flex flex-col items-center gap-4">
data-testid="save-success-toast" <SpinnerGapIcon
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${ size={48}
showSaveOverlay weight="bold"
? "opacity-100 scale-100 translate-y-0" className="animate-spin text-primary"
: "opacity-0 scale-95 translate-y-1" />
}`} <p
> data-testid="opening-draft-overlay"
<DownloadSimpleIcon size={18} weight="bold" /> className="text-xxs uppercase tracking-widester font-bold text-base-content/40"
<span className="font-bold">Your letter is saved!</span> >
</div> Opening your draft...
)} </p>
</div>
</div>
)}
{saveOverlay === "ERROR" && ( {saveOverlay !== "IDLE" && (
<div <Modal isOpen={showSaveOverlay}>
role="alert" {saveOverlay === "SAVING" && (
className={`alert alert-error shadow-lg transition-all duration-300 ${ <div
showSaveOverlay role="alert"
? "opacity-100 scale-100 translate-y-0" className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${showSaveOverlay
: "opacity-0 scale-95 translate-y-1" ? "opacity-100 scale-100 translate-y-0"
}`} : "opacity-0 scale-95 translate-y-1"
> }`}
<XIcon size={18} weight="bold" /> >
<span className="font-bold">Failed to save letter</span> <SpinnerGapIcon
</div> size={18}
)} weight="bold"
</Modal> className="animate-spin"
)} />
<span className="font-bold">Securing your letter...</span>
</div>
)}
{confirmModal === "VAULT" && ( {saveOverlay === "SAVED" && (
<VaultConfirmModal <div
onSave={handleSave} role="alert"
setConfirmModal={setConfirmModal} data-testid="save-success-toast"
setUnlockDate={setUnlockDate} className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${showSaveOverlay
/> ? "opacity-100 scale-100 translate-y-0"
)} : "opacity-0 scale-95 translate-y-1"
{sealedTargetId && ( }`}
<PostSealModal >
sealedTargetId={sealedTargetId} <DownloadSimpleIcon size={18} weight="bold" />
navigate={navigate} <span className="font-bold">Your letter is saved!</span>
type={status === "VAULT" ? "VAULT" : "KEPT"} </div>
/> )}
)}
<div className="max-w-180 mx-auto px-1 md:px-0"> {saveOverlay === "ERROR" && (
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0"> <div
<div className="flex flex-col gap-2 flex-1"> role="alert"
<label className={`alert alert-error shadow-lg transition-all duration-300 ${showSaveOverlay
htmlFor="recipient" ? "opacity-100 scale-100 translate-y-0"
className="text-xxs uppercase tracking-widester text-secondary-content font-bold" : "opacity-0 scale-95 translate-y-1"
> }`}
Recipient >
</label> <XIcon size={18} weight="bold" />
<input <span className="font-bold">Failed to save letter</span>
id="recipient" </div>
data-testid="recipient-input" )}
type="text" </Modal>
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" ? ( {confirmModal === "VAULT" && (
<ToolBar <VaultConfirmModal
onAddImage={() => fileInputRef.current?.click()} onSave={handleSave}
sealBtnClicked={sealBtnClicked} setConfirmModal={setConfirmModal}
setSealBtnClicked={setSealBtnClicked} setUnlockDate={setUnlockDate}
onSave={handleSave} />
setConfirmModal={setConfirmModal} )}
onFontChange={setCanvasFontStyle} {sealedTargetId && (
latestFontStyle={canvasFontStyle} <PostSealModal
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"}
/>
</>
);
} }
+318 -315
View File
@@ -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,339 +26,342 @@ 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 (_err) {
} 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 (_err) {
} 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();
await decryptLetterData(data, cryptoUtils); const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
} catch (err) { try {
setLogTrace({ await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
message: `Failed to load letter ☹`, } catch {
log: err instanceof Error ? err.message : "Unknown error", // shouldn't obstruct share if api operation fails (since it's client side share)
type: "ERROR", } finally {
}); setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
} }
}; };
loadAndDecrypt().then(() => setIsDecrypting(false)); const burnLetter = async () => {
}, [public_id, sharingKey, masterKey]); 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(() => { useEffect(() => {
if ( if (!(sharingKey || masterKey)) {
!isDecrypting && navigateRef.current("/login", {
revealState === "REVEALED" && state: { redirectUrl: `/read/${public_id}` },
decryptedCanvasData && });
canvasRef.current return;
) { }
canvasRef.current.loadData(decryptedCanvasData);
}
}, [isDecrypting, revealState, decryptedCanvasData]);
if (isDecrypting) { const decryptImages = async (
return ( canvasData: CanvasJSON,
<div className="flex items-center h-screen w-screen justify-center bg-base-100 font-sans"> images: LetterImageData[],
<div className="fixed inset-0 bg-vig pointer-events-none" /> encrypted_dek: string,
<div className="text-center space-y-6 z-10"> cryptoUtils: CryptoUtils,
<Logo /> ) => {
<div className="flex flex-col items-center gap-2"> if (!images?.length) return;
<span className="loading loading-ring loading-md text-primary/40"></span> const isShared = !!sharingKey;
<p try {
data-testid="decryption-overlay" if (isShared) {
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse" await decryptCanvasImagesWithSharingKey(
> canvasData,
Breaking the seal... images,
</p> sharingKey,
</div> cryptoUtils,
</div> );
</div> } else {
); await decryptCanvasImages(
} canvasData,
images,
if (logTrace) { encrypted_dek,
return ( // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
<LogModal masterKey!,
isOpen={!!logTrace} cryptoUtils,
onClose={() => { );
if (logTrace.type === "ERROR") window.location.href = "/";
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")} } catch (err) {
ignite={ignite} 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> </div>
</div> );
)} }
</div>
{ignite && <PostActionOverlay revealState={revealState} />} 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}
/>
);
}
{revealState === "REVEALED" && ( return (
<div className="max-w-180 m-8 mx-auto space-y-8 h-full relative inset-0 z-100"> <section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
<div className="relative group perspective-1000"> <div className="fixed inset-0 bg-vig pointer-events-none z-0" />
<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={`transition-all delay-300 duration-1000 relative ${revealState === "REVEALED"
<div className="bg-paper shadow-warm rounded-sm overflow-hidden animate-[opacity_1s_ease-in-out_1]"> ? "opacity-0 w-0 h-0 overflow-hidden invisible"
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" /> : "opacity-100"
<ComposeCanvas ref={canvasRef} readOnly /> }`}
>
{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> </div>
{metadata?.recipient && ( {ignite && <PostActionOverlay revealState={revealState} />}
<p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
For {metadata.recipient} {revealState === "REVEALED" && (
</p> <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>
{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>
); );
} }