mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: add Navbar component and integrate into Editor page
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { ArrowArcLeftIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ROUTES } from "../../config/routes";
|
||||
|
||||
export const Navbar = ({ child }: { child?: React.ReactNode }) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 h-16 z-999 border-b border-base-content/5 bg-base-300/60 backdrop-blur-xl animate-in fade-in slide-in-from-top-4 duration-700">
|
||||
<div className="max-w-280 h-full mx-auto px-6 flex items-center justify-between">
|
||||
{/* Left: Back to Drawer */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(ROUTES.DRAWER)}
|
||||
className="group flex items-center gap-2 px-0 hover:bg-transparent cursor-pointer"
|
||||
>
|
||||
<div className="p-1.5 rounded-full bg-base-content/5 transition-colors group-hover:bg-primary/10">
|
||||
<ArrowArcLeftIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="text-base-content/40 group-hover:text-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-sans text-[10px] tracking-[0.3em] uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
|
||||
Drawer
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Right: Custom child */}
|
||||
<div className="flex items-center gap-2">{child}</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
+152
-33
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
ClockIcon,
|
||||
DownloadSimpleIcon,
|
||||
ImageIcon,
|
||||
LockIcon,
|
||||
SpinnerGapIcon,
|
||||
TrayIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -13,21 +16,33 @@ import {
|
||||
ComposeCanvas,
|
||||
} from "../components/ui/ComposeCanvas";
|
||||
import DateDisplay from "../components/ui/DateDisplay";
|
||||
import { Navbar } from "../components/ui/Navbar";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import { formatRelativeDate } from "../utils/dateFormat";
|
||||
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
||||
|
||||
type SaveOverlay = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const OVERLAY_FADE_MS = 250;
|
||||
const SAVED_VISIBLE_MS = 1400;
|
||||
const ERROR_VISIBLE_MS = 2400;
|
||||
|
||||
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 [lastSaved, setLastSaved] = useState<string | null>(null);
|
||||
const [isSaveDatePulsing, setIsSaveDatePulsing] = useState(false);
|
||||
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
|
||||
|
||||
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle");
|
||||
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
|
||||
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const { masterKey } = useKeyStore();
|
||||
@@ -46,6 +61,8 @@ export default function Editor() {
|
||||
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||
const letterData = res.data;
|
||||
|
||||
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
|
||||
|
||||
const metadata = await cryptoUtils.decryptMetadata(
|
||||
{
|
||||
encrypted_content: letterData.encrypted_metadata,
|
||||
@@ -85,7 +102,42 @@ export default function Editor() {
|
||||
loadExistingLetter();
|
||||
}, [public_id, masterKey]);
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (lastSavedPulseTick === 0) return;
|
||||
|
||||
setIsSaveDatePulsing(true);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsSaveDatePulsing(false);
|
||||
}, 10000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [lastSavedPulseTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveOverlay === "idle" || saveOverlay === "saving") return;
|
||||
|
||||
const visibleTimer = setTimeout(
|
||||
() => {
|
||||
setShowSaveOverlay(false);
|
||||
},
|
||||
saveOverlay === "saved" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
|
||||
);
|
||||
|
||||
const unmountTimer = setTimeout(
|
||||
() => {
|
||||
setSaveOverlay("idle");
|
||||
},
|
||||
(saveOverlay === "saved" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
|
||||
OVERLAY_FADE_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(visibleTimer);
|
||||
clearTimeout(unmountTimer);
|
||||
};
|
||||
}, [saveOverlay]);
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -102,8 +154,10 @@ export default function Editor() {
|
||||
letterIdRef.current = public_id;
|
||||
}
|
||||
|
||||
if (isSealing || !masterKey) return;
|
||||
setIsSealing(true);
|
||||
if (saveOverlay === "saving" || !masterKey) return;
|
||||
|
||||
setSaveOverlay("saving");
|
||||
setShowSaveOverlay(true);
|
||||
|
||||
const cryptoUtils = new CryptoUtils();
|
||||
await cryptoUtils.initialize();
|
||||
@@ -145,29 +199,58 @@ export default function Editor() {
|
||||
});
|
||||
|
||||
await api.put(`${endpoints.LETTERS}${letterIdRef.current}/`, formData);
|
||||
setIsSaveSuccess(true);
|
||||
|
||||
setLastSaved(formatRelativeDate(new Date()));
|
||||
setLastSavedPulseTick((prev) => prev + 1);
|
||||
|
||||
if (status === "SEALED" && encrypted_letter.sharingKey) {
|
||||
const link = `${window.location.origin}${PATHS.read(letterIdRef.current)}#${encrypted_letter.sharingKey}`;
|
||||
const link = `${window.location.origin}${PATHS.read(
|
||||
letterIdRef.current,
|
||||
)}#${encrypted_letter.sharingKey}`;
|
||||
setShareLink(link);
|
||||
setShowSaveOverlay(false);
|
||||
setTimeout(() => setSaveOverlay("idle"), OVERLAY_FADE_MS);
|
||||
} else {
|
||||
setSaveOverlay("saved");
|
||||
setShowSaveOverlay(true);
|
||||
}
|
||||
|
||||
setTimeout(() => setIsSaveSuccess(false), 5000);
|
||||
} catch (_error) {
|
||||
} finally {
|
||||
setIsSealing(false);
|
||||
setSaveOverlay("error");
|
||||
setShowSaveOverlay(true);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<>
|
||||
<Navbar
|
||||
child={
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
isSaveDatePulsing ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
<ClockIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className="text-neutral-content/30"
|
||||
/>
|
||||
<p className="text-sm text-neutral-content/30 flex-col justify-end leading-none text-right">
|
||||
<span className="text-[10px] uppercase tracking-widest font-bold">
|
||||
Last Save
|
||||
</span>
|
||||
<br />
|
||||
<span className="italic">{lastSaved}</span>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 pt-32 pb-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">
|
||||
@@ -184,14 +267,14 @@ export default function Editor() {
|
||||
)}
|
||||
|
||||
{shareLink && (
|
||||
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-[100]">
|
||||
<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)}
|
||||
>
|
||||
✕
|
||||
<XCircleIcon size={18} weight="bold" />
|
||||
</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">
|
||||
@@ -200,8 +283,8 @@ export default function Editor() {
|
||||
<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.
|
||||
This letter is now encrypted. Share this secret link with
|
||||
your recipient.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -228,24 +311,58 @@ export default function Editor() {
|
||||
</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>
|
||||
{saveOverlay !== "idle" && !shareLink && (
|
||||
<div
|
||||
className={`modal modal-open bg-base-100/20 backdrop-blur-md transition-opacity duration-300 ${
|
||||
showSaveOverlay ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="modal-box p-0 bg-transparent shadow-none transition-all duration-300">
|
||||
{saveOverlay === "saving" && (
|
||||
<div
|
||||
role="alert"
|
||||
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
|
||||
showSaveOverlay
|
||||
? "opacity-100 scale-100 translate-y-0"
|
||||
: "opacity-0 scale-95 translate-y-1"
|
||||
}`}
|
||||
>
|
||||
<SpinnerGapIcon
|
||||
size={18}
|
||||
weight="bold"
|
||||
className="animate-spin"
|
||||
/>
|
||||
<span className="font-bold">Securing your letter...</span>
|
||||
</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>
|
||||
{saveOverlay === "saved" && (
|
||||
<div
|
||||
role="alert"
|
||||
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
|
||||
showSaveOverlay
|
||||
? "opacity-100 scale-100 translate-y-0"
|
||||
: "opacity-0 scale-95 translate-y-1"
|
||||
}`}
|
||||
>
|
||||
<DownloadSimpleIcon size={18} weight="bold" />
|
||||
<span className="font-bold">Your letter is saved!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveOverlay === "error" && (
|
||||
<div
|
||||
role="alert"
|
||||
className={`alert alert-error shadow-lg transition-all duration-300 ${
|
||||
showSaveOverlay
|
||||
? "opacity-100 scale-100 translate-y-0"
|
||||
: "opacity-0 scale-95 translate-y-1"
|
||||
}`}
|
||||
>
|
||||
<XIcon size={18} weight="bold" />
|
||||
<span className="font-bold">Failed to save letter</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,8 +432,10 @@ export default function Editor() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComposeCanvas ref={canvasRef} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user