feat: add a post-seal navigation flow to the reader page

This commit is contained in:
ramvignesh-b
2026-04-24 06:34:08 +05:30
parent 00c16627cc
commit ae52a79bd0
3 changed files with 244 additions and 120 deletions
+50 -67
View File
@@ -8,7 +8,6 @@ import {
StampIcon,
TrayIcon,
VaultIcon,
XCircleIcon,
XIcon,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
@@ -26,7 +25,7 @@ import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal";
import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes";
import { PATHS, ROUTES } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { formatRelativeDate } from "../utils/dateFormat";
@@ -60,7 +59,7 @@ export default function Editor() {
}>({ status: "RESET", message: "", log: "" });
const [isInitialLoading, setIsInitialLoading] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);
const [sealedTargetId, setSealedTargetId] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null);
const [status, setLetterStatus] = useState<"DRAFT" | "SEALED" | "VAULT">(
"DRAFT",
@@ -165,8 +164,6 @@ export default function Editor() {
});
}
console.log(canvasData);
if (canvasRef.current) {
await canvasRef.current.loadData(canvasData);
}
@@ -271,7 +268,6 @@ export default function Editor() {
const formData = new FormData();
if (status === "VAULT") {
const finalDate = vaultDate || unlockDate;
console.log(finalDate?.toISOString());
formData.append("type", "VAULT");
if (finalDate) {
formData.append("unlock_at", finalDate.toISOString());
@@ -305,27 +301,59 @@ export default function Editor() {
setLetterStatus(status);
setLastSavedPulseTick((prev) => prev + 1);
if (status === "SEALED" && encrypted_letter.sharingKey) {
const link = `${window.location.origin}${PATHS.read(
targetId,
)}#${encrypted_letter.sharingKey}`;
setShareLink(link);
setShowSaveOverlay(false);
setTimeout(() => setSaveOverlay("idle"), OVERLAY_FADE_MS);
} else {
setSaveOverlay("saved");
setShowSaveOverlay(true);
if (status === "SEALED") {
setSealedTargetId(targetId);
}
setSaveOverlay("saved");
setShowSaveOverlay(true);
} catch (_error) {
setSaveOverlay("error");
setShowSaveOverlay(true);
}
};
const copyToClipboard = async () => {
if (!shareLink) return;
await navigator.clipboard.writeText(shareLink);
};
function SealedModal() {
if (!sealedTargetId) return null;
return (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-1000">
<div className="modal-box flex flex-col items-center text-center gap-6">
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
<p className="text-base-content/60">
It's encrypted and always safe in your drawer.
</p>
<p className="text-base-content font-sans">
When you're ready,
<br />
you can{" "}
<span className="text-primary font-bold font-display">read</span>{" "}
it, <span className="text-accent font-bold font-display">send</span>{" "}
it to someone, or{" "}
<span className="text-error font-bold font-display">burn</span> it
to release
</p>
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)}
>
Keep it to myself
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(PATHS.read(sealedTargetId), { replace: true })
}
>
View letter
</button>
</div>
</div>
</div>
);
}
function ToolBar() {
return (
@@ -547,53 +575,7 @@ export default function Editor() {
</div>
)}
{shareLink && (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
<button
type="button"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setShareLink(null)}
aria-label="Close"
>
<XCircleIcon size={18} weight="bold" />
</button>
<div className="flex flex-col items-center text-center gap-6 py-4">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
<LockIcon size={32} weight="fill" className="text-primary" />
</div>
<div className="space-y-2">
<h3 className="font-serif text-3xl">Sealed & Ready</h3>
<p className="text-base-content/60 text-sm max-w-xs">
This letter is now encrypted. Share this secret link with
your recipient.
</p>
</div>
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl group relative">
<input
readOnly
value={shareLink}
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
/>
<button
type="button"
onClick={copyToClipboard}
className="btn btn-primary btn-sm rounded-lg"
>
Copy
</button>
</div>
<p className="text-[10px] uppercase tracking-widest text-base-content/30">
Zero-Knowledge: The key is in the link, not our servers.
</p>
</div>
</div>
</div>
)}
{saveOverlay !== "idle" && !shareLink && (
{saveOverlay !== "idle" && (
<div
className={`modal modal-open bg-base-100/20 backdrop-blur-md transition-opacity duration-300 ${
showSaveOverlay ? "opacity-100" : "opacity-0"
@@ -650,6 +632,7 @@ export default function Editor() {
)}
{confirmModal === "VAULT" && <VaultConfirm />}
{sealedTargetId && <SealedModal />}
<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">
+151 -31
View File
@@ -1,4 +1,10 @@
import { CampfireIcon, FlameIcon } from "@phosphor-icons/react";
import {
CampfireIcon,
EyeSlashIcon,
FlameIcon,
PaperPlaneTiltIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { api } from "../api/apiClient";
@@ -51,10 +57,92 @@ export default function Reader() {
const [showBurnModal, setShowBurnModal] = useState(false);
const [isBurning, setIsBurning] = useState(false);
const [ignite, setIgnite] = useState(false);
const [encryptedDek, setEncryptedDek] = useState<string | null>(null);
const [shareLink, setShareLink] = useState<string | null>(null);
const { masterKey } = useKeyStore();
const isOwner = !!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) {
console.error("Failed to update letter:", err);
} finally {
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
}
};
const copyToClipboard = async () => {
if (!shareLink) return;
await navigator.clipboard.writeText(shareLink);
};
function ShareModal() {
return (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
<button
type="button"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setShareLink(null)}
aria-label="Close"
>
<XCircleIcon size={18} weight="bold" />
</button>
<div className="flex flex-col items-center justify-center text-center gap-6 py-4">
<div className="space-y-2">
<PaperPlaneTiltIcon
size={48}
weight="bold"
className="mb-4 text-primary mx-auto animate-[bounce_3s_ease-in-out_infinite]"
/>
<h3 className="font-serif text-3xl">Send this letter</h3>
<p className="text-base-content/80 text-sm font-sans mt-4">
You've carried these words long enough. Send your letter now,
and let the{" "}
<span className="text-accent font-display">unsaid</span> finally
find its home.
</p>
<div className="divider mx-auto" />
<blockquote className="text-sm info text-neutral-content/60 font-sans">
The recipient will have the same viewing experience like you do
now.
</blockquote>
</div>
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl">
<input
id="share-link-input"
readOnly
value={shareLink ?? ""}
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
/>
<button
type="button"
onClick={copyToClipboard}
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
>
Copy
</button>
</div>
<div className="flex flex-col gap-1 uppercase tracking-widest text-base-content/30 font-sans">
<p className="textarea-xs flex items-center justify-center">
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />{" "}
Zero-Knowledge Share:
</p>
<p className="textarea-xs font-mono text-center">
The key never leaves your or the recipient's browser.
</p>
</div>
</div>
</div>
</div>
);
}
const burnLetter = async () => {
console.log("Burning letter...");
@@ -95,27 +183,42 @@ export default function Reader() {
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
return (
<div className="modal modal-open bg-base-100/20 backdrop-blur-md">
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md">
<div
className={`modal-box flex flex-col items-center gap-4 text-center transition-all duration-200 ease-in-out ${burnClicked ? "animate-[pulse_15s_linear_infinite]" : ""}`}
className={`modal-box flex flex-col items-center gap-4 py-8 text-center transition-all duration-200 ease-in-out ${burnClicked ? "animate-[pulse_15s_linear_infinite]" : ""}`}
style={
{
transform: `rotate(${rotate}deg)`,
} as React.CSSProperties
}
>
<CampfireIcon size={36} weight="duotone" className="text-error" />
<button
type="button"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setShowBurnModal(false)}
aria-label="Close"
>
<XCircleIcon size={18} weight="bold" />
</button>
<CampfireIcon
size={48}
weight="duotone"
className="text-error animate-pulse"
/>
<h3 className="font-serif text-2xl">
Are you ready to burn this letter?
</h3>
<p className="text-sm font-sans text-base-content/60 max-w-xs">
The ashes will be released into the winds.
<p className="text-sm font-sans text-base-content/80 mt-4">
Some words are meant to be unsaid, but they don't have to linger
forever.
<br />
<span className="text-error font-semibold">Press</span> and{" "}
<span className="text-error font-semibold">hold</span> the{" "}
<span className="text-amber-300 font-semibold">flame</span> to
proceed.
Let the echoes of your unsaid be finally released.
</p>
<div className="mt-4 font-sans text-sm">
<span className="text-error">Press</span> and{" "}
<span className="text-error">hold</span> the{" "}
<span className="text-amber-300">flame</span> to proceed.
</div>
<div className="modal-action w-full justify-center gap-3 mt-2">
<div
className="absolute -mt-2 w-28 h-28 radial-progress pointer-events-none text-amber-200/60"
@@ -179,6 +282,8 @@ export default function Reader() {
if (status === "BURNED")
throw new Error("This letter has been burned.");
if (encrypted_dek) setEncryptedDek(encrypted_dek);
const cryptoUtils = new CryptoUtils();
const isShared = !!sharingKey;
@@ -333,13 +438,16 @@ export default function Reader() {
<div
className={`text-xl ${revealState === "burned" ? "opacity-100" : "opacity-0"} lg:text-4xl text-center font-extralight text-base-content font-display mt-8 delay-3000 transition-all duration-2000 tracking-wide`}
>
<p className="w-full overflow-hidden">
May your soul find solace like your{" "}
<span className="text-accent italic">unsaid</span> words did.
<p className="w-full">
May your <span className="italic text-primary">soul</span> find
solace,
<br />
just like your <span className="text-accent italic">unsaid</span>{" "}
words did.
</p>
<div className="divider mx-auto w-24 text-center"></div>
<button
className="btn btn-ghost text-xs text-neutral-content/60 font-sans"
className="btn btn-ghost text-sm text-neutral-content/60 font-sans"
onClick={() => navigate(ROUTES.DRAWER)}
>
Turn the page
@@ -372,27 +480,39 @@ export default function Reader() {
</p>
)}
</div>
{isOwner && (
<div className="flex justify-center">
<button
id="burn-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
onClick={() => setShowBurnModal(true)}
>
<FlameIcon size={26} weight="duotone" />
<span className="text-[10px] uppercase font-sans tracking-widest">
Burn this letter
</span>
</button>
</div>
)}
</div>
)}
{shareLink && <ShareModal />}
{showBurnModal && <BurnModal />}
{isAuthor && revealState !== "burned" && (
<div className="flex justify-center gap-2 mt-8 z-10 relative">
<button
id="share-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
onClick={handleShare}
>
<PaperPlaneTiltIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Send to someone...
</span>
</button>
<button
id="burn-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
onClick={() => setShowBurnModal(true)}
>
<FlameIcon size={16} weight="duotone" />
<span className="text-md uppercase font-sans tracking-widest">
Burn the letter
</span>
</button>
</div>
)}
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
<p className="text-xs font-sans uppercase tracking-[0.5em]">
Read. Remember. Release.