refactor: fix whitespace indents in copy
CI / Generate Certificates (pull_request) Successful in 37s
CI / Frontend CI (pull_request) Successful in 1m8s
CI / Backend CI (pull_request) Successful in 1m8s
CI / E2E Tests (pull_request) Successful in 6m53s

This commit is contained in:
me
2026-05-08 23:20:27 +05:30
parent a3a56d4316
commit fff90902b5
14 changed files with 2556 additions and 2536 deletions
@@ -1,98 +1,102 @@
import { GearFineIcon } from "@phosphor-icons/react"; import { GearFineIcon } from "@phosphor-icons/react";
interface DrawerSectionProps { interface DrawerSectionProps {
id: string; id: string;
title: string; title: string;
count: number; count: number;
subtext: string; subtext: string;
isOpen: boolean; isOpen: boolean;
onClick: () => void; onClick: () => void;
children: React.ReactNode; children: React.ReactNode;
icon: React.ReactNode; icon: React.ReactNode;
} }
export function DrawerSection({ export function DrawerSection({
id, id,
title, title,
count, count,
subtext, subtext,
isOpen, isOpen,
onClick, onClick,
children, children,
icon, icon,
}: DrawerSectionProps) { }: DrawerSectionProps) {
return ( return (
<div
id={id}
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
>
<div
className={`bg-neutral/10 transition-all duration-1000 ease-in-out overflow-visible ${isOpen ? "max-h-125" : "max-h-0 pointer-events-none"}`}
>
<div <div
id={id} className={`transition-opacity ease-in-out ${
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`} isOpen
? "opacity-100 py-3 border-b border-base-content/5 duration-700 delay-500"
: "opacity-0 duration-100"
}`}
> >
<div {children}
className={`bg-neutral/10 transition-all duration-1000 ease-in-out overflow-visible ${isOpen ? "max-h-125" : "max-h-0 pointer-events-none"}`} {count === 0 && (
<p
data-testid={`empty-drawer-message-${id}`}
className="text-center text-base-content/20 mt-4"
> >
<div This drawer remains silent
className={`transition-opacity ease-in-out ${isOpen </p>
? "opacity-100 py-3 border-b border-base-content/5 duration-700 delay-500" )}
: "opacity-0 duration-100"
}`}
>
{children}
{count === 0 && (
<p
data-testid={`empty-drawer-message-${id}`}
className="text-center text-base-content/20 mt-4"
>
This drawer remains silent
</p>
)}
</div>
</div>
<button
type="button"
onClick={onClick}
data-testid={`drawer-section-${id}`}
className="w-full relative p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 overflow-hidden 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
data-testid="drawer-section-title"
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${isOpen
? "text-base-content"
: "text-base-content/40 group-hover:text-base-content/80"
}`}
>
{title}
</div>
<div className="font-sans text-xs text-base-content/20 mt-1">
<span className="font-mono text-xs md:text-base -mt-1 absolute text-primary/30">
{count}
</span>&nbsp;
<span className="ml-3">{subtext}</span>
</div>
<div className="absolute right-5 -translate-y-15 text-base-content/4">
{icon}
</div>
</div>
{id === "vault" ? (
<GearFineIcon
className={
"-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
}
weight={"duotone"}
size={30}
/>
) : (
<div
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${isOpen
? "bg-primary/80! opacity-80 scale-110"
: "group-hover:bg-primary"
}`}
>
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
</div>
)}
</button>
</div> </div>
); </div>
<button
type="button"
onClick={onClick}
data-testid={`drawer-section-${id}`}
className="w-full relative p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 overflow-hidden 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
data-testid="drawer-section-title"
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${
isOpen
? "text-base-content"
: "text-base-content/40 group-hover:text-base-content/80"
}`}
>
{title}
</div>
<div className="font-sans text-xs text-base-content/20 mt-1">
<span className="font-mono text-xs md:text-base -mt-1 absolute text-primary/30">
{count}
</span>
&nbsp;
<span className="ml-3">{subtext}</span>
</div>
<div className="absolute right-5 -translate-y-15 text-base-content/4">
{icon}
</div>
</div>
{id === "vault" ? (
<GearFineIcon
className={
"-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
}
weight={"duotone"}
size={30}
/>
) : (
<div
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${
isOpen
? "bg-primary/80! opacity-80 scale-110"
: "group-hover:bg-primary"
}`}
>
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
</div>
)}
</button>
</div>
);
} }
@@ -4,84 +4,85 @@ import { PATHS, ROUTES } from "../../config/routes";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
interface PostSealModalProps { interface PostSealModalProps {
sealedTargetId: string | null; sealedTargetId: string | null;
navigate: NavigateFunction; navigate: NavigateFunction;
type: "KEPT" | "VAULT"; type: "KEPT" | "VAULT";
} }
export function PostSealModal({ export function PostSealModal({
sealedTargetId, sealedTargetId,
navigate, navigate,
type = "KEPT", type = "KEPT",
}: PostSealModalProps) { }: PostSealModalProps) {
return ( return (
<Modal isOpen={!!sealedTargetId} data-testid="post-seal-modal"> <Modal isOpen={!!sealedTargetId} data-testid="post-seal-modal">
<LockIcon size={32} weight="duotone" className="text-primary mt-3" /> <LockIcon size={32} weight="duotone" className="text-primary mt-3" />
<h3 className="font-serif text-2xl">Your letter is sealed</h3> <h3 className="font-serif text-2xl">Your letter is sealed</h3>
<p className="text-base-content/60"> <p className="text-base-content/60">
It's encrypted and always safe in your drawer. It's encrypted and always safe in your drawer.
</p> </p>
{type === "KEPT" ? ( {type === "KEPT" ? (
<p className="text-base-content/80 text-sm font-sans"> <p className="text-base-content/80 text-sm font-sans">
When you're ready, When you're ready,
<br /> <br />
you can&nbsp; you can&nbsp;
<span className="text-primary font-bold font-display">read</span> it,&nbsp; <span className="text-primary font-bold font-display">read</span>
<span className="text-accent font-bold font-display">send</span> it to &nbsp; it,&nbsp;
someone, or&nbsp; <span className="text-accent font-bold font-display">send</span> it to
<span className="text-error font-bold font-display">burn</span> it to someone, or&nbsp;
release <span className="text-error font-bold font-display">burn</span> it to
</p> release
) : ( </p>
<p className="text-base-content/80 text-sm font-sans"> ) : (
Be assured that the letter will find you when the time is right. <p className="text-base-content/80 text-sm font-sans">
<br /> Be assured that the letter will find you when the time is right.
Till then,&nbsp; <br />
<span className="font-bold font-display text-primary"> Till then,&nbsp;
take a deep breath <span className="font-bold font-display text-primary">
</span> take a deep breath
, <span className="font-bold font-display text-accent">manifest</span> </span>
, and&nbsp; , <span className="font-bold font-display text-accent">manifest</span>
<span className="font-bold font-display text-success"> , and&nbsp;
let it rest <span className="font-bold font-display text-success">
</span> let it rest
. </span>
</p> .
)} </p>
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4"> )}
{type === "KEPT" ? ( <div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
<> {type === "KEPT" ? (
<button <>
type="button" <button
data-testid="keep-it-btn" type="button"
className="btn btn-ghost btn-sm" data-testid="keep-it-btn"
onClick={() => navigate(ROUTES.DRAWER)} className="btn btn-ghost btn-sm"
> onClick={() => navigate(ROUTES.DRAWER)}
Keep it to myself >
</button> Keep it to myself
<button </button>
type="button" <button
data-testid="view-letter-btn" type="button"
className="btn btn-primary btn-sm" data-testid="view-letter-btn"
onClick={() => { className="btn btn-primary btn-sm"
if (sealedTargetId) { onClick={() => {
navigate(PATHS.read(sealedTargetId)); if (sealedTargetId) {
} navigate(PATHS.read(sealedTargetId));
}} }
> }}
View letter >
</button> View letter
</> </button>
) : ( </>
<button ) : (
type="button" <button
className="btn btn-ghost btn-sm" type="button"
onClick={() => navigate(ROUTES.DRAWER)} className="btn btn-ghost btn-sm"
> onClick={() => navigate(ROUTES.DRAWER)}
Step Away... >
</button> Step Away...
)} </button>
</div> )}
</Modal> </div>
); </Modal>
);
} }
+291 -292
View File
@@ -1,321 +1,320 @@
import { import {
CircleHalfTiltIcon, CircleHalfTiltIcon,
ImageIcon, ImageIcon,
LockIcon, LockIcon,
PaintBucketIcon, PaintBucketIcon,
QuestionIcon, QuestionIcon,
StampIcon, StampIcon,
TextAUnderlineIcon, TextAUnderlineIcon,
TrayIcon, TrayIcon,
VaultIcon, VaultIcon,
XCircleIcon, XCircleIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
import type { CanvasStyle } from "./ComposeCanvas"; import type { CanvasStyle } from "./ComposeCanvas";
interface ToolBarProps { interface ToolBarProps {
onAddImage: () => void; onAddImage: () => void;
sealBtnClicked: boolean; sealBtnClicked: boolean;
setSealBtnClicked: (v: boolean) => void; setSealBtnClicked: (v: boolean) => void;
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>; onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void; setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
onFontChange: (style: CanvasStyle) => void; onFontChange: (style: CanvasStyle) => void;
latestFontStyle: CanvasStyle; latestFontStyle: CanvasStyle;
} }
const FONT_FAMILIES: Map<string, string> = new Map([ const FONT_FAMILIES: Map<string, string> = new Map([
["Serif", "Playfair Display Variable"], ["Serif", "Playfair Display Variable"],
["Sans", "Jost Variable"], ["Sans", "Jost Variable"],
["Cursive", "Playwrite HR Lijeva Variable"], ["Cursive", "Playwrite HR Lijeva Variable"],
["Handwriting", "Architects Daughter"], ["Handwriting", "Architects Daughter"],
["Slab", "Cutive Mono"], ["Slab", "Cutive Mono"],
["Mono", "Space Mono"], ["Mono", "Space Mono"],
["Ink", "Kavivanar"], ["Ink", "Kavivanar"],
["Crazy(pls no)", "Redacted Script"], ["Crazy(pls no)", "Redacted Script"],
]); ]);
const FONT_COLORS: Map<string, string> = new Map([ const FONT_COLORS: Map<string, string> = new Map([
["Black", "#000"], ["Black", "#000"],
["Gold", "#866a0e"], ["Gold", "#866a0e"],
["Purple", "#711caf"], ["Purple", "#711caf"],
["Green", "#1f5b1f"], ["Green", "#1f5b1f"],
["Blue", "#111e67"], ["Blue", "#111e67"],
]); ]);
export function ToolBar({ export function ToolBar({
onAddImage, onAddImage,
sealBtnClicked, sealBtnClicked,
setSealBtnClicked, setSealBtnClicked,
onSave, onSave,
setConfirmModal, setConfirmModal,
onFontChange, onFontChange,
latestFontStyle, latestFontStyle,
}: ToolBarProps) { }: ToolBarProps) {
return ( return (
<div <div
id="writer-toolbar" id="writer-toolbar"
className="relative z-10 flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6" className="relative z-10 flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
>
<div className="flex gap-4">
{/* Image upload */}
<button
type="button"
className="btn btn-ghost btn-sm group"
onClick={onAddImage}
> >
<div className="flex gap-4"> <ImageIcon size={18} weight="bold" />
{/* Image upload */} <span className="hidden md:inline group-hover:inline transition-all duration-1000">
<button Add Image
type="button" </span>
className="btn btn-ghost btn-sm group" </button>
onClick={onAddImage} <div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
>
<ImageIcon size={18} weight="bold" />
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
Add Image
</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
{/* Font Family */} {/* Font Family */}
<div className={"flex items-center gap-2 group"}> <div className={"flex items-center gap-2 group"}>
<TextAUnderlineIcon <TextAUnderlineIcon
size={24} size={24}
weight="bold" weight="bold"
className={"hidden md:inline"} className={"hidden md:inline"}
/> />
<select <select
className="select select-sm" className="select select-sm"
onChange={(e) => { onChange={(e) => {
onFontChange({ ...latestFontStyle, fontFamily: e.target.value }); onFontChange({ ...latestFontStyle, fontFamily: e.target.value });
}} }}
value={latestFontStyle.fontFamily} value={latestFontStyle.fontFamily}
> >
{Array.from(FONT_FAMILIES.entries()).map( {Array.from(FONT_FAMILIES.entries()).map(
([fontFamily, fontName]) => { ([fontFamily, fontName]) => {
return ( return (
<option key={fontName} value={fontName}> <option key={fontName} value={fontName}>
{fontFamily} {fontFamily}
</option> </option>
); );
}, },
)} )}
</select> </select>
</div>
<div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
{/* Font Color */}
<div className="dropdown dropdown-bottom flex items-center gap-2 group">
<PaintBucketIcon
size={16}
weight="bold"
className={"hidden md:flex"}
/>
<button
className="btn btn-ghost btn-sm px-2 gap-2 flex items-center"
type={"button"}
>
<CircleHalfTiltIcon
size={18}
style={{ color: latestFontStyle.fontColor }}
weight="duotone"
/>
</button>
<ul className="dropdown-content z-50 menu p-2 shadow bg-base-200/95 rounded-full md:ml-4">
{Array.from(FONT_COLORS.entries()).map(([_, colorCode]) => (
<li key={colorCode}>
<button
type="button"
className={`${latestFontStyle.fontColor === colorCode ? "active" : ""}`}
onClick={() => {
onFontChange({ ...latestFontStyle, fontColor: colorCode });
(document.activeElement as HTMLButtonElement)?.blur();
}}
>
<CircleHalfTiltIcon
size={18}
style={{ color: colorCode }}
weight="fill"
/>
</button>
</li>
))}
</ul>
</div>
</div>
{/* Draft */}
<div className="flex items-center gap-2">
<button
type="button"
data-testid="draft-btn"
className="btn btn-ghost btn-sm text-xxs group tracking-widester uppercase font-bold text-base-content/60 hover:text-base-content"
title="Store in your private drawer"
onClick={() => onSave("DRAFT")}
>
<TrayIcon size={18} weight="bold" />
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
Draft
</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2 hidden md:inline" />
{/*Seal */}
<button
type="button"
data-testid="seal-trigger-btn"
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
onClick={() => setSealBtnClicked(true)}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span
className={`hidden md:inline ${sealBtnClicked ? "inline" : ""} group-hover:inline transition-all duration-1000`}
>
Seal
</span>
</button>
</div>
<div
className={`flex-col items-center gap-2 absolute right-0 z-10 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
>
<button
type="button"
data-testid="seal-confirm-btn"
className="btn btn-accent btn-sm rounded-full px-6 group"
onClick={() => onSave("SEALED")}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span className="transition-all duration-1000">Seal</span>
</button>
<div className="w-full divider text-neutral-content/60 mt-2 mb-2">
or
</div>
<button
type="button"
data-testid="vault-trigger-btn"
className="btn btn-neutral btn-sm rounded-full px-6 group"
onClick={() => setConfirmModal("VAULT")}
>
<VaultIcon size={16} weight="fill" className="mr-1" />
<span className="transition-all duration-1000">Vault</span>
</button>
</div>
<button
className={`z-100001 absolute right-0 bg-transparent cursor-pointer ${sealBtnClicked ? "" : "hidden"}`}
type="button"
onClick={() => setSealBtnClicked(false)}
>
<XCircleIcon weight="duotone" size={20} className={"text-error"} />
</button>
<button
type="button"
aria-label="Help"
className={`bg-transparent cursor-pointer -mt-2 absolute z-100001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
>
<div className="tooltip tooltip-left">
<div className="tooltip-content -translate-x-38 text-left">
<span className="font-bold text-accent">Seal</span> puts the letter
in an envelope, ready to be read right away.
<div className="divider my-0"></div>
<span className="font-bold text-success">Vault</span> keeps it
locked away until the right moment, even from yourself.
</div>
<QuestionIcon
weight="duotone"
size={20}
className={"absolute -translate-x-38 -translate-y-3"}
/>
</div>
</button>
</div> </div>
); <div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
{/* Font Color */}
<div className="dropdown dropdown-bottom flex items-center gap-2 group">
<PaintBucketIcon
size={16}
weight="bold"
className={"hidden md:flex"}
/>
<button
className="btn btn-ghost btn-sm px-2 gap-2 flex items-center"
type={"button"}
>
<CircleHalfTiltIcon
size={18}
style={{ color: latestFontStyle.fontColor }}
weight="duotone"
/>
</button>
<ul className="dropdown-content z-50 menu p-2 shadow bg-base-200/95 rounded-full md:ml-4">
{Array.from(FONT_COLORS.entries()).map(([_, colorCode]) => (
<li key={colorCode}>
<button
type="button"
className={`${latestFontStyle.fontColor === colorCode ? "active" : ""}`}
onClick={() => {
onFontChange({ ...latestFontStyle, fontColor: colorCode });
(document.activeElement as HTMLButtonElement)?.blur();
}}
>
<CircleHalfTiltIcon
size={18}
style={{ color: colorCode }}
weight="fill"
/>
</button>
</li>
))}
</ul>
</div>
</div>
{/* Draft */}
<div className="flex items-center gap-2">
<button
type="button"
data-testid="draft-btn"
className="btn btn-ghost btn-sm text-xxs group tracking-widester uppercase font-bold text-base-content/60 hover:text-base-content"
title="Store in your private drawer"
onClick={() => onSave("DRAFT")}
>
<TrayIcon size={18} weight="bold" />
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
Draft
</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2 hidden md:inline" />
{/*Seal */}
<button
type="button"
data-testid="seal-trigger-btn"
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
onClick={() => setSealBtnClicked(true)}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span
className={`hidden md:inline ${sealBtnClicked ? "inline" : ""} group-hover:inline transition-all duration-1000`}
>
Seal
</span>
</button>
</div>
<div
className={`flex-col items-center gap-2 absolute right-0 z-10 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
>
<button
type="button"
data-testid="seal-confirm-btn"
className="btn btn-accent btn-sm rounded-full px-6 group"
onClick={() => onSave("SEALED")}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span className="transition-all duration-1000">Seal</span>
</button>
<div className="w-full divider text-neutral-content/60 mt-2 mb-2">
or
</div>
<button
type="button"
data-testid="vault-trigger-btn"
className="btn btn-neutral btn-sm rounded-full px-6 group"
onClick={() => setConfirmModal("VAULT")}
>
<VaultIcon size={16} weight="fill" className="mr-1" />
<span className="transition-all duration-1000">Vault</span>
</button>
</div>
<button
className={`z-100001 absolute right-0 bg-transparent cursor-pointer ${sealBtnClicked ? "" : "hidden"}`}
type="button"
onClick={() => setSealBtnClicked(false)}
>
<XCircleIcon weight="duotone" size={20} className={"text-error"} />
</button>
<button
type="button"
aria-label="Help"
className={`bg-transparent cursor-pointer -mt-2 absolute z-100001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
>
<div className="tooltip tooltip-left">
<div className="tooltip-content -translate-x-38 text-left">
<span className="font-bold text-accent">Seal</span> puts the letter
in an envelope, ready to be read right away.
<div className="divider my-0"></div>
<span className="font-bold text-success">Vault</span> keeps it
locked away until the right moment, even from yourself.
</div>
<QuestionIcon
weight="duotone"
size={20}
className={"absolute -translate-x-38 -translate-y-3"}
/>
</div>
</button>
</div>
);
} }
export function LetterHead() { export function LetterHead() {
return ( return (
<div className="flex items-center justify-center mb-8 h-14"> <div className="flex items-center justify-center mb-8 h-14">
<div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full"> <div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full">
<LockIcon size={14} weight="fill" /> <LockIcon size={14} weight="fill" />
<span className="text-xxs uppercase tracking-widest font-bold"> <span className="text-xxs uppercase tracking-widest font-bold">
Sealed & View Only Sealed & View Only
</span> </span>
</div> </div>
</div> </div>
); );
} }
interface VaultConfirmModalProps { interface VaultConfirmModalProps {
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>; onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void; setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
setUnlockDate: (d: Date | null) => void; setUnlockDate: (d: Date | null) => void;
} }
export function VaultConfirmModal({ export function VaultConfirmModal({
onSave, onSave,
setConfirmModal, setConfirmModal,
setUnlockDate, setUnlockDate,
}: VaultConfirmModalProps) { }: VaultConfirmModalProps) {
return ( return (
<Modal isOpen={true}> <Modal isOpen={true}>
<VaultIcon <VaultIcon
size={48} size={48}
className="text-primary mx-auto mb-8 animate-pulse" className="text-primary mx-auto mb-8 animate-pulse"
/> />
<h3 className="font-serif text-3xl">Take it away, then?</h3> <h3 className="font-serif text-3xl">Take it away, then?</h3>
<p className="text-base-content/60 text-sm text-center mt-4"> <p className="text-base-content/60 text-sm text-center mt-4">
By vaulting this letter, you ask me to hold on to this. By vaulting this letter, you ask me to hold on to this.
<br /> <br />
I'll remember to mail you this on the unlock date. I'll remember to mail you this on the unlock date.
<br /> <br />
<span className={"font-bold text-primary"}> <span className={"font-bold text-primary"}>
&nbsp; &nbsp; But I won't let you read or rewrite this letter until then.
But I won't let you read or rewrite this letter until then. </span>
</span> <br />
<br /> </p>
</p> <form
<form onSubmit={async (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 onSave("VAULT", newUnlockDate);
await onSave("VAULT", newUnlockDate); setConfirmModal(null);
setConfirmModal(null); }}
}} id="vault-form"
id="vault-form" className="min-w-75"
className="min-w-75" >
> <div className={"divider tracking-tightest font-display text-sm"}>
<div className={"divider tracking-tightest font-display text-sm"}> Set an unlock date
Set an unlock date </div>
</div> <input
<input required
required type="date"
type="date" className="input input-bordered w-full"
className="input input-bordered w-full" name="vault-date"
name="vault-date" />
/> <div className="w-full flex justify-center gap-8 mt-4">
<div className="w-full flex justify-center gap-8 mt-4"> <button
<button type="button"
type="button" data-testid="vault-cancel-btn"
data-testid="vault-cancel-btn" className="btn btn-ghost btn-sm mt-4"
className="btn btn-ghost btn-sm mt-4" onClick={() => setConfirmModal(null)}
onClick={() => setConfirmModal(null)} >
> I need time
I need time </button>
</button> <button
<button className="btn btn-primary btn-sm mt-4"
className="btn btn-primary btn-sm mt-4" type="submit"
type="submit" data-testid="vault-confirm-btn"
data-testid="vault-confirm-btn" form="vault-form"
form="vault-form" >
> Take it
Take it </button>
</button> </div>
</div> </form>
</form> </Modal>
</Modal> );
);
} }
+75 -73
View File
@@ -1,85 +1,87 @@
import { import {
HandPalmIcon, HandPalmIcon,
ShieldCheckIcon, ShieldCheckIcon,
WarningIcon, WarningIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import Logo from "../Logo"; import Logo from "../Logo";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan"; import Saajan from "../ui/Saajan";
export default function WelcomeModal({ export default function WelcomeModal({
setShowWelcome, setShowWelcome,
}: { }: {
setShowWelcome: (show: boolean) => void; setShowWelcome: (show: boolean) => void;
}) { }) {
return ( return (
<> <>
<Modal isOpen={true}> <Modal isOpen={true}>
<div className="flex flex-col items-center text-center gap-2 md:gap-4"> <div className="flex flex-col items-center text-center gap-2 md:gap-4">
<div className="bg-primary/10 p-4 rounded-full animate-pulse"> <div className="bg-primary/10 p-4 rounded-full animate-pulse">
<ShieldCheckIcon <ShieldCheckIcon
size={48} size={48}
weight="duotone" weight="duotone"
className="text-primary" className="text-primary"
/> />
</div> </div>
<h3 className="font-display text-2xl font-bold text-primary"> <h3 className="font-display text-2xl font-bold text-primary">
Welcome to &nbsp; Welcome to&nbsp;
<Logo /> <Logo type="inline" />
</h3> </h3>
<p className="text-sm md:text-base text-base-content/80 md:leading-relaxed"> <p className="inline text-sm md:text-base text-base-content/80">
Before we begin, let me make a small promise. Before we begin, let me make a small promise.
<HandPalmIcon <HandPalmIcon
size={18} size={18}
className="inline text-primary" className="inline text-primary"
weight="fill" weight="fill"
/> />
<span className="divider my-0"></span> <span className="divider my-0"></span>
Everything you write here is sealed with your password,&nbsp; Everything you write here is sealed with your password,&nbsp;
<span className="font-display text-success">cryptographically</span> <span className="font-display text-success">cryptographically</span>
, before it leaves your hands. , before it leaves your hands.
<br />A fancy way of saying, I couldn't if I tried. <br />
</p> <br />A fancy way of saying, no one else can read them without your
key&mdash;not even me.
</p>
<div className="alert alert-warning flex items-start gap-3 text-left py-3"> <div className="alert alert-warning flex items-start gap-3 text-left py-3">
<WarningIcon size={24} weight="fill" className="shrink-0" /> <WarningIcon size={24} weight="fill" className="shrink-0" />
<div className="text-xs md:text-sm font-medium text-primary-content"> <div className="text-xs md:text-sm font-medium text-primary-content tracking-tight">
If you ever happen to forget your password, your letters are lost If you ever happen to forget your password, your letters are lost
to time, forever. to time, forever.
<br /> <span className="mt-2 block">
<span className="font-bold mt-2 block"> I highly, <span className="font-bold italic">highly</span>&nbsp;
I highly, highly recommend storing this password in your&nbsp; recommend storing this password in your&nbsp;
<a <a
href="https://www.privacyguides.org/en/passwords/" href="https://www.privacyguides.org/en/passwords/"
target="_blank" target="_blank"
className="link link-neutral!" className="link link-neutral!"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
password manager password manager
</a>&nbsp; </a>
or somewhere safe to remember it. &nbsp; or somewhere safe to remember it.
</span> </span>
</div>
</div>
<div className="modal-action w-full">
<button
type="button"
data-testid="welcome-dismiss-btn"
onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg"
>
I'll remember
</button>
</div>
</div>
</Modal>
<div className="absolute bottom-0 right-0 z-1000 font-sans w-full">
<Saajan
position="left"
message={"I've lost words before.\nI know what it feels like."}
/>
</div> </div>
</> </div>
);
<div className="modal-action w-full">
<button
type="button"
data-testid="welcome-dismiss-btn"
onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg"
>
I'll remember
</button>
</div>
</div>
</Modal>
<div className="absolute bottom-0 md:right-5/12 z-1000 font-sans w-full flex justify-center">
<Saajan
position="left"
message={"I've lost words before.\nI know what it feels like."}
/>
</div>
</>
);
} }
+88 -88
View File
@@ -3,97 +3,97 @@ import { useEffect, useState } from "react";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
interface BurnModalProps { interface BurnModalProps {
burnLetter: () => void; burnLetter: () => void;
isBurning: boolean; isBurning: boolean;
setShowBurnModal: (show: boolean) => void; setShowBurnModal: (show: boolean) => void;
setRevealState: (state: "SEALED" | "REVEALED" | "BURNING" | "BURNED") => void; setRevealState: (state: "SEALED" | "REVEALED" | "BURNING" | "BURNED") => void;
} }
export function BurnModal({ export function BurnModal({
burnLetter, burnLetter,
isBurning, isBurning,
setShowBurnModal, setShowBurnModal,
setRevealState, setRevealState,
}: BurnModalProps) { }: BurnModalProps) {
const [flameOn, setFlameOn] = useState(0); const [flameOn, setFlameOn] = useState(0);
const [rotate, setRotate] = useState(0); const [rotate, setRotate] = useState(0);
const [burnClicked, setBurnClicked] = useState(false); const [burnClicked, setBurnClicked] = useState(false);
useEffect(() => { useEffect(() => {
if (!burnClicked) return; if (!burnClicked) return;
if (flameOn === 100) { if (flameOn === 100) {
setRevealState("SEALED"); setRevealState("SEALED");
burnLetter(); 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 (
<Modal isOpen={true} onClose={() => setShowBurnModal(false)}>
<div
className={`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
} }
const interval = setInterval(() => { >
setFlameOn((prev) => prev + 1); <CampfireIcon
setRotate(Math.random() * 4 - 2); size={48}
}, 100); weight="duotone"
return () => clearInterval(interval); className="text-error animate-pulse"
}, [burnClicked, flameOn, setRevealState, burnLetter]); />
<h3 className="font-serif text-2xl">
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`; Are you ready to burn this letter?
</h3>
return ( <p className="text-sm font-sans text-base-content/80 mt-4">
<Modal isOpen={true} onClose={() => setShowBurnModal(false)}> Some words are meant to be unsaid, but they don't have to linger
<div forever.
className={`flex flex-col items-center gap-4 text-center transition-all duration-200 ease-in-out ${burnClicked ? "animate-[pulse_15s_linear_infinite]" : ""}`} <br />
style={ Let the echoes of your unsaid be finally released.
{ </p>
transform: `rotate(${rotate}deg)`, <div className="mt-4 font-sans text-sm">
} as React.CSSProperties <span className="text-error">Press</span> and&nbsp;
} <span className="text-error">hold</span> the&nbsp;
> <span className="text-amber-300">flame</span> to proceed.
<CampfireIcon </div>
size={48} <div className="modal-action w-full justify-center gap-3 mt-2">
weight="duotone" <div
className="text-error animate-pulse" className="absolute -mt-2 w-28 h-28 radial-progress pointer-events-none text-amber-200/60"
/> style={
<h3 className="font-serif text-2xl"> { "--value": flameOn, filter: burnStyle } as React.CSSProperties
Are you ready to burn this letter? }
</h3> role="progressbar"
<p className="text-sm font-sans text-base-content/80 mt-4"> ></div>
Some words are meant to be unsaid, but they don't have to linger <button
forever. type="button"
<br /> className={`btn btn-error btn-dashed btn-circle w-24 h-24`}
Let the echoes of your unsaid be finally released. style={
</p> {
<div className="mt-4 font-sans text-sm"> filter: burnStyle,
<span className="text-error">Press</span> and&nbsp; cursor: burnClicked ? "grabbing" : "grab",
<span className="text-error">hold</span> the&nbsp; } as React.CSSProperties
<span className="text-amber-300">flame</span> to proceed. }
</div> onMouseDown={() => setBurnClicked(true)}
<div className="modal-action w-full justify-center gap-3 mt-2"> onMouseUp={() => {
<div setFlameOn(0);
className="absolute -mt-2 w-28 h-28 radial-progress pointer-events-none text-amber-200/60" setBurnClicked(false);
style={ }}
{ "--value": flameOn, filter: burnStyle } as React.CSSProperties disabled={isBurning}
} >
role="progressbar" {isBurning ? (
></div> <span className="loading loading-spinner loading-xs" />
<button ) : (
type="button" <FlameIcon size={54} weight="duotone" />
className={`btn btn-error btn-dashed btn-circle w-24 h-24`} )}
style={ </button>
{ </div>
filter: burnStyle, </div>
cursor: burnClicked ? "grabbing" : "grab", </Modal>
} 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>
</Modal>
);
} }
@@ -2,39 +2,39 @@ import { useNavigate } from "react-router-dom";
import { ROUTES } from "../../config/routes"; import { ROUTES } from "../../config/routes";
interface PostActionOverlayProps { interface PostActionOverlayProps {
revealState: "SEALED" | "REVEALED" | "BURNING" | "BURNED"; revealState: "SEALED" | "REVEALED" | "BURNING" | "BURNED";
} }
export function PostActionOverlay({ revealState }: PostActionOverlayProps) { export function PostActionOverlay({ revealState }: PostActionOverlayProps) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div <div
className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "BURNED" ? "opacity-100" : "opacity-0"} transition-all delay-1000 duration-1000`} className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "BURNED" ? "opacity-100" : "opacity-0"} transition-all delay-1000 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>
&nbsp; 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)}
> >
<h1 Turn the page
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]`} </button>
> </div>
It is done </div>
</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>&nbsp;
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>
);
} }
+69 -69
View File
@@ -3,77 +3,77 @@ import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan"; import Saajan from "../ui/Saajan";
interface ShareModalProps { interface ShareModalProps {
shareLink: string | null; shareLink: string | null;
setShareLink: (link: string | null) => void; setShareLink: (link: string | null) => void;
} }
export function ShareModal({ shareLink, setShareLink }: ShareModalProps) { export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (!shareLink) return; if (!shareLink) return;
await navigator.clipboard.writeText(shareLink); await navigator.clipboard.writeText(shareLink);
}; };
return ( return (
<> <>
<Modal <Modal
isOpen={!!shareLink} isOpen={!!shareLink}
onClose={() => setShareLink(null)} onClose={() => setShareLink(null)}
data-testid="share-letter-modal" data-testid="share-letter-modal"
>
<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.
<br />
Send your letter now, and let the&nbsp;
<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">
They'll receive it exactly as you're seeing it now.
<br />
Nothing more, nothing less.
</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}
data-testid="copy-link-btn"
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
> >
<div className="flex flex-col items-center justify-center text-center gap-6 py-4"> Copy
<div className="space-y-2"> </button>
<PaperPlaneTiltIcon </div>
size={48} <div className="flex flex-col gap-1 uppercase tracking-widest text-base-content/30 font-sans">
weight="bold" <p className="textarea-xs flex items-center justify-center">
className="mb-4 text-primary mx-auto animate-[bounce_3s_ease-in-out_infinite]" <EyeSlashIcon weight="duotone" size={18} className="mr-2" />
/> &nbsp; Zero-Knowledge Share:
<h3 className="font-serif text-3xl">Send this letter</h3> </p>
<p className="text-base-content/80 text-sm font-sans mt-4"> <p className="textarea-xs font-mono text-center">
You've carried these words long enough. The key never leaves your or the recipient's browser.
<br /> </p>
Send your letter now, and let the&nbsp; </div>
<span className="text-accent font-display">unsaid</span> finally </div>
find its home. </Modal>
</p> <div className="absolute bottom-0 md:right-5/11 z-1000 font-sans w-full">
<div className="divider mx-auto" /> <Saajan
<blockquote className="text-sm info text-neutral-content/60 font-sans"> position="top"
They'll receive it exactly as you're seeing it now. message={`Someone once said,\n"To send a letter is a good way to go somewhere without moving anything but your heart."\nThey were not wrong.`}
<br /> />
Nothing more, nothing less. </div>
</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}
data-testid="copy-link-btn"
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" />&nbsp;
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>
</Modal>
<div className="absolute bottom-0 z-1000 font-sans w-full">
<Saajan
position="top"
message={`Someone once said,\n"To send a letter is a good way to go somewhere without moving anything but your heart."\nThey were not wrong.`}
/>
</div>
</>
);
} }
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -59,7 +59,8 @@ export default function Activate() {
You're in. You're in.
</h2> </h2>
<p className="opacity-70 leading-relaxed"> <p className="opacity-70 leading-relaxed">
Welcome to <Logo scale={1} /> Welcome to&nbsp;
<Logo type="inline" />
<br /> <br />
Just one more step and you can start writing timeless letters. Just one more step and you can start writing timeless letters.
</p> </p>
+182 -182
View File
@@ -1,9 +1,9 @@
import { import {
ArchiveIcon, ArchiveIcon,
FeatherIcon, FeatherIcon,
FileDashedIcon, FileDashedIcon,
PaperPlaneTiltIcon, PaperPlaneTiltIcon,
VaultIcon, VaultIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -17,191 +17,191 @@ import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters"; import { useLetters } from "../hooks/useLetters";
import { import {
formatRelativeDate, formatRelativeDate,
formatRelativeDateWithoutTime, formatRelativeDateWithoutTime,
} from "../utils/dateFormat"; } from "../utils/dateFormat";
export default function Drawer() { export default function Drawer() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [openSection, setOpenSection] = useState<string | null>(null); const [openSection, setOpenSection] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [showWelcomeLetter, setShowWelcomeLetter] = useState( const [showWelcomeLetter, setShowWelcomeLetter] = useState(
!!location.state?.firstTime, !!location.state?.firstTime,
); );
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters(); const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
if (!user) return null; if (!user) return null;
const toggleSection = (id: string) => const toggleSection = (id: string) =>
setOpenSection(openSection === id ? null : id); setOpenSection(openSection === id ? null : id);
return ( return (
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors"> <div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig pointer-events-none z-0" />
{showWelcomeLetter && ( {showWelcomeLetter && (
<WelcomeLetterOverlay <WelcomeLetterOverlay
userName={user.full_name} userName={user.full_name}
onComplete={() => { onComplete={() => {
setShowWelcomeLetter(false); setShowWelcomeLetter(false);
navigate(location.pathname, { replace: true, state: {} }); navigate(location.pathname, { replace: true, state: {} });
}} }}
/> />
)} )}
{isAuthRequired && <PasskeyModal />} {isAuthRequired && <PasskeyModal />}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500"> <header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
<Logo /> <Logo />
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2"> <div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
Personal Archive Personal Archive
</div>
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
Welcome Back&nbsp;
<span className="font-semibold text-primary">{user.full_name}</span>
<button
type="button"
onClick={logout}
className="ml-3 cursor-pointer underline underline-offset-4 text-xs hover:text-primary transition-colors"
>
Sign Out
</button>
</div>
</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 duration-500 delay-200 min-h-64 flex flex-col">
{loading ? (
<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
data-testid="drawer-loading-state"
className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse"
>
Opening your cabinet...
</span>
</div>
) : (
<>
<DrawerSection
id="drafts"
title="Drafts"
count={drafts.length}
subtext="unfinished whispers"
isOpen={openSection === "drafts"}
onClick={() => toggleSection("drafts")}
icon={<FileDashedIcon weight="thin" size={128} />}
>
{drafts.map((draft) => (
<LetterItem
id={draft.public_id}
status={draft.status}
key={draft.public_id}
preview={draft.metadata?.recipient || "Untitled Draft"}
timestamp={formatRelativeDate(draft.updated_at)}
/>
))}
</DrawerSection>
<DrawerSection
id="kept"
title="Kept"
count={kept.length}
subtext="private letters"
isOpen={openSection === "kept"}
onClick={() => toggleSection("kept")}
icon={<ArchiveIcon weight="thin" size={128} />}
>
{kept.map((letter) => (
<LetterItem
id={letter.public_id}
status={letter.status}
key={letter.public_id}
preview={letter.metadata?.recipient || "Someone dear..."}
timestamp={formatRelativeDate(letter.updated_at)}
/>
))}
</DrawerSection>
<DrawerSection
id="sent"
title="Sent"
count={sent.length}
subtext="shared truths"
isOpen={openSection === "sent"}
onClick={() => toggleSection("sent")}
icon={<PaperPlaneTiltIcon weight="thin" size={128} />}
>
{sent.map((letter) => (
<LetterItem
key={letter.public_id}
status={letter.status}
id={letter.public_id}
preview={letter.metadata?.recipient || "Someone dear..."}
timestamp={formatRelativeDate(letter.updated_at)}
/>
))}
</DrawerSection>
<DrawerSection
id="vault"
title="Vault"
count={vault.length}
subtext="things locked—not lost—in time"
isOpen={openSection === "vault"}
onClick={() => toggleSection("vault")}
icon={<VaultIcon weight="thin" size={128} />}
>
{vault.map((letter) => (
<LetterItem
key={letter.public_id}
status={letter.status}
id={letter.public_id}
preview={letter.metadata?.recipient || "Future Self"}
timestamp={formatRelativeDate(letter.updated_at)}
unlock_at={formatRelativeDateWithoutTime(
letter.unlock_at || "",
)}
isLocked={letter.unlock_at > new Date().toISOString()}
/>
))}
</DrawerSection>
</>
)}
</div>
<button
type="button"
id="write-letter-btn"
data-testid="write-letter-btn"
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
onClick={() => navigate(PATHS.write(""))}
>
<FeatherIcon
size={18}
weight="duotone"
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
/>
Write something&nbsp;
<span className="relative inline-flex">
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
. . . . . .
</span>
<span className="absolute inset-0 text-primary transition-opacity duration-300 opacity-0 group-hover:opacity-100">
unsaid
</span>
</span>
</button>
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
For your unsaid.
</footer>
{!showWelcomeLetter && (
<div className="absolute bottom-0 z-50 font-sans">
<Saajan
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
position="top"
/>
</div>
)}
</div> </div>
); <div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
Welcome Back&nbsp;
<span className="font-semibold text-primary">{user.full_name}</span>
<button
type="button"
onClick={logout}
className="ml-3 cursor-pointer underline underline-offset-4 text-xs hover:text-primary transition-colors"
>
Sign Out
</button>
</div>
</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 duration-500 delay-200 min-h-64 flex flex-col">
{loading ? (
<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
data-testid="drawer-loading-state"
className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse"
>
Opening your cabinet...
</span>
</div>
) : (
<>
<DrawerSection
id="drafts"
title="Drafts"
count={drafts.length}
subtext="unfinished whispers"
isOpen={openSection === "drafts"}
onClick={() => toggleSection("drafts")}
icon={<FileDashedIcon weight="thin" size={128} />}
>
{drafts.map((draft) => (
<LetterItem
id={draft.public_id}
status={draft.status}
key={draft.public_id}
preview={draft.metadata?.recipient || "Untitled Draft"}
timestamp={formatRelativeDate(draft.updated_at)}
/>
))}
</DrawerSection>
<DrawerSection
id="kept"
title="Kept"
count={kept.length}
subtext="private letters"
isOpen={openSection === "kept"}
onClick={() => toggleSection("kept")}
icon={<ArchiveIcon weight="thin" size={128} />}
>
{kept.map((letter) => (
<LetterItem
id={letter.public_id}
status={letter.status}
key={letter.public_id}
preview={letter.metadata?.recipient || "Someone dear..."}
timestamp={formatRelativeDate(letter.updated_at)}
/>
))}
</DrawerSection>
<DrawerSection
id="sent"
title="Sent"
count={sent.length}
subtext="shared truths"
isOpen={openSection === "sent"}
onClick={() => toggleSection("sent")}
icon={<PaperPlaneTiltIcon weight="thin" size={128} />}
>
{sent.map((letter) => (
<LetterItem
key={letter.public_id}
status={letter.status}
id={letter.public_id}
preview={letter.metadata?.recipient || "Someone dear..."}
timestamp={formatRelativeDate(letter.updated_at)}
/>
))}
</DrawerSection>
<DrawerSection
id="vault"
title="Vault"
count={vault.length}
subtext="things locked—not lost—in time"
isOpen={openSection === "vault"}
onClick={() => toggleSection("vault")}
icon={<VaultIcon weight="thin" size={128} />}
>
{vault.map((letter) => (
<LetterItem
key={letter.public_id}
status={letter.status}
id={letter.public_id}
preview={letter.metadata?.recipient || "Future Self"}
timestamp={formatRelativeDate(letter.updated_at)}
unlock_at={formatRelativeDateWithoutTime(
letter.unlock_at || "",
)}
isLocked={letter.unlock_at > new Date().toISOString()}
/>
))}
</DrawerSection>
</>
)}
</div>
<button
type="button"
id="write-letter-btn"
data-testid="write-letter-btn"
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
onClick={() => navigate(PATHS.write(""))}
>
<FeatherIcon
size={18}
weight="duotone"
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
/>
Write something&nbsp;
<span className="relative inline-flex">
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
. . . . . .
</span>
<span className="absolute inset-0 text-primary transition-opacity duration-300 opacity-0 group-hover:opacity-100">
unsaid
</span>
</span>
</button>
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
For your unsaid.
</footer>
{!showWelcomeLetter && (
<div className="absolute bottom-0 z-50 font-sans">
<Saajan
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
position="top"
/>
</div>
)}
</div>
);
} }
+375 -376
View File
@@ -1,10 +1,10 @@
import { InfoIcon } from "@phosphor-icons/react"; import { InfoIcon } from "@phosphor-icons/react";
import { ReactLenis } from "lenis/react"; import { ReactLenis } from "lenis/react";
import { import {
motion, motion,
useMotionValueEvent, useMotionValueEvent,
useScroll, useScroll,
useTransform, useTransform,
} from "motion/react"; } from "motion/react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -19,383 +19,382 @@ import "@fontsource/space-mono/index.css";
import "@fontsource/architects-daughter/index.css"; import "@fontsource/architects-daughter/index.css";
export default function Home() { export default function Home() {
const sectionContainer1 = useRef<HTMLDivElement>(null); const sectionContainer1 = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ const { scrollYProgress } = useScroll({
target: sectionContainer1, target: sectionContainer1,
}); });
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true); const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
const [flapOpen, setFlapOpen] = useState(false); const [flapOpen, setFlapOpen] = useState(false);
const [recipient, setRecipient] = useState("someone dear"); const [recipient, setRecipient] = useState("someone dear");
const [ignite, setIgnite] = useState(false); const [ignite, setIgnite] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => { useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
if (latestScrollValue > 0.54) { if (latestScrollValue > 0.54) {
setFlapOpen(false); setFlapOpen(false);
} else { } else {
setFlapOpen(true); setFlapOpen(true);
} }
if (latestScrollValue <= 0.6) { if (latestScrollValue <= 0.6) {
setIsEnvelopeFlipped(true); setIsEnvelopeFlipped(true);
} else { } else {
setIsEnvelopeFlipped(false); setIsEnvelopeFlipped(false);
} }
if (latestScrollValue > 0.68) { if (latestScrollValue > 0.68) {
setRecipient("future me"); setRecipient("future me");
} else { } else {
setRecipient("someone dear"); setRecipient("someone dear");
} }
if (latestScrollValue > 0.77) { if (latestScrollValue > 0.77) {
setIgnite(true); setIgnite(true);
} else { } else {
setIgnite(false); setIgnite(false);
} }
}); });
return ( return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}> <ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
<section <section
ref={sectionContainer1} ref={sectionContainer1}
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90" className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
>
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
{/* Intro */}
<motion.div
className="absolute flex flex-col items-center justify-center pointer-events-none"
style={{
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
}}
>
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6">
You've been carrying something
</h1>
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
unsaid
</motion.h2>
</motion.div>
<motion.div
className="absolute text-center"
style={{
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
}}
>
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
and that's okay...
</div>
</motion.div>
{/* pi. ku. */}
<motion.div
className="absolute text-center px-6"
style={{
opacity: useTransform(
scrollYProgress,
[0.18, 0.25, 0.3],
[0, 1, 0],
),
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
}}
transition={{ delay: 4 }}
>
<Logo type="logo" scale={1.5} ul={true} />
<motion.div
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
style={{
opacity: useTransform(
scrollYProgress,
[0.22, 0.25, 0.35, 0.4],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
),
}}
> >
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden"> is a{" "}
{/* Intro */} <span className="font-display text-primary font-extralight">
<motion.div safe space
className="absolute flex flex-col items-center justify-center pointer-events-none" </span>
style={{ ,<br />
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]), <motion.span
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]), className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
}} transition={{ delay: 5 }}
> whileInView={{ opacity: 1 }}
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6"> viewport={{ once: false, amount: 0.3 }}
You've been carrying something >
</h1> where you can
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light"> </motion.span>
unsaid </motion.div>
</motion.h2> </motion.div>
</motion.div>
<motion.div <div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
className="absolute text-center" <motion.h2
style={{ style={{
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]), opacity: useTransform(
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]), scrollYProgress,
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]), [0.3, 0.35, 0.4, 0.45],
}} [0, 1, 1, 0],
> ),
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic"> y: useTransform(
and that's okay... scrollYProgress,
</div> [0.3, 0.35, 0.4, 0.45],
</motion.div> [40, 0, 0, -40],
{/* pi. ku. */} ),
<motion.div }}
className="absolute text-center px-6" className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
style={{ >
opacity: useTransform( pen down your unsaid words into&nbsp;
scrollYProgress, <span className="font-display text-primary font-extralight">
[0.18, 0.25, 0.3], letters
[0, 1, 0], </span>
), .
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]), </motion.h2>
}} {/* Seal */}
transition={{ delay: 4 }} <motion.h2
> style={{
<Logo type="logo" scale={1.5} ul={true} /> opacity: useTransform(
<motion.div scrollYProgress,
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral " [0.45, 0.5, 0.55, 0.6],
style={{ [0, 1, 1, 0],
opacity: useTransform( ),
scrollYProgress, y: useTransform(
[0.22, 0.25, 0.35, 0.4], scrollYProgress,
[0, 1, 1, 0], [0.45, 0.5, 0.55, 0.6],
), [40, 0, 0, -40],
y: useTransform( ),
scrollYProgress, }}
[0.25, 0.3, 0.35, 0.4], className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
[20, 0, 0, -20], >
), seal it&nbsp;
}} <span className="text-success font-mono tracking-tighter font-extrabold">
> secure
is a{" "} </span>
<span className="font-display text-primary font-extralight"> &nbsp; and&nbsp;
safe space <span className="text-info font-mono tracking-tighter italic">
</span> private
,<br /> </span>
<motion.span .
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral" </motion.h2>
transition={{ delay: 5 }} {/* Send / vault */}
whileInView={{ opacity: 1 }} <motion.h2
viewport={{ once: false, amount: 0.3 }} style={{
> opacity: useTransform(
where you can scrollYProgress,
</motion.span> [0.6, 0.63, 0.72, 0.75],
</motion.div> [0, 1, 1, 0],
</motion.div> ),
y: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
send it to&nbsp;
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
someone dear
</motion.span>
<motion.span
style={{
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
}}
>
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
&nbsp; or&nbsp;
</motion.span>
<span className="font-display text-success">
yourself in the future
</span>
.
</motion.span>
</motion.h2>
{/* Burn */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
and even <span className="font-display text-error">burn it</span>
&nbsp; to release the burden.
</motion.h2>
{/* Outro */}
<motion.h2
className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
}
style={{
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
}}
>
You've been carrying it long enough.
</motion.h2>
{/* CTA */}
<motion.div
className={
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
}
style={{
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
display: useTransform(
scrollYProgress,
[0.96, 1],
["none", "flex"],
),
}}
>
<button
className={
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
>
<InfoIcon className={"text-primary"} />
Tell me More
</button>
<button
className={
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
>
I'm ready
</button>
</motion.div>
</div>
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20"> <div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.h2 <motion.div
style={{ className={"z-21 absolute"}
opacity: useTransform( style={{
scrollYProgress, opacity: useTransform(
[0.3, 0.35, 0.4, 0.45], scrollYProgress,
[0, 1, 1, 0], [0.3, 0.4, 0.5, 0.52],
), [0, 1, 0.1, 0],
y: useTransform( ),
scrollYProgress, y: useTransform(
[0.3, 0.35, 0.4, 0.45], scrollYProgress,
[40, 0, 0, -40], [0.3, 0.45, 0.5],
), [300, 0, 200],
}} ),
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight" scale: useTransform(
> scrollYProgress,
pen down your unsaid words into&nbsp; [0.3, 0.4, 0.5],
<span className="font-display text-primary font-extralight"> [1, 1, 0.6],
letters ),
</span> }}
. >
</motion.h2> <div className="mockup-phone w-[75vw] border-primary">
{/* Seal */} <div className="mockup-phone-camera"></div>
<motion.h2 <div className="mockup-phone-display">
style={{ <img alt="letter" src={letterSample} />
opacity: useTransform(
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
seal it&nbsp;
<span className="text-success font-mono tracking-tighter font-extrabold">
secure
</span>&nbsp;
and&nbsp;
<span className="text-info font-mono tracking-tighter italic">
private
</span>
.
</motion.h2>
{/* Send / vault */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
send it to&nbsp;
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
someone dear
</motion.span>
<motion.span
style={{
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
}}
>
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
&nbsp;
or&nbsp;
</motion.span>
<span className="font-display text-success">
yourself in the future
</span>
.
</motion.span>
</motion.h2>
{/* Burn */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
and even <span className="font-display text-error">burn it</span>&nbsp;
to release the burden.
</motion.h2>
{/* Outro */}
<motion.h2
className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
}
style={{
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
}}
>
You've been carrying it long enough.
</motion.h2>
{/* CTA */}
<motion.div
className={
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
}
style={{
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
display: useTransform(
scrollYProgress,
[0.96, 1],
["none", "flex"],
),
}}
>
<button
className={
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
>
<InfoIcon className={"text-primary"} />
Tell me More
</button>
<button
className={
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
>
I'm ready
</button>
</motion.div>
</div>
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.div
className={"z-21 absolute"}
style={{
opacity: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(
scrollYProgress,
[0.3, 0.45, 0.5],
[300, 0, 200],
),
scale: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
}}
>
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src={letterSample} />
</div>
</div>
</motion.div>
{/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
scrollYProgress,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
isInteractive={false}
ignite={ignite}
recipient={recipient}
date={formatDate(new Date().toISOString())}
onRevealComplete={() => { }}
isFlip={isEnvelopeFlipped}
openFlap={flapOpen}
/>
</motion.div>
{/* Saajan */}
<motion.div
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
scrollYProgress,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
}}
>
<Saajan
message={
"I think we forget things\nif there is nobody to tell them."
}
position={"top"}
/>
</motion.div>
{/* Orb */}
<motion.div
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
transition={{
backgroundColor: { ease: "easeIn", duration: 2 },
}}
style={{
backgroundColor: useTransform(
scrollYProgress,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-success)",
"var(--color-error)",
],
),
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div> </div>
</section> </div>
</ReactLenis> </motion.div>
); {/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
scrollYProgress,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
isInteractive={false}
ignite={ignite}
recipient={recipient}
date={formatDate(new Date().toISOString())}
onRevealComplete={() => {}}
isFlip={isEnvelopeFlipped}
openFlap={flapOpen}
/>
</motion.div>
{/* Saajan */}
<motion.div
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
scrollYProgress,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
}}
>
<Saajan
message={
"I think we forget things\nif there is nobody to tell them."
}
position={"top"}
/>
</motion.div>
{/* Orb */}
<motion.div
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
transition={{
backgroundColor: { ease: "easeIn", duration: 2 },
}}
style={{
backgroundColor: useTransform(
scrollYProgress,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-success)",
"var(--color-error)",
],
),
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div>
</section>
</ReactLenis>
);
} }
+118 -117
View File
@@ -16,135 +16,136 @@ import { useAuth } from "../hooks/useAuth";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.email("Please enter a valid email"), email: z.email("Please enter a valid email"),
password: z.string().min(1, "Password is required"), password: z.string().min(1, "Password is required"),
}); });
type LoginInputs = z.infer<typeof loginSchema>; type LoginInputs = z.infer<typeof loginSchema>;
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null); const [apiError, setApiError] = useState<string | null>(null);
const { setAuthStore } = useAuth(); const { setAuthStore } = useAuth();
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime); const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
const [saajanMessage, setSaajanMessage] = useState<string>( const [saajanMessage, setSaajanMessage] = useState<string>(
"I was wondering when you'd return.", "I was wondering when you'd return.",
); );
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER; const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<LoginInputs>({ } = useForm<LoginInputs>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
}); });
const onSubmit = async (data: LoginInputs) => { const onSubmit = async (data: LoginInputs) => {
setIsLoading(true); setIsLoading(true);
setApiError(null); setApiError(null);
try { try {
// client side key derivation for e2e encryption // client side key derivation for e2e encryption
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle( const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
data.password, data.password,
data.email, data.email,
); );
// send just the authHash as the password to the server // send just the authHash as the password to the server
const { data: authData } = await publicApi.post(endpoints.LOGIN, { const { data: authData } = await publicApi.post(endpoints.LOGIN, {
email: data.email, email: data.email,
password: authHash, password: authHash,
}); });
const { data: userData } = await api.get(endpoints.ME, { const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${authData.access}` }, headers: { Authorization: `Bearer ${authData.access}` },
}); });
await setAuthStore(authData.access, userData, masterKey); await setAuthStore(authData.access, userData, masterKey);
navigate(nextRoute, { replace: true, state: location.state }); navigate(nextRoute, { replace: true, state: location.state });
} catch (err) { } catch (err) {
let message = let message =
"Sorry, we're experiencing technical issues.\nPlease try again later."; "Sorry, we're experiencing technical issues.\nPlease try again later.";
if (axios.isAxiosError(err) && err.response?.status !== 500) { if (axios.isAxiosError(err) && err.response?.status !== 500) {
message = err.response?.data?.detail || err.response?.data?.message; message = err.response?.data?.detail || err.response?.data?.message;
} }
setApiError(message); setApiError(message);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
{!showWelcome && <Saajan message={saajanMessage} position="top" />} {!showWelcome && <Saajan message={saajanMessage} position="top" />}
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />} {showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom"> <div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<h1 className="flex items-center font-display text-2xl justify-center text-primary/80 tracking-tight"> <h1 className="flex items-center font-display text-2xl justify-center text-primary/80 tracking-tight">
&nbsp;&nbsp;Enter <Logo type="logo" scale={0.7} /> Archive &nbsp;&nbsp;Enter <Logo type="logo" scale={0.7} /> Archive
</h1> </h1>
{apiError && ( {apiError && (
<div className="alert alert-error text-xs py-2 rounded-md"> <div className="alert alert-error text-xs py-2 rounded-md">
<span data-testid="login-error-message">{apiError}</span> <span data-testid="login-error-message">{apiError}</span>
</div>
)}
<FormField
label="Email"
type="email"
placeholder="f.kafka@wrongtrain.com"
data-testid="email-input"
registration={register("email")}
error={errors.email?.message}
handleFocus={() => setSaajanMessage("I remember you.")}
/>
<FormField
label="Password"
type="password"
placeholder="••••••••"
data-testid="password-input"
registration={register("password")}
error={errors.password?.message}
handleFocus={() =>
setSaajanMessage("The one thing I cannot know for you.")
}
/>
<div className="card-actions mt-4">
<button
type="submit"
name="login"
disabled={isLoading}
data-testid="login-submit-btn"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Continue"
)}
</button>
</div>
<div className="divider text-neutral my-0">or</div>
<div className="text-center text-sm font-medium text-neutral">
New to <Logo type="inline" />?&nbsp;
<button
type="button"
name="register"
onClick={() => navigate(ROUTES.ONBOARD)}
className="link link-primary"
>
Start here
</button>
.
</div>
</form>
</div> </div>
</div> )}
);
<FormField
label="Email"
type="email"
placeholder="f.kafka@wrongtrain.com"
data-testid="email-input"
registration={register("email")}
error={errors.email?.message}
handleFocus={() => setSaajanMessage("I remember you.")}
/>
<FormField
label="Password"
type="password"
placeholder="••••••••"
data-testid="password-input"
registration={register("password")}
error={errors.password?.message}
handleFocus={() =>
setSaajanMessage("The one thing I cannot know for you.")
}
/>
<div className="card-actions mt-4">
<button
type="submit"
name="login"
disabled={isLoading}
data-testid="login-submit-btn"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Continue"
)}
</button>
</div>
<div className="divider text-neutral my-0">or</div>
<div className="text-center text-sm font-medium text-neutral">
New to <Logo type="inline" />
?&nbsp;
<button
type="button"
name="register"
onClick={() => navigate(ROUTES.ONBOARD)}
className="link link-primary"
>
Start here
</button>
.
</div>
</form>
</div>
</div>
);
} }
+158 -157
View File
@@ -14,171 +14,172 @@ import { ROUTES } from "../config/routes";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
const registerSchema = z const registerSchema = z
.object({ .object({
full_name: z.string().min(2, "Name must be at least 2 characters"), full_name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Please enter a valid email"), email: z.email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"), password: z.string().min(8, "Password must be at least 8 characters"),
confirm_password: z.string(), confirm_password: z.string(),
}) })
.refine((data) => data.password === data.confirm_password, { .refine((data) => data.password === data.confirm_password, {
message: "Passwords don't match", message: "Passwords don't match",
path: ["confirm_password"], path: ["confirm_password"],
}); });
type RegisterInputs = z.infer<typeof registerSchema>; type RegisterInputs = z.infer<typeof registerSchema>;
export default function Register() { export default function Register() {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null); const [apiError, setApiError] = useState<string | null>(null);
const [saajanMessage, setSaajanMessage] = useState<string>( const [saajanMessage, setSaajanMessage] = useState<string>(
"I didn't think I'd be here either.\nAnd yet, here we are.", "I didn't think I'd be here either.\nAnd yet, here we are.",
); );
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<RegisterInputs>({ } = useForm<RegisterInputs>({
resolver: zodResolver(registerSchema), resolver: zodResolver(registerSchema),
}); });
const onSubmit = async (data: RegisterInputs) => { const onSubmit = async (data: RegisterInputs) => {
setSaajanMessage("Good. I'll remember that."); setSaajanMessage("Good. I'll remember that.");
setIsLoading(true); setIsLoading(true);
setApiError(null); setApiError(null);
try { try {
// we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db. // we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db.
const { authHash } = await CryptoUtils.deriveKeyBundle( const { authHash } = await CryptoUtils.deriveKeyBundle(
data.password, data.password,
data.email, data.email,
); );
await publicApi.post(endpoints.REGISTER, { await publicApi.post(endpoints.REGISTER, {
full_name: data.full_name, full_name: data.full_name,
email: data.email, email: data.email,
password: authHash, password: authHash,
}); });
navigate(ROUTES.VERIFY_EMAIL, { replace: true }); navigate(ROUTES.VERIFY_EMAIL, { replace: true });
} catch (err) { } catch (err) {
let message = "Registration failed. Please try again."; let message = "Registration failed. Please try again.";
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
message = err.response?.data?.message || message; message = err.response?.data?.message || message;
} }
setApiError(message); setApiError(message);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<Saajan message={saajanMessage} position="right" /> <Saajan message={saajanMessage} position="right" />
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom"> <div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap"> <div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
Create a <Logo type="logo" scale={0.7} /> Account Create a<Logo type="logo" scale={0.7} />
</div> Account
</div>
{apiError && ( {apiError && (
<div className="alert alert-error text-xs py-2 rounded-md"> <div className="alert alert-error text-xs py-2 rounded-md">
<span>{apiError}</span> <span>{apiError}</span>
</div>
)}
<FormField
label="Pen Name"
placeholder="Word Smith"
data-testid="pen-name-input"
registration={register("full_name")}
error={errors.full_name?.message}
handleFocus={() =>
setSaajanMessage("Hello friend. What should I call you?")
}
/>
<FormField
label="Email"
type="email"
placeholder="f.kafka@wrongtrain.com"
data-testid="email-input"
registration={register("email")}
error={errors.email?.message}
handleFocus={() =>
setSaajanMessage(
"Where should I send your letters?\nNo empty lunchboxes, please.",
)
}
/>
<FormField
label="Password"
type="password"
placeholder="••••••••"
data-testid="password-input"
registration={register("password")}
error={errors.password?.message}
handleFocus={() =>
setSaajanMessage(
"Something only you know.\nI have one of those too.",
)
}
/>
<FormField
label="Confirm Password"
type="password"
placeholder="••••••••"
data-testid="confirm-password-input"
registration={register("confirm_password")}
error={errors.confirm_password?.message}
handleFocus={() =>
setSaajanMessage(
"Just once? Trust me, \nsome things are worth repeating twice.",
)
}
/>
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
<p className="text-sm font-semibold">
Choose a password you won't forget. <br />
Just like life,&nbsp;
<span className="underline decoration-2">there is no reset</span>&nbsp;
here. If you lose it, your letters cannot be recovered.
</p>
</div>
<div className="card-actions mt-4">
<button
type="submit"
disabled={isLoading}
aria-label="Register"
data-testid="register-submit-btn"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Begin"
)}
</button>
</div>
<div className="divider text-neutral my-0">or</div>
<div className="text-center text-sm font-medium text-neutral">
Been here before?&nbsp;
<button
type="button"
name="register"
onClick={() => navigate(ROUTES.LOGIN)}
className="link link-primary"
>
Continue where you left off
</button>
.
</div>
</form>
</div> </div>
</div> )}
);
<FormField
label="Pen Name"
placeholder="Word Smith"
data-testid="pen-name-input"
registration={register("full_name")}
error={errors.full_name?.message}
handleFocus={() =>
setSaajanMessage("Hello friend. What should I call you?")
}
/>
<FormField
label="Email"
type="email"
placeholder="f.kafka@wrongtrain.com"
data-testid="email-input"
registration={register("email")}
error={errors.email?.message}
handleFocus={() =>
setSaajanMessage(
"Where should I send your letters?\nNo empty lunchboxes, please.",
)
}
/>
<FormField
label="Password"
type="password"
placeholder="••••••••"
data-testid="password-input"
registration={register("password")}
error={errors.password?.message}
handleFocus={() =>
setSaajanMessage(
"Something only you know.\nI have one of those too.",
)
}
/>
<FormField
label="Confirm Password"
type="password"
placeholder="••••••••"
data-testid="confirm-password-input"
registration={register("confirm_password")}
error={errors.confirm_password?.message}
handleFocus={() =>
setSaajanMessage(
"Just once? Trust me, \nsome things are worth repeating twice.",
)
}
/>
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
<p className="text-sm font-semibold">
Choose a password you won't forget. <br />
Just like life,&nbsp;
<span className="underline decoration-2">there is no reset</span>
&nbsp; here. If you lose it, your letters cannot be recovered.
</p>
</div>
<div className="card-actions mt-4">
<button
type="submit"
disabled={isLoading}
aria-label="Register"
data-testid="register-submit-btn"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Begin"
)}
</button>
</div>
<div className="divider text-neutral my-0">or</div>
<div className="text-center text-sm font-medium text-neutral">
Been here before?&nbsp;
<button
type="button"
name="register"
onClick={() => navigate(ROUTES.LOGIN)}
className="link link-primary"
>
Continue where you left off
</button>
.
</div>
</form>
</div>
</div>
);
} }
+47 -47
View File
@@ -3,53 +3,53 @@ import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan"; import Saajan from "../components/ui/Saajan";
export default function VerifyEmail() { export default function VerifyEmail() {
return ( return (
<div className="relative"> <div className="relative">
<Saajan <Saajan
message={"I sent something to your inbox.\nOpen it, and we can begin."} message={"I sent something to your inbox.\nOpen it, and we can begin."}
/> />
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom"> <div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
<div className="auth-icon-container"> <div className="auth-icon-container">
<EnvelopeSimpleOpenIcon <EnvelopeSimpleOpenIcon
size={32} size={32}
weight="duotone" weight="duotone"
className="text-primary" className="text-primary"
/> />
</div>
<div className="space-y-2">
<h2 className="font-display text-xl text-primary">
Check Your Mailbox
</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
You're one train away from starting your <Logo scale={0.8} />&nbsp;
journey.
</p>
</div>
<div className="divider opacity-10 my-0"></div>
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed opacity-70 text-center">
<p>
Nothing yet? Sometimes letters take the wrong train. Check your spam
folder.
<br />
<span className="underline font-bold">
The link expires in 24 hours.
</span>
<br /> I'm patient... but not endlessly so
</p>
</div>
<button
type="button"
className="text-xs italic opacity-40 cursor-pointer underline"
onClick={() => window.close()}
>
You can close this window now.
</button>
</div>
</div> </div>
);
<div className="space-y-2">
<h2 className="font-display text-xl text-primary">
Check Your Mailbox
</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
You're one train away from starting your <Logo scale={0.8} />
&nbsp; journey.
</p>
</div>
<div className="divider opacity-10 my-0"></div>
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed opacity-70 text-center">
<p>
Nothing yet? Sometimes letters take the wrong train. Check your spam
folder.
<br />
<span className="underline font-bold">
The link expires in 24 hours.
</span>
<br /> I'm patient... but not endlessly so
</p>
</div>
<button
type="button"
className="text-xs italic opacity-40 cursor-pointer underline"
onClick={() => window.close()}
>
You can close this window now.
</button>
</div>
</div>
);
} }