mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: add burn letter option and fix modal middle placement
This commit is contained in:
@@ -200,7 +200,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
// Click the letter to pull it out
|
// Click the letter to pull it out
|
||||||
await page.locator("#letter").click();
|
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
|
||||||
|
|
||||||
// Wait for reveal transition
|
// Wait for reveal transition
|
||||||
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
|
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WavesIcon } from "@phosphor-icons/react";
|
import { WavesIcon } from "@phosphor-icons/react";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import stamp from "../../assets/envelope/stamp.png";
|
import stamp from "../../assets/envelope/stamp.png";
|
||||||
import waxSeal from "../../assets/envelope/waxSeal.png";
|
import waxSeal from "../../assets/envelope/waxSeal.png";
|
||||||
|
|
||||||
@@ -7,18 +7,33 @@ export interface EnvelopeRevealProps {
|
|||||||
recipient?: string;
|
recipient?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
onRevealComplete: () => void;
|
onRevealComplete: () => void;
|
||||||
|
ignite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnvelopeReveal({
|
export function EnvelopeReveal({
|
||||||
recipient,
|
recipient,
|
||||||
date,
|
date,
|
||||||
onRevealComplete,
|
onRevealComplete,
|
||||||
|
ignite,
|
||||||
}: EnvelopeRevealProps) {
|
}: EnvelopeRevealProps) {
|
||||||
const [revealLetter, setRevealLetter] = useState(false);
|
const [revealLetter, setRevealLetter] = useState(false);
|
||||||
const [isFlipped, setIsFlipped] = useState(false);
|
const [isFlipped, setIsFlipped] = useState(false);
|
||||||
|
|
||||||
|
const [burn, setBurn] = useState<{ width: number; height: number }>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const flapCheckbox = useRef<HTMLInputElement>(null);
|
const flapCheckbox = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ignite) return;
|
||||||
|
const burnInterval = setInterval(() => {
|
||||||
|
setBurn((prev) => ({ width: prev.width + 4, height: prev.height + 6 }));
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(burnInterval);
|
||||||
|
}, [ignite]);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (revealLetter) return;
|
if (revealLetter) return;
|
||||||
setRevealLetter(true);
|
setRevealLetter(true);
|
||||||
@@ -28,7 +43,7 @@ export function EnvelopeReveal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen mx-auto items-center flex justify-center">
|
<div className="h-screen mx-auto flex-col items-center flex justify-center">
|
||||||
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
|
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
|
||||||
<div
|
<div
|
||||||
className={`relative h-70 w-105 transform-3d transition-transform duration-2000 ${isFlipped ? "rotate-y-180" : ""}`}
|
className={`relative h-70 w-105 transform-3d transition-transform duration-2000 ${isFlipped ? "rotate-y-180" : ""}`}
|
||||||
@@ -54,7 +69,7 @@ export function EnvelopeReveal({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
id="letter"
|
id="letter"
|
||||||
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-63 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-2000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
|
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-2000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
></button>
|
></button>
|
||||||
|
|
||||||
@@ -68,7 +83,7 @@ export function EnvelopeReveal({
|
|||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="env-bottom"
|
id="env-bottom"
|
||||||
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] mt-15 scale-x-240 z-3 pointer-events-none"
|
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] mt-15 scale-x-240 z-3"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,6 +116,17 @@ export function EnvelopeReveal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{ignite && (
|
||||||
|
<div className="absolute w-90 h-60 bg-transparent z-100 overflow-hidden flex align-baseline">
|
||||||
|
<div
|
||||||
|
className="absolute border-2 border-amber-200 -bottom-3 -right-3 w-0 h-0 transition-all duration-500 bg-base-100 rounded-tl-full rounded-bl-full origin-bottom-right"
|
||||||
|
style={{
|
||||||
|
width: 2 * burn.width,
|
||||||
|
height: 2 * burn.height,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const LogModal = ({
|
|||||||
return status === "RESET" || !isOpen ? (
|
return status === "RESET" || !isOpen ? (
|
||||||
<div></div>
|
<div></div>
|
||||||
) : (
|
) : (
|
||||||
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
||||||
<div className="modal-box bg-transparent border-none shadow-none relative">
|
<div className="modal-box bg-transparent border-none shadow-none relative">
|
||||||
<div
|
<div
|
||||||
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ export default function Editor() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{shareLink && (
|
{shareLink && (
|
||||||
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
<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">
|
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+172
-14
@@ -1,5 +1,6 @@
|
|||||||
|
import { CampfireIcon, FlameIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
import { EnvelopeReveal } from "../components/ui/EnvelopeReveal";
|
import { EnvelopeReveal } from "../components/ui/EnvelopeReveal";
|
||||||
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 { 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";
|
||||||
@@ -26,14 +28,15 @@ interface LetterMetadata {
|
|||||||
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 sharingKey = location.hash.replace("#", "");
|
const sharingKey = location.hash.replace("#", "");
|
||||||
|
|
||||||
const canvasRef = useRef<CanvasTools>(null);
|
const canvasRef = useRef<CanvasTools>(null);
|
||||||
|
|
||||||
const [isDecrypting, setIsDecrypting] = useState(true);
|
const [isDecrypting, setIsDecrypting] = useState(true);
|
||||||
const [revealState, setRevealState] = useState<"sealed" | "revealed">(
|
const [revealState, setRevealState] = useState<
|
||||||
"sealed",
|
"sealed" | "revealed" | "burned"
|
||||||
);
|
>("sealed");
|
||||||
const [error, setError] = useState<{
|
const [error, setError] = useState<{
|
||||||
message: string;
|
message: string;
|
||||||
log: string;
|
log: string;
|
||||||
@@ -45,9 +48,111 @@ export default function Reader() {
|
|||||||
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 [isBurning, setIsBurning] = useState(false);
|
||||||
|
const [ignite, setIgnite] = useState(false);
|
||||||
|
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
|
const isOwner = !!masterKey && !sharingKey;
|
||||||
|
|
||||||
|
const burnLetter = async () => {
|
||||||
|
console.log("Burning letter...");
|
||||||
|
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");
|
||||||
|
}, 13000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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]" : ""}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
transform: `rotate(${rotate}deg)`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CampfireIcon size={36} weight="duotone" className="text-error" />
|
||||||
|
<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.
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<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)) {
|
||||||
setError({
|
setError({
|
||||||
@@ -68,15 +173,19 @@ export default function Reader() {
|
|||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
images,
|
images,
|
||||||
updated_at,
|
updated_at,
|
||||||
|
status,
|
||||||
} = response.data;
|
} = response.data;
|
||||||
|
|
||||||
|
if (status === "BURNED")
|
||||||
|
throw new Error("This letter has been burned.");
|
||||||
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
const isShared = !!sharingKey;
|
const isShared = !!sharingKey;
|
||||||
|
|
||||||
if (isShared && !encrypted_content) throw new Error("Content missing");
|
if (isShared && !encrypted_content) throw new Error("Content missing");
|
||||||
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
|
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
|
||||||
if (!(isShared || isDecryptionKeyAvailable))
|
if (!(isShared || isDecryptionKeyAvailable))
|
||||||
throw new Error("Auth required");
|
throw new Error("Auth required: Decryption key is not available");
|
||||||
|
|
||||||
// Decrypt Metadata
|
// Decrypt Metadata
|
||||||
const decryptedMetadata = isShared
|
const decryptedMetadata = isShared
|
||||||
@@ -198,16 +307,47 @@ export default function Reader() {
|
|||||||
: "opacity-100"
|
: "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<EnvelopeReveal
|
{revealState === "sealed" && (
|
||||||
recipient={metadata?.recipient || "Someone dear"}
|
<EnvelopeReveal
|
||||||
date={
|
recipient={metadata?.recipient || "Someone dear"}
|
||||||
metadata?.updated_at
|
date={
|
||||||
? formatDate(new Date(metadata.updated_at))
|
metadata?.updated_at
|
||||||
: undefined
|
? formatDate(new Date(metadata.updated_at))
|
||||||
}
|
: undefined
|
||||||
onRevealComplete={() => setRevealState("revealed")}
|
}
|
||||||
/>
|
onRevealComplete={() => setRevealState("revealed")}
|
||||||
|
ignite={ignite}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{ignite && (
|
||||||
|
<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 overflow-hidden">
|
||||||
|
May your soul find solace 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"
|
||||||
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
|
>
|
||||||
|
Turn the page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<LogModal
|
<LogModal
|
||||||
isOpen={!!warning}
|
isOpen={!!warning}
|
||||||
onClose={() => setWarning(null)}
|
onClose={() => setWarning(null)}
|
||||||
@@ -232,9 +372,27 @@ export default function Reader() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showBurnModal && <BurnModal />}
|
||||||
|
|
||||||
<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-[0.5em]">
|
<p className="text-xs font-sans uppercase tracking-[0.5em]">
|
||||||
Read. Remember. Release.
|
Read. Remember. Release.
|
||||||
|
|||||||
Reference in New Issue
Block a user