mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
refactor: extract Editor components into modular files and implement PostSealModal
This commit is contained in:
+1
-1
@@ -42,7 +42,7 @@
|
|||||||
"noUnusedVariables": "error"
|
"noUnusedVariables": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"includes": ["**/src", "!backend"]
|
"includes": ["**", "!backend"]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { LockIcon } from "@phosphor-icons/react";
|
||||||
|
import type { NavigateFunction } from "react-router-dom";
|
||||||
|
import { PATHS, ROUTES } from "../../config/routes";
|
||||||
|
|
||||||
|
interface PostSealModalProps {
|
||||||
|
sealedTargetId: string | null;
|
||||||
|
navigate: NavigateFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostSealModal({
|
||||||
|
sealedTargetId,
|
||||||
|
navigate,
|
||||||
|
}: PostSealModalProps) {
|
||||||
|
if (!sealedTargetId) return null;
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-1000">
|
||||||
|
<div className="modal-box flex flex-col items-center text-center gap-6">
|
||||||
|
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
|
||||||
|
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
|
||||||
|
<p className="text-base-content/60">
|
||||||
|
It's encrypted and always safe in your drawer.
|
||||||
|
</p>
|
||||||
|
<p className="text-base-content font-sans">
|
||||||
|
When you're ready,
|
||||||
|
<br />
|
||||||
|
you can{" "}
|
||||||
|
<span className="text-primary font-bold font-display">read</span> it,{" "}
|
||||||
|
<span className="text-accent font-bold font-display">send</span> it to
|
||||||
|
someone, or{" "}
|
||||||
|
<span className="text-error font-bold font-display">burn</span> it to
|
||||||
|
release
|
||||||
|
</p>
|
||||||
|
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
|
>
|
||||||
|
Keep it to myself
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(PATHS.read(sealedTargetId), { replace: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View letter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
ImageIcon,
|
||||||
|
LockIcon,
|
||||||
|
QuestionIcon,
|
||||||
|
StampIcon,
|
||||||
|
TrayIcon,
|
||||||
|
VaultIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface ToolBarProps {
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
sealBtnClicked: boolean;
|
||||||
|
setSealBtnClicked: (v: boolean) => void;
|
||||||
|
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
|
||||||
|
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolBar({
|
||||||
|
fileInputRef,
|
||||||
|
sealBtnClicked,
|
||||||
|
setSealBtnClicked,
|
||||||
|
onSave,
|
||||||
|
setConfirmModal,
|
||||||
|
}: ToolBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="writer-toolbar"
|
||||||
|
className="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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm group"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<ImageIcon size={18} weight="bold" />
|
||||||
|
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
|
||||||
|
Add Image
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm text-[10px] group tracking-[0.2em] 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" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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-100000 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
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
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSealBtnClicked(false)}
|
||||||
|
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
|
||||||
|
>
|
||||||
|
<QuestionIcon weight="duotone" size={20} className={""} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LetterHead() {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<LockIcon size={14} weight="fill" />
|
||||||
|
<span className="text-[10px] uppercase tracking-widest font-bold">
|
||||||
|
Sealed & View Only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultConfirmModalProps {
|
||||||
|
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
|
||||||
|
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
|
||||||
|
setUnlockDate: (d: Date | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VaultConfirmModal({
|
||||||
|
onSave,
|
||||||
|
setConfirmModal,
|
||||||
|
setUnlockDate,
|
||||||
|
}: VaultConfirmModalProps) {
|
||||||
|
return (
|
||||||
|
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}>
|
||||||
|
<div className="modal-box p-12 flex flex-col items-center">
|
||||||
|
<VaultIcon
|
||||||
|
size={48}
|
||||||
|
className="text-primary mx-auto mb-8 animate-pulse"
|
||||||
|
/>
|
||||||
|
<h3 className="font-serif text-3xl">Vault this letter?</h3>
|
||||||
|
<p className="text-base-content/60 text-sm text-center mt-4">
|
||||||
|
Vaulting locks the letter permanently and will be{" "}
|
||||||
|
<span className={"font-bold text-primary"}>mailed</span> to you
|
||||||
|
automatically on the unlock date.
|
||||||
|
<br />
|
||||||
|
<span className={"underline"}>
|
||||||
|
You cannot edit or view the contents of the letter until then.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const unlockDateStr = formData.get("vault-date") as string;
|
||||||
|
const newUnlockDate = new Date(unlockDateStr);
|
||||||
|
setUnlockDate(newUnlockDate);
|
||||||
|
await onSave("VAULT", newUnlockDate);
|
||||||
|
setConfirmModal(null);
|
||||||
|
}}
|
||||||
|
id="vault-form"
|
||||||
|
>
|
||||||
|
<div className={"divider tracking-tightest font-display text-sm"}>
|
||||||
|
Set an unlock date
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
name="vault-date"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary mt-4"
|
||||||
|
type="submit"
|
||||||
|
form="vault-form"
|
||||||
|
>
|
||||||
|
Vault
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost mt-4"
|
||||||
|
onClick={() => setConfirmModal(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,8 +66,10 @@ export function EnvelopeReveal({
|
|||||||
src={waxSeal}
|
src={waxSeal}
|
||||||
alt="Seal"
|
alt="Seal"
|
||||||
onClick={() => flapCheckbox.current?.click()}
|
onClick={() => flapCheckbox.current?.click()}
|
||||||
|
onKeyDown={() => flapCheckbox.current?.click()}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
id="letter"
|
id="letter"
|
||||||
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-2000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
|
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-2000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -82,12 +84,14 @@ export function EnvelopeReveal({
|
|||||||
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-4 -ml-48 z-3 pointer-events-none"
|
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-4 -ml-48 z-3 pointer-events-none"
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
id="env-bottom"
|
id="env-bottom"
|
||||||
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] mt-15 scale-x-240 z-3"
|
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] mt-15 scale-x-240 z-3"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
className="p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2"
|
className="p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2"
|
||||||
onClick={() => setIsFlipped((prev) => !prev)}
|
onClick={() => setIsFlipped((prev) => !prev)}
|
||||||
>
|
>
|
||||||
@@ -113,7 +117,7 @@ export function EnvelopeReveal({
|
|||||||
className={"absolute mt-0 mr-4 top-18 right-8 text-primary"}
|
className={"absolute mt-0 mr-4 top-18 right-8 text-primary"}
|
||||||
size={50}
|
size={50}
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ignite && (
|
{ignite && (
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { LockKeyIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface PasskeyModalProps {
|
||||||
|
onUnlock: (password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open bg-base-100/20 backdrop-blur-md z-100">
|
||||||
|
<div className="modal-box p-12 flex flex-col items-center">
|
||||||
|
<LockKeyIcon
|
||||||
|
size={48}
|
||||||
|
className="text-primary mx-auto mb-8 animate-pulse"
|
||||||
|
/>
|
||||||
|
<h3 className="font-bold text-lg font-display text-primary">
|
||||||
|
Authentication Required
|
||||||
|
</h3>
|
||||||
|
<p className="py-4 font-sans">
|
||||||
|
We need your passkey to open your letters
|
||||||
|
</p>
|
||||||
|
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
||||||
|
<p className="text-xs text-neutral-content/30 font-mono italic">
|
||||||
|
Your passkey is used to decrypt your data locally.
|
||||||
|
</p>
|
||||||
|
<div className="modal-action items-center gap-4">
|
||||||
|
<form
|
||||||
|
className="form-control w-full inline-flex"
|
||||||
|
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const password = formData.get("password") as string;
|
||||||
|
if (!password) return;
|
||||||
|
await onUnlock(password);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="password"
|
||||||
|
className="font-sans validator input input-bordered rounded-r-none"
|
||||||
|
/>
|
||||||
|
<div className="validator-message text-xs text-error"></div>
|
||||||
|
<button type="submit" className="btn btn-primary rounded-l-none">
|
||||||
|
Unlock
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,9 +48,7 @@ export const useAuth = () => {
|
|||||||
try {
|
try {
|
||||||
const masterKey = await loadMasterKey();
|
const masterKey = await loadMasterKey();
|
||||||
if (masterKey) setMasterKey(masterKey);
|
if (masterKey) setMasterKey(masterKey);
|
||||||
} catch {
|
} catch {}
|
||||||
console.error("Master key restoration failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// If session in memory, don't trigger refresh/me again
|
// If session in memory, don't trigger refresh/me again
|
||||||
if (accessToken && user) {
|
if (accessToken && user) {
|
||||||
@@ -82,9 +80,7 @@ export const useAuth = () => {
|
|||||||
);
|
);
|
||||||
await saveMasterKey(masterKey);
|
await saveMasterKey(masterKey);
|
||||||
setMasterKey(masterKey);
|
setMasterKey(masterKey);
|
||||||
} catch {
|
} catch {}
|
||||||
console.error("Master key restoration failed");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FeatherIcon, LockKeyIcon } from "@phosphor-icons/react";
|
import { FeatherIcon } from "@phosphor-icons/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import { DrawerSection } from "../components/ui/DrawerSection";
|
import { DrawerSection } from "../components/ui/DrawerSection";
|
||||||
import { LetterItem } from "../components/ui/LetterItem";
|
import { LetterItem } from "../components/ui/LetterItem";
|
||||||
|
import { PasskeyModal } from "../components/ui/PasskeyModal";
|
||||||
import { PATHS } from "../config/routes";
|
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";
|
||||||
@@ -12,57 +13,6 @@ import {
|
|||||||
formatRelativeDateWithoutTime,
|
formatRelativeDateWithoutTime,
|
||||||
} from "../utils/dateFormat.ts";
|
} from "../utils/dateFormat.ts";
|
||||||
|
|
||||||
interface PasskeyModalProps {
|
|
||||||
onUnlock: (password: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
|
||||||
return (
|
|
||||||
<div className="modal modal-open bg-base-100/20 backdrop-blur-md z-100">
|
|
||||||
<div className="modal-box p-12 flex flex-col items-center">
|
|
||||||
<LockKeyIcon
|
|
||||||
size={48}
|
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
|
||||||
/>
|
|
||||||
<h3 className="font-bold text-lg font-display text-primary">
|
|
||||||
Authentication Required
|
|
||||||
</h3>
|
|
||||||
<p className="py-4 font-sans">
|
|
||||||
We need you to re-enter your passkey to open your letters
|
|
||||||
</p>
|
|
||||||
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
|
||||||
<p className="text-xs text-neutral-content/30 font-mono italic">
|
|
||||||
P.S. We don't validate your input at the moment.
|
|
||||||
</p>
|
|
||||||
<div className="modal-action items-center gap-4">
|
|
||||||
<form
|
|
||||||
className="form-control w-full inline-flex"
|
|
||||||
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const password = formData.get("password") as string;
|
|
||||||
if (!password) return;
|
|
||||||
await onUnlock(password);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
placeholder="password"
|
|
||||||
className="font-sans validator input input-bordered rounded-r-none"
|
|
||||||
/>
|
|
||||||
<div className="validator-message text-xs text-error"></div>
|
|
||||||
<button type="submit" className="btn btn-primary rounded-l-none">
|
|
||||||
Unlock
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Drawer() {
|
export default function Drawer() {
|
||||||
const { user, logout, unlock } = useAuth();
|
const { user, logout, unlock } = useAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -24,21 +24,20 @@ vi.mock("../components/ui/ComposeCanvas", () => ({
|
|||||||
// Mock CryptoUtils to avoid real crypto calls in UI tests
|
// Mock CryptoUtils to avoid real crypto calls in UI tests
|
||||||
vi.mock("../utils/crypto", () => {
|
vi.mock("../utils/crypto", () => {
|
||||||
return {
|
return {
|
||||||
CryptoUtils: vi.fn().mockImplementation(function () {
|
CryptoUtils: () => ({
|
||||||
return {
|
initialize: vi.fn().mockResolvedValue(undefined),
|
||||||
initialize: vi.fn().mockResolvedValue(undefined),
|
encryptLetter: vi.fn().mockResolvedValue({
|
||||||
encryptLetter: vi.fn().mockResolvedValue({
|
encrypted_content: "enc-content",
|
||||||
encrypted_content: "enc-content",
|
encrypted_dek: "enc-dek",
|
||||||
encrypted_dek: "enc-dek",
|
sharingKey: "share-key",
|
||||||
sharingKey: "share-key",
|
}),
|
||||||
}),
|
encryptMetadata: vi.fn().mockResolvedValue({
|
||||||
encryptMetadata: vi.fn().mockResolvedValue({
|
encrypted_content: "enc-meta",
|
||||||
encrypted_content: "enc-meta",
|
encrypted_dek: "enc-dek",
|
||||||
encrypted_dek: "enc-dek",
|
}),
|
||||||
}),
|
decryptMetadata: vi.fn().mockResolvedValue({ recipient: "Test User" }),
|
||||||
decryptMetadata: vi.fn().mockResolvedValue({ recipient: "Test User" }),
|
decryptLetter: vi.fn().mockResolvedValue("{}"),
|
||||||
decryptLetter: vi.fn().mockResolvedValue("{}"),
|
extractSharingKey: vi.fn().mockResolvedValue("share-key"),
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -160,7 +159,7 @@ describe("Editor Page", () => {
|
|||||||
fireEvent.click(secondarySealBtn);
|
fireEvent.click(secondarySealBtn);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/Sealed & Ready/i)).toBeInTheDocument();
|
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
||||||
|
|||||||
+10
-244
@@ -1,13 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
DownloadSimpleIcon,
|
DownloadSimpleIcon,
|
||||||
ImageIcon,
|
|
||||||
LockIcon,
|
|
||||||
QuestionIcon,
|
|
||||||
SpinnerGapIcon,
|
SpinnerGapIcon,
|
||||||
StampIcon,
|
|
||||||
TrayIcon,
|
|
||||||
VaultIcon,
|
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -17,6 +11,12 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
|
import { PostSealModal } from "../components/editor/PostSealModal";
|
||||||
|
import {
|
||||||
|
LetterHead,
|
||||||
|
ToolBar,
|
||||||
|
VaultConfirmModal,
|
||||||
|
} from "../components/editor/ToolBar";
|
||||||
import {
|
import {
|
||||||
type CanvasTools,
|
type CanvasTools,
|
||||||
ComposeCanvas,
|
ComposeCanvas,
|
||||||
@@ -24,8 +24,9 @@ import {
|
|||||||
import DateDisplay from "../components/ui/DateDisplay";
|
import DateDisplay from "../components/ui/DateDisplay";
|
||||||
import { LogModal } from "../components/ui/LogModal";
|
import { LogModal } from "../components/ui/LogModal";
|
||||||
import { Navbar } from "../components/ui/Navbar";
|
import { Navbar } from "../components/ui/Navbar";
|
||||||
|
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { PATHS, ROUTES } from "../config/routes";
|
import { PATHS } from "../config/routes";
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
import { formatRelativeDate } from "../utils/dateFormat";
|
import { formatRelativeDate } from "../utils/dateFormat";
|
||||||
@@ -43,241 +44,6 @@ const toPlaceholderList = [
|
|||||||
"Something to bear...",
|
"Something to bear...",
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SealedModalProps {
|
|
||||||
sealedTargetId: string | null;
|
|
||||||
navigate: NavigateFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SealedModal({ sealedTargetId, navigate }: SealedModalProps) {
|
|
||||||
if (!sealedTargetId) return null;
|
|
||||||
return (
|
|
||||||
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-1000">
|
|
||||||
<div className="modal-box flex flex-col items-center text-center gap-6">
|
|
||||||
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
|
|
||||||
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
|
|
||||||
<p className="text-base-content/60">
|
|
||||||
It's encrypted and always safe in your drawer.
|
|
||||||
</p>
|
|
||||||
<p className="text-base-content font-sans">
|
|
||||||
When you're ready,
|
|
||||||
<br />
|
|
||||||
you can{" "}
|
|
||||||
<span className="text-primary font-bold font-display">read</span> it,{" "}
|
|
||||||
<span className="text-accent font-bold font-display">send</span> it to
|
|
||||||
someone, or{" "}
|
|
||||||
<span className="text-error font-bold font-display">burn</span> it to
|
|
||||||
release
|
|
||||||
</p>
|
|
||||||
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
|
||||||
>
|
|
||||||
Keep it to myself
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(PATHS.read(sealedTargetId), { replace: true })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View letter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolBarProps {
|
|
||||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
|
||||||
sealBtnClicked: boolean;
|
|
||||||
setSealBtnClicked: (v: boolean) => void;
|
|
||||||
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
|
|
||||||
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolBar({
|
|
||||||
fileInputRef,
|
|
||||||
sealBtnClicked,
|
|
||||||
setSealBtnClicked,
|
|
||||||
onSave,
|
|
||||||
setConfirmModal,
|
|
||||||
}: ToolBarProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="writer-toolbar"
|
|
||||||
className="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">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm group"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<ImageIcon size={18} weight="bold" />
|
|
||||||
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
|
|
||||||
Add Image
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm text-[10px] group tracking-[0.2em] 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" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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-100000 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSealBtnClicked(false)}
|
|
||||||
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
|
|
||||||
>
|
|
||||||
<QuestionIcon weight="duotone" size={20} className={""} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LetterHead() {
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
<LockIcon size={14} weight="fill" />
|
|
||||||
<span className="text-[10px] uppercase tracking-widest font-bold">
|
|
||||||
Sealed & View Only
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VaultConfirmProps {
|
|
||||||
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
|
|
||||||
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
|
|
||||||
setUnlockDate: (d: Date | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VaultConfirm({
|
|
||||||
onSave,
|
|
||||||
setConfirmModal,
|
|
||||||
setUnlockDate,
|
|
||||||
}: VaultConfirmProps) {
|
|
||||||
return (
|
|
||||||
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}>
|
|
||||||
<div className="modal-box p-12 flex flex-col items-center">
|
|
||||||
<VaultIcon
|
|
||||||
size={48}
|
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
|
||||||
/>
|
|
||||||
<h3 className="font-serif text-3xl">Vault this letter?</h3>
|
|
||||||
<p className="text-base-content/60 text-sm text-center mt-4">
|
|
||||||
Vaulting locks the letter permanently and will be{" "}
|
|
||||||
<span className={"font-bold text-primary"}>mailed</span> to you
|
|
||||||
automatically on the unlock date.
|
|
||||||
<br />
|
|
||||||
<span className={"underline"}>
|
|
||||||
You cannot edit or view the contents of the letter until then.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const unlockDateStr = formData.get("vault-date") as string;
|
|
||||||
const newUnlockDate = new Date(unlockDateStr);
|
|
||||||
setUnlockDate(newUnlockDate);
|
|
||||||
await onSave("VAULT", newUnlockDate);
|
|
||||||
setConfirmModal(null);
|
|
||||||
}}
|
|
||||||
id="vault-form"
|
|
||||||
>
|
|
||||||
<div className={"divider tracking-tightest font-display text-sm"}>
|
|
||||||
Set an unlock date
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="date"
|
|
||||||
className="input input-bordered w-full"
|
|
||||||
name="vault-date"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary mt-4"
|
|
||||||
type="submit"
|
|
||||||
form="vault-form"
|
|
||||||
>
|
|
||||||
Vault
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost mt-4"
|
|
||||||
onClick={() => setConfirmModal(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||||
@@ -646,14 +412,14 @@ export default function Editor() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmModal === "VAULT" && (
|
{confirmModal === "VAULT" && (
|
||||||
<VaultConfirm
|
<VaultConfirmModal
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
setConfirmModal={setConfirmModal}
|
setConfirmModal={setConfirmModal}
|
||||||
setUnlockDate={setUnlockDate}
|
setUnlockDate={setUnlockDate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sealedTargetId && (
|
{sealedTargetId && (
|
||||||
<SealedModal sealedTargetId={sealedTargetId} navigate={navigate} />
|
<PostSealModal sealedTargetId={sealedTargetId} navigate={navigate} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-w-180 mx-auto px-1 md:px-0">
|
<div className="max-w-180 mx-auto px-1 md:px-0">
|
||||||
|
|||||||
@@ -65,13 +65,12 @@ export default function Reader() {
|
|||||||
const isAuthor = !!masterKey && !sharingKey;
|
const isAuthor = !!masterKey && !sharingKey;
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (!encryptedDek || !masterKey || !public_id) return;
|
if (!(encryptedDek && masterKey && public_id)) return;
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
||||||
try {
|
try {
|
||||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
|
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
console.error("Failed to update letter:", err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
||||||
}
|
}
|
||||||
@@ -145,14 +144,13 @@ export default function Reader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const burnLetter = async () => {
|
const burnLetter = async () => {
|
||||||
console.log("Burning letter...");
|
|
||||||
if (!public_id || isBurning) return;
|
if (!public_id || isBurning) return;
|
||||||
setIsBurning(true);
|
setIsBurning(true);
|
||||||
try {
|
try {
|
||||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
||||||
status: "BURNED",
|
status: "BURNED",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsBurning(false);
|
setIsBurning(false);
|
||||||
setShowBurnModal(false);
|
setShowBurnModal(false);
|
||||||
@@ -447,6 +445,7 @@ export default function Reader() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="divider mx-auto w-24 text-center"></div>
|
<div className="divider mx-auto w-24 text-center"></div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="btn btn-ghost text-sm text-neutral-content/60 font-sans"
|
className="btn btn-ghost text-sm text-neutral-content/60 font-sans"
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user