mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: iadd drawer and editor navigation, and introduce letter management hooks
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import LetterView
|
from .views import LetterDetailView, LetterView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", LetterView.as_view(), name="letter-list-create"),
|
path("", LetterView.as_view(), name="letter-list-create"),
|
||||||
path("<str:public_id>/", LetterView.as_view(), name="letter-create-retrieve-update-delete"),
|
path("<str:public_id>/", LetterDetailView.as_view(), name="letter-detail"),
|
||||||
]
|
]
|
||||||
|
|||||||
+19
-12
@@ -15,23 +15,30 @@ class LetterView(generics.ListCreateAPIView):
|
|||||||
"""return only letters of the authenticated user"""
|
"""return only letters of the authenticated user"""
|
||||||
return Letter.objects.filter(user=self.request.user)
|
return Letter.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
serializer_class = LetterSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
lookup_field = "public_id"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Letter.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
def put(self, request, public_id):
|
def put(self, request, public_id):
|
||||||
# avoiding deepcopy due to osmething called pickle
|
# upsert: create if doesn't exist, else update
|
||||||
data = request.data.dict()
|
letter, created = Letter.objects.get_or_create(public_id=public_id, user=request.user)
|
||||||
print(data)
|
|
||||||
# remove public_id from data to avoid UniqueValidator firing
|
|
||||||
# since we use it from the URL for update_or_create anyway
|
|
||||||
data.pop("public_id", None)
|
|
||||||
serializer = self.get_serializer(data=data)
|
|
||||||
|
|
||||||
|
# request.data handles both JSON and Multipart automatically in DRF
|
||||||
|
serializer = self.get_serializer(letter, data=request.data, partial=True)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
letter, created = Letter.objects.update_or_create(
|
# Note: image_files is a list of binary files in request.FILES
|
||||||
public_id=public_id, user=self.request.user, defaults=serializer.validated_data
|
if "image_files" in request.FILES:
|
||||||
)
|
letter.images.all().delete()
|
||||||
|
|
||||||
LetterImage.objects.filter(letter=letter).delete()
|
|
||||||
for image_file in request.FILES.getlist("image_files"):
|
for image_file in request.FILES.getlist("image_files"):
|
||||||
LetterImage.objects.create(letter=letter, file=image_file, file_name=image_file.name)
|
LetterImage.objects.create(letter=letter, file=image_file, file_name=image_file.name)
|
||||||
|
|
||||||
|
# Return fresh data including the new image URLs
|
||||||
|
serializer = self.get_serializer(letter)
|
||||||
return Response(serializer.data, status=201 if created else 200)
|
return Response(serializer.data, status=201 if created else 200)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={ROUTES.WRITE()}
|
path={`${ROUTES.WRITE()}:public_id?`}
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Editor />
|
<Editor />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type CanvasTools = {
|
|||||||
getData: () => { objects: CanvasJSON["objects"] }; // no-any hack :/
|
getData: () => { objects: CanvasJSON["objects"] }; // no-any hack :/
|
||||||
getJsonData: () => string;
|
getJsonData: () => string;
|
||||||
getImages: () => { src: string; file: File }[];
|
getImages: () => { src: string; file: File }[];
|
||||||
|
loadData: (data: any) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FabricImageWithFile extends fabric.FabricImage {
|
export interface FabricImageWithFile extends fabric.FabricImage {
|
||||||
@@ -27,7 +28,6 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
let canvas: fabric.Canvas | null = null;
|
let canvas: fabric.Canvas | null = null;
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
// lazy populate
|
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
const waitForLayout = (): Promise<number> => {
|
const waitForLayout = (): Promise<number> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -48,23 +48,21 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
600,
|
600,
|
||||||
);
|
);
|
||||||
|
|
||||||
// init canvas
|
|
||||||
canvas = new fabric.Canvas(canvasRef.current, {
|
canvas = new fabric.Canvas(canvasRef.current, {
|
||||||
width: finalWidth,
|
width: finalWidth,
|
||||||
height: initialHeight,
|
height: initialHeight,
|
||||||
selection: false,
|
selection: false,
|
||||||
preserveObjectStacking: true,
|
preserveObjectStacking: true,
|
||||||
allowTouchScrolling: true, // for mobile
|
allowTouchScrolling: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
fabricRef.current = canvas;
|
fabricRef.current = canvas;
|
||||||
|
|
||||||
// transparent background
|
|
||||||
const wrapperEl = canvas.getElement().parentElement;
|
const wrapperEl = canvas.getElement().parentElement;
|
||||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
if (wrapperEl) wrapperEl.style.background = "transparent";
|
||||||
|
|
||||||
// the core textbox
|
|
||||||
const textbox = new fabric.Textbox("Take a deep breath...", {
|
const textbox = new fabric.Textbox("Take a deep breath...", {
|
||||||
|
name: "main-textbox",
|
||||||
originX: "left",
|
originX: "left",
|
||||||
originY: "top",
|
originY: "top",
|
||||||
left: PAD,
|
left: PAD,
|
||||||
@@ -78,7 +76,7 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
editable: true,
|
editable: true,
|
||||||
hasControls: false,
|
hasControls: false,
|
||||||
hasBorders: false,
|
hasBorders: false,
|
||||||
objectCaching: false, // for font crispness
|
objectCaching: false,
|
||||||
splitByGrapheme: false,
|
splitByGrapheme: false,
|
||||||
lockMovementX: true,
|
lockMovementX: true,
|
||||||
lockMovementY: true,
|
lockMovementY: true,
|
||||||
@@ -89,7 +87,6 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
textboxRef.current = textbox;
|
textboxRef.current = textbox;
|
||||||
canvas.add(textbox);
|
canvas.add(textbox);
|
||||||
|
|
||||||
// automatically adjust height
|
|
||||||
textbox.on("changed", () => {
|
textbox.on("changed", () => {
|
||||||
if (!canvas || !wrapperRef.current) return;
|
if (!canvas || !wrapperRef.current) return;
|
||||||
const neededHeight = textbox.top + textbox.height + PAD;
|
const neededHeight = textbox.top + textbox.height + PAD;
|
||||||
@@ -100,15 +97,12 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// auto focus
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
canvas?.setActiveObject(textbox);
|
canvas?.setActiveObject(textbox);
|
||||||
textbox.enterEditing();
|
textbox.enterEditing();
|
||||||
canvas?.renderAll();
|
canvas?.renderAll();
|
||||||
|
|
||||||
// Accessibility fix for Fabric.js hidden textarea
|
|
||||||
// searching globally in case it is appended to body
|
|
||||||
const hiddenTextareas = document.querySelectorAll(
|
const hiddenTextareas = document.querySelectorAll(
|
||||||
'textarea[data-fabric="textarea"]',
|
'textarea[data-fabric="textarea"]',
|
||||||
);
|
);
|
||||||
@@ -151,8 +145,7 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
fabricRef.current?.add(img);
|
fabricRef.current?.add(img);
|
||||||
fabricRef.current?.setActiveObject(img);
|
fabricRef.current?.setActiveObject(img);
|
||||||
fabricRef.current?.requestRenderAll();
|
fabricRef.current?.requestRenderAll();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
URL.revokeObjectURL(url); // cleanup browser upload
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getData: () => {
|
getData: () => {
|
||||||
@@ -167,12 +160,36 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
if (!fabricRef.current) return [];
|
if (!fabricRef.current) return [];
|
||||||
const images = fabricRef.current.getObjects(
|
const images = fabricRef.current.getObjects(
|
||||||
"Image",
|
"Image",
|
||||||
) as FabricImageWithFile[];
|
) as fabric.FabricImage[];
|
||||||
return images.map((img) => ({
|
return images.map((img) => ({
|
||||||
src: (img.getElement() as HTMLImageElement).currentSrc,
|
src: img.getSrc(),
|
||||||
file: img._customRawFile,
|
file: (img as any)._customRawFile,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
loadData: async (data: any) => {
|
||||||
|
if (!fabricRef.current) return;
|
||||||
|
await fabricRef.current.loadFromJSON(data);
|
||||||
|
|
||||||
|
// find the textbox and restore focus
|
||||||
|
const objects = fabricRef.current.getObjects("Textbox");
|
||||||
|
if (objects.length > 0) {
|
||||||
|
const textbox = objects[0] as fabric.Textbox;
|
||||||
|
textbox.lockMovementX = true;
|
||||||
|
textbox.lockMovementY = true;
|
||||||
|
textbox.hasControls = false;
|
||||||
|
textbox.hasBorders = false;
|
||||||
|
textboxRef.current = textbox;
|
||||||
|
fabricRef.current.setActiveObject(textbox);
|
||||||
|
if (textbox.text) {
|
||||||
|
// move cursor to end
|
||||||
|
textbox.selectionStart = textbox.text.length;
|
||||||
|
textbox.selectionEnd = textbox.text.length;
|
||||||
|
}
|
||||||
|
textbox.enterEditing();
|
||||||
|
}
|
||||||
|
|
||||||
|
fabricRef.current.renderAll();
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
interface DrawerSectionProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
count: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerSection({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
isOpen,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: DrawerSectionProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={`join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${
|
||||||
|
isOpen
|
||||||
|
? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
|
||||||
|
: "max-h-0 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-300 ${
|
||||||
|
isOpen
|
||||||
|
? "text-base-content"
|
||||||
|
: "text-base-content/40 group-hover:text-base-content/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans text-[0.6rem] text-base-content/20 mt-1">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ROUTES } from "../../config/routes";
|
||||||
|
|
||||||
|
export function LetterItem({
|
||||||
|
preview,
|
||||||
|
timestamp,
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
preview: string;
|
||||||
|
timestamp: string;
|
||||||
|
id: string;
|
||||||
|
status: "DRAFT" | "SEALED" | "BURNED";
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
function handleNavigate(): void {
|
||||||
|
if (status === "SEALED") {
|
||||||
|
navigate(ROUTES.READ(id));
|
||||||
|
} else {
|
||||||
|
navigate(ROUTES.WRITE(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
className="p-[16px_28px_16px_76px] border-b border-base-content/3 flex items-center gap-4 hover:bg-base-content/5 transition-colors group w-full text-left"
|
||||||
|
>
|
||||||
|
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60">
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans text-[0.6rem] text-base-content/20">
|
||||||
|
{timestamp}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ export const ROUTES = {
|
|||||||
ACTIVATE: "/activate/:uidb64/:token",
|
ACTIVATE: "/activate/:uidb64/:token",
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
DRAWER: "/drawer",
|
DRAWER: "/drawer",
|
||||||
WRITE: (public_id?: string) =>
|
WRITE: (public_id?: string) => `/quill/${public_id ? public_id : ""}`,
|
||||||
`/quill/${public_id ? public_id : ":public_id?"}`,
|
READ: (public_id?: string) => `/read/${public_id ? public_id : ""}`,
|
||||||
READ: "/read",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { api } from "../api/apiClient";
|
||||||
|
import { endpoints } from "../config/endpoints";
|
||||||
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
|
||||||
|
export interface Letter {
|
||||||
|
public_id: string;
|
||||||
|
type: "KEPT" | "VAULT" | "SENT";
|
||||||
|
status: "DRAFT" | "SEALED" | "BURNED";
|
||||||
|
updated_at: string;
|
||||||
|
sealed_at?: string;
|
||||||
|
unlock_at?: string;
|
||||||
|
encrypted_metadata: string;
|
||||||
|
encrypted_content: string;
|
||||||
|
encrypted_dek: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LetterMetadata {
|
||||||
|
recipient: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedLetter extends Letter {
|
||||||
|
metadata: LetterMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptLetters(
|
||||||
|
letters: Letter[],
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
): Promise<ProcessedLetter[]> {
|
||||||
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
letters.map(async (letter) => {
|
||||||
|
try {
|
||||||
|
const metadata = (await cryptoUtils.decryptMetadata(
|
||||||
|
{
|
||||||
|
encrypted_content: letter.encrypted_metadata,
|
||||||
|
encrypted_dek: letter.encrypted_dek,
|
||||||
|
},
|
||||||
|
masterKey,
|
||||||
|
)) as LetterMetadata;
|
||||||
|
|
||||||
|
return { ...letter, metadata };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Decryption failed for letter:", letter.public_id, err);
|
||||||
|
return {
|
||||||
|
...letter,
|
||||||
|
metadata: { recipient: "Encrypted Letter" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLetters() {
|
||||||
|
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!masterKey) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.get(endpoints.LETTERS)
|
||||||
|
.then((res) => decryptLetters(res.data, masterKey))
|
||||||
|
.then(setLetters)
|
||||||
|
.catch((err) => console.error("Drawer load failed:", err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [masterKey]);
|
||||||
|
|
||||||
|
const drawerItems = useMemo(() => {
|
||||||
|
return {
|
||||||
|
drafts: letters.filter((l) => l.status === "DRAFT"),
|
||||||
|
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
|
||||||
|
vault: letters.filter((l) => l.type === "VAULT"),
|
||||||
|
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
||||||
|
};
|
||||||
|
}, [letters]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...drawerItems,
|
||||||
|
loading,
|
||||||
|
refreshLetters: () => setLoading(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||||
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
|
import Drawer from "./Drawer";
|
||||||
|
|
||||||
|
describe("Drawer Page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Setup authenticated state
|
||||||
|
useAuthStore.setState({
|
||||||
|
user: mockUser,
|
||||||
|
accessToken: "fake-token",
|
||||||
|
isInitializing: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the cabinet sections and empty state message", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Drawer />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
+135
-10
@@ -1,28 +1,153 @@
|
|||||||
|
import { FeatherIcon } from "@phosphor-icons/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import Logo from "../components/Logo";
|
||||||
|
import { DrawerSection } from "../components/ui/Drawer";
|
||||||
|
import { LetterItem } from "../components/ui/LetterItem";
|
||||||
|
import { ROUTES } from "../config/routes";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
import { useLetters } from "../hooks/useLetters";
|
||||||
|
|
||||||
export default function Drawer() {
|
export default function Drawer() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const [openSection, setOpenSection] = useState<string | null>("kept");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { drafts, kept, sent, vault, loading } = useLetters();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
const toggleSection = (id: string) =>
|
||||||
|
setOpenSection(openSection === id ? null : id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col gap-6 fade-zoom">
|
<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="space-y-2">
|
<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" />
|
||||||
<h1 className="font-display text-2xl font-bold text-primary">
|
|
||||||
Your Drawer
|
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-1000">
|
||||||
</h1>
|
<Logo />
|
||||||
<p className="text-sm opacity-70">Welcome back, {user.full_name}</p>
|
<div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2">
|
||||||
|
Personal Archive
|
||||||
</div>
|
</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">
|
||||||
<div className="divider opacity-10" />
|
Welcome Back{" "}
|
||||||
|
<span className="font-semibold text-primary">{user.full_name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="btn btn-link btn-xs opacity-40 hover:opacity-100 no-underline transition-all"
|
className="ml-3 cursor-pointer underline underline-offset-4 text-xs hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 overflow-hidden animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200 fill-mode-backwards 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 className="text-[10px] uppercase tracking-[0.3em] font-sans text-base-content/20 animate-pulse">
|
||||||
|
Opening your cabinet...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DrawerSection
|
||||||
|
id="drafts"
|
||||||
|
title="Drafts"
|
||||||
|
count={`${drafts.length} unfinished whispers`}
|
||||||
|
isOpen={openSection === "drafts"}
|
||||||
|
onClick={() => toggleSection("drafts")}
|
||||||
|
>
|
||||||
|
{drafts.map((draft) => (
|
||||||
|
<LetterItem
|
||||||
|
id={draft.public_id}
|
||||||
|
status={draft.status}
|
||||||
|
key={draft.public_id}
|
||||||
|
preview={draft.metadata?.recipient || "Untitled Draft"}
|
||||||
|
timestamp={draft.updated_at}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DrawerSection>
|
||||||
|
|
||||||
|
<DrawerSection
|
||||||
|
id="kept"
|
||||||
|
title="Kept"
|
||||||
|
count={`${kept.length} private letters`}
|
||||||
|
isOpen={openSection === "kept"}
|
||||||
|
onClick={() => toggleSection("kept")}
|
||||||
|
>
|
||||||
|
{kept.map((letter) => (
|
||||||
|
<LetterItem
|
||||||
|
id={letter.public_id}
|
||||||
|
status={letter.status}
|
||||||
|
key={letter.public_id}
|
||||||
|
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||||
|
timestamp={letter.updated_at}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DrawerSection>
|
||||||
|
<DrawerSection
|
||||||
|
id="sent"
|
||||||
|
title="Sent"
|
||||||
|
count={`${sent.length} shared truths`}
|
||||||
|
isOpen={openSection === "sent"}
|
||||||
|
onClick={() => toggleSection("sent")}
|
||||||
|
>
|
||||||
|
{sent.map((letter) => (
|
||||||
|
<LetterItem
|
||||||
|
key={letter.public_id}
|
||||||
|
status={letter.status}
|
||||||
|
id={letter.public_id}
|
||||||
|
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||||
|
timestamp={letter.updated_at}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DrawerSection>
|
||||||
|
<DrawerSection
|
||||||
|
id="vault"
|
||||||
|
title="Vault"
|
||||||
|
count={`${vault.length} things locked;not lost;in time`}
|
||||||
|
isOpen={openSection === "vault"}
|
||||||
|
onClick={() => toggleSection("vault")}
|
||||||
|
>
|
||||||
|
{vault.map((letter) => (
|
||||||
|
<LetterItem
|
||||||
|
key={letter.public_id}
|
||||||
|
status={letter.status}
|
||||||
|
id={letter.public_id}
|
||||||
|
preview={letter.metadata?.recipient || "Future Self"}
|
||||||
|
timestamp={letter.updated_at}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DrawerSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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-1000"
|
||||||
|
onClick={() => navigate(ROUTES.WRITE(""), { replace: true })}
|
||||||
|
>
|
||||||
|
<FeatherIcon
|
||||||
|
size={18}
|
||||||
|
weight="duotone"
|
||||||
|
className="text-primary/30 transition-all duration-700 group-hover:text-primary"
|
||||||
|
/>
|
||||||
|
Write something{" "}
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<span className="transition-opacity duration-1500 opacity-80 group-hover:opacity-0">
|
||||||
|
. . . . . .
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-0 text-primary transition-opacity duration-1000 opacity-0 group-hover:opacity-100">
|
||||||
|
unsaid
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10">
|
||||||
|
Kept. Unsent.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-49
@@ -2,9 +2,10 @@ import {
|
|||||||
DownloadSimpleIcon,
|
DownloadSimpleIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
|
SpinnerGapIcon,
|
||||||
TrayIcon,
|
TrayIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import {
|
import {
|
||||||
@@ -17,20 +18,116 @@ import { ROUTES } from "../config/routes";
|
|||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
|
||||||
|
// convert blob url to file
|
||||||
|
async function blobUrlToFile(
|
||||||
|
blobUrl: string,
|
||||||
|
fileName: string,
|
||||||
|
mimeType?: string,
|
||||||
|
): Promise<File> {
|
||||||
|
const response = await fetch(blobUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], fileName, { type: mimeType ?? blob.type });
|
||||||
|
}
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// check for existing letter
|
|
||||||
const { public_id } = useParams();
|
const { public_id } = useParams();
|
||||||
const letterIdRef = useRef<string>(public_id ?? "");
|
const letterIdRef = useRef<string>(public_id ?? "");
|
||||||
|
|
||||||
|
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||||
const [isSealing, setIsSealing] = useState(false);
|
const [isSealing, setIsSealing] = useState(false);
|
||||||
const [isSaveSuccess, setIsSaveSuccess] = useState(false);
|
const [isSaveSuccess, setIsSaveSuccess] = useState(false);
|
||||||
|
|
||||||
const [recipient, setRecipient] = useState("");
|
const [recipient, setRecipient] = useState("");
|
||||||
const masterKey = useKeyStore.getState().masterKey;
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
const canvasRef = useRef<CanvasTools>(null);
|
const canvasRef = useRef<CanvasTools>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initial load: Fetch and decrypt existing letter
|
||||||
|
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;
|
||||||
|
|
||||||
|
// metadata for recipient
|
||||||
|
const metadata = await cryptoUtils.decryptMetadata(
|
||||||
|
{
|
||||||
|
encrypted_content: letterData.encrypted_metadata,
|
||||||
|
encrypted_dek: letterData.encrypted_dek,
|
||||||
|
},
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
setRecipient(metadata.recipient || "");
|
||||||
|
|
||||||
|
// decrypt canvas data
|
||||||
|
const decryptedJsonStr = await cryptoUtils.decryptLetter(
|
||||||
|
{
|
||||||
|
encrypted_content: letterData.encrypted_content,
|
||||||
|
encrypted_dek: letterData.encrypted_dek,
|
||||||
|
},
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
const canvasData = JSON.parse(decryptedJsonStr);
|
||||||
|
|
||||||
|
// traverse through canvas images and replace encrypted image with decrypted image
|
||||||
|
if (canvasData.objects) {
|
||||||
|
for (const obj of canvasData.objects) {
|
||||||
|
if (obj.type === "Image" && typeof obj.src === "string") {
|
||||||
|
const filename = obj.src;
|
||||||
|
const remoteImage = letterData.images.find(
|
||||||
|
(img: any) => img.file_name === filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remoteImage) {
|
||||||
|
try {
|
||||||
|
// fetch encrypted image blob using authenticated API
|
||||||
|
const imageRes = await api.get(remoteImage.file, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
const encryptedBlob = imageRes.data;
|
||||||
|
|
||||||
|
// decrypt image blob
|
||||||
|
const blobUrl = await cryptoUtils.decryptImage(
|
||||||
|
encryptedBlob,
|
||||||
|
letterData.encrypted_dek,
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
obj.src = blobUrl;
|
||||||
|
obj._customRawFile = await blobUrlToFile(blobUrl, filename);
|
||||||
|
console.log("Decrypted image object:", obj);
|
||||||
|
} catch (imgErr) {
|
||||||
|
console.error(
|
||||||
|
"Failed to decrypt image object:",
|
||||||
|
filename,
|
||||||
|
imgErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// load updated data into canvas
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
canvasRef.current?.loadData(canvasData);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load existing letter:", err);
|
||||||
|
} finally {
|
||||||
|
setIsInitialLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadExistingLetter();
|
||||||
|
}, [public_id, masterKey]);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------------------
|
||||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]; // pick one file at a time
|
const file = e.target.files?.[0]; // pick one file at a time
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -39,11 +136,13 @@ export default function Editor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleSeal(): Promise<void> {
|
const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
|
||||||
if (!public_id) {
|
if (!public_id && !letterIdRef.current) {
|
||||||
// if no uuid slug, then generate a new one and update params
|
// if no uuid slug, then generate a new one and update params
|
||||||
letterIdRef.current = crypto.randomUUID();
|
letterIdRef.current = crypto.randomUUID();
|
||||||
navigate(ROUTES.WRITE(letterIdRef.current), { replace: true });
|
navigate(ROUTES.WRITE(letterIdRef.current), { replace: true });
|
||||||
|
} else if (public_id) {
|
||||||
|
letterIdRef.current = public_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSealing) return;
|
if (isSealing) return;
|
||||||
@@ -60,6 +159,8 @@ export default function Editor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
|
if (image.src.endsWith(".bin")) continue;
|
||||||
|
try {
|
||||||
const encrypted_image = await cryptoUtils.encryptImage(
|
const encrypted_image = await cryptoUtils.encryptImage(
|
||||||
image.file,
|
image.file,
|
||||||
masterKey,
|
masterKey,
|
||||||
@@ -69,18 +170,21 @@ export default function Editor() {
|
|||||||
encrypted_image.filename,
|
encrypted_image.filename,
|
||||||
encrypted_image.encryptedBlob,
|
encrypted_image.encryptedBlob,
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to re-encrypt image:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace image src with encrypted image filename
|
// replace image src with encrypted image filename
|
||||||
const canvasData = canvasRef.current?.getData();
|
const canvasData = canvasRef.current?.getData();
|
||||||
canvasData?.objects?.map(
|
if (canvasData?.objects) {
|
||||||
(
|
canvasData.objects = canvasData.objects.map((obj: any) => {
|
||||||
obj: Record<string, unknown>, // fabric is too quirky for any other type
|
if (obj.type === "Image" && imageEncMap.has(obj.src)) {
|
||||||
) =>
|
return { ...obj, src: imageEncMap.get(obj.src) };
|
||||||
obj.type === "Image"
|
}
|
||||||
? { ...obj, src: imageEncMap.get(obj.src as string) }
|
return obj;
|
||||||
: obj,
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
const encrypted_letter = await cryptoUtils.encryptLetter(
|
const encrypted_letter = await cryptoUtils.encryptLetter(
|
||||||
JSON.stringify(canvasData),
|
JSON.stringify(canvasData),
|
||||||
@@ -88,25 +192,14 @@ export default function Editor() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
||||||
{ recipient },
|
{ recipient, tags: [] },
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
// upload to server
|
|
||||||
/*
|
|
||||||
payload = {
|
|
||||||
"type": "SENT",
|
|
||||||
"status": "SEALED",
|
|
||||||
"encrypted_content": "enc_content==",
|
|
||||||
"encrypted_metadata": "enc_metadata==",
|
|
||||||
"encrypted_dek": "enc_dek==",
|
|
||||||
"image_files": [image1, image2],
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("public_id", letterIdRef.current);
|
formData.append("public_id", letterIdRef.current);
|
||||||
formData.append("type", "SENT");
|
formData.append("type", "KEPT");
|
||||||
formData.append("status", "SEALED");
|
formData.append("status", status);
|
||||||
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
||||||
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
||||||
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
|
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
|
||||||
@@ -125,30 +218,51 @@ export default function Editor() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSealing(false);
|
setIsSealing(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 py-12 bg-base-300">
|
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 py-12 bg-base-300 relative">
|
||||||
{isSaveSuccess && (
|
{isInitialLoading && (
|
||||||
<div className="modal modal-open bg-transparent backdrop-blur-md transition-all duration-300 ease-in-out">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-base-300/80 backdrop-blur-sm">
|
||||||
<div className="modal-box bg-transparent">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="alert alert-success">
|
<SpinnerGapIcon
|
||||||
<DownloadSimpleIcon size={18} weight="bold" />
|
size={48}
|
||||||
<h3 className="font-bold text-lg">Your letter is sealed!</h3>
|
weight="bold"
|
||||||
</div>
|
className="animate-spin text-primary"
|
||||||
{/* <div className="modal-action">
|
/>
|
||||||
<button
|
<p className="text-[10px] uppercase tracking-[0.4em] font-bold text-base-content/40">
|
||||||
type="button"
|
Opening your draft...
|
||||||
className="btn btn-primary"
|
</p>
|
||||||
onClick={() => setIsSaveSuccess(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-[720px] mx-auto px-1 md:px-0">
|
{isSaveSuccess && (
|
||||||
|
<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 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">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
<label
|
<label
|
||||||
@@ -194,10 +308,11 @@ export default function Editor() {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
className="btn btn-ghost btn-sm text-[10px] tracking-[0.2em] uppercase font-bold text-base-content/60 hover:text-base-content"
|
||||||
title="Keep in your private drawer"
|
title="Store in your private drawer"
|
||||||
|
onClick={() => handleSave("DRAFT")}
|
||||||
>
|
>
|
||||||
<TrayIcon size={18} weight="bold" />
|
<TrayIcon size={18} weight="bold" />
|
||||||
<span className="hidden md:inline">Keep</span>
|
<span className="hidden md:inline">Store</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-4 bg-base-content/10 mx-2" />
|
<div className="w-px h-4 bg-base-content/10 mx-2" />
|
||||||
@@ -205,7 +320,7 @@ export default function Editor() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm rounded-full px-6"
|
className="btn btn-primary btn-sm rounded-full px-6"
|
||||||
onClick={handleSeal}
|
onClick={() => handleSave("SEALED")}
|
||||||
>
|
>
|
||||||
<LockIcon size={14} weight="fill" className="mr-1" />
|
<LockIcon size={14} weight="fill" className="mr-1" />
|
||||||
Seal
|
Seal
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ export default function Login() {
|
|||||||
navigate(ROUTES.DRAWER);
|
navigate(ROUTES.DRAWER);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
let message = "Invalid email or password";
|
let message =
|
||||||
if (axios.isAxiosError(err)) {
|
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
||||||
message =
|
if (axios.isAxiosError(err) && err.response?.status !== 500) {
|
||||||
err.response?.data?.detail || err.response?.data?.message || message;
|
message = err.response?.data?.detail || err.response?.data?.message;
|
||||||
}
|
}
|
||||||
setApiError(message);
|
setApiError(message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -90,6 +90,7 @@ export default function Login() {
|
|||||||
<div className="card-actions mt-4">
|
<div className="card-actions mt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
name="login"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="Sign In"
|
aria-label="Sign In"
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
@@ -106,6 +107,7 @@ export default function Login() {
|
|||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
name="register"
|
||||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||||
className="link link-primary no-underline hover:underline font-bold"
|
className="link link-primary no-underline hover:underline font-bold"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
export interface EncryptedLetter {
|
export interface EncryptedLetter {
|
||||||
encrypted_content: string;
|
encrypted_content: string;
|
||||||
encrypted_dek: string;
|
encrypted_dek: string;
|
||||||
sharingKey: string;
|
sharingKey?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedLetterMetadata {
|
||||||
|
encrypted_content: string;
|
||||||
|
encrypted_dek: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncryptedImageUpload {
|
export interface EncryptedImageUpload {
|
||||||
@@ -199,7 +204,7 @@ export class CryptoUtils {
|
|||||||
public async decryptMetadata(
|
public async decryptMetadata(
|
||||||
encrypted_metadata: EncryptedLetter,
|
encrypted_metadata: EncryptedLetter,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, any>> {
|
||||||
const plainBytes = await this.openEnvelope(
|
const plainBytes = await this.openEnvelope(
|
||||||
encrypted_metadata.encrypted_content,
|
encrypted_metadata.encrypted_content,
|
||||||
encrypted_metadata.encrypted_dek,
|
encrypted_metadata.encrypted_dek,
|
||||||
|
|||||||
Reference in New Issue
Block a user