Style/Revamp #4
@@ -2,63 +2,63 @@ import { DotIcon } from "@phosphor-icons/react";
|
||||
import "@fontsource/knewave/400.css";
|
||||
|
||||
interface LogoProps {
|
||||
scale?: number;
|
||||
type?: "inline" | "mono" | "logo" | null;
|
||||
ul?: boolean;
|
||||
scale?: number;
|
||||
type?: "inline" | "mono" | "logo" | null;
|
||||
ul?: boolean;
|
||||
}
|
||||
|
||||
export default function Logo({
|
||||
scale = 1,
|
||||
type = null,
|
||||
ul = false,
|
||||
scale = 1,
|
||||
type = null,
|
||||
ul = false,
|
||||
}: LogoProps) {
|
||||
if (type === "inline") {
|
||||
return (
|
||||
<span className={"text-accent font-display italic "}>
|
||||
pi<span className="text-primary">.</span> ku
|
||||
<span className="text-primary">.</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "mono") {
|
||||
return (
|
||||
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
|
||||
pi. ku.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "logo") {
|
||||
return (
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Pi. Ku. logo"
|
||||
className="mx-4"
|
||||
width={scale * 100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "inline") {
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Pi. Ku. logo"
|
||||
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
|
||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
||||
>
|
||||
<span className="text-3xl font-light text-accent">Pi</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-primary translate-y-1 -mx-px"
|
||||
/>
|
||||
<span className="text-3xl font-light text-accent"> Ku</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-primary translate-y-1 -mx-px"
|
||||
/>
|
||||
</div>
|
||||
<span className={"text-accent font-display italic "}>
|
||||
pi<span className="text-primary">.</span> ku
|
||||
<span className="text-primary">.</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "mono") {
|
||||
return (
|
||||
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
|
||||
pi. ku.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "logo") {
|
||||
return (
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Pi. Ku. logo"
|
||||
className="mx-4"
|
||||
width={scale * 100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Pi. Ku. logo"
|
||||
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
|
||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
||||
>
|
||||
<span className="text-3xl font-light text-accent">Pi</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-primary translate-y-1 -mx-px"
|
||||
/>
|
||||
<span className="text-3xl font-light text-accent"> Ku</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-primary translate-y-1 -mx-px"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ function renderGuard(ui: React.ReactNode, mountPath: "/protected" | "/public") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[mountPath]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div data-testid="login-page">Login Page</div>} />
|
||||
<Route path="/drawer" element={<div data-testid="drawer-page">Drawer Page</div>} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={<div data-testid="login-page">Login Page</div>}
|
||||
/>
|
||||
<Route
|
||||
path="/drawer"
|
||||
element={<div data-testid="drawer-page">Drawer Page</div>}
|
||||
/>
|
||||
<Route path="/protected" element={ui} />
|
||||
<Route path="/public" element={ui} />
|
||||
</Routes>
|
||||
|
||||
@@ -3,7 +3,10 @@ import Logo from "./Logo";
|
||||
|
||||
export default function SplashScreen() {
|
||||
return (
|
||||
<div data-testid="splash-screen" className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')">
|
||||
<div
|
||||
data-testid="splash-screen"
|
||||
className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-6 animate-pulse">
|
||||
<Logo />
|
||||
|
||||
|
||||
@@ -1,95 +1,101 @@
|
||||
import { GearFineIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface DrawerSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
subtext: string;
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
subtext: string;
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DrawerSection({
|
||||
id,
|
||||
title,
|
||||
count,
|
||||
subtext,
|
||||
isOpen,
|
||||
onClick,
|
||||
children,
|
||||
icon,
|
||||
id,
|
||||
title,
|
||||
count,
|
||||
subtext,
|
||||
isOpen,
|
||||
onClick,
|
||||
children,
|
||||
icon,
|
||||
}: DrawerSectionProps) {
|
||||
return (
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`bg-neutral/10 transition-all duration-1000 ease-in-out overflow-visible ${isOpen ? "max-h-125" : "max-h-0 pointer-events-none"}`}
|
||||
>
|
||||
<div
|
||||
id={id}
|
||||
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
|
||||
className={`transition-opacity ease-in-out ${
|
||||
isOpen
|
||||
? "opacity-100 py-3 border-b border-base-content/5 duration-700 delay-500"
|
||||
: "opacity-0 duration-100"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`bg-neutral/10 transition-all duration-1000 ease-in-out overflow-visible ${isOpen ? "max-h-125" : "max-h-0 pointer-events-none"}`}
|
||||
{children}
|
||||
{count === 0 && (
|
||||
<p
|
||||
data-testid={`empty-drawer-message-${id}`}
|
||||
className="text-center text-base-content/20 mt-4"
|
||||
>
|
||||
<div
|
||||
className={`transition-opacity ease-in-out ${isOpen
|
||||
? "opacity-100 py-3 border-b border-base-content/5 duration-700 delay-500"
|
||||
: "opacity-0 duration-100"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{count === 0 && (
|
||||
<p data-testid={`empty-drawer-message-${id}`} className="text-center text-base-content/20 mt-4">
|
||||
This drawer remains silent
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-testid={`drawer-section-${id}`}
|
||||
className="w-full relative p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 overflow-hidden 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
|
||||
data-testid="drawer-section-title"
|
||||
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${isOpen
|
||||
? "text-base-content"
|
||||
: "text-base-content/40 group-hover:text-base-content/80"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="font-sans text-xs text-base-content/20 mt-1">
|
||||
<span className="font-mono text-xs md:text-base -mt-1 absolute text-primary/30">
|
||||
{count}
|
||||
</span>{" "}
|
||||
<span className="ml-3">{subtext}</span>
|
||||
</div>
|
||||
<div className="absolute right-5 -translate-y-15 text-base-content/4">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{id === "vault" ? (
|
||||
<GearFineIcon
|
||||
className={
|
||||
"-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
|
||||
}
|
||||
weight={"duotone"}
|
||||
size={30}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
This drawer remains silent
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-testid={`drawer-section-${id}`}
|
||||
className="w-full relative p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 overflow-hidden 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
|
||||
data-testid="drawer-section-title"
|
||||
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${
|
||||
isOpen
|
||||
? "text-base-content"
|
||||
: "text-base-content/40 group-hover:text-base-content/80"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div className="font-sans text-xs text-base-content/20 mt-1">
|
||||
<span className="font-mono text-xs md:text-base -mt-1 absolute text-primary/30">
|
||||
{count}
|
||||
</span>{" "}
|
||||
<span className="ml-3">{subtext}</span>
|
||||
</div>
|
||||
<div className="absolute right-5 -translate-y-15 text-base-content/4">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{id === "vault" ? (
|
||||
<GearFineIcon
|
||||
className={
|
||||
"-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
|
||||
}
|
||||
weight={"duotone"}
|
||||
size={30}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export function PasskeyModal() {
|
||||
className="text-primary mx-auto mb-8 animate-pulse"
|
||||
weight="duotone"
|
||||
/>
|
||||
<h3 data-testid="passkey-modal-title" className="font-bold text-lg font-display text-primary">
|
||||
<h3
|
||||
data-testid="passkey-modal-title"
|
||||
className="font-bold text-lg font-display text-primary"
|
||||
>
|
||||
You've been away a while.
|
||||
</h3>
|
||||
<p className="py-4 font-sans">
|
||||
|
||||
@@ -123,7 +123,12 @@ export function EnvelopeReveal({
|
||||
<span className={"text-neutral-content/60 font-xs font-display"}>
|
||||
to
|
||||
</span>
|
||||
<h1 data-testid="envelope-recipient" className="text-3xl font-bold text-base-content">{recipient}</h1>
|
||||
<h1
|
||||
data-testid="envelope-recipient"
|
||||
className="text-3xl font-bold text-base-content"
|
||||
>
|
||||
{recipient}
|
||||
</h1>
|
||||
<p className="text-base-content/60 font-display mt-8">{date}</p>
|
||||
<img
|
||||
src={stamp}
|
||||
|
||||
+52
-50
@@ -2,75 +2,77 @@
|
||||
@plugin "daisyui";
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "piku";
|
||||
default: true;
|
||||
prefersdark: true;
|
||||
color-scheme: dark;
|
||||
name: "piku";
|
||||
default: true;
|
||||
prefersdark: true;
|
||||
color-scheme: dark;
|
||||
|
||||
--color-base-100: oklch(14% 0.012 35);
|
||||
--color-base-200: oklch(18% 0.014 33);
|
||||
--color-base-300: oklch(22% 0.016 32);
|
||||
--color-base-content: oklch(82% 0.02 70);
|
||||
--color-base-100: oklch(14% 0.012 35);
|
||||
--color-base-200: oklch(18% 0.014 33);
|
||||
--color-base-300: oklch(22% 0.016 32);
|
||||
--color-base-content: oklch(82% 0.02 70);
|
||||
|
||||
--color-primary: oklch(67% 0.11 78);
|
||||
--color-primary-content: oklch(15% 0.03 70);
|
||||
--color-primary: oklch(67% 0.11 78);
|
||||
--color-primary-content: oklch(15% 0.03 70);
|
||||
|
||||
--color-secondary: oklch(48% 0.08 305);
|
||||
--color-secondary-content: oklch(92% 0.01 305);
|
||||
--color-secondary: oklch(48% 0.08 305);
|
||||
--color-secondary-content: oklch(92% 0.01 305);
|
||||
|
||||
--color-accent: oklch(55% 0.06 325);
|
||||
--color-accent-content: oklch(18% 0.03 295);
|
||||
--color-accent: oklch(55% 0.06 325);
|
||||
--color-accent-content: oklch(18% 0.03 295);
|
||||
|
||||
--color-neutral: oklch(38% 0.02 45);
|
||||
--color-neutral-content: oklch(80% 0.015 60);
|
||||
--color-neutral: oklch(38% 0.02 45);
|
||||
--color-neutral-content: oklch(80% 0.015 60);
|
||||
|
||||
--color-info: oklch(60% 0.06 250);
|
||||
--color-info-content: oklch(95% 0.01 240);
|
||||
--color-success: oklch(65% 0.05 140);
|
||||
--color-success-content: oklch(16% 0.03 150);
|
||||
--color-warning: oklch(68% 0.08 72);
|
||||
--color-warning-content: oklch(18% 0.03 60);
|
||||
--color-error: oklch(55% 0.1 22);
|
||||
--color-error-content: oklch(92% 0.01 22);
|
||||
--color-info: oklch(60% 0.06 250);
|
||||
--color-info-content: oklch(95% 0.01 240);
|
||||
--color-success: oklch(65% 0.05 140);
|
||||
--color-success-content: oklch(16% 0.03 150);
|
||||
--color-warning: oklch(68% 0.08 72);
|
||||
--color-warning-content: oklch(18% 0.03 60);
|
||||
--color-error: oklch(55% 0.1 22);
|
||||
--color-error-content: oklch(92% 0.01 22);
|
||||
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.375rem;
|
||||
--radius-box: 0.5rem;
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.375rem;
|
||||
--radius-box: 0.5rem;
|
||||
|
||||
--depth: 1;
|
||||
--noise: 0.03;
|
||||
--depth: 1;
|
||||
--noise: 0.03;
|
||||
|
||||
--border: 1px;
|
||||
--border: 1px;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
||||
--font-sans: "Jost Variable", sans-serif;
|
||||
--font-serif: "Playfair Display Variable", serif;
|
||||
--font-mono: "Space Mono", monospace;
|
||||
--font-ink: "Kavivanar", sans-serif;
|
||||
--font-redact: "Redacted Script", cursive;
|
||||
--font-slab: "Cutive Mono", monospace;
|
||||
--font-hand: "Architects Daughter", cursive;
|
||||
--color-glass-bg: rgba(28, 22, 16, 0.45);
|
||||
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
||||
--radius-xl: 1.5rem;
|
||||
--color-paper: oklch(97% 0.008 80);
|
||||
--text-xxs: 10px;
|
||||
--tracking-widester: 0.5em;
|
||||
--background-image-vig: radial-gradient(circle at center,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.4) 100%);
|
||||
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
||||
--font-sans: "Jost Variable", sans-serif;
|
||||
--font-serif: "Playfair Display Variable", serif;
|
||||
--font-mono: "Space Mono", monospace;
|
||||
--font-ink: "Kavivanar", sans-serif;
|
||||
--font-redact: "Redacted Script", cursive;
|
||||
--font-slab: "Cutive Mono", monospace;
|
||||
--font-hand: "Architects Daughter", cursive;
|
||||
--color-glass-bg: rgba(28, 22, 16, 0.45);
|
||||
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
||||
--radius-xl: 1.5rem;
|
||||
--color-paper: oklch(97% 0.008 80);
|
||||
--text-xxs: 10px;
|
||||
--tracking-widester: 0.5em;
|
||||
--background-image-vig: radial-gradient(
|
||||
circle at center,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.4) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@apply bg-glass-bg max-w-xs md:max-w-sm backdrop-blur-xl border border-neutral-content/10 shadow-warm rounded-xl m-4;
|
||||
@apply bg-glass-bg max-w-xs md:max-w-sm backdrop-blur-xl border border-neutral-content/10 shadow-warm rounded-xl m-4;
|
||||
}
|
||||
|
||||
.ul-wavy {
|
||||
@apply decoration-primary/40 underline decoration-wavy underline-offset-4;
|
||||
@apply decoration-primary/40 underline decoration-wavy underline-offset-4;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-primary underline decoration-base-content/20 underline-offset-4 hover:decoration-primary/60 transition-colors;
|
||||
@apply text-primary underline decoration-base-content/20 underline-offset-4 hover:decoration-primary/60 transition-colors;
|
||||
}
|
||||
|
||||
@@ -9,116 +9,120 @@ import Drawer from "./Drawer";
|
||||
|
||||
vi.mock("../hooks/useLetters");
|
||||
vi.mock("../components/drawer/WelcomeLetterOverlay", () => ({
|
||||
WelcomeLetterOverlay: ({ onComplete }: WelcomeLetterOverlayProps) => (
|
||||
<div data-testid="welcome-letter-overlay">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="overlay-exit-button"
|
||||
onClick={onComplete}
|
||||
>
|
||||
I'll see you
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
WelcomeLetterOverlay: ({ onComplete }: WelcomeLetterOverlayProps) => (
|
||||
<div data-testid="welcome-letter-overlay">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="overlay-exit-button"
|
||||
onClick={onComplete}
|
||||
>
|
||||
I'll see you
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Drawer Page", () => {
|
||||
beforeEach(() => {
|
||||
// Setup authenticated state for the test
|
||||
useAuthStore.setState({
|
||||
user: mockUser,
|
||||
accessToken: "fake-token",
|
||||
isInitializing: false,
|
||||
});
|
||||
|
||||
vi.mocked(useLetters).mockReturnValue({
|
||||
drafts: [],
|
||||
kept: [],
|
||||
sent: [],
|
||||
vault: [],
|
||||
loading: false,
|
||||
isAuthRequired: false,
|
||||
});
|
||||
beforeEach(() => {
|
||||
// Setup authenticated state for the test
|
||||
useAuthStore.setState({
|
||||
user: mockUser,
|
||||
accessToken: "fake-token",
|
||||
isInitializing: false,
|
||||
});
|
||||
|
||||
it("renders the drawer sections and empty state message", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
vi.mocked(useLetters).mockReturnValue({
|
||||
drafts: [],
|
||||
kept: [],
|
||||
sent: [],
|
||||
vault: [],
|
||||
loading: false,
|
||||
isAuthRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("drawer-section-drafts")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("drawer-section-title").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByTestId("drawer-section-vault")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("empty-drawer-message-drafts")).toBeInTheDocument();
|
||||
it("renders the drawer sections and empty state message", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("drawer-section-drafts")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByTestId("drawer-section-title").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByTestId("drawer-section-vault")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("empty-drawer-message-drafts"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the loading state", () => {
|
||||
vi.mocked(useLetters).mockReturnValue({
|
||||
drafts: [],
|
||||
kept: [],
|
||||
sent: [],
|
||||
vault: [],
|
||||
loading: true,
|
||||
isAuthRequired: false,
|
||||
});
|
||||
|
||||
it("renders the loading state", () => {
|
||||
vi.mocked(useLetters).mockReturnValue({
|
||||
drafts: [],
|
||||
kept: [],
|
||||
sent: [],
|
||||
vault: [],
|
||||
loading: true,
|
||||
isAuthRequired: false,
|
||||
});
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("drawer-loading-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("drawer-loading-state")).toBeInTheDocument();
|
||||
it("renders the authentication required modal when api requires auth", () => {
|
||||
vi.mocked(useLetters).mockReturnValue({
|
||||
drafts: [],
|
||||
kept: [],
|
||||
sent: [],
|
||||
vault: [],
|
||||
loading: false,
|
||||
isAuthRequired: true,
|
||||
});
|
||||
|
||||
it("renders the authentication required modal when api requires auth", () => {
|
||||
vi.mocked(useLetters).mockReturnValue({
|
||||
drafts: [],
|
||||
kept: [],
|
||||
sent: [],
|
||||
vault: [],
|
||||
loading: false,
|
||||
isAuthRequired: true,
|
||||
});
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("passkey-modal-title")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("passkey-modal-title")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
it("renders the welcome letter when firstTime state is present", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
||||
>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
it("renders the welcome letter when firstTime state is present", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
||||
>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("welcome-letter-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("welcome-letter-overlay")).toBeInTheDocument();
|
||||
});
|
||||
it("renders the drawer content when the letter is closed", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
||||
>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
it("renders the drawer content when the letter is closed", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
||||
>
|
||||
<Drawer />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const completeButton = screen.getByTestId("overlay-exit-button");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
const completeButton = screen.getByTestId("overlay-exit-button");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("welcome-letter-overlay"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId("welcome-letter-overlay"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+182
-179
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
FeatherIcon,
|
||||
FileDashedIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
VaultIcon,
|
||||
ArchiveIcon,
|
||||
FeatherIcon,
|
||||
FileDashedIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
VaultIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -17,188 +17,191 @@ import { PATHS } from "../config/routes";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useLetters } from "../hooks/useLetters";
|
||||
import {
|
||||
formatRelativeDate,
|
||||
formatRelativeDateWithoutTime,
|
||||
formatRelativeDate,
|
||||
formatRelativeDateWithoutTime,
|
||||
} from "../utils/dateFormat.ts";
|
||||
|
||||
export default function Drawer() {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showWelcomeLetter, setShowWelcomeLetter] = useState(
|
||||
!!location.state?.firstTime,
|
||||
);
|
||||
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
|
||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showWelcomeLetter, setShowWelcomeLetter] = useState(
|
||||
!!location.state?.firstTime,
|
||||
);
|
||||
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
|
||||
|
||||
if (!user) return null;
|
||||
if (!user) return null;
|
||||
|
||||
const toggleSection = (id: string) =>
|
||||
setOpenSection(openSection === id ? null : id);
|
||||
const toggleSection = (id: string) =>
|
||||
setOpenSection(openSection === id ? null : id);
|
||||
|
||||
return (
|
||||
<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-vig pointer-events-none z-0" />
|
||||
return (
|
||||
<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-vig pointer-events-none z-0" />
|
||||
|
||||
{showWelcomeLetter && (
|
||||
<WelcomeLetterOverlay
|
||||
userName={user.full_name}
|
||||
onComplete={() => {
|
||||
setShowWelcomeLetter(false);
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showWelcomeLetter && (
|
||||
<WelcomeLetterOverlay
|
||||
userName={user.full_name}
|
||||
onComplete={() => {
|
||||
setShowWelcomeLetter(false);
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAuthRequired && <PasskeyModal />}
|
||||
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<Logo />
|
||||
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
||||
Personal Archive
|
||||
</div>
|
||||
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
|
||||
Welcome Back{" "}
|
||||
<span className="font-semibold text-primary">{user.full_name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="ml-3 cursor-pointer underline underline-offset-4 text-xs hover:text-primary transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm duration-500 delay-200 min-h-64 flex flex-col">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
|
||||
<span className="loading loading-ring loading-lg text-primary opacity-20"></span>
|
||||
<span data-testid="drawer-loading-state" className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse">
|
||||
Opening your cabinet...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DrawerSection
|
||||
id="drafts"
|
||||
title="Drafts"
|
||||
count={drafts.length}
|
||||
subtext="unfinished whispers"
|
||||
isOpen={openSection === "drafts"}
|
||||
onClick={() => toggleSection("drafts")}
|
||||
icon={<FileDashedIcon weight="thin" size={128} />}
|
||||
>
|
||||
{drafts.map((draft) => (
|
||||
<LetterItem
|
||||
id={draft.public_id}
|
||||
status={draft.status}
|
||||
key={draft.public_id}
|
||||
preview={draft.metadata?.recipient || "Untitled Draft"}
|
||||
timestamp={formatRelativeDate(draft.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection
|
||||
id="kept"
|
||||
title="Kept"
|
||||
count={kept.length}
|
||||
subtext="private letters"
|
||||
isOpen={openSection === "kept"}
|
||||
onClick={() => toggleSection("kept")}
|
||||
icon={<ArchiveIcon weight="thin" size={128} />}
|
||||
>
|
||||
{kept.map((letter) => (
|
||||
<LetterItem
|
||||
id={letter.public_id}
|
||||
status={letter.status}
|
||||
key={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
<DrawerSection
|
||||
id="sent"
|
||||
title="Sent"
|
||||
count={sent.length}
|
||||
subtext="shared truths"
|
||||
isOpen={openSection === "sent"}
|
||||
onClick={() => toggleSection("sent")}
|
||||
icon={<PaperPlaneTiltIcon weight="thin" size={128} />}
|
||||
>
|
||||
{sent.map((letter) => (
|
||||
<LetterItem
|
||||
key={letter.public_id}
|
||||
status={letter.status}
|
||||
id={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
<DrawerSection
|
||||
id="vault"
|
||||
title="Vault"
|
||||
count={vault.length}
|
||||
subtext="things locked—not lost—in time"
|
||||
isOpen={openSection === "vault"}
|
||||
onClick={() => toggleSection("vault")}
|
||||
icon={<VaultIcon weight="thin" size={128} />}
|
||||
>
|
||||
{vault.map((letter) => (
|
||||
<LetterItem
|
||||
key={letter.public_id}
|
||||
status={letter.status}
|
||||
id={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Future Self"}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
unlock_at={formatRelativeDateWithoutTime(
|
||||
letter.unlock_at || "",
|
||||
)}
|
||||
isLocked={letter.unlock_at > new Date().toISOString()}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="write-letter-btn"
|
||||
data-testid="write-letter-btn"
|
||||
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
|
||||
onClick={() => navigate(PATHS.write(""))}
|
||||
>
|
||||
<FeatherIcon
|
||||
size={18}
|
||||
weight="duotone"
|
||||
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
|
||||
/>
|
||||
Write something{" "}
|
||||
<span className="relative inline-flex">
|
||||
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
|
||||
. . . . . .
|
||||
</span>
|
||||
<span className="absolute inset-0 text-primary transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||||
unsaid
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
||||
For your unsaid.
|
||||
</footer>
|
||||
{!showWelcomeLetter && (
|
||||
<div className="absolute bottom-0 z-50 font-sans">
|
||||
<Saajan
|
||||
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
||||
position="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isAuthRequired && <PasskeyModal />}
|
||||
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<Logo />
|
||||
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
||||
Personal Archive
|
||||
</div>
|
||||
);
|
||||
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
|
||||
Welcome Back{" "}
|
||||
<span className="font-semibold text-primary">{user.full_name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="ml-3 cursor-pointer underline underline-offset-4 text-xs hover:text-primary transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm duration-500 delay-200 min-h-64 flex flex-col">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
|
||||
<span className="loading loading-ring loading-lg text-primary opacity-20"></span>
|
||||
<span
|
||||
data-testid="drawer-loading-state"
|
||||
className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse"
|
||||
>
|
||||
Opening your cabinet...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DrawerSection
|
||||
id="drafts"
|
||||
title="Drafts"
|
||||
count={drafts.length}
|
||||
subtext="unfinished whispers"
|
||||
isOpen={openSection === "drafts"}
|
||||
onClick={() => toggleSection("drafts")}
|
||||
icon={<FileDashedIcon weight="thin" size={128} />}
|
||||
>
|
||||
{drafts.map((draft) => (
|
||||
<LetterItem
|
||||
id={draft.public_id}
|
||||
status={draft.status}
|
||||
key={draft.public_id}
|
||||
preview={draft.metadata?.recipient || "Untitled Draft"}
|
||||
timestamp={formatRelativeDate(draft.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection
|
||||
id="kept"
|
||||
title="Kept"
|
||||
count={kept.length}
|
||||
subtext="private letters"
|
||||
isOpen={openSection === "kept"}
|
||||
onClick={() => toggleSection("kept")}
|
||||
icon={<ArchiveIcon weight="thin" size={128} />}
|
||||
>
|
||||
{kept.map((letter) => (
|
||||
<LetterItem
|
||||
id={letter.public_id}
|
||||
status={letter.status}
|
||||
key={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
<DrawerSection
|
||||
id="sent"
|
||||
title="Sent"
|
||||
count={sent.length}
|
||||
subtext="shared truths"
|
||||
isOpen={openSection === "sent"}
|
||||
onClick={() => toggleSection("sent")}
|
||||
icon={<PaperPlaneTiltIcon weight="thin" size={128} />}
|
||||
>
|
||||
{sent.map((letter) => (
|
||||
<LetterItem
|
||||
key={letter.public_id}
|
||||
status={letter.status}
|
||||
id={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
<DrawerSection
|
||||
id="vault"
|
||||
title="Vault"
|
||||
count={vault.length}
|
||||
subtext="things locked—not lost—in time"
|
||||
isOpen={openSection === "vault"}
|
||||
onClick={() => toggleSection("vault")}
|
||||
icon={<VaultIcon weight="thin" size={128} />}
|
||||
>
|
||||
{vault.map((letter) => (
|
||||
<LetterItem
|
||||
key={letter.public_id}
|
||||
status={letter.status}
|
||||
id={letter.public_id}
|
||||
preview={letter.metadata?.recipient || "Future Self"}
|
||||
timestamp={formatRelativeDate(letter.updated_at)}
|
||||
unlock_at={formatRelativeDateWithoutTime(
|
||||
letter.unlock_at || "",
|
||||
)}
|
||||
isLocked={letter.unlock_at > new Date().toISOString()}
|
||||
/>
|
||||
))}
|
||||
</DrawerSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="write-letter-btn"
|
||||
data-testid="write-letter-btn"
|
||||
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
|
||||
onClick={() => navigate(PATHS.write(""))}
|
||||
>
|
||||
<FeatherIcon
|
||||
size={18}
|
||||
weight="duotone"
|
||||
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
|
||||
/>
|
||||
Write something{" "}
|
||||
<span className="relative inline-flex">
|
||||
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
|
||||
. . . . . .
|
||||
</span>
|
||||
<span className="absolute inset-0 text-primary transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||||
unsaid
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
||||
For your unsaid.
|
||||
</footer>
|
||||
{!showWelcomeLetter && (
|
||||
<div className="absolute bottom-0 z-50 font-sans">
|
||||
<Saajan
|
||||
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
||||
position="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -79,7 +84,9 @@ describe("Editor Page", () => {
|
||||
);
|
||||
|
||||
// Wait for initial load to complete
|
||||
await waitForElementToBeRemoved(() => screen.queryByTestId("opening-draft-overlay"));
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.queryByTestId("opening-draft-overlay"),
|
||||
);
|
||||
|
||||
const canvas = screen.getByTestId("canvas");
|
||||
expect(canvas.getAttribute("data-readonly")).toBe("false");
|
||||
@@ -136,7 +143,9 @@ describe("Editor Page", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByTestId("opening-draft-overlay"));
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.queryByTestId("opening-draft-overlay"),
|
||||
);
|
||||
|
||||
const canvas = screen.getByTestId("canvas");
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ describe("Login Page", () => {
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "password123");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByTestId("login-error-message")).toHaveTextContent(/technical issues/i);
|
||||
expect(await screen.findByTestId("login-error-message")).toHaveTextContent(
|
||||
/technical issues/i,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -73,8 +75,14 @@ describe("Login Page", () => {
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/drawer" element={<div data-testid="drawer-page">Drawer</div>} />
|
||||
<Route path="/read/:publicId" element={<div data-testid="reader-page">Reader</div>} />
|
||||
<Route
|
||||
path="/drawer"
|
||||
element={<div data-testid="drawer-page">Drawer</div>}
|
||||
/>
|
||||
<Route
|
||||
path="/read/:publicId"
|
||||
element={<div data-testid="reader-page">Reader</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -83,7 +91,8 @@ describe("Login Page", () => {
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "password123");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
const expectedTestId = nextRoute.toLowerCase() === "drawer" ? "drawer-page" : "reader-page";
|
||||
const expectedTestId =
|
||||
nextRoute.toLowerCase() === "drawer" ? "drawer-page" : "reader-page";
|
||||
expect(await screen.findByTestId(expectedTestId)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+117
-117
@@ -16,135 +16,135 @@ import { useAuth } from "../hooks/useAuth";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.email("Please enter a valid email"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
email: z.email("Please enter a valid email"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
type LoginInputs = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const { setAuthStore } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
|
||||
const [saajanMessage, setSaajanMessage] = useState<string>(
|
||||
"I was wondering when you'd return.",
|
||||
);
|
||||
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const { setAuthStore } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
|
||||
const [saajanMessage, setSaajanMessage] = useState<string>(
|
||||
"I was wondering when you'd return.",
|
||||
);
|
||||
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginInputs>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginInputs>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginInputs) => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
// client side key derivation for e2e encryption
|
||||
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
||||
data.password,
|
||||
data.email,
|
||||
);
|
||||
const onSubmit = async (data: LoginInputs) => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
// client side key derivation for e2e encryption
|
||||
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
||||
data.password,
|
||||
data.email,
|
||||
);
|
||||
|
||||
// send just the authHash as the password to the server
|
||||
const { data: authData } = await publicApi.post(endpoints.LOGIN, {
|
||||
email: data.email,
|
||||
password: authHash,
|
||||
});
|
||||
// send just the authHash as the password to the server
|
||||
const { data: authData } = await publicApi.post(endpoints.LOGIN, {
|
||||
email: data.email,
|
||||
password: authHash,
|
||||
});
|
||||
|
||||
const { data: userData } = await api.get(endpoints.ME, {
|
||||
headers: { Authorization: `Bearer ${authData.access}` },
|
||||
});
|
||||
const { data: userData } = await api.get(endpoints.ME, {
|
||||
headers: { Authorization: `Bearer ${authData.access}` },
|
||||
});
|
||||
|
||||
await setAuthStore(authData.access, userData, masterKey);
|
||||
await setAuthStore(authData.access, userData, masterKey);
|
||||
|
||||
navigate(nextRoute, { replace: true, state: location.state });
|
||||
} catch (err) {
|
||||
let message =
|
||||
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
||||
if (axios.isAxiosError(err) && err.response?.status !== 500) {
|
||||
message = err.response?.data?.detail || err.response?.data?.message;
|
||||
}
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
navigate(nextRoute, { replace: true, state: location.state });
|
||||
} catch (err) {
|
||||
let message =
|
||||
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
||||
if (axios.isAxiosError(err) && err.response?.status !== 500) {
|
||||
message = err.response?.data?.detail || err.response?.data?.message;
|
||||
}
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{!showWelcome && <Saajan message={saajanMessage} position="top" />}
|
||||
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
|
||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||
<h1 className="flex items-center font-display text-2xl justify-center text-primary/80 tracking-tight">
|
||||
Enter <Logo type="logo" scale={0.7} /> Archive
|
||||
</h1>
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{!showWelcome && <Saajan message={saajanMessage} position="top" />}
|
||||
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
|
||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||
<h1 className="flex items-center font-display text-2xl justify-center text-primary/80 tracking-tight">
|
||||
Enter <Logo type="logo" scale={0.7} /> Archive
|
||||
</h1>
|
||||
|
||||
{apiError && (
|
||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||
<span data-testid="login-error-message">{apiError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="f.kafka@wrongtrain.com"
|
||||
data-testid="email-input"
|
||||
registration={register("email")}
|
||||
error={errors.email?.message}
|
||||
handleFocus={() => setSaajanMessage("I remember you.")}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
data-testid="password-input"
|
||||
registration={register("password")}
|
||||
error={errors.password?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage("The one thing I cannot know for you.")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-actions mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
name="login"
|
||||
disabled={isLoading}
|
||||
aria-label="Sign In"
|
||||
data-testid="login-submit-btn"
|
||||
className="btn btn-primary w-full shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm font-medium text-base-content/70">
|
||||
Don't have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
name="register"
|
||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||
className="link link-primary no-underline hover:underline font-bold"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{apiError && (
|
||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||
<span data-testid="login-error-message">{apiError}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="f.kafka@wrongtrain.com"
|
||||
data-testid="email-input"
|
||||
registration={register("email")}
|
||||
error={errors.email?.message}
|
||||
handleFocus={() => setSaajanMessage("I remember you.")}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
data-testid="password-input"
|
||||
registration={register("password")}
|
||||
error={errors.password?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage("The one thing I cannot know for you.")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-actions mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
name="login"
|
||||
disabled={isLoading}
|
||||
aria-label="Sign In"
|
||||
data-testid="login-submit-btn"
|
||||
className="btn btn-primary w-full shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm font-medium text-base-content/70">
|
||||
Don't have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
name="register"
|
||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||
className="link link-primary no-underline hover:underline font-bold"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ describe("Reader Page", () => {
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await screen.findByTestId("envelope-recipient")).toHaveTextContent(/Guest/i);
|
||||
expect(await screen.findByTestId("envelope-recipient")).toHaveTextContent(
|
||||
/Guest/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("should display an error message if the server request fails", async () => {
|
||||
@@ -97,9 +99,9 @@ describe("Reader Page", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("log-modal-message"),
|
||||
).toHaveTextContent(/Failed to load letter/i);
|
||||
expect(await screen.findByTestId("log-modal-message")).toHaveTextContent(
|
||||
/Failed to load letter/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate to the login page with redirect url when the letter has no sharing key and the user is not logged in", async () => {
|
||||
|
||||
+144
-144
@@ -14,158 +14,158 @@ import { ROUTES } from "../config/routes";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
full_name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Please enter a valid email"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirm_password: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirm_password, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirm_password"],
|
||||
});
|
||||
.object({
|
||||
full_name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Please enter a valid email"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirm_password: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirm_password, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirm_password"],
|
||||
});
|
||||
|
||||
type RegisterInputs = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [saajanMessage, setSaajanMessage] = useState<string>(
|
||||
"I didn't think I'd be here either.\nAnd yet, here we are.",
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [saajanMessage, setSaajanMessage] = useState<string>(
|
||||
"I didn't think I'd be here either.\nAnd yet, here we are.",
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterInputs>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterInputs>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterInputs) => {
|
||||
setSaajanMessage("Good. I'll remember that.");
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
// we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db.
|
||||
const { authHash } = await CryptoUtils.deriveKeyBundle(
|
||||
data.password,
|
||||
data.email,
|
||||
);
|
||||
const onSubmit = async (data: RegisterInputs) => {
|
||||
setSaajanMessage("Good. I'll remember that.");
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
// we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db.
|
||||
const { authHash } = await CryptoUtils.deriveKeyBundle(
|
||||
data.password,
|
||||
data.email,
|
||||
);
|
||||
|
||||
await publicApi.post(endpoints.REGISTER, {
|
||||
full_name: data.full_name,
|
||||
email: data.email,
|
||||
password: authHash,
|
||||
});
|
||||
navigate(ROUTES.VERIFY_EMAIL, { replace: true });
|
||||
} catch (err) {
|
||||
let message = "Registration failed. Please try again.";
|
||||
if (axios.isAxiosError(err)) {
|
||||
message = err.response?.data?.message || message;
|
||||
}
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
await publicApi.post(endpoints.REGISTER, {
|
||||
full_name: data.full_name,
|
||||
email: data.email,
|
||||
password: authHash,
|
||||
});
|
||||
navigate(ROUTES.VERIFY_EMAIL, { replace: true });
|
||||
} catch (err) {
|
||||
let message = "Registration failed. Please try again.";
|
||||
if (axios.isAxiosError(err)) {
|
||||
message = err.response?.data?.message || message;
|
||||
}
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Saajan message={saajanMessage} position="right" />
|
||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
||||
Create a <Logo type="logo" scale={0.7} /> Account
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Saajan message={saajanMessage} position="right" />
|
||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
||||
Create a <Logo type="logo" scale={0.7} /> Account
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||
<span>{apiError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Pen Name"
|
||||
placeholder="Word Smith"
|
||||
data-testid="pen-name-input"
|
||||
registration={register("full_name")}
|
||||
error={errors.full_name?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage("Hello friend. What should I call you?")
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="f.kafka@wrongtrain.com"
|
||||
data-testid="email-input"
|
||||
registration={register("email")}
|
||||
error={errors.email?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage(
|
||||
"Where should I send your letters?\nNo empty lunchboxes, please.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
data-testid="password-input"
|
||||
registration={register("password")}
|
||||
error={errors.password?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage(
|
||||
"Something only you know.\nI have one of those too.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
data-testid="confirm-password-input"
|
||||
registration={register("confirm_password")}
|
||||
error={errors.confirm_password?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage(
|
||||
"Just once? Trust me, \nsome things are worth repeating twice.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
||||
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
||||
<p className="text-sm font-semibold">
|
||||
Choose a password you won't forget. <br />
|
||||
Just like life,{" "}
|
||||
<span className="underline decoration-2">there is no reset</span>{" "}
|
||||
here. If you lose it, your letters cannot be recovered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card-actions mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
aria-label="Register"
|
||||
data-testid="register-submit-btn"
|
||||
className="btn btn-primary w-full shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
) : (
|
||||
"Register"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{apiError && (
|
||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||
<span>{apiError}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Pen Name"
|
||||
placeholder="Word Smith"
|
||||
data-testid="pen-name-input"
|
||||
registration={register("full_name")}
|
||||
error={errors.full_name?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage("Hello friend. What should I call you?")
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="f.kafka@wrongtrain.com"
|
||||
data-testid="email-input"
|
||||
registration={register("email")}
|
||||
error={errors.email?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage(
|
||||
"Where should I send your letters?\nNo empty lunchboxes, please.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
data-testid="password-input"
|
||||
registration={register("password")}
|
||||
error={errors.password?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage(
|
||||
"Something only you know.\nI have one of those too.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
data-testid="confirm-password-input"
|
||||
registration={register("confirm_password")}
|
||||
error={errors.confirm_password?.message}
|
||||
handleFocus={() =>
|
||||
setSaajanMessage(
|
||||
"Just once? Trust me, \nsome things are worth repeating twice.",
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
||||
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
||||
<p className="text-sm font-semibold">
|
||||
Choose a password you won't forget. <br />
|
||||
Just like life,{" "}
|
||||
<span className="underline decoration-2">there is no reset</span>{" "}
|
||||
here. If you lose it, your letters cannot be recovered.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card-actions mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
aria-label="Register"
|
||||
data-testid="register-submit-btn"
|
||||
className="btn btn-primary w-full shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
) : (
|
||||
"Register"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user