mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: implement EnvelopeReveal component for letter interactions and integrate into reader workflow
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
|
import { EnvelopeOpenIcon } from "@phosphor-icons/react";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
|
|
||||||
export default function SplashScreen() {
|
export default function SplashScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
<div className="fixed w-screen h-screen inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
||||||
<div className="flex flex-col items-center gap-6 animate-pulse">
|
<div className="flex flex-col items-center gap-6 animate-pulse">
|
||||||
<Logo />
|
<Logo />
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<EnvelopeOpenIcon weight="duotone" fill="primary" size={12} />
|
||||||
<span className="loading loading-ring loading-lg text-primary" />
|
<span className="loading loading-ring loading-lg text-primary" />
|
||||||
<p className="text-xs uppercase font-sans tracking-widest opacity-40">
|
<p className="text-xs uppercase font-sans tracking-widest opacity-40">
|
||||||
Unsealing...
|
Unsealing...
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export function DrawerSection({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={`overflow-visible join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`}
|
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`transition-normal duration-1000 ease-in-out bg-neutral/10 ${
|
className={`transition-all duration-2000 ease-in-out bg-neutral/10 ${
|
||||||
isOpen
|
isOpen
|
||||||
? "opacity-100 py-3 border-b border-base-content/5"
|
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible"
|
||||||
: "max-h-0 opacity-0 pointer-events-none"
|
: "max-h-0 opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -35,11 +35,11 @@ export function DrawerSection({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-none outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
|
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div
|
<div
|
||||||
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-300 ${
|
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-800 ${
|
||||||
isOpen
|
isOpen
|
||||||
? "text-base-content"
|
? "text-base-content"
|
||||||
: "text-base-content/40 group-hover:text-base-content/80"
|
: "text-base-content/40 group-hover:text-base-content/80"
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export interface EnvelopeRevealProps {
|
||||||
|
recipient?: string;
|
||||||
|
date?: string;
|
||||||
|
onRevealComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnvelopeReveal({
|
||||||
|
recipient,
|
||||||
|
date,
|
||||||
|
onRevealComplete,
|
||||||
|
}: EnvelopeRevealProps) {
|
||||||
|
const [revealLetter, setRevealLetter] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setRevealLetter(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onRevealComplete();
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl m-8 mx-auto space-y-8 h-screen w-screen flex items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center transition-all duration-1000">
|
||||||
|
<div
|
||||||
|
id="env-top"
|
||||||
|
className="z-4 transition-transform duration-2000 absolute peer h-40 w-54 -mt-30 bg-base-200 mask mask-triangle-2 scale-x-234 has-checked:scale-y-[-1] has-checked:-translate-y-full has-checked:z-1 has-checked:duration-1000"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className=" transition-all checkbox duration-1000 absolute h-full w-full bg-transparent z-100 text-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="letter"
|
||||||
|
className={`max-w-4xl m-8 mx-auto space-y-8 transition-all peer-has-checked:duration-2000 duration-500 h-65 w-105 bg-paper peer-has-checked:mb-24 hover:mb-54 cursor-pointer ${revealLetter ? "w-screen h-screen z-101" : "peer-has-checked:z-1"}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="env-right"
|
||||||
|
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-3 -mr-48 z-3"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="env-left"
|
||||||
|
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-4 -ml-48 z-3"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="env-bottom"
|
||||||
|
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] -mb-31 scale-x-240 z-3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export function LetterItem({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNavigate}
|
onClick={handleNavigate}
|
||||||
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
|
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all delay-75 duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
|
||||||
>
|
>
|
||||||
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
|
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
|
||||||
{preview}
|
{preview}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function Drawer() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200 fill-mode-backwards min-h-64 flex flex-col">
|
<div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm duration-1000 delay-200 min-h-64 flex flex-col">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
|
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
|
||||||
<span className="loading loading-ring loading-lg text-primary opacity-20"></span>
|
<span className="loading loading-ring loading-lg text-primary opacity-20"></span>
|
||||||
|
|||||||
@@ -455,14 +455,14 @@ export default function Editor() {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const unlockDateStr = formData.get("vault-date") as string;
|
const unlockDateStr = formData.get("vault-date") as string;
|
||||||
const newUnlockDate = new Date(unlockDateStr);
|
const newUnlockDate = new Date(unlockDateStr);
|
||||||
setUnlockDate(newUnlockDate);
|
setUnlockDate(newUnlockDate);
|
||||||
|
await handleSave("VAULT", newUnlockDate);
|
||||||
setConfirmModal(null);
|
setConfirmModal(null);
|
||||||
handleSave("VAULT", newUnlockDate);
|
|
||||||
}}
|
}}
|
||||||
id="vault-form"
|
id="vault-form"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
type CanvasTools,
|
type CanvasTools,
|
||||||
ComposeCanvas,
|
ComposeCanvas,
|
||||||
} from "../components/ui/ComposeCanvas";
|
} from "../components/ui/ComposeCanvas";
|
||||||
|
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 { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
import { formatRelativeDate } from "../utils/dateFormat";
|
||||||
import {
|
import {
|
||||||
decryptCanvasImages,
|
decryptCanvasImages,
|
||||||
decryptCanvasImagesWithSharingKey,
|
decryptCanvasImagesWithSharingKey,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
|
|
||||||
interface LetterMetadata {
|
interface LetterMetadata {
|
||||||
recipient?: string;
|
recipient?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Reader() {
|
export default function Reader() {
|
||||||
@@ -28,6 +31,9 @@ export default function Reader() {
|
|||||||
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">(
|
||||||
|
"sealed",
|
||||||
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [warning, setWarning] = useState<{
|
const [warning, setWarning] = useState<{
|
||||||
message: string;
|
message: string;
|
||||||
@@ -51,8 +57,13 @@ export default function Reader() {
|
|||||||
const loadAndDecrypt = async () => {
|
const loadAndDecrypt = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||||
const { encrypted_content, encrypted_metadata, encrypted_dek, images } =
|
const {
|
||||||
response.data;
|
encrypted_content,
|
||||||
|
encrypted_metadata,
|
||||||
|
encrypted_dek,
|
||||||
|
images,
|
||||||
|
updated_at,
|
||||||
|
} = response.data;
|
||||||
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
const isShared = !!sharingKey;
|
const isShared = !!sharingKey;
|
||||||
@@ -73,7 +84,10 @@ export default function Reader() {
|
|||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
masterKey!,
|
masterKey!,
|
||||||
);
|
);
|
||||||
setMetadata(decryptedMetadata as LetterMetadata);
|
setMetadata({
|
||||||
|
...(decryptedMetadata as LetterMetadata),
|
||||||
|
updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
// Decrypt Content
|
// Decrypt Content
|
||||||
const decryptedContent = isShared
|
const decryptedContent = isShared
|
||||||
@@ -128,10 +142,15 @@ export default function Reader() {
|
|||||||
}, [public_id, sharingKey, masterKey]);
|
}, [public_id, sharingKey, masterKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDecrypting && decryptedCanvasData && canvasRef.current) {
|
if (
|
||||||
|
!isDecrypting &&
|
||||||
|
revealState === "revealed" &&
|
||||||
|
decryptedCanvasData &&
|
||||||
|
canvasRef.current
|
||||||
|
) {
|
||||||
canvasRef.current.loadData(decryptedCanvasData);
|
canvasRef.current.loadData(decryptedCanvasData);
|
||||||
}
|
}
|
||||||
}, [isDecrypting, decryptedCanvasData]);
|
}, [isDecrypting, revealState, decryptedCanvasData]);
|
||||||
|
|
||||||
if (isDecrypting) {
|
if (isDecrypting) {
|
||||||
return (
|
return (
|
||||||
@@ -163,8 +182,7 @@ export default function Reader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="min-h-screen w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
||||||
{/* Background Ambience */}
|
|
||||||
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
|
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
|
||||||
|
|
||||||
<LogModal
|
<LogModal
|
||||||
@@ -175,34 +193,12 @@ export default function Reader() {
|
|||||||
status="WARN"
|
status="WARN"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto space-y-8 relative z-10">
|
{revealState === "revealed" && (
|
||||||
{/* Floating Header */}
|
<div className="max-w-4xl m-8 mx-auto space-y-8 relative inset-0 z-100">
|
||||||
<div className="glass-card px-6 py-4 flex items-center justify-between animate-in fade-in slide-in-from-top-6 duration-1000">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Logo />
|
|
||||||
<div className="h-4 w-px bg-base-content/10 hidden sm:block" />
|
|
||||||
{metadata?.recipient && (
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.2em] text-base-content/40 hidden sm:block">
|
|
||||||
A sealed letter for{" "}
|
|
||||||
<span className="text-base-content/60 font-semibold">
|
|
||||||
{metadata.recipient}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-circle btn-sm hover:rotate-90 transition-transform duration-500"
|
|
||||||
onClick={() => (window.location.href = "/")}
|
|
||||||
aria-label="Close"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The Letter */}
|
|
||||||
<div className="relative group perspective-1000">
|
<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="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-in fade-in zoom-in-95 slide-in-from-bottom-8 duration-1000 delay-300 fill-mode-backwards rotate-[-0.3deg] hover:rotate-0 transition-transform">
|
<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" />
|
<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 />
|
<ComposeCanvas ref={canvasRef} readOnly />
|
||||||
</div>
|
</div>
|
||||||
@@ -214,10 +210,23 @@ export default function Reader() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-1000 relative ${revealState === "revealed" ? "opacity-0 w-0 h-0" : "opacity-100"}`}
|
||||||
|
>
|
||||||
|
<EnvelopeReveal
|
||||||
|
recipient={metadata?.recipient}
|
||||||
|
date={
|
||||||
|
metadata?.updated_at
|
||||||
|
? formatRelativeDate(new Date(metadata.updated_at))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onRevealComplete={() => setRevealState("revealed")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Atmospheric Footer */}
|
|
||||||
<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-[9px] uppercase tracking-[0.5em]">
|
<p className="text-xs font-sans uppercase tracking-[0.5em]">
|
||||||
Read. Remember. Release.
|
Read. Remember. Release.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
Reference in New Issue
Block a user