refactor: UX enhancement for drawer

This commit is contained in:
ramvignesh-b
2026-04-18 18:51:43 +05:30
parent d17d5c01e8
commit ad5bc57eee
4 changed files with 96 additions and 86 deletions
+24 -11
View File
@@ -1,3 +1,5 @@
import { GearFineIcon } from "@phosphor-icons/react";
interface DrawerSectionProps { interface DrawerSectionProps {
id: string; id: string;
title: string; title: string;
@@ -21,7 +23,7 @@ export function DrawerSection({
className={`overflow-visible join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`} className={`overflow-visible join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`}
> >
<div <div
className={`transition-all duration-1000 ease-in-out bg-neutral/10 ${ className={`transition-normal duration-1000 ease-in-out bg-neutral/10 ${
isOpen isOpen
? "max-h-125 opacity-100 py-3 border-b border-base-content/5" ? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
: "max-h-0 opacity-0 pointer-events-none" : "max-h-0 opacity-0 pointer-events-none"
@@ -33,7 +35,7 @@ export function DrawerSection({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-1000 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`} className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-none outline-none focus-visible:ring-2 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 className="flex-1">
<div <div
@@ -49,15 +51,26 @@ export function DrawerSection({
{count} {count}
</div> </div>
</div> </div>
<div
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${ {id === "vault" ? (
isOpen <GearFineIcon
? "bg-primary/80! opacity-80 scale-110" className={
: "group-hover:bg-primary" "-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
}`} }
> weight={"duotone"}
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" /> size={30}
</div> />
) : (
<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> </button>
</div> </div>
); );
+1 -1
View File
@@ -25,7 +25,7 @@ export function LetterItem({
<button <button
type="button" type="button"
onClick={handleNavigate} onClick={handleNavigate}
className="p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-transform duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2" className="p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2"
> >
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none"> <div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none">
{preview} {preview}
+48 -47
View File
@@ -18,6 +18,53 @@ export default function Drawer() {
if (!user) return null; if (!user) return null;
function PasskeyModal() {
return (
<div className="modal modal-open bg-base-100/20 backdrop-blur-md">
<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">
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 unlock(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>
);
}
const toggleSection = (id: string) => const toggleSection = (id: string) =>
setOpenSection(openSection === id ? null : id); setOpenSection(openSection === id ? null : id);
@@ -25,53 +72,7 @@ export default function Drawer() {
<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-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" /> <div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
{isAuthRequired && ( {isAuthRequired && <PasskeyModal />}
<div className="modal modal-open bg-base-100/20 backdrop-blur-md">
<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">
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;
unlock(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>
)}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-1000"> <header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-1000">
<Logo /> <Logo />
<div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2"> <div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2">
+23 -27
View File
@@ -9,20 +9,6 @@ import Reader from "./Reader";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
// Spy on crypto methods so we don't have to do actual decryption in the UI test
const spyDecryptLetter = vi.spyOn(
CryptoUtils.prototype,
"decryptLetterWithSharingKey",
);
const spyDecryptMetadata = vi.spyOn(
CryptoUtils.prototype,
"decryptMetadataWithSharingKey",
);
const spyDecryptImage = vi.spyOn(
CryptoUtils.prototype,
"decryptImageWithSharingKey",
);
// Fabric.js needs to know when fonts are loaded // Fabric.js needs to know when fonts are loaded
Object.defineProperty(document, "fonts", { Object.defineProperty(document, "fonts", {
value: { ready: Promise.resolve() }, value: { ready: Promise.resolve() },
@@ -30,13 +16,16 @@ Object.defineProperty(document, "fonts", {
}); });
describe("Reader Page", () => { describe("Reader Page", () => {
beforeEach(() => { let masterKey: CryptoKey;
let utils: CryptoUtils;
beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default mock behavior for successful decryption utils = new CryptoUtils();
spyDecryptLetter.mockResolvedValue('{"objects": []}'); await utils.initialize();
spyDecryptMetadata.mockResolvedValue({ recipient: "Guest" }); const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
spyDecryptImage.mockResolvedValue("blob:url"); masterKey = bundle.masterKey;
// Clear the URL hash // Clear the URL hash
vi.stubGlobal("location", { vi.stubGlobal("location", {
@@ -61,19 +50,27 @@ describe("Reader Page", () => {
it("should load and decrypt the letter when a valid key is provided", async () => { it("should load and decrypt the letter when a valid key is provided", async () => {
const mockPublicId = "test-uuid"; const mockPublicId = "test-uuid";
const mockKey = "fake-key"; const letterContent = JSON.stringify({ objects: [] });
const metadata = { recipient: "Guest" };
const encryptedLetter = await utils.encryptLetter(letterContent, masterKey);
const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey);
const sharingKey = encryptedLetter.sharingKey as string;
server.use( server.use(
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => { http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
return HttpResponse.json({ return HttpResponse.json({
encrypted_content: "packed-content", encrypted_content: encryptedLetter.encrypted_content,
encrypted_metadata: "packed-metadata", encrypted_metadata: encryptedMetadata.encrypted_content, // Reader expects .encrypted_content for metadata too
encrypted_dek: encryptedLetter.encrypted_dek,
images: [], images: [],
}); });
}), }),
); );
render( render(
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${mockKey}`]}> <MemoryRouter initialEntries={[`/read/${mockPublicId}#${sharingKey}`]}>
<Routes> <Routes>
<Route path="/read/:public_id" element={<Reader />} /> <Route path="/read/:public_id" element={<Reader />} />
</Routes> </Routes>
@@ -81,11 +78,10 @@ describe("Reader Page", () => {
); );
// Should show loading state first // Should show loading state first
expect(screen.getByText(/Decrypting.../i)).toBeInTheDocument(); expect(screen.getByText(/Breaking the seal.../i)).toBeInTheDocument();
expect( expect(await screen.findByText(/A sealed letter for/i)).toBeInTheDocument();
await screen.findByText(/A sealed message for/i), expect(screen.getAllByText(/Guest/i).length).toBeGreaterThanOrEqual(1);
).toBeInTheDocument();
}); });
it("should display an error message if the server request fails", async () => { it("should display an error message if the server request fails", async () => {