mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 00:56:34 +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"
|
||||
}
|
||||
},
|
||||
"includes": ["**/src", "!backend"]
|
||||
"includes": ["**", "!backend"]
|
||||
},
|
||||
"assist": {
|
||||
"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}
|
||||
alt="Seal"
|
||||
onClick={() => flapCheckbox.current?.click()}
|
||||
onKeyDown={() => flapCheckbox.current?.click()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"}`}
|
||||
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"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
></button>
|
||||
</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"
|
||||
onClick={() => setIsFlipped((prev) => !prev)}
|
||||
>
|
||||
@@ -113,7 +117,7 @@ export function EnvelopeReveal({
|
||||
className={"absolute mt-0 mr-4 top-18 right-8 text-primary"}
|
||||
size={50}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{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 {
|
||||
const masterKey = await loadMasterKey();
|
||||
if (masterKey) setMasterKey(masterKey);
|
||||
} catch {
|
||||
console.error("Master key restoration failed");
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// If session in memory, don't trigger refresh/me again
|
||||
if (accessToken && user) {
|
||||
@@ -82,9 +80,7 @@ export const useAuth = () => {
|
||||
);
|
||||
await saveMasterKey(masterKey);
|
||||
setMasterKey(masterKey);
|
||||
} catch {
|
||||
console.error("Master key restoration failed");
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FeatherIcon, LockKeyIcon } from "@phosphor-icons/react";
|
||||
import { FeatherIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Logo from "../components/Logo";
|
||||
import { DrawerSection } from "../components/ui/DrawerSection";
|
||||
import { LetterItem } from "../components/ui/LetterItem";
|
||||
import { PasskeyModal } from "../components/ui/PasskeyModal";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useLetters } from "../hooks/useLetters";
|
||||
@@ -12,57 +13,6 @@ import {
|
||||
formatRelativeDateWithoutTime,
|
||||
} 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() {
|
||||
const { user, logout, unlock } = useAuth();
|
||||
|
||||
|
||||
@@ -24,21 +24,20 @@ vi.mock("../components/ui/ComposeCanvas", () => ({
|
||||
// Mock CryptoUtils to avoid real crypto calls in UI tests
|
||||
vi.mock("../utils/crypto", () => {
|
||||
return {
|
||||
CryptoUtils: vi.fn().mockImplementation(function () {
|
||||
return {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
encryptLetter: vi.fn().mockResolvedValue({
|
||||
encrypted_content: "enc-content",
|
||||
encrypted_dek: "enc-dek",
|
||||
sharingKey: "share-key",
|
||||
}),
|
||||
encryptMetadata: vi.fn().mockResolvedValue({
|
||||
encrypted_content: "enc-meta",
|
||||
encrypted_dek: "enc-dek",
|
||||
}),
|
||||
decryptMetadata: vi.fn().mockResolvedValue({ recipient: "Test User" }),
|
||||
decryptLetter: vi.fn().mockResolvedValue("{}"),
|
||||
};
|
||||
CryptoUtils: () => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
encryptLetter: vi.fn().mockResolvedValue({
|
||||
encrypted_content: "enc-content",
|
||||
encrypted_dek: "enc-dek",
|
||||
sharingKey: "share-key",
|
||||
}),
|
||||
encryptMetadata: vi.fn().mockResolvedValue({
|
||||
encrypted_content: "enc-meta",
|
||||
encrypted_dek: "enc-dek",
|
||||
}),
|
||||
decryptMetadata: vi.fn().mockResolvedValue({ recipient: "Test User" }),
|
||||
decryptLetter: vi.fn().mockResolvedValue("{}"),
|
||||
extractSharingKey: vi.fn().mockResolvedValue("share-key"),
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -160,7 +159,7 @@ describe("Editor Page", () => {
|
||||
fireEvent.click(secondarySealBtn);
|
||||
|
||||
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");
|
||||
|
||||
+10
-244
@@ -1,13 +1,7 @@
|
||||
import {
|
||||
ClockIcon,
|
||||
DownloadSimpleIcon,
|
||||
ImageIcon,
|
||||
LockIcon,
|
||||
QuestionIcon,
|
||||
SpinnerGapIcon,
|
||||
StampIcon,
|
||||
TrayIcon,
|
||||
VaultIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -17,6 +11,12 @@ import {
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import { api } from "../api/apiClient";
|
||||
import { PostSealModal } from "../components/editor/PostSealModal";
|
||||
import {
|
||||
LetterHead,
|
||||
ToolBar,
|
||||
VaultConfirmModal,
|
||||
} from "../components/editor/ToolBar";
|
||||
import {
|
||||
type CanvasTools,
|
||||
ComposeCanvas,
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
import DateDisplay from "../components/ui/DateDisplay";
|
||||
import { LogModal } from "../components/ui/LogModal";
|
||||
import { Navbar } from "../components/ui/Navbar";
|
||||
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { PATHS, ROUTES } from "../config/routes";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import { formatRelativeDate } from "../utils/dateFormat";
|
||||
@@ -43,241 +44,6 @@ const toPlaceholderList = [
|
||||
"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() {
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||
@@ -646,14 +412,14 @@ export default function Editor() {
|
||||
)}
|
||||
|
||||
{confirmModal === "VAULT" && (
|
||||
<VaultConfirm
|
||||
<VaultConfirmModal
|
||||
onSave={handleSave}
|
||||
setConfirmModal={setConfirmModal}
|
||||
setUnlockDate={setUnlockDate}
|
||||
/>
|
||||
)}
|
||||
{sealedTargetId && (
|
||||
<SealedModal sealedTargetId={sealedTargetId} navigate={navigate} />
|
||||
<PostSealModal sealedTargetId={sealedTargetId} navigate={navigate} />
|
||||
)}
|
||||
|
||||
<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 handleShare = async () => {
|
||||
if (!encryptedDek || !masterKey || !public_id) return;
|
||||
if (!(encryptedDek && masterKey && public_id)) return;
|
||||
const cryptoUtils = new CryptoUtils();
|
||||
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
||||
try {
|
||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
|
||||
} catch (err) {
|
||||
console.error("Failed to update letter:", err);
|
||||
} catch (_err) {
|
||||
} finally {
|
||||
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
||||
}
|
||||
@@ -145,14 +144,13 @@ export default function Reader() {
|
||||
}
|
||||
|
||||
const burnLetter = async () => {
|
||||
console.log("Burning letter...");
|
||||
if (!public_id || isBurning) return;
|
||||
setIsBurning(true);
|
||||
try {
|
||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
||||
status: "BURNED",
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
} finally {
|
||||
setIsBurning(false);
|
||||
setShowBurnModal(false);
|
||||
@@ -447,6 +445,7 @@ export default function Reader() {
|
||||
</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)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user