feat: add modular Burn, Share, and PostAction components to the Reader page flow

This commit is contained in:
ramvignesh-b
2026-04-24 18:59:13 +05:30
parent 42493a950c
commit 18e9af651d
4 changed files with 222 additions and 200 deletions
@@ -0,0 +1,99 @@
import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
export function BurnModal({
burnLetter,
isBurning,
setShowBurnModal,
setRevealState,
}) {
const [flameOn, setFlameOn] = useState(0);
const [rotate, setRotate] = useState(0);
const [burnClicked, setBurnClicked] = useState(false);
useEffect(() => {
if (!burnClicked) return;
if (flameOn === 100) {
setRevealState("sealed");
burnLetter();
}
const interval = setInterval(() => {
setFlameOn((prev) => prev + 1);
setRotate(Math.random() * 4 - 2);
}, 100);
return () => clearInterval(interval);
}, [burnClicked, flameOn, setRevealState, burnLetter]);
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
return (
<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 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
}
>
<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/80 mt-4">
Some words are meant to be unsaid, but they don't have to linger
forever.
<br />
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"
style={
{ "--value": flameOn, filter: burnStyle } as React.CSSProperties
}
role="progressbar"
></div>
<button
type="button"
className={`btn btn-error btn-dashed btn-circle w-24 h-24`}
style={
{
filter: burnStyle,
cursor: burnClicked ? "grabbing" : "grab",
} as React.CSSProperties
}
onMouseDown={() => setBurnClicked(true)}
onMouseUp={() => {
setFlameOn(0);
setBurnClicked(false);
}}
disabled={isBurning}
>
{isBurning ? (
<span className="loading loading-spinner loading-xs" />
) : (
<FlameIcon size={54} weight="duotone" />
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { useNavigate } from "react-router-dom";
import { ROUTES } from "../../config/routes";
export function PostActionOverlay({ revealState }) {
const navigate = useNavigate();
return (
<div
className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "burned" ? "opacity-100" : "opacity-0"} transition-all delay-300 duration-1000`}
>
<h1
className={`text-6xl ${revealState === "burned" ? "opacity-100" : "opacity-0"} lg:text-9xl italic font-extralight text-base-content animate-[pulse_3s_ease-in-out_3]`}
>
It is done
</h1>
<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">
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
type="button"
className="btn btn-ghost text-sm text-neutral-content/60 font-sans"
onClick={() => navigate(ROUTES.DRAWER)}
>
Turn the page
</button>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import {
EyeSlashIcon,
PaperPlaneTiltIcon,
XCircleIcon,
} from "@phosphor-icons/react";
export function ShareModal({ shareLink, setShareLink }) {
const copyToClipboard = async () => {
if (!shareLink) return;
await navigator.clipboard.writeText(shareLink);
};
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>
);
}
+17 -200
View File
@@ -1,10 +1,4 @@
import { import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
CampfireIcon,
EyeSlashIcon,
FlameIcon,
PaperPlaneTiltIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
type NavigateFunction, type NavigateFunction,
@@ -19,10 +13,13 @@ import {
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 { EnvelopeReveal } from "../components/reader/EnvelopeReveal"; import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
import { PostActionOverlay } from "../components/reader/PostActionOverlay";
import { ShareModal } from "../components/reader/ShareModal";
import { LogModal } from "../components/ui/LogModal"; import { LogModal } from "../components/ui/LogModal";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { PATHS, ROUTES } from "../config/routes"; import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore"; 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";
@@ -82,73 +79,6 @@ export default function Reader() {
} }
}; };
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 () => { const burnLetter = async () => {
if (!public_id || isBurning) return; if (!public_id || isBurning) return;
setIsBurning(true); setIsBurning(true);
@@ -167,99 +97,6 @@ export default function Reader() {
} }
}; };
function BurnModal() {
const [flameOn, setFlameOn] = useState(0);
const [rotate, setRotate] = useState(0);
const [burnClicked, setBurnClicked] = useState(false);
useEffect(() => {
if (!burnClicked) return;
if (flameOn === 100) {
setRevealState("sealed");
burnLetter();
}
const interval = setInterval(() => {
setFlameOn((prev) => prev + 1);
setRotate(Math.random() * 4 - 2);
}, 100);
return () => clearInterval(interval);
}, [burnClicked, flameOn]);
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
return (
<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 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
}
>
<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/80 mt-4">
Some words are meant to be unsaid, but they don't have to linger
forever.
<br />
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"
style={
{ "--value": flameOn, filter: burnStyle } as React.CSSProperties
}
role="progressbar"
></div>
<button
type="button"
className={`btn btn-error btn-dashed btn-circle w-24 h-24`}
style={
{
filter: burnStyle,
cursor: burnClicked ? "grabbing" : "grab",
} as React.CSSProperties
}
// onClick={handleBurn}
onMouseDown={() => setBurnClicked(true)}
onMouseUp={() => {
setFlameOn(0);
setBurnClicked(false);
}}
disabled={isBurning}
>
{isBurning ? (
<span className="loading loading-spinner loading-xs" />
) : (
<FlameIcon size={54} weight="duotone" />
)}
</button>
</div>
</div>
</div>
);
}
useEffect(() => { useEffect(() => {
if (!(sharingKey || masterKey)) { if (!(sharingKey || masterKey)) {
navigateRef.current("/login", { navigateRef.current("/login", {
@@ -427,36 +264,7 @@ export default function Reader() {
)} )}
</div> </div>
{ignite && ( {ignite && <PostActionOverlay revealState={revealState} />}
<div
className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "burned" ? "opacity-100" : "opacity-0"} transition-all delay-300 duration-1000`}
>
<h1
className={`text-6xl ${revealState === "burned" ? "opacity-100" : "opacity-0"} lg:text-9xl italic font-extralight text-base-content animate-[pulse_3s_ease-in-out_3]`}
>
It is done
</h1>
<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">
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
type="button"
className="btn btn-ghost text-sm text-neutral-content/60 font-sans"
onClick={() => navigate(ROUTES.DRAWER)}
>
Turn the page
</button>
</div>
</div>
)}
<LogModal <LogModal
isOpen={!!warning} isOpen={!!warning}
@@ -485,8 +293,17 @@ export default function Reader() {
</div> </div>
)} )}
{shareLink && <ShareModal />} {shareLink && (
{showBurnModal && <BurnModal />} <ShareModal shareLink={shareLink} setShareLink={setShareLink} />
)}
{showBurnModal && (
<BurnModal
burnLetter={burnLetter}
isBurning={isBurning}
setShowBurnModal={setShowBurnModal}
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">