refactor: fix whitespace indents in copy
This commit is contained in:
@@ -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>
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<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
|
you can
|
||||||
<span className="text-primary font-bold font-display">read</span> it,
|
<span className="text-primary font-bold font-display">read</span>
|
||||||
<span className="text-accent font-bold font-display">send</span> it to
|
it,
|
||||||
someone, or
|
<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
|
||||||
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,
|
<br />
|
||||||
<span className="font-bold font-display text-primary">
|
Till then,
|
||||||
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
|
, <span className="font-bold font-display text-accent">manifest</span>
|
||||||
<span className="font-bold font-display text-success">
|
, and
|
||||||
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}>
|
||||||
|
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>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
Welcome to
|
||||||
<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,
|
Everything you write here is sealed with your password,
|
||||||
<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—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>
|
||||||
I highly, highly recommend storing this password in your
|
recommend storing this password in your
|
||||||
<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>
|
</a>
|
||||||
or somewhere safe to remember it.
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
<span className="text-error">hold</span> the
|
||||||
>
|
<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
|
cursor: burnClicked ? "grabbing" : "grab",
|
||||||
<span className="text-error">hold</span> the
|
} 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>
|
||||||
|
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>
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
<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" />
|
||||||
/>
|
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
|
</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" />
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+953
-941
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
<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
@@ -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
|
|
||||||
<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
|
|
||||||
<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
|
||||||
|
<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
|
||||||
|
<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
@@ -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
|
||||||
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
|
||||||
}}
|
<span className="text-success font-mono tracking-tighter font-extrabold">
|
||||||
>
|
secure
|
||||||
is a{" "}
|
</span>
|
||||||
<span className="font-display text-primary font-extralight">
|
and
|
||||||
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
|
||||||
|
<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)"],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
or
|
||||||
|
</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>
|
||||||
|
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
|
[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
|
|
||||||
<span className="text-success font-mono tracking-tighter font-extrabold">
|
|
||||||
secure
|
|
||||||
</span>
|
|
||||||
and
|
|
||||||
<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
|
|
||||||
<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)"],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
or
|
|
||||||
</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>
|
|
||||||
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
@@ -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">
|
||||||
Enter <Logo type="logo" scale={0.7} /> Archive
|
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" />?
|
|
||||||
<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" />
|
||||||
|
?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
name="register"
|
||||||
|
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||||
|
className="link link-primary"
|
||||||
|
>
|
||||||
|
Start here
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-157
@@ -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,
|
|
||||||
<span className="underline decoration-2">there is no reset</span>
|
|
||||||
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?
|
|
||||||
<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,
|
||||||
|
<span className="underline decoration-2">there is no reset</span>
|
||||||
|
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?
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
name="register"
|
||||||
|
onClick={() => navigate(ROUTES.LOGIN)}
|
||||||
|
className="link link-primary"
|
||||||
|
>
|
||||||
|
Continue where you left off
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
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} />
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user