- {status === "loading" && (
-
-
-
Activating your account...
-
- )}
-
- {status === "success" && (
-
-
-
-
-
- You're in.
-
-
- Welcome to
-
- Just one more step and you can start writing timeless letters.
-
-
-
-
- )}
-
- {status === "error" && (
-
-
-
-
-
Activation Failed
-
- The link might be expired or already used. Please try registering
- again.
-
-
-
-
- )}
+ return (
+
+ {status === "loading" && (
+
+
+
Activating your account...
- );
+ )}
+
+ {status === "success" && (
+
+
+
+
+
+ You're in.
+
+
+ Welcome to
+
+ Just one more step and you can start writing timeless letters.
+
+
+
+
+ )}
+
+ {status === "error" && (
+
+
+
+
+
Activation Failed
+
+ The link might be expired or already used. Please try registering
+ again.
+
+
+
+
+ )}
+
+ );
}
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx
index 44e16bd..ca6dc3c 100644
--- a/frontend/src/pages/Editor.tsx
+++ b/frontend/src/pages/Editor.tsx
@@ -1,27 +1,27 @@
import {
- ClockIcon,
- DownloadSimpleIcon,
- SpinnerGapIcon,
- XIcon,
+ ClockIcon,
+ DownloadSimpleIcon,
+ SpinnerGapIcon,
+ XIcon,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import {
- type NavigateFunction,
- useNavigate,
- useParams,
+ type NavigateFunction,
+ useNavigate,
+ useParams,
} from "react-router-dom";
import { api } from "../api/apiClient";
import type { LetterResponseData } from "../api/response";
import {
- type CanvasStyle,
- type CanvasTools,
- ComposeCanvas,
+ type CanvasStyle,
+ type CanvasTools,
+ ComposeCanvas,
} from "../components/editor/ComposeCanvas";
import { PostSealModal } from "../components/editor/PostSealModal";
import {
- LetterHead,
- ToolBar,
- VaultConfirmModal,
+ LetterHead,
+ ToolBar,
+ VaultConfirmModal,
} from "../components/editor/ToolBar";
import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal";
@@ -42,482 +42,486 @@ const ERROR_VISIBLE_MS = 2400;
const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000;
const toPlaceholderList = [
- "Someone dear...",
- "Somewhere near...",
- "Something to bear...",
+ "Someone dear...",
+ "Somewhere near...",
+ "Something to bear...",
];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
export default function Editor() {
- const navigate = useNavigate();
- const navigateRef = useRef
(navigate);
- navigateRef.current = navigate;
+ const navigate = useNavigate();
+ const navigateRef = useRef(navigate);
+ navigateRef.current = navigate;
- const { public_id } = useParams();
- const letterIdRef = useRef(public_id ?? "");
- const justSavedRef = useRef(false);
+ const { public_id } = useParams();
+ const letterIdRef = useRef(public_id ?? "");
+ const justSavedRef = useRef(false);
- const [decryptionStatus, setDecryptionStatus] = useState<{
- status: "SUCCESS" | "WARN" | "ERROR" | "RESET";
- message: string;
- log: string;
- }>({ status: "RESET", message: "", log: "" });
+ const [decryptionStatus, setDecryptionStatus] = useState<{
+ status: "SUCCESS" | "WARN" | "ERROR" | "RESET";
+ message: string;
+ log: string;
+ }>({ status: "RESET", message: "", log: "" });
- const [isInitialLoading, setIsInitialLoading] = useState(false);
- const [sealedTargetId, setSealedTargetId] = useState(null);
- const [lastSaved, setLastSaved] = useState(null);
- const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
- "DRAFT",
- );
- const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
- const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
- const [sealBtnClicked, setSealBtnClicked] = useState(false);
+ const [isInitialLoading, setIsInitialLoading] = useState(false);
+ const [sealedTargetId, setSealedTargetId] = useState(null);
+ const [lastSaved, setLastSaved] = useState(null);
+ const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
+ "DRAFT",
+ );
+ const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
+ const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
+ const [sealBtnClicked, setSealBtnClicked] = useState(false);
- const [saveOverlay, setSaveOverlay] = useState("IDLE");
- const [logStatus, setLogStatus] = useState<{
- status: "WARN" | "ERROR" | "RESET";
- message: string;
- }>({
- status: "RESET",
- message: "",
+ const [saveOverlay, setSaveOverlay] = useState("IDLE");
+ const [logStatus, setLogStatus] = useState<{
+ status: "WARN" | "ERROR" | "RESET";
+ message: string;
+ }>({
+ status: "RESET",
+ message: "",
+ });
+ const [showSaveOverlay, setShowSaveOverlay] = useState(false);
+ const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
+ null,
+ );
+
+ const [recipient, setRecipient] = useState("");
+ const [unlockDate, setUnlockDate] = useState(null);
+ const [placeholderIndex, setPlaceholderIndex] = useState(0);
+ const [canvasFontStyle, setCanvasFontStyle] = useState({
+ fontColor: "",
+ fontFamily: "",
+ });
+
+ const { masterKey } = useKeyStore();
+
+ const canvasRef = useRef(null);
+ const fileInputRef = useRef(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);
- const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
- null,
+ }, [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 [recipient, setRecipient] = useState("");
- const [unlockDate, setUnlockDate] = useState(null);
- const [placeholderIndex, setPlaceholderIndex] = useState(0);
- const [canvasFontStyle, setCanvasFontStyle] = useState({
- fontColor: "",
- fontFamily: "",
+ return () => {
+ clearTimeout(visibleTimer);
+ clearTimeout(unmountTimer);
+ };
+ }, [saveOverlay]);
+
+ const handleImageUpload = (e: React.ChangeEvent) => {
+ 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 => {
+ 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(null);
- const fileInputRef = useRef(null);
+ const handleSave = async (
+ status: "SEALED" | "DRAFT" | "VAULT",
+ vaultDate?: Date,
+ ): Promise => {
+ 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
- useEffect(() => {
- const interval = setInterval(() => {
- setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
- }, 4000);
+ if (saveOverlay === "SAVING" || !masterKey) return;
- return () => clearInterval(interval);
- }, []);
+ setSaveOverlay("SAVING");
+ setShowSaveOverlay(true);
- // 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 || "");
+ try {
+ const formData = await getRequestData(targetId, status, vaultDate);
+ await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
- const decryptedJsonStr = await cryptoUtils.decryptLetter(
- {
- encrypted_content: letterData.encrypted_content,
- encrypted_dek: letterData.encrypted_dek,
- },
- masterKey,
- );
- const canvasData = JSON.parse(decryptedJsonStr);
+ justSavedRef.current = true;
+ if (!public_id) {
+ letterIdRef.current = targetId;
+ navigate(PATHS.write(targetId), { replace: true });
+ }
- const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
- await decryptCanvasImages(
- canvasData,
- letterData.images ?? [],
- letterData.encrypted_dek,
- masterKey,
- cryptoUtils,
- true,
- );
+ setLastSaved(formatRelativeDate(new Date()));
+ setLetterStatus(status);
+ setLastSavedPulseTick((prev) => prev + 1);
- if (isPartialFailure) {
- setDecryptionStatus({
- status: "WARN",
- message: "Failed to decrypt some elements. Please check the render.",
- log: errors.toString(),
- });
- }
+ if (status === "SEALED" || status === "VAULT") {
+ setSealedTargetId(targetId);
+ }
+ setSaveOverlay("SAVED");
+ setShowSaveOverlay(true);
+ } catch {
+ setSaveOverlay("ERROR");
+ setShowSaveOverlay(true);
+ }
+ };
- 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,
- );
-
- return () => {
- clearTimeout(visibleTimer);
- clearTimeout(unmountTimer);
- };
- }, [saveOverlay]);
-
- const handleImageUpload = (e: React.ChangeEvent) => {
- 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 => {
- 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 => {
- 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 (
- <>
-
-
-
- Last Save
-
-
- {lastSaved}
-
-
-
- }
+ return (
+ <>
+