mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import {
|
|
DownloadSimpleIcon,
|
|
ImageIcon,
|
|
LockIcon,
|
|
SpinnerGapIcon,
|
|
TrayIcon,
|
|
} from "@phosphor-icons/react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { api } from "../api/apiClient";
|
|
import {
|
|
type CanvasTools,
|
|
ComposeCanvas,
|
|
} from "../components/ui/ComposeCanvas";
|
|
import DateDisplay from "../components/ui/DateDisplay";
|
|
import { endpoints } from "../config/endpoints";
|
|
import { PATHS } from "../config/routes";
|
|
import { useKeyStore } from "../store/useKeyStore";
|
|
import { CryptoUtils } from "../utils/crypto";
|
|
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
|
|
|
export default function Editor() {
|
|
const navigate = useNavigate();
|
|
const { public_id } = useParams();
|
|
const letterIdRef = useRef<string>(public_id ?? "");
|
|
|
|
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
|
const [isSealing, setIsSealing] = useState(false);
|
|
const [isSaveSuccess, setIsSaveSuccess] = useState(false);
|
|
const [shareLink, setShareLink] = useState<string | null>(null);
|
|
|
|
const [recipient, setRecipient] = useState("");
|
|
const { masterKey } = useKeyStore();
|
|
|
|
const canvasRef = useRef<CanvasTools>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!(public_id && masterKey)) return;
|
|
|
|
const loadExistingLetter = async () => {
|
|
setIsInitialLoading(true);
|
|
const cryptoUtils = new CryptoUtils();
|
|
|
|
try {
|
|
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
|
const letterData = res.data;
|
|
|
|
const metadata = await cryptoUtils.decryptMetadata(
|
|
{
|
|
encrypted_content: letterData.encrypted_metadata,
|
|
encrypted_dek: letterData.encrypted_dek,
|
|
},
|
|
masterKey,
|
|
);
|
|
setRecipient(metadata.recipient || "");
|
|
|
|
const decryptedJsonStr = await cryptoUtils.decryptLetter(
|
|
{
|
|
encrypted_content: letterData.encrypted_content,
|
|
encrypted_dek: letterData.encrypted_dek,
|
|
},
|
|
masterKey,
|
|
);
|
|
const canvasData = JSON.parse(decryptedJsonStr);
|
|
|
|
await decryptCanvasImages(
|
|
canvasData,
|
|
letterData.images ?? [],
|
|
letterData.encrypted_dek,
|
|
masterKey,
|
|
cryptoUtils,
|
|
true,
|
|
);
|
|
|
|
requestAnimationFrame(() => {
|
|
canvasRef.current?.loadData(canvasData);
|
|
});
|
|
} catch (_err) {
|
|
} finally {
|
|
setIsInitialLoading(false);
|
|
}
|
|
};
|
|
|
|
loadExistingLetter();
|
|
}, [public_id, masterKey]);
|
|
|
|
// --------------------------------------------------------------------------------------
|
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const url = URL.createObjectURL(file);
|
|
canvasRef.current?.addImage(url, file);
|
|
}
|
|
};
|
|
|
|
const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
|
|
if (!(public_id || letterIdRef.current)) {
|
|
letterIdRef.current = crypto.randomUUID();
|
|
navigate(PATHS.write(letterIdRef.current), { replace: true });
|
|
} else if (public_id) {
|
|
letterIdRef.current = public_id;
|
|
}
|
|
|
|
if (isSealing || !masterKey) return;
|
|
setIsSealing(true);
|
|
|
|
const cryptoUtils = new CryptoUtils();
|
|
await cryptoUtils.initialize();
|
|
|
|
try {
|
|
const canvasData = canvasRef.current?.getData() || { objects: [] };
|
|
const canvasImages = canvasRef.current?.getImages() || [];
|
|
|
|
const encImageFilesMap = await encryptCanvasImages(
|
|
canvasData,
|
|
canvasImages,
|
|
masterKey,
|
|
cryptoUtils,
|
|
);
|
|
|
|
const encrypted_letter = await cryptoUtils.encryptLetter(
|
|
JSON.stringify(canvasData),
|
|
masterKey,
|
|
);
|
|
|
|
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
|
{ recipient, tags: [] },
|
|
masterKey,
|
|
);
|
|
|
|
const formData = new FormData();
|
|
formData.append("public_id", letterIdRef.current);
|
|
formData.append("type", "KEPT");
|
|
formData.append("status", status);
|
|
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
|
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
|
formData.append(
|
|
"encrypted_metadata",
|
|
encrypted_metadata.encrypted_content,
|
|
);
|
|
|
|
encImageFilesMap.forEach((blob, filename) => {
|
|
formData.append("image_files", blob, filename);
|
|
});
|
|
|
|
await api.put(`${endpoints.LETTERS}${letterIdRef.current}/`, formData);
|
|
setIsSaveSuccess(true);
|
|
|
|
if (status === "SEALED" && encrypted_letter.sharingKey) {
|
|
const link = `${window.location.origin}${PATHS.read(letterIdRef.current)}#${encrypted_letter.sharingKey}`;
|
|
setShareLink(link);
|
|
}
|
|
|
|
setTimeout(() => setIsSaveSuccess(false), 5000);
|
|
} catch (_error) {
|
|
} finally {
|
|
setIsSealing(false);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = async () => {
|
|
if (!shareLink) return;
|
|
try {
|
|
await navigator.clipboard.writeText(shareLink);
|
|
} catch (_err) {}
|
|
};
|
|
|
|
return (
|
|
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 py-12 bg-base-300 relative">
|
|
{isInitialLoading && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<SpinnerGapIcon
|
|
size={48}
|
|
weight="bold"
|
|
className="animate-spin text-primary"
|
|
/>
|
|
<p className="text-[10px] uppercase tracking-[0.4em] font-bold text-base-content/40">
|
|
Opening your draft...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{shareLink && (
|
|
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-[100]">
|
|
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
|
onClick={() => setShareLink(null)}
|
|
>
|
|
✕
|
|
</button>
|
|
<div className="flex flex-col items-center text-center gap-6 py-4">
|
|
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
|
|
<LockIcon size={32} weight="fill" className="text-primary" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="font-serif text-3xl">Sealed & Ready</h3>
|
|
<p className="text-base-content/60 text-sm max-w-xs">
|
|
This letter is now encrypted. Share this secret link with your
|
|
recipient.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl group relative">
|
|
<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}
|
|
className="btn btn-primary btn-sm rounded-lg"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-[10px] uppercase tracking-widest text-base-content/30">
|
|
Zero-Knowledge: The key is in the link, not our servers.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isSaveSuccess && !shareLink && (
|
|
<div className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out animate-fade-in opacity-80">
|
|
<div className="alert alert-success opacity-90">
|
|
<DownloadSimpleIcon size={18} weight="bold" />
|
|
<h3 className="font-bold text-lg text-success-content">
|
|
Your letter is saved!
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isSealing && (
|
|
<div className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out animate-fade-in opacity-80">
|
|
<div className="alert alert-neutral">
|
|
<SpinnerGapIcon size={18} weight="bold" className="animate-spin" />
|
|
<h3 className="font-bold text-neutral-content text-lg animate-pulse">
|
|
Securing your letter...
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="max-w-180 mx-auto px-1 md:px-0">
|
|
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
|
|
<div className="flex flex-col gap-2 flex-1">
|
|
<label
|
|
htmlFor="recipient"
|
|
className="text-[10px] uppercase tracking-[0.4em] text-secondary-content font-bold"
|
|
>
|
|
Recipient
|
|
</label>
|
|
<input
|
|
id="recipient"
|
|
type="text"
|
|
placeholder="Someone dear..."
|
|
value={recipient}
|
|
onChange={(e) => setRecipient(e.target.value)}
|
|
className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full"
|
|
/>
|
|
</div>
|
|
<DateDisplay />
|
|
</div>
|
|
|
|
<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"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<ImageIcon size={18} weight="bold" />
|
|
</button>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleImageUpload}
|
|
accept="image/*"
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sm text-[10px] tracking-[0.2em] uppercase font-bold text-base-content/60 hover:text-base-content"
|
|
title="Store in your private drawer"
|
|
onClick={() => handleSave("DRAFT")}
|
|
>
|
|
<TrayIcon size={18} weight="bold" />
|
|
<span className="hidden md:inline">Store</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"
|
|
onClick={() => handleSave("SEALED")}
|
|
>
|
|
<LockIcon size={14} weight="fill" className="mr-1" />
|
|
Seal
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ComposeCanvas ref={canvasRef} />
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|