fix: reader letter image decryption #11
@@ -64,7 +64,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await expect(page.getByTestId("opening-draft-overlay")).toBeHidden();
|
||||
|
||||
// Check recipient
|
||||
await expect(page.getByTestId("recipient-input")).toHaveValue(recipientName);
|
||||
await expect(page.getByTestId("recipient-input")).toHaveValue(
|
||||
recipientName,
|
||||
);
|
||||
|
||||
// Check canvas content
|
||||
// We wait for the content to appear in the textarea.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import pino from "pino";
|
||||
import { MailpitHelper } from "./mailpit";
|
||||
import { handleWelcomeLetter } from "./envelope";
|
||||
import { MailpitHelper } from "./mailpit";
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Page, expect } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import pino from "pino";
|
||||
|
||||
const logger = pino({
|
||||
@@ -22,7 +22,9 @@ export async function revealEnvelope(page: Page) {
|
||||
await page.getByTestId("wax-seal").click();
|
||||
|
||||
// Click letter to reveal
|
||||
await page.getByTestId("envelope-letter").click({ position: { x: 30, y: 15 } });
|
||||
await page
|
||||
.getByTestId("envelope-letter")
|
||||
.click({ position: { x: 30, y: 15 } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+68
-68
@@ -1,70 +1,70 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b & vite build",
|
||||
"build:prod": "vite build --mode production",
|
||||
"lint": "biome lint --write ./src",
|
||||
"format": "biome format --write ./src",
|
||||
"check": "biome check --write ./src",
|
||||
"check-all": "biome check --write .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/fraunces": "^5.2.9",
|
||||
"@fontsource-variable/josefin-slab": "^5.2.7",
|
||||
"@fontsource-variable/jost": "^5.2.8",
|
||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||
"@fontsource/architects-daughter": "^5.2.7",
|
||||
"@fontsource/kavivanar": "^5.2.8",
|
||||
"@fontsource/knewave": "^5.2.7",
|
||||
"@fontsource/redacted-script": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"axios": "^1.15.0",
|
||||
"daisyui": "^5.5.19",
|
||||
"fabric": "^7.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"lenis": "^1.3.23",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.11",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.2",
|
||||
"msw": "^2.13.2",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b & vite build",
|
||||
"build:prod": "vite build --mode production",
|
||||
"lint": "biome lint --write ./src",
|
||||
"format": "biome format --write ./src",
|
||||
"check": "biome check --write ./src",
|
||||
"check-all": "biome check --write .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/fraunces": "^5.2.9",
|
||||
"@fontsource-variable/josefin-slab": "^5.2.7",
|
||||
"@fontsource-variable/jost": "^5.2.8",
|
||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||
"@fontsource/architects-daughter": "^5.2.7",
|
||||
"@fontsource/kavivanar": "^5.2.8",
|
||||
"@fontsource/knewave": "^5.2.7",
|
||||
"@fontsource/redacted-script": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"axios": "^1.15.0",
|
||||
"daisyui": "^5.5.19",
|
||||
"fabric": "^7.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"lenis": "^1.3.23",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.11",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.2",
|
||||
"msw": "^2.13.2",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,19 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -15,406 +15,408 @@ const DEFAULT_FONT_FAMILY = "Fraunces Variable";
|
||||
const DEFAULT_FONT_COLOR = "#000";
|
||||
|
||||
export interface FabricObjectJSON {
|
||||
type: string;
|
||||
name?: string;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
type: string;
|
||||
name?: string;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FabricImageJSON extends FabricObjectJSON {
|
||||
type: "Image";
|
||||
src: string;
|
||||
_customRawFile?: File;
|
||||
type: "Image";
|
||||
src: string;
|
||||
_customRawFile?: File;
|
||||
}
|
||||
|
||||
export interface CanvasJSON {
|
||||
objects: (FabricObjectJSON | FabricImageJSON)[];
|
||||
canvasWidth?: number;
|
||||
canvasHeight?: number;
|
||||
objects: (FabricObjectJSON | FabricImageJSON)[];
|
||||
canvasWidth?: number;
|
||||
canvasHeight?: number;
|
||||
}
|
||||
|
||||
export interface CanvasStyle {
|
||||
fontFamily: string;
|
||||
fontColor: string;
|
||||
fontFamily: string;
|
||||
fontColor: string;
|
||||
}
|
||||
|
||||
export type CanvasTools = {
|
||||
addImage: (url: string, file: File) => void;
|
||||
getData: () => CanvasJSON;
|
||||
getImages: () => { src: string; file: File }[];
|
||||
loadData: (data: CanvasJSON) => Promise<void>;
|
||||
getStyle: () => CanvasStyle;
|
||||
addImage: (url: string, file: File) => void;
|
||||
getData: () => CanvasJSON;
|
||||
getImages: () => { src: string; file: File }[];
|
||||
loadData: (data: CanvasJSON) => Promise<void>;
|
||||
getStyle: () => CanvasStyle;
|
||||
};
|
||||
|
||||
export interface FabricImageWithFile extends fabric.FabricImage {
|
||||
_customRawFile: File;
|
||||
_customRawFile: File;
|
||||
}
|
||||
|
||||
// NOTE: We use the same canvasData to render on both mobile and desktop viewports.
|
||||
// Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up)
|
||||
// over the last saved canvas size.
|
||||
const applyResponsiveViewport = (
|
||||
canvas: fabric.Canvas,
|
||||
wrapper: HTMLDivElement,
|
||||
logicalWidth: number,
|
||||
logicalHeight: number,
|
||||
canvas: fabric.Canvas,
|
||||
wrapper: HTMLDivElement,
|
||||
logicalWidth: number,
|
||||
logicalHeight: number,
|
||||
) => {
|
||||
const physicalWidth = wrapper.clientWidth || logicalWidth;
|
||||
const zoomMultiplier = physicalWidth / logicalWidth;
|
||||
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
|
||||
const physicalWidth = wrapper.clientWidth || logicalWidth;
|
||||
const zoomMultiplier = physicalWidth / logicalWidth;
|
||||
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
|
||||
|
||||
canvas.setDimensions({
|
||||
width: physicalWidth,
|
||||
height: physicalHeight,
|
||||
});
|
||||
canvas.setDimensions({
|
||||
width: physicalWidth,
|
||||
height: physicalHeight,
|
||||
});
|
||||
|
||||
wrapper.style.height = `${physicalHeight}px`;
|
||||
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
|
||||
canvas.requestRenderAll();
|
||||
wrapper.style.height = `${physicalHeight}px`;
|
||||
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
|
||||
canvas.requestRenderAll();
|
||||
};
|
||||
|
||||
// to find the maximum height of the content to dynamically resize the canvas
|
||||
// would've been wayyy easier only if canvas supported fit-content like CSS property :)
|
||||
const measureLogicalContentHeight = (
|
||||
canvas: fabric.Canvas,
|
||||
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
|
||||
canvas: fabric.Canvas,
|
||||
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
|
||||
) => {
|
||||
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
|
||||
const top = currObj.top;
|
||||
const height = currObj.getScaledHeight();
|
||||
return Math.max(maxHeight, top + height);
|
||||
}, 0);
|
||||
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
|
||||
const top = currObj.top;
|
||||
const height = currObj.getScaledHeight();
|
||||
return Math.max(maxHeight, top + height);
|
||||
}, 0);
|
||||
|
||||
return Math.max(minimumHeight, maxBottom + PAD);
|
||||
return Math.max(minimumHeight, maxBottom + PAD);
|
||||
};
|
||||
|
||||
const DEFAULT_INIT_TEXT = "Take a deep breath...";
|
||||
|
||||
interface ComposeCanvasProps {
|
||||
readOnly?: boolean;
|
||||
initialData?: CanvasJSON | null;
|
||||
style?: CanvasStyle;
|
||||
ref?: React.Ref<CanvasTools>;
|
||||
readOnly?: boolean;
|
||||
initialData?: CanvasJSON | null;
|
||||
style?: CanvasStyle;
|
||||
ref?: React.Ref<CanvasTools>;
|
||||
}
|
||||
|
||||
export function ComposeCanvas({
|
||||
readOnly = false,
|
||||
initialData = null,
|
||||
style,
|
||||
ref,
|
||||
readOnly = false,
|
||||
initialData = null,
|
||||
style,
|
||||
ref,
|
||||
}: ComposeCanvasProps) {
|
||||
// wrapper is the parent div box
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||
// wrapper is the parent div box
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||
|
||||
const textboxRef = useRef<fabric.Textbox | null>(null);
|
||||
const deferredDataRef = useRef<CanvasJSON | null>(null);
|
||||
const logicalSizeRef = useRef({
|
||||
width: BASE_WIDTH,
|
||||
height: DEFAULT_LOGICAL_HEIGHT,
|
||||
});
|
||||
|
||||
// re-calculates height based on content and applies the zoom transform
|
||||
const syncViewport = useCallback(() => {
|
||||
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||
textboxRef.current?.initDimensions();
|
||||
|
||||
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
|
||||
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||
fabricRef.current,
|
||||
minHeight,
|
||||
);
|
||||
|
||||
applyResponsiveViewport(
|
||||
fabricRef.current,
|
||||
wrapperRef.current,
|
||||
logicalSizeRef.current.width,
|
||||
logicalSizeRef.current.height,
|
||||
);
|
||||
|
||||
fabricRef.current.requestRenderAll();
|
||||
}, [initialData]);
|
||||
|
||||
// auto focus the cursor into the main textbox no matter the latest element added
|
||||
const focusTextbox = useCallback(
|
||||
(textbox: fabric.Textbox) => {
|
||||
if (readOnly || !fabricRef.current) return;
|
||||
|
||||
fabricRef.current.setActiveObject(textbox);
|
||||
textbox.enterEditing();
|
||||
|
||||
// move the cursor to the end of the text
|
||||
const textLength = textbox.text?.length ?? 0;
|
||||
textbox.selectionStart = textLength;
|
||||
textbox.selectionEnd = textLength;
|
||||
|
||||
fabricRef.current.requestRenderAll();
|
||||
},
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
const loadContent = useCallback(
|
||||
async (data: CanvasJSON | null) => {
|
||||
const canvas = fabricRef.current;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!(canvas && wrapper)) return;
|
||||
|
||||
// clean the canvas everytime and set fresh
|
||||
canvas.clear();
|
||||
let textbox: fabric.Textbox | null = null;
|
||||
|
||||
// restore logical size from prev saved data if available (in case of existing letter)
|
||||
logicalSizeRef.current = {
|
||||
width: data?.canvasWidth ?? BASE_WIDTH,
|
||||
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
|
||||
};
|
||||
|
||||
if (data?.objects?.length) {
|
||||
await canvas.loadFromJSON(data);
|
||||
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
|
||||
} else {
|
||||
// Create a fresh letter if no data exists
|
||||
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
|
||||
name: "main-textbox",
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
left: PAD,
|
||||
top: PAD,
|
||||
width: BASE_WIDTH - PAD * 2,
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fill: DEFAULT_FONT_COLOR,
|
||||
lineHeight: 1.5,
|
||||
splitByGrapheme: false,
|
||||
lockMovementX: true,
|
||||
lockMovementY: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
lockRotation: true,
|
||||
hasControls: false,
|
||||
hasBorders: false,
|
||||
objectCaching: false,
|
||||
noScaleCache: false,
|
||||
});
|
||||
canvas.add(textbox);
|
||||
}
|
||||
|
||||
if (!textbox) return;
|
||||
|
||||
// readonly contraints applicable for post seal view
|
||||
textbox.selectable = !readOnly;
|
||||
textbox.evented = !readOnly;
|
||||
textbox.editable = !readOnly;
|
||||
textbox.hasBorders = false;
|
||||
|
||||
textboxRef.current = textbox;
|
||||
|
||||
// observe and auto-resize the canvas height whenever typed
|
||||
textbox.on("changed", syncViewport);
|
||||
|
||||
// trapping the focus into the textbox wherever clicked on canvas (except images)
|
||||
canvas.on("mouse:down", (e) => {
|
||||
if (!e.target || e.target === textbox) {
|
||||
focusTextbox(textbox);
|
||||
}
|
||||
});
|
||||
|
||||
for (const img of canvas.getObjects("Image")) {
|
||||
img.set({
|
||||
hasControls: !readOnly,
|
||||
hasBorders: !readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
|
||||
await document.fonts.ready;
|
||||
textbox.set("dirty", true);
|
||||
syncViewport();
|
||||
|
||||
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
||||
// otherwise it goes to the front
|
||||
if (!readOnly) {
|
||||
setTimeout(() => focusTextbox(textbox), 200);
|
||||
}
|
||||
},
|
||||
[readOnly, syncViewport, focusTextbox],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (style && textboxRef.current) {
|
||||
const textBox = textboxRef.current;
|
||||
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
|
||||
textBox.fill = style.fontColor || textBox.fill;
|
||||
syncViewport();
|
||||
}
|
||||
}, [style, syncViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastWidth = 0;
|
||||
|
||||
const getInitialWidth = async () => {
|
||||
if (!wrapperRef.current) return BASE_WIDTH;
|
||||
let width = wrapperRef.current.clientWidth;
|
||||
if (width === 0) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
const initResizeOberver = () => {
|
||||
if (!wrapperRef.current) return null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = wrapperRef.current?.clientWidth;
|
||||
if (!nextWidth || nextWidth === lastWidth) return;
|
||||
lastWidth = nextWidth;
|
||||
syncViewport();
|
||||
});
|
||||
observer.observe(wrapperRef.current);
|
||||
return observer;
|
||||
};
|
||||
|
||||
const initCanvas = async () => {
|
||||
// HACK: actual font may change the text-width - small ux improvement
|
||||
await document.fonts.ready;
|
||||
|
||||
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
||||
|
||||
const width = await getInitialWidth();
|
||||
|
||||
// init the fabric instance
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
width,
|
||||
const textboxRef = useRef<fabric.Textbox | null>(null);
|
||||
const deferredDataRef = useRef<CanvasJSON | null>(null);
|
||||
const logicalSizeRef = useRef({
|
||||
width: BASE_WIDTH,
|
||||
height: DEFAULT_LOGICAL_HEIGHT,
|
||||
selection: !readOnly,
|
||||
preserveObjectStacking: true,
|
||||
allowTouchScrolling: true,
|
||||
enableRetinaScaling: true,
|
||||
objectCaching: false,
|
||||
});
|
||||
});
|
||||
|
||||
// remove default fabric background to let our CSS show through
|
||||
// TODO: provision custom bg (color in scope, but how does img fit?)
|
||||
const wrapperEl = canvas.getElement().parentElement;
|
||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
||||
// re-calculates height based on content and applies the zoom transform
|
||||
const syncViewport = useCallback(() => {
|
||||
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||
textboxRef.current?.initDimensions();
|
||||
|
||||
fabricRef.current = canvas;
|
||||
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
|
||||
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||
fabricRef.current,
|
||||
minHeight,
|
||||
);
|
||||
|
||||
await loadContent(initialData);
|
||||
applyResponsiveViewport(
|
||||
fabricRef.current,
|
||||
wrapperRef.current,
|
||||
logicalSizeRef.current.width,
|
||||
logicalSizeRef.current.height,
|
||||
);
|
||||
|
||||
// sometimes loadData() may be called before the canvas finished the init render
|
||||
// so we retry that stashed render right after the init
|
||||
if (deferredDataRef.current) {
|
||||
await loadContent(deferredDataRef.current);
|
||||
deferredDataRef.current = null;
|
||||
}
|
||||
fabricRef.current.requestRenderAll();
|
||||
}, [initialData]);
|
||||
|
||||
// auto window resizing based width
|
||||
lastWidth = wrapperRef.current.clientWidth;
|
||||
resizeObserver = initResizeOberver();
|
||||
};
|
||||
// auto focus the cursor into the main textbox no matter the latest element added
|
||||
const focusTextbox = useCallback(
|
||||
(textbox: fabric.Textbox) => {
|
||||
if (readOnly || !fabricRef.current) return;
|
||||
|
||||
initCanvas().then();
|
||||
fabricRef.current.setActiveObject(textbox);
|
||||
textbox.enterEditing();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
resizeObserver?.disconnect();
|
||||
fabricRef.current?.dispose();
|
||||
fabricRef.current = null;
|
||||
textboxRef.current = null;
|
||||
};
|
||||
}, [initialData, loadContent, readOnly, syncViewport]);
|
||||
// move the cursor to the end of the text
|
||||
const textLength = textbox.text?.length ?? 0;
|
||||
textbox.selectionStart = textLength;
|
||||
textbox.selectionEnd = textLength;
|
||||
|
||||
// WHY?: fabric doesn't work like react with state and props based optimized re-renders.
|
||||
// everytime we there's a change in the data, we should force the render,
|
||||
// so we let the parent Editor component take control of this.
|
||||
useImperativeHandle(ref, () => ({
|
||||
addImage: (url: string, file: File) => {
|
||||
if (!fabricRef.current) return;
|
||||
fabricRef.current.requestRenderAll();
|
||||
},
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
fabric.FabricImage.fromURL(url).then((img) => {
|
||||
img.scaleToWidth(Math.min(300, img.width));
|
||||
img.set({
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
left: PAD,
|
||||
top: PAD,
|
||||
noScaleCache: false,
|
||||
objectCaching: false,
|
||||
// WHY?: after image object clean-up, its src becomes local blob://
|
||||
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
|
||||
_customRawFile: file,
|
||||
} as Partial<FabricImageWithFile>);
|
||||
const loadContent = useCallback(
|
||||
async (data: CanvasJSON | null) => {
|
||||
const canvas = fabricRef.current;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!(canvas && wrapper)) return;
|
||||
|
||||
fabricRef.current?.add(img);
|
||||
fabricRef.current?.setActiveObject(img);
|
||||
// clean the canvas everytime and set fresh
|
||||
canvas.clear();
|
||||
let textbox: fabric.Textbox | null = null;
|
||||
|
||||
syncViewport();
|
||||
// clean up memory
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
},
|
||||
// restore logical size from prev saved data if available (in case of existing letter)
|
||||
logicalSizeRef.current = {
|
||||
width: data?.canvasWidth ?? BASE_WIDTH,
|
||||
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
|
||||
};
|
||||
|
||||
getData: () => {
|
||||
if (!fabricRef.current) return { objects: [] };
|
||||
syncViewport();
|
||||
if (data?.objects?.length) {
|
||||
await canvas.loadFromJSON(data);
|
||||
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
|
||||
} else {
|
||||
// Create a fresh letter if no data exists
|
||||
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
|
||||
name: "main-textbox",
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
left: PAD,
|
||||
top: PAD,
|
||||
width: BASE_WIDTH - PAD * 2,
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fill: DEFAULT_FONT_COLOR,
|
||||
lineHeight: 1.5,
|
||||
splitByGrapheme: false,
|
||||
lockMovementX: true,
|
||||
lockMovementY: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
lockRotation: true,
|
||||
hasControls: false,
|
||||
hasBorders: false,
|
||||
objectCaching: false,
|
||||
noScaleCache: false,
|
||||
});
|
||||
canvas.add(textbox);
|
||||
}
|
||||
|
||||
const json = fabricRef.current.toJSON() as CanvasJSON;
|
||||
json.canvasWidth = logicalSizeRef.current.width;
|
||||
json.canvasHeight = logicalSizeRef.current.height;
|
||||
return json;
|
||||
},
|
||||
if (!textbox) return;
|
||||
|
||||
getImages: () => {
|
||||
if (!fabricRef.current) return [];
|
||||
const images = fabricRef.current.getObjects(
|
||||
"Image",
|
||||
) as FabricImageWithFile[];
|
||||
return images.map((img) => ({
|
||||
src: img.getSrc(),
|
||||
file: img._customRawFile,
|
||||
}));
|
||||
},
|
||||
// readonly contraints applicable for post seal view
|
||||
textbox.selectable = !readOnly;
|
||||
textbox.evented = !readOnly;
|
||||
textbox.editable = !readOnly;
|
||||
textbox.hasBorders = false;
|
||||
|
||||
loadData: async (data: CanvasJSON) => {
|
||||
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
|
||||
if (!fabricRef.current) {
|
||||
deferredDataRef.current = data;
|
||||
return;
|
||||
}
|
||||
await loadContent(data);
|
||||
},
|
||||
textboxRef.current = textbox;
|
||||
|
||||
getStyle: () => {
|
||||
const textBox = textboxRef.current;
|
||||
// observe and auto-resize the canvas height whenever typed
|
||||
textbox.on("changed", syncViewport);
|
||||
|
||||
return {
|
||||
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
|
||||
};
|
||||
},
|
||||
}));
|
||||
// trapping the focus into the textbox wherever clicked on canvas (except images)
|
||||
canvas.on("mouse:down", (e) => {
|
||||
if (!e.target || e.target === textbox) {
|
||||
focusTextbox(textbox);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute top-0 left-0"
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
for (const img of canvas.getObjects("Image")) {
|
||||
img.set({
|
||||
hasControls: !readOnly,
|
||||
hasBorders: !readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
|
||||
await document.fonts.ready;
|
||||
textbox.set("dirty", true);
|
||||
syncViewport();
|
||||
|
||||
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
||||
// otherwise it goes to the front
|
||||
if (!readOnly) {
|
||||
setTimeout(() => focusTextbox(textbox), 200);
|
||||
}
|
||||
},
|
||||
[readOnly, syncViewport, focusTextbox],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (style && textboxRef.current) {
|
||||
const textBox = textboxRef.current;
|
||||
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
|
||||
textBox.fill = style.fontColor || textBox.fill;
|
||||
syncViewport();
|
||||
}
|
||||
}, [style, syncViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastWidth = 0;
|
||||
|
||||
const getInitialWidth = async () => {
|
||||
if (!wrapperRef.current) return BASE_WIDTH;
|
||||
let width = wrapperRef.current.clientWidth;
|
||||
if (width === 0) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
const initResizeOberver = () => {
|
||||
if (!wrapperRef.current) return null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = wrapperRef.current?.clientWidth;
|
||||
if (!nextWidth || nextWidth === lastWidth) return;
|
||||
lastWidth = nextWidth;
|
||||
syncViewport();
|
||||
});
|
||||
observer.observe(wrapperRef.current);
|
||||
return observer;
|
||||
};
|
||||
|
||||
const initCanvas = async () => {
|
||||
// HACK: actual font may change the text-width - small ux improvement
|
||||
await document.fonts.ready;
|
||||
|
||||
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
||||
|
||||
const width = await getInitialWidth();
|
||||
|
||||
// init the fabric instance
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
width,
|
||||
height: DEFAULT_LOGICAL_HEIGHT,
|
||||
selection: !readOnly,
|
||||
preserveObjectStacking: true,
|
||||
allowTouchScrolling: true,
|
||||
enableRetinaScaling: true,
|
||||
objectCaching: false,
|
||||
});
|
||||
|
||||
// remove default fabric background to let our CSS show through
|
||||
// TODO: provision custom bg (color in scope, but how does img fit?)
|
||||
const wrapperEl = canvas.getElement().parentElement;
|
||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
||||
|
||||
fabricRef.current = canvas;
|
||||
|
||||
await loadContent(initialData);
|
||||
|
||||
// sometimes loadData() may be called before the canvas finished the init render
|
||||
// so we retry that stashed render right after the init
|
||||
if (deferredDataRef.current) {
|
||||
await loadContent(deferredDataRef.current);
|
||||
deferredDataRef.current = null;
|
||||
}
|
||||
|
||||
if (!(isMounted && wrapperRef.current)) return;
|
||||
|
||||
// auto window resizing based width
|
||||
lastWidth = wrapperRef.current.clientWidth;
|
||||
resizeObserver = initResizeOberver();
|
||||
};
|
||||
|
||||
initCanvas().then();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
resizeObserver?.disconnect();
|
||||
fabricRef.current?.dispose();
|
||||
fabricRef.current = null;
|
||||
textboxRef.current = null;
|
||||
};
|
||||
}, [initialData, loadContent, readOnly, syncViewport]);
|
||||
|
||||
// WHY?: fabric doesn't work like react with state and props based optimized re-renders.
|
||||
// everytime we there's a change in the data, we should force the render,
|
||||
// so we let the parent Editor component take control of this.
|
||||
useImperativeHandle(ref, () => ({
|
||||
addImage: (url: string, file: File) => {
|
||||
if (!fabricRef.current) return;
|
||||
|
||||
fabric.FabricImage.fromURL(url).then((img) => {
|
||||
img.scaleToWidth(Math.min(300, img.width));
|
||||
img.set({
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
left: PAD,
|
||||
top: PAD,
|
||||
noScaleCache: false,
|
||||
objectCaching: false,
|
||||
// WHY?: after image object clean-up, its src becomes local blob://
|
||||
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
|
||||
_customRawFile: file,
|
||||
} as Partial<FabricImageWithFile>);
|
||||
|
||||
fabricRef.current?.add(img);
|
||||
fabricRef.current?.setActiveObject(img);
|
||||
|
||||
syncViewport();
|
||||
// clean up memory
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
},
|
||||
|
||||
getData: () => {
|
||||
if (!fabricRef.current) return { objects: [] };
|
||||
syncViewport();
|
||||
|
||||
const json = fabricRef.current.toJSON() as CanvasJSON;
|
||||
json.canvasWidth = logicalSizeRef.current.width;
|
||||
json.canvasHeight = logicalSizeRef.current.height;
|
||||
return json;
|
||||
},
|
||||
|
||||
getImages: () => {
|
||||
if (!fabricRef.current) return [];
|
||||
const images = fabricRef.current.getObjects(
|
||||
"Image",
|
||||
) as FabricImageWithFile[];
|
||||
return images.map((img) => ({
|
||||
src: img.getSrc(),
|
||||
file: img._customRawFile,
|
||||
}));
|
||||
},
|
||||
|
||||
loadData: async (data: CanvasJSON) => {
|
||||
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
|
||||
if (!fabricRef.current) {
|
||||
deferredDataRef.current = data;
|
||||
return;
|
||||
}
|
||||
await loadContent(data);
|
||||
},
|
||||
|
||||
getStyle: () => {
|
||||
const textBox = textboxRef.current;
|
||||
|
||||
return {
|
||||
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute top-0 left-0"
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ComposeCanvas.displayName = "ComposeCanvas";
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="absolute bottom-0 md:right-5/11 z-1000 font-sans w-full">
|
||||
<div className="absolute bottom-0 z-1000 font-sans w-screen">
|
||||
<Saajan
|
||||
position="top"
|
||||
message={`Someone once said,\n"To send a letter is a good way to go somewhere without moving anything but your heart."\nThey were not wrong.`}
|
||||
|
||||
@@ -2,36 +2,37 @@ import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PasswordInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function PasswordInput({
|
||||
className,
|
||||
error,
|
||||
...props
|
||||
className,
|
||||
error,
|
||||
...props
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
{...props}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={`input input-bordered focus:input-primary w-full pr-12 ${error ? "input-error" : ""
|
||||
} ${className}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-neutral-content/40 hover:text-primary transition-all duration-300 cursor-pointer"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon size={22} weight="duotone" />
|
||||
) : (
|
||||
<EyeIcon size={22} weight="duotone" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
{...props}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className={`input input-bordered focus:input-primary w-full pr-12 ${
|
||||
error ? "input-error" : ""
|
||||
} ${className}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-neutral-content/40 hover:text-primary transition-all duration-300 cursor-pointer"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon size={22} weight="duotone" />
|
||||
) : (
|
||||
<EyeIcon size={22} weight="duotone" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function Saajan({ message, position = "right" }: SaajanProps) {
|
||||
<img
|
||||
src={sf_mini}
|
||||
alt="saajan"
|
||||
className={`sepia-20 w-35 -mb-6 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
|
||||
className={`sepia-20 w-30 -mb-6 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
|
||||
.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-2 md:m-4;
|
||||
}
|
||||
|
||||
.ul-wavy {
|
||||
|
||||
+375
-375
@@ -1,10 +1,10 @@
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { ReactLenis } from "lenis/react";
|
||||
import {
|
||||
motion,
|
||||
useMotionValueEvent,
|
||||
useScroll,
|
||||
useTransform,
|
||||
motion,
|
||||
useMotionValueEvent,
|
||||
useScroll,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -19,382 +19,382 @@ import "@fontsource/space-mono/index.css";
|
||||
import "@fontsource/architects-daughter/index.css";
|
||||
|
||||
export default function Home() {
|
||||
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionContainer1,
|
||||
});
|
||||
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
||||
const [flapOpen, setFlapOpen] = useState(false);
|
||||
const [recipient, setRecipient] = useState("someone dear");
|
||||
const [ignite, setIgnite] = useState(false);
|
||||
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionContainer1,
|
||||
});
|
||||
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
||||
const [flapOpen, setFlapOpen] = useState(false);
|
||||
const [recipient, setRecipient] = useState("someone dear");
|
||||
const [ignite, setIgnite] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
|
||||
if (latestScrollValue > 0.54) {
|
||||
setFlapOpen(false);
|
||||
} else {
|
||||
setFlapOpen(true);
|
||||
}
|
||||
if (latestScrollValue <= 0.6) {
|
||||
setIsEnvelopeFlipped(true);
|
||||
} else {
|
||||
setIsEnvelopeFlipped(false);
|
||||
}
|
||||
if (latestScrollValue > 0.68) {
|
||||
setRecipient("future me");
|
||||
} else {
|
||||
setRecipient("someone dear");
|
||||
}
|
||||
if (latestScrollValue > 0.77) {
|
||||
setIgnite(true);
|
||||
} else {
|
||||
setIgnite(false);
|
||||
}
|
||||
});
|
||||
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
|
||||
if (latestScrollValue > 0.54) {
|
||||
setFlapOpen(false);
|
||||
} else {
|
||||
setFlapOpen(true);
|
||||
}
|
||||
if (latestScrollValue <= 0.6) {
|
||||
setIsEnvelopeFlipped(true);
|
||||
} else {
|
||||
setIsEnvelopeFlipped(false);
|
||||
}
|
||||
if (latestScrollValue > 0.68) {
|
||||
setRecipient("future me");
|
||||
} else {
|
||||
setRecipient("someone dear");
|
||||
}
|
||||
if (latestScrollValue > 0.77) {
|
||||
setIgnite(true);
|
||||
} else {
|
||||
setIgnite(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
|
||||
<section
|
||||
ref={sectionContainer1}
|
||||
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
|
||||
>
|
||||
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
||||
{/* Intro */}
|
||||
<motion.div
|
||||
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
|
||||
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
|
||||
}}
|
||||
>
|
||||
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6">
|
||||
You've been carrying something
|
||||
</h1>
|
||||
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
|
||||
unsaid
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
return (
|
||||
<ReactLenis root options={{ lerp: 0.01, duration: 5, smoothWheel: true }}>
|
||||
<section
|
||||
ref={sectionContainer1}
|
||||
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
|
||||
>
|
||||
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
||||
{/* Intro */}
|
||||
<motion.div
|
||||
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
|
||||
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
|
||||
}}
|
||||
>
|
||||
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6">
|
||||
You've been carrying something
|
||||
</h1>
|
||||
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
|
||||
unsaid
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute text-center"
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
|
||||
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
|
||||
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
|
||||
}}
|
||||
>
|
||||
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
|
||||
and that's okay...
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* pi. ku. */}
|
||||
<motion.div
|
||||
className="absolute text-center px-6"
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.18, 0.25, 0.3],
|
||||
[0, 1, 0],
|
||||
),
|
||||
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
|
||||
}}
|
||||
transition={{ delay: 4 }}
|
||||
>
|
||||
<Logo type="logo" scale={1.5} ul={true} />
|
||||
<motion.div
|
||||
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.22, 0.25, 0.35, 0.4],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.25, 0.3, 0.35, 0.4],
|
||||
[20, 0, 0, -20],
|
||||
),
|
||||
}}
|
||||
>
|
||||
is a{" "}
|
||||
<span className="font-display text-primary font-extralight">
|
||||
safe space
|
||||
</span>
|
||||
,<br />
|
||||
<motion.span
|
||||
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
|
||||
transition={{ delay: 5 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: false, amount: 0.3 }}
|
||||
>
|
||||
where you can
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="absolute text-center"
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
|
||||
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
|
||||
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
|
||||
}}
|
||||
>
|
||||
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
|
||||
and that's okay...
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* pi. ku. */}
|
||||
<motion.div
|
||||
className="absolute text-center px-6"
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.18, 0.25, 0.3],
|
||||
[0, 1, 0],
|
||||
),
|
||||
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
|
||||
}}
|
||||
transition={{ delay: 4 }}
|
||||
>
|
||||
<Logo type="logo" scale={1.5} ul={true} />
|
||||
<motion.div
|
||||
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.22, 0.25, 0.35, 0.4],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.25, 0.3, 0.35, 0.4],
|
||||
[20, 0, 0, -20],
|
||||
),
|
||||
}}
|
||||
>
|
||||
is a{" "}
|
||||
<span className="font-display text-primary font-extralight">
|
||||
safe space
|
||||
</span>
|
||||
,<br />
|
||||
<motion.span
|
||||
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
|
||||
transition={{ delay: 5 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: false, amount: 0.3 }}
|
||||
>
|
||||
where you can
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.35, 0.4, 0.45],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.35, 0.4, 0.45],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
pen down your unsaid words into
|
||||
<span className="font-display text-primary font-extralight">
|
||||
letters
|
||||
</span>
|
||||
.
|
||||
</motion.h2>
|
||||
{/* Seal */}
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.45, 0.5, 0.55, 0.6],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.45, 0.5, 0.55, 0.6],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
seal it
|
||||
<span className="text-success font-mono tracking-tighter font-extrabold">
|
||||
secure
|
||||
</span>
|
||||
and
|
||||
<span className="text-info font-mono tracking-tighter italic">
|
||||
private
|
||||
</span>
|
||||
.
|
||||
</motion.h2>
|
||||
{/* Send / vault */}
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.6, 0.63, 0.72, 0.75],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.6, 0.63, 0.72, 0.75],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
send it to
|
||||
<motion.span
|
||||
className="font-display text-accent"
|
||||
style={{
|
||||
color: useTransform(
|
||||
scrollYProgress,
|
||||
[0.67, 1],
|
||||
["var(--color-accent)", "var(--color-neutral)"],
|
||||
),
|
||||
}}
|
||||
>
|
||||
someone dear
|
||||
</motion.span>
|
||||
<motion.span
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
className="font-display text-accent"
|
||||
style={{
|
||||
color: useTransform(
|
||||
scrollYProgress,
|
||||
[0.67, 1],
|
||||
["var(--color-accent)", "var(--color-neutral)"],
|
||||
),
|
||||
}}
|
||||
>
|
||||
or
|
||||
</motion.span>
|
||||
<span className="font-display text-success">
|
||||
yourself in the future
|
||||
</span>
|
||||
.
|
||||
</motion.span>
|
||||
</motion.h2>
|
||||
{/* Burn */}
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.75, 0.8, 0.85, 0.9],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.75, 0.8, 0.85, 0.9],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
and even <span className="font-display text-error">burn it</span>
|
||||
to release the burden.
|
||||
</motion.h2>
|
||||
{/* Outro */}
|
||||
<motion.h2
|
||||
className={
|
||||
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
|
||||
}
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
|
||||
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
|
||||
}}
|
||||
>
|
||||
You've been carrying it long enough.
|
||||
</motion.h2>
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
className={
|
||||
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
||||
}
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
|
||||
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
|
||||
display: useTransform(
|
||||
scrollYProgress,
|
||||
[0.96, 1],
|
||||
["none", "flex"],
|
||||
),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={
|
||||
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||
}
|
||||
type={"button"}
|
||||
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
|
||||
>
|
||||
<InfoIcon className={"text-primary"} />
|
||||
Tell me More
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||
}
|
||||
type={"button"}
|
||||
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
||||
>
|
||||
I'm ready
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.35, 0.4, 0.45],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.35, 0.4, 0.45],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
pen down your unsaid words into
|
||||
<span className="font-display text-primary font-extralight">
|
||||
letters
|
||||
</span>
|
||||
.
|
||||
</motion.h2>
|
||||
{/* Seal */}
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.45, 0.5, 0.55, 0.6],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.45, 0.5, 0.55, 0.6],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
seal it
|
||||
<span className="text-success font-mono tracking-tighter font-extrabold">
|
||||
secure
|
||||
</span>
|
||||
and
|
||||
<span className="text-info font-mono tracking-tighter italic">
|
||||
private
|
||||
</span>
|
||||
.
|
||||
</motion.h2>
|
||||
{/* Send / vault */}
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.6, 0.63, 0.72, 0.75],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.6, 0.63, 0.72, 0.75],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
send it to
|
||||
<motion.span
|
||||
className="font-display text-accent"
|
||||
style={{
|
||||
color: useTransform(
|
||||
scrollYProgress,
|
||||
[0.67, 1],
|
||||
["var(--color-accent)", "var(--color-neutral)"],
|
||||
),
|
||||
}}
|
||||
>
|
||||
someone dear
|
||||
</motion.span>
|
||||
<motion.span
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
className="font-display text-accent"
|
||||
style={{
|
||||
color: useTransform(
|
||||
scrollYProgress,
|
||||
[0.67, 1],
|
||||
["var(--color-accent)", "var(--color-neutral)"],
|
||||
),
|
||||
}}
|
||||
>
|
||||
or
|
||||
</motion.span>
|
||||
<span className="font-display text-success">
|
||||
yourself in the future
|
||||
</span>
|
||||
.
|
||||
</motion.span>
|
||||
</motion.h2>
|
||||
{/* Burn */}
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.75, 0.8, 0.85, 0.9],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.75, 0.8, 0.85, 0.9],
|
||||
[40, 0, 0, -40],
|
||||
),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
>
|
||||
and even <span className="font-display text-error">burn it</span>
|
||||
to release the burden.
|
||||
</motion.h2>
|
||||
{/* Outro */}
|
||||
<motion.h2
|
||||
className={
|
||||
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
|
||||
}
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
|
||||
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
|
||||
}}
|
||||
>
|
||||
You've been carrying it long enough.
|
||||
</motion.h2>
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
className={
|
||||
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
||||
}
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
|
||||
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
|
||||
display: useTransform(
|
||||
scrollYProgress,
|
||||
[0.96, 1],
|
||||
["none", "flex"],
|
||||
),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={
|
||||
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||
}
|
||||
type={"button"}
|
||||
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
|
||||
>
|
||||
<InfoIcon className={"text-primary"} />
|
||||
Tell me More
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||
}
|
||||
type={"button"}
|
||||
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
||||
>
|
||||
I'm ready
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
|
||||
<motion.div
|
||||
className={"z-21 absolute"}
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.4, 0.5, 0.52],
|
||||
[0, 1, 0.1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.45, 0.5],
|
||||
[300, 0, 200],
|
||||
),
|
||||
scale: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.4, 0.5],
|
||||
[1, 1, 0.6],
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="mockup-phone w-[75vw] border-primary">
|
||||
<div className="mockup-phone-camera"></div>
|
||||
<div className="mockup-phone-display">
|
||||
<img alt="letter" src={letterSample} />
|
||||
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
|
||||
<motion.div
|
||||
className={"z-21 absolute"}
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.4, 0.5, 0.52],
|
||||
[0, 1, 0.1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.45, 0.5],
|
||||
[300, 0, 200],
|
||||
),
|
||||
scale: useTransform(
|
||||
scrollYProgress,
|
||||
[0.3, 0.4, 0.5],
|
||||
[1, 1, 0.6],
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="mockup-phone w-[75vw] border-primary">
|
||||
<div className="mockup-phone-camera"></div>
|
||||
<div className="mockup-phone-display">
|
||||
<img alt="letter" src={letterSample} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Envelope */}
|
||||
<motion.div
|
||||
className="absolute scale-50 md:scale-80 z-10"
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
|
||||
[0, 0.6, 1, 1, 0.3, 0],
|
||||
),
|
||||
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
|
||||
}}
|
||||
>
|
||||
<EnvelopeReveal
|
||||
isInteractive={false}
|
||||
ignite={ignite}
|
||||
recipient={recipient}
|
||||
date={formatDate(new Date().toISOString())}
|
||||
onRevealComplete={() => { }}
|
||||
isFlip={isEnvelopeFlipped}
|
||||
openFlap={flapOpen}
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Saajan */}
|
||||
<motion.div
|
||||
className="fixed bottom-0 z-10 font-sans -mb-6 md:scale-100 md:mb-0"
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.98, 0.995, 1],
|
||||
[0, 0.5, 1],
|
||||
),
|
||||
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
|
||||
}}
|
||||
>
|
||||
<Saajan
|
||||
message={
|
||||
"I think we forget things\nif there is nobody to tell them."
|
||||
}
|
||||
position={"top"}
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Orb */}
|
||||
<motion.div
|
||||
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
|
||||
transition={{
|
||||
backgroundColor: { ease: "easeIn", duration: 2 },
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: useTransform(
|
||||
scrollYProgress,
|
||||
[0.45, 0.5, 0.7, 0.75, 1],
|
||||
[
|
||||
"var(--color-primary)",
|
||||
"var(--color-secondary)",
|
||||
"var(--color-accent)",
|
||||
"var(--color-success)",
|
||||
"var(--color-error)",
|
||||
],
|
||||
),
|
||||
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
|
||||
}}
|
||||
/>
|
||||
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Envelope */}
|
||||
<motion.div
|
||||
className="absolute scale-50 md:scale-80 z-10"
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
|
||||
[0, 0.6, 1, 1, 0.3, 0],
|
||||
),
|
||||
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
|
||||
}}
|
||||
>
|
||||
<EnvelopeReveal
|
||||
isInteractive={false}
|
||||
ignite={ignite}
|
||||
recipient={recipient}
|
||||
date={formatDate(new Date().toISOString())}
|
||||
onRevealComplete={() => {}}
|
||||
isFlip={isEnvelopeFlipped}
|
||||
openFlap={flapOpen}
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Saajan */}
|
||||
<motion.div
|
||||
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
scrollYProgress,
|
||||
[0.98, 0.995, 1],
|
||||
[0, 0.5, 1],
|
||||
),
|
||||
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
|
||||
}}
|
||||
>
|
||||
<Saajan
|
||||
message={
|
||||
"I think we forget things\nif there is nobody to tell them."
|
||||
}
|
||||
position={"top"}
|
||||
/>
|
||||
</motion.div>
|
||||
{/* Orb */}
|
||||
<motion.div
|
||||
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
|
||||
transition={{
|
||||
backgroundColor: { ease: "easeIn", duration: 2 },
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: useTransform(
|
||||
scrollYProgress,
|
||||
[0.45, 0.5, 0.7, 0.75, 1],
|
||||
[
|
||||
"var(--color-primary)",
|
||||
"var(--color-secondary)",
|
||||
"var(--color-accent)",
|
||||
"var(--color-success)",
|
||||
"var(--color-error)",
|
||||
],
|
||||
),
|
||||
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
|
||||
}}
|
||||
/>
|
||||
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ReactLenis>
|
||||
);
|
||||
</section>
|
||||
</ReactLenis>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function Login() {
|
||||
const { setAuthStore } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
|
||||
const [saajanMessage, setSaajanMessage] = useState<string>(
|
||||
"I was wondering when you'd return.",
|
||||
"I was wondering, if you'd ever return.",
|
||||
);
|
||||
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
|
||||
|
||||
@@ -82,9 +82,12 @@ export default function Login() {
|
||||
{!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">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="card-body gap-4 px-2"
|
||||
>
|
||||
<h1 className="flex items-center font-display text-2xl justify-center text-primary/80 tracking-tight">
|
||||
Enter <Logo type="logo" scale={0.7} /> Archive
|
||||
Unlock <Logo type="logo" scale={0.6} /> Archive
|
||||
</h1>
|
||||
|
||||
{apiError && (
|
||||
|
||||
@@ -8,6 +8,27 @@ import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import Reader from "./Reader";
|
||||
|
||||
vi.mock("../components/reader/EnvelopeReveal", () => ({
|
||||
EnvelopeReveal: ({
|
||||
recipient,
|
||||
onRevealComplete,
|
||||
}: {
|
||||
recipient?: string;
|
||||
onRevealComplete: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="envelope-recipient">{recipient}</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reveal-button"
|
||||
onClick={onRevealComplete}
|
||||
>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Fabric.js needs to know when fonts are loaded
|
||||
@@ -16,6 +37,13 @@ Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver = MockResizeObserver;
|
||||
|
||||
describe("Reader Page", () => {
|
||||
let masterKey: CryptoKey;
|
||||
let utils: CryptoUtils;
|
||||
@@ -47,7 +75,7 @@ describe("Reader Page", () => {
|
||||
});
|
||||
|
||||
it("should load and decrypt the letter when a valid key is provided and display the envelope", async () => {
|
||||
const mockPublicId = "test-uuid";
|
||||
const mockPublicId = "41123-e2c3-f2115";
|
||||
const letterContent = JSON.stringify({ objects: [] });
|
||||
const metadata = { recipient: "Guest" };
|
||||
// simulate guest
|
||||
@@ -139,3 +167,93 @@ describe("Reader Page", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reader Page - (write a letter) CTA", () => {
|
||||
let masterKey: CryptoKey;
|
||||
let utils: CryptoUtils;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
utils = new CryptoUtils();
|
||||
await utils.initialize();
|
||||
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
|
||||
masterKey = bundle.masterKey;
|
||||
|
||||
vi.stubGlobal("location", {
|
||||
hash: "",
|
||||
href: "http://localhost/",
|
||||
});
|
||||
});
|
||||
|
||||
it("should display CTA for guest and navigate to homepage", async () => {
|
||||
const mockPublicId = "41123-e2c3-f2115";
|
||||
const letterContent = JSON.stringify({ objects: [] });
|
||||
const metadata = { recipient: "Guest" };
|
||||
useKeyStore.setState({ masterKey: null });
|
||||
const encryptedLetter = await utils.encryptLetter(letterContent, masterKey);
|
||||
const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey);
|
||||
const sharingKey = encryptedLetter.sharingKey as string;
|
||||
server.use(
|
||||
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
|
||||
return HttpResponse.json({
|
||||
encrypted_content: encryptedLetter.encrypted_content,
|
||||
encrypted_metadata: encryptedMetadata.encrypted_content,
|
||||
encrypted_dek: encryptedLetter.encrypted_dek,
|
||||
images: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${sharingKey}`]}>
|
||||
<Routes>
|
||||
<Route path="/read/:public_id" element={<Reader />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={<div data-testid="home-page">Home Page</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const revealBtn = await screen.findByTestId("reveal-button");
|
||||
revealBtn.click();
|
||||
const ctaBtn = await screen.findByTestId("reader-cta-btn");
|
||||
ctaBtn.click();
|
||||
|
||||
expect(ctaBtn).toHaveTextContent(/write a letter/i);
|
||||
expect(await screen.findByTestId("home-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the CTA for the author of the letter", async () => {
|
||||
const mockPublicId = "41123-e2c3-f2115";
|
||||
const letterContent = JSON.stringify({ objects: [] });
|
||||
const metadata = { recipient: "Guest" };
|
||||
useKeyStore.setState({ masterKey });
|
||||
const encryptedLetter = await utils.encryptLetter(letterContent, masterKey);
|
||||
const encryptedMetadata = await utils.encryptMetadata(metadata, masterKey);
|
||||
server.use(
|
||||
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
|
||||
return HttpResponse.json({
|
||||
encrypted_content: encryptedLetter.encrypted_content,
|
||||
encrypted_metadata: encryptedMetadata.encrypted_content,
|
||||
encrypted_dek: encryptedLetter.encrypted_dek,
|
||||
images: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/read/${mockPublicId}`]}>
|
||||
<Routes>
|
||||
<Route path="/read/:public_id" element={<Reader />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const revealBtn = await screen.findByTestId("reveal-button");
|
||||
revealBtn.click();
|
||||
await screen.findByTestId("envelope-recipient");
|
||||
|
||||
expect(screen.queryByTestId("reader-cta-btn")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import { PostActionOverlay } from "../components/reader/PostActionOverlay";
|
||||
import { ShareModal } from "../components/reader/ShareModal";
|
||||
import { LogModal } from "../components/ui/LogModal";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { PATHS, ROUTES } from "../config/routes";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import { formatDate } from "../utils/dateFormat";
|
||||
@@ -114,27 +114,29 @@ export default function Reader() {
|
||||
images: LetterImageData[],
|
||||
encrypted_dek: string,
|
||||
cryptoUtils: CryptoUtils,
|
||||
) => {
|
||||
if (!images?.length) return;
|
||||
): Promise<CanvasJSON> => {
|
||||
if (!images?.length) return canvasData;
|
||||
const isShared = !!sharingKey;
|
||||
try {
|
||||
if (isShared) {
|
||||
await decryptCanvasImagesWithSharingKey(
|
||||
canvasData,
|
||||
images,
|
||||
sharingKey,
|
||||
cryptoUtils,
|
||||
);
|
||||
} else {
|
||||
await decryptCanvasImages(
|
||||
canvasData,
|
||||
images,
|
||||
encrypted_dek,
|
||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||
masterKey!,
|
||||
cryptoUtils,
|
||||
);
|
||||
const { canvasDataWithDecryptedImages } =
|
||||
await decryptCanvasImagesWithSharingKey(
|
||||
canvasData,
|
||||
images,
|
||||
sharingKey,
|
||||
cryptoUtils,
|
||||
);
|
||||
return canvasDataWithDecryptedImages;
|
||||
}
|
||||
const { canvasDataWithDecryptedImages } = await decryptCanvasImages(
|
||||
canvasData,
|
||||
images,
|
||||
encrypted_dek,
|
||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||
masterKey!,
|
||||
cryptoUtils,
|
||||
);
|
||||
return canvasDataWithDecryptedImages;
|
||||
} catch (err) {
|
||||
setLogTrace({
|
||||
message:
|
||||
@@ -142,6 +144,7 @@ export default function Reader() {
|
||||
log: err instanceof Error ? err.message : "Unknown error",
|
||||
type: "WARN",
|
||||
});
|
||||
return canvasData;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,8 +190,13 @@ export default function Reader() {
|
||||
);
|
||||
|
||||
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
||||
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
|
||||
setDecryptedCanvasData(canvasData);
|
||||
const decryptedCanvasData = await decryptImages(
|
||||
canvasData,
|
||||
images,
|
||||
encrypted_dek,
|
||||
cryptoUtils,
|
||||
);
|
||||
setDecryptedCanvasData(decryptedCanvasData);
|
||||
};
|
||||
|
||||
const processLetterData = async (data: LetterResponseData) => {
|
||||
@@ -319,6 +327,19 @@ export default function Reader() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revealState === "REVEALED" && !isAuthor && (
|
||||
<button
|
||||
data-testid="reader-cta-btn"
|
||||
type="button"
|
||||
className="btn btn-ghost btn-wide font-sans tracking-widest mx-auto cursor-pointer flex text-neutral hover:text-neutral-content focus:text-neutral-content"
|
||||
onClick={() => {
|
||||
navigate(ROUTES.HOME);
|
||||
}}
|
||||
>
|
||||
write a letter
|
||||
</button>
|
||||
)}
|
||||
|
||||
{shareLink && (
|
||||
<ShareModal shareLink={shareLink} setShareLink={setShareLink} />
|
||||
)}
|
||||
@@ -360,8 +381,8 @@ export default function Reader() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
|
||||
<p className="text-xs font-sans uppercase tracking-widester">
|
||||
<footer className="mt-16 text-center z-10 text-neutral pointer-events-none">
|
||||
<p className="text-xxs font-sans uppercase font-extrabold tracking-widester">
|
||||
Read. Remember. Release.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
@@ -75,9 +75,12 @@ export default function Register() {
|
||||
<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} />
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="card-body px-2 gap-4"
|
||||
>
|
||||
<div className="card-title font-display text-xl md:text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
||||
Create a<Logo type="logo" scale={0.6} />
|
||||
Account
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user