5 Commits

Author SHA1 Message Date
me 9a415a964c chore: rename postgres service to test-db in CI workflow
CI / Generate Certificates (pull_request) Successful in 51s
ci.yml / Backend CI (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Frontend CI (pull_request) Has been cancelled
2026-05-05 18:57:55 +05:30
me 601dbd5c9b chore: remove PGDATA configuration from CI workflow
CI / Generate Certificates (pull_request) Successful in 29s
CI / Frontend CI (pull_request) Successful in 1m7s
CI / E2E Tests (pull_request) Successful in 6m33s
ci.yml / Backend CI (pull_request) Has been cancelled
2026-05-05 18:39:17 +05:30
me d2d8c88f69 fix: add internal host routing for Gitea Actions environment in e2e
CI / Generate Certificates (pull_request) Successful in 29s
CI / Frontend CI (pull_request) Successful in 1m10s
CI / E2E Tests (pull_request) Successful in 6m36s
ci.yml / Backend CI (pull_request) Has been cancelled
2026-05-05 18:30:06 +05:30
me 283417fe24 fix: configure tmp postgres data directory differently
CI / Frontend CI (pull_request) Successful in 1m6s
ci.yml / Backend CI (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Generate Certificates (pull_request) Successful in 27s
2026-05-05 18:25:05 +05:30
ramvignesh-b df56754c55 ci: introduce tmp filespace to prevent postgres persist and migrate to custom artiifact upload
CI / Frontend CI (pull_request) Successful in 1m8s
CI / Backend CI (pull_request) Failing after 48s
CI / Generate Certificates (pull_request) Successful in 26s
CI / E2E Tests (pull_request) Failing after 2m52s
2026-05-05 18:08:09 +05:30
19 changed files with 426 additions and 771 deletions
+54 -52
View File
@@ -26,6 +26,59 @@ jobs:
path: certs/ path: certs/
retention-days: 1 retention-days: 1
backend:
name: Backend CI
runs-on: ubuntu-latest
needs: setup-environment
services:
test-db:
image: postgres:16-alpine
env:
POSTGRES_DB: piku_test
POSTGRES_USER: test
POSTGRES_PASSWORD: password123
ports:
- 5442:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "backend/uv.lock"
- name: Restore certificates
uses: christopherHX/gitea-download-artifact@v4
with:
name: ssl-certs
path: certs/
- name: Setup Environment
run: |
cp ../.env.example ../.env
uv sync
if [ "$GITEA_ACTIONS" = "true" ]; then
export DB_HOST="test-db"
export DB_PORT="5432"
else
export DB_HOST="127.0.0.1"
export DB_PORT="5442"
fi
export DB_PASSWORD='password123'
export DB_NAME="piku_test"
export DB_USER="test"
- name: Lint & Test
run: |
uv run ruff check
uv run python manage.py test
frontend: frontend:
name: Frontend CI name: Frontend CI
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -57,57 +110,6 @@ jobs:
- name: Unit Tests - name: Unit Tests
run: bun run test run: bun run test
backend:
name: Backend CI
runs-on: ubuntu-latest
needs: setup-environment
services:
db:
image: postgres:16-alpine
env:
POSTGRES_DB: piku__test
POSTGRES_USER: test
POSTGRES_PASSWORD: password123
ports:
- 5442:5432
options: --tmpfs /var/lib/postgresql/data --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "backend/uv.lock"
- name: Restore certificates
uses: christopherHX/gitea-download-artifact@v4
with:
name: ssl-certs
path: certs/
- name: Setup & Test
run: |
cp ../.env.example ../.env
uv sync
export DB_NAME="piku__test"
export DB_USER="test"
export DB_PASSWORD="password123"
if [ "$GITEA_ACTIONS" = "true" ]; then
export DB_HOST="db"
export DB_PORT="5432"
else
export DB_HOST="127.0.0.1"
export DB_PORT="5442"
fi
uv run ruff check
uv run python manage.py test
e2e: e2e:
name: E2E Tests name: E2E Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -133,7 +135,7 @@ jobs:
# Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing # Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing
# TODO: setup cache server in Gitea # TODO: setup cache server in Gitea
if: github.server_url == 'https://github.com' if: github.server_url == 'https://github.com'
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/bun.lock') }} key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/bun.lock') }}
+1 -1
View File
@@ -208,7 +208,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
// Verify it opens the Reader without a hash // Verify it opens the Reader without a hash
logger.info(">> [Drawer] Verifying Reader page..."); logger.info(">> [Drawer] Verifying Reader page...");
// Give it a bit more time for decryption // Give it a bit more time for decryption
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
// Reveal and check decrypted content in Reader // Reveal and check decrypted content in Reader
await expect(page.getByText(/breaking the seal/i)).toBeHidden({ await expect(page.getByText(/breaking the seal/i)).toBeHidden({
timeout: 10000, timeout: 10000,
+1 -29
View File
@@ -54,34 +54,6 @@ async function registerAndLogin(
await page.getByRole("button", { name: /sign in/i }).click(); await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/drawer/); await expect(page).toHaveURL(/\/drawer/);
await handleWelcomeLetter(page);
logger.info(`[Auth] Successfully authenticated ${email}`); logger.info(`[Auth] Successfully authenticated ${email}`);
} }
export const AuthHelper = { registerAndLogin };
/**
* Handles and dismisses the first welocme letter
*/
async function handleWelcomeLetter(page: Page) {
logger.info("[Auth] Handling Welcome Letter...");
// Click envelope to flip
const envelope = page.locator("#env-front");
await envelope.waitFor({ state: "visible", timeout: 10000 });
await envelope.click();
// Click seal to open flap
const seal = page.getByAltText("Seal");
await seal.waitFor({ state: "visible" });
await seal.click();
// Click letter to reveal
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
// Click "I'll see you" button
const completeButton = page.getByRole("button", { name: /I'll see you/i });
await completeButton.waitFor({ state: "visible", timeout: 10000 });
await completeButton.click();
await expect(completeButton).toBeHidden();
}
export const AuthHelper = { registerAndLogin, handleWelcomeLetter };
+1 -1
View File
@@ -15,7 +15,7 @@ const baseUrl = getBaseUrl(
); );
export default defineConfig({ export default defineConfig({
timeout: 80000, timeout: 60000,
expect: { expect: {
timeout: 10000, timeout: 10000,
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

+8 -2
View File
@@ -1,5 +1,11 @@
import { lazy, Suspense, useEffect, useRef } from "react"; import { lazy, Suspense, useEffect, useRef } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import {
BrowserRouter,
Navigate,
Route,
Routes,
ScrollRestoration,
} from "react-router-dom";
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards"; import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
import SplashScreen from "./components/SplashScreen"; import SplashScreen from "./components/SplashScreen";
import { ROUTES } from "./config/routes"; import { ROUTES } from "./config/routes";
@@ -31,7 +37,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-50 before:pointer-events-none before:bg-[url('assets/noise.gif')]"> <main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<Suspense fallback={<SplashScreen />}> <Suspense fallback={<SplashScreen />}>
<Routes> <Routes>
<Route path={ROUTES.HOME} element={<Home />} /> <Route path={ROUTES.HOME} element={<Home />} />
+10 -10
View File
@@ -1,26 +1,26 @@
import { HourglassSimpleMediumIcon } from "@phosphor-icons/react"; import { LockKeyIcon } from "@phosphor-icons/react";
import { useAuth } from "../../hooks/useAuth";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
export function PasskeyModal() { interface PasskeyModalProps {
const { unlock } = useAuth(); onUnlock: (password: string) => Promise<void>;
}
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
return ( return (
<Modal isOpen={true}> <Modal isOpen={true}>
<HourglassSimpleMediumIcon <LockKeyIcon
size={48} size={48}
className="text-primary mx-auto mb-8 animate-pulse" className="text-primary mx-auto mb-8 animate-pulse"
weight="duotone"
/> />
<h3 className="font-bold text-lg font-display text-primary"> <h3 className="font-bold text-lg font-display text-primary">
You've been away a while. Authentication Required
</h3> </h3>
<p className="py-4 font-sans"> <p className="py-4 font-sans">
Your letters are still there. Just need the key once more. We need your passkey to open your letters
</p> </p>
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div> <div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
<p className="text-xs text-neutral-content/30 font-mono italic"> <p className="text-xs text-neutral-content/30 font-mono italic">
Nothing was lost. Your passkey is used to decrypt your data locally.
</p> </p>
<div className="modal-action items-center gap-4"> <div className="modal-action items-center gap-4">
<form <form
@@ -30,7 +30,7 @@ export function PasskeyModal() {
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const password = formData.get("password") as string; const password = formData.get("password") as string;
if (!password) return; if (!password) return;
await unlock(password); await onUnlock(password);
}} }}
> >
<input <input
@@ -1,77 +0,0 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { getWelcomeLetterContent } from "../../config/welcomeLetter";
import { formatDate } from "../../utils/dateFormat";
import { type CanvasTools, ComposeCanvas } from "../editor/ComposeCanvas";
import { EnvelopeReveal } from "../reader/EnvelopeReveal";
interface WelcomeLetterOverlayProps {
onComplete: () => void;
userName: string;
}
export function WelcomeLetterOverlay({
onComplete,
userName,
}: WelcomeLetterOverlayProps) {
const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">(
"SEALED",
);
const canvasRef = useRef<CanvasTools>(null);
useEffect(() => {
if (revealState === "REVEALED" && canvasRef.current) {
const welcomeContent = getWelcomeLetterContent(userName);
canvasRef.current.loadData(welcomeContent);
}
}, [revealState, userName]);
return (
<div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
<div className="w-full max-w-4xl z-10 flex flex-col items-center">
<AnimatePresence mode="wait">
{revealState === "SEALED" && (
<motion.div
key="envelope"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 0.8, opacity: 1 }}
exit={{
scale: 1,
opacity: 0,
transition: { duration: 0.5, ease: "easeOut" },
}}
transition={{ duration: 4, delay: 1 }}
>
<EnvelopeReveal
recipient={userName}
date={formatDate(new Date())}
onRevealComplete={() => setRevealState("REVEALED")}
ignite={false}
/>
</motion.div>
)}
</AnimatePresence>
<div
className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`}
>
<div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180">
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
<ComposeCanvas ref={canvasRef} readOnly />
</div>
<div className="flex justify-center mt-12">
<button
type="button"
onClick={onComplete}
className="btn btn-accent opacity-80 px-12 shadow-lg"
>
I'll see you
</button>
</div>
</div>
</div>
</div>
);
}
@@ -2,12 +2,6 @@ import * as fabric from "fabric";
import type * as React from "react"; import type * as React from "react";
import { useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/cutive-mono/index.css";
import "@fontsource/architects-daughter/index.css";
import "@fontsource/redacted-script/index.css";
const PAD = 36; const PAD = 36;
const BASE_WIDTH = 680; const BASE_WIDTH = 680;
const DEFAULT_LOGICAL_HEIGHT = 900; const DEFAULT_LOGICAL_HEIGHT = 900;
@@ -190,7 +184,9 @@ export function ComposeCanvas({
fontFamily: DEFAULT_FONT_FAMILY, fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR, fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5, lineHeight: 1.5,
splitByGrapheme: false, // NOTE: splitByGrapheme is required for word wrap and re-low
// but fabric asks to disable this for clear font?? So we disable it for read view
splitByGrapheme: !readOnly,
lockMovementX: true, lockMovementX: true,
lockMovementY: true, lockMovementY: true,
lockScalingX: true, lockScalingX: true,
@@ -224,16 +220,6 @@ export function ComposeCanvas({
} }
}); });
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(); syncViewport();
// Hack: Fabric needs a small initial delay to mount before it will accept focus. // Hack: Fabric needs a small initial delay to mount before it will accept focus.
@@ -34,7 +34,8 @@ export default function WelcomeModal({
className="inline text-primary" className="inline text-primary"
weight="fill" weight="fill"
/> />
<span className="divider my-0 block"></span> <div className="divider my-0"></div>
<br />
Everything you write here is sealed with your password,{" "} Everything you write here is sealed with your password,{" "}
<span className="font-display text-success">cryptographically</span> <span className="font-display text-success">cryptographically</span>
, before it leaves your hands. , before it leaves your hands.
@@ -43,11 +44,11 @@ export default function WelcomeModal({
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3"> <div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" /> <WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
<div className="text-sm font-medium text-primary-content"> <p className="text-sm font-medium text-primary-content">
If you ever happen to forget your password, your letters are lost If you ever happen to forget your password, your letters are lost
to time, forever. to time, forever.
<br /> <br />
<span className="font-bold mt-2 block"> <span className="font-bold mt-2">
I highly, highly recommend storing this password in your{" "} I highly, highly recommend storing this password in your{" "}
<a <a
href="https://www.privacyguides.org/en/passwords/" href="https://www.privacyguides.org/en/passwords/"
@@ -59,7 +60,7 @@ export default function WelcomeModal({
</a>{" "} </a>{" "}
or somewhere safe to remember it. or somewhere safe to remember it.
</span> </span>
</div> </p>
</div> </div>
<div className="modal-action w-full"> <div className="modal-action w-full">
-101
View File
@@ -1,101 +0,0 @@
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
export function getWelcomeLetterContent(userName: string): CanvasJSON {
return {
objects: [
{
fontSize: 18,
fontWeight: 500,
fontFamily: "Kavivanar",
fontStyle: "normal",
lineHeight: 1.5,
text: `\nDear ${userName}, \n\nYou made it this far, which means something already brought you here. \nA name, maybe. A feeling you haven't been able to shake. Something you typed and deleted too many times to count.\n\nMost people carry it quietly. They tell themselves it doesn't matter anymore, or that too much time has passed, or that the other person wouldn't understand anyway. And maybe they're right. \n\nBut the thing is — the unsaid thing doesn't really care about any of that. \nIt just stays.\n\nSo here you are.\n\nYou don't have to know what you want to say yet. \nYou don't have to have it figured out — who it's for, or why it still matters, or what you're hoping will happen after. \n\nA lot of letters written here start without any of that. They find their way.\n\nTake your time. \nNo one's watching. \n\nWhen you're ready, write a letter.\n\nSometimes the wrong train takes you to the right station.\n- S.F.`,
charSpacing: 0,
textAlign: "left",
styles: [],
pathStartOffset: 0,
pathSide: "left",
pathAlign: "baseline",
underline: false,
overline: false,
linethrough: false,
textBackgroundColor: "",
direction: "ltr",
textDecorationThickness: 66.667,
minWidth: 20,
splitByGrapheme: false,
type: "Textbox",
version: "7.2.0",
originX: "left",
originY: "top",
left: 36,
top: 36,
width: 608,
height: 813.6,
fill: "#111e67",
stroke: null,
strokeWidth: 1,
strokeDashArray: null,
strokeLineCap: "butt",
strokeDashOffset: 0,
strokeLineJoin: "miter",
strokeUniform: false,
strokeMiterLimit: 4,
scaleX: 1,
scaleY: 1,
angle: 0,
flipX: false,
flipY: false,
opacity: 1,
shadow: null,
visible: true,
backgroundColor: "",
fillRule: "nonzero",
paintFirst: "fill",
globalCompositeOperation: "source-over",
skewX: 0,
skewY: 0,
},
{
cropX: 0,
cropY: 0,
type: "Image",
version: "7.2.0",
originX: "left",
originY: "top",
left: 298.4065,
top: 660.2853,
width: 512,
height: 400,
fill: "rgb(0,0,0)",
stroke: null,
strokeWidth: 0,
strokeDashArray: null,
strokeLineCap: "butt",
strokeDashOffset: 0,
strokeLineJoin: "miter",
strokeUniform: false,
strokeMiterLimit: 4,
scaleX: 0.4753,
scaleY: 0.4753,
angle: 355.5436,
flipX: false,
flipY: false,
opacity: 1,
shadow: null,
visible: true,
backgroundColor: "",
fillRule: "nonzero",
paintFirst: "fill",
globalCompositeOperation: "source-over",
skewX: 0,
skewY: 0,
src: "/screenshots/train.png",
crossOrigin: null,
filters: [],
},
],
canvasWidth: 680,
canvasHeight: 900,
};
}
-72
View File
@@ -6,7 +6,6 @@ import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server"; import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { import {
clearMasterKey, clearMasterKey,
loadMasterKey, loadMasterKey,
@@ -15,7 +14,6 @@ import {
import { useAuth } from "./useAuth"; import { useAuth } from "./useAuth";
vi.mock("../utils/keystore"); vi.mock("../utils/keystore");
vi.mock("../utils/crypto");
const VITE_API_URL = "http://piku-server"; const VITE_API_URL = "http://piku-server";
@@ -32,11 +30,6 @@ beforeEach(() => {
isInitializing: true, isInitializing: true,
}); });
useKeyStore.setState({ masterKey: null }); useKeyStore.setState({ masterKey: null });
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
masterKey: mockMasterKey,
authHash: "mock-hash",
});
}); });
describe("isAuthenticated", () => { describe("isAuthenticated", () => {
@@ -208,68 +201,3 @@ describe("initialize", () => {
expect(useKeyStore.getState().masterKey).not.toBeNull(); expect(useKeyStore.getState().masterKey).not.toBeNull();
}); });
}); });
describe("unlock", () => {
beforeEach(() => {
useAuthStore.setState({
accessToken: "valid-token",
user: mockUser,
isInitializing: false,
});
});
it("should derive the master key from the user password, validate it via API, and persist it", async () => {
let loginCalled = false;
server.use(
http.post(`${VITE_API_URL}/api/auth/login/`, async () => {
loginCalled = true;
return HttpResponse.json({ access: "token", user: mockUser });
}),
);
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.unlock("password");
});
expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith(
"password",
mockUser.email,
);
expect(loginCalled).toBe(true);
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey);
});
it("should logout if user is not present", async () => {
useAuthStore.setState({ user: null });
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.unlock("password");
});
expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled();
expect(saveMasterKey).not.toHaveBeenCalled();
expect(useAuthStore.getState().accessToken).toBeNull();
expect(clearMasterKey).toHaveBeenCalled();
});
it("should throw an error and not persist the key if validation fails", async () => {
server.use(
http.post(
`${VITE_API_URL}/api/auth/login/`,
() => new HttpResponse(null, { status: 400 }),
),
);
const { result } = renderHook(() => useAuth());
await act(async () => {
await expect(result.current.unlock("wrong-password")).rejects.toThrow();
});
expect(saveMasterKey).not.toHaveBeenCalled();
expect(useKeyStore.getState().masterKey).toBeNull();
});
});
+10 -17
View File
@@ -57,6 +57,7 @@ export const useAuth = () => {
} }
try { try {
// try session refresh
const { data: refreshData } = await publicApi.post(endpoints.REFRESH); const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
const { data: userData } = await api.get(endpoints.ME, { const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${refreshData.access}` }, headers: { Authorization: `Bearer ${refreshData.access}` },
@@ -70,24 +71,16 @@ export const useAuth = () => {
}, [setMasterKey]); }, [setMasterKey]);
const unlock = async (password: string) => { const unlock = async (password: string) => {
if (!user) { if (!user) return;
await logout();
return;
}
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle( try {
password, const { masterKey } = await CryptoUtils.deriveKeyBundle(
user.email, password,
); user.email,
);
// Validate password by calling login endpoint await saveMasterKey(masterKey);
await api.post(endpoints.LOGIN, { setMasterKey(masterKey);
email: user.email, } catch {}
password: authHash,
});
await saveMasterKey(masterKey);
setMasterKey(masterKey);
}; };
return { return {
+2 -44
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture"; import { mockUser } from "../../test/fixtures/user.fixture";
@@ -7,19 +7,6 @@ import { useAuthStore } from "../store/useAuthStore";
import Drawer from "./Drawer"; import Drawer from "./Drawer";
vi.mock("../hooks/useLetters"); vi.mock("../hooks/useLetters");
vi.mock("../components/drawer/WelcomeLetterOverlay", () => ({
WelcomeLetterOverlay: ({ onComplete }: any) => (
<div data-testid="welcome-letter-overlay">
<button
type="button"
data-testid="overlay-exit-button"
onClick={onComplete}
>
I'll see you
</button>
</div>
),
}));
describe("Drawer Page", () => { describe("Drawer Page", () => {
beforeEach(() => { beforeEach(() => {
@@ -88,36 +75,7 @@ describe("Drawer Page", () => {
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByText(/You've been away a while./i)).toBeInTheDocument(); expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
}); });
it("renders the welcome letter when firstTime state is present", () => {
render(
<MemoryRouter
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
>
<Drawer />
</MemoryRouter>,
);
expect(screen.getByTestId("welcome-letter-overlay")).toBeInTheDocument();
});
it("renders the drawer content when the letter is closed", () => {
render(
<MemoryRouter
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
>
<Drawer />
</MemoryRouter>,
);
const completeButton = screen.getByTestId("overlay-exit-button");
fireEvent.click(completeButton);
expect(
screen.queryByTestId("welcome-letter-overlay"),
).not.toBeInTheDocument();
});
}); });
+9 -26
View File
@@ -1,10 +1,9 @@
import { FeatherIcon } from "@phosphor-icons/react"; import { FeatherIcon } from "@phosphor-icons/react";
import { useState } from "react"; import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DrawerSection } from "../components/drawer/DrawerSection.tsx"; import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
import { LetterItem } from "../components/drawer/LetterItem.tsx"; import { LetterItem } from "../components/drawer/LetterItem.tsx";
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx"; import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan.tsx"; import Saajan from "../components/ui/Saajan.tsx";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
@@ -16,14 +15,10 @@ import {
} from "../utils/dateFormat.ts"; } from "../utils/dateFormat.ts";
export default function Drawer() { export default function Drawer() {
const { user, logout } = useAuth(); const { user, logout, unlock } = useAuth();
const [openSection, setOpenSection] = useState<string | null>(null); const [openSection, setOpenSection] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [showWelcomeLetter, setShowWelcomeLetter] = useState(
!!location.state?.firstTime,
);
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters(); const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
if (!user) return null; if (!user) return null;
@@ -35,17 +30,7 @@ export default function Drawer() {
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors"> <div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig pointer-events-none z-0" />
{showWelcomeLetter && ( {isAuthRequired && <PasskeyModal onUnlock={unlock} />}
<WelcomeLetterOverlay
userName={user.full_name}
onComplete={() => {
setShowWelcomeLetter(false);
navigate(location.pathname, { replace: true, state: {} });
}}
/>
)}
{isAuthRequired && <PasskeyModal />}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500"> <header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
<Logo /> <Logo />
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2"> <div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
@@ -181,14 +166,12 @@ export default function Drawer() {
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10"> <footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
For your unsaid. For your unsaid.
</footer> </footer>
{!showWelcomeLetter && ( <div className="absolute bottom-0 z-50 font-sans">
<div className="absolute bottom-0 z-50 font-sans"> <Saajan
<Saajan message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`} position="top"
position="top" />
/> </div>
</div>
)}
</div> </div>
); );
} }
+7 -3
View File
@@ -34,6 +34,12 @@ import { CryptoUtils } from "../utils/crypto";
import { formatRelativeDate } from "../utils/dateFormat"; import { formatRelativeDate } from "../utils/dateFormat";
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic"; import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/cutive-mono/index.css";
import "@fontsource/architects-daughter/index.css";
import "@fontsource/redacted-script/index.css";
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR"; type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
const OVERLAY_FADE_MS = 250; const OVERLAY_FADE_MS = 250;
@@ -262,9 +268,7 @@ export default function Editor() {
await cryptoUtils.initialize(); await cryptoUtils.initialize();
try { try {
const canvasData = (await canvasRef.current?.getData()) || { const canvasData = canvasRef.current?.getData() || { objects: [] };
objects: [],
};
const canvasImages = canvasRef.current?.getImages() || []; const canvasImages = canvasRef.current?.getImages() || [];
const { encryptedImageFiles, encryptedCanvasData } = const { encryptedImageFiles, encryptedCanvasData } =
+311 -312
View File
@@ -1,9 +1,9 @@
import { InfoIcon } from "@phosphor-icons/react"; import { InfoIcon } from "@phosphor-icons/react";
import { ReactLenis } from "lenis/react";
import { import {
motion, motion,
useMotionValueEvent, useMotionValueEvent,
useScroll, useScroll,
useSpring,
useTransform, useTransform,
} from "motion/react"; } from "motion/react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -16,9 +16,14 @@ import { formatDate } from "../utils/dateFormat.ts";
export default function Home() { export default function Home() {
const sectionContainer1 = useRef<HTMLDivElement>(null); const sectionContainer1 = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ const { scrollYProgress: section1ScrollProgress } = useScroll({
target: sectionContainer1, target: sectionContainer1,
}); });
const smoothProgress1 = useSpring(section1ScrollProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true); const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
const [flapOpen, setFlapOpen] = useState(false); const [flapOpen, setFlapOpen] = useState(false);
const [recipient, setRecipient] = useState("someone dear"); const [recipient, setRecipient] = useState("someone dear");
@@ -26,7 +31,7 @@ export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => { useMotionValueEvent(section1ScrollProgress, "change", (latestScrollValue) => {
if (latestScrollValue > 0.54) { if (latestScrollValue > 0.54) {
setFlapOpen(false); setFlapOpen(false);
} else { } else {
@@ -50,348 +55,342 @@ export default function Home() {
}); });
return ( return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}> <section
<section ref={sectionContainer1}
ref={sectionContainer1} className="relative w-full h-[850vh] bg-base-100 font-serif"
className="relative w-full h-[850vh] bg-base-100 font-serif" >
> <div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden"> {/* Intro */}
{/* Intro */} <motion.div
<motion.div className="absolute flex flex-col items-center justify-center pointer-events-none"
className="absolute flex flex-col items-center justify-center pointer-events-none" style={{
style={{ opacity: useTransform(smoothProgress1, [0, 0.12, 1], [1, 0, 0]),
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]), scale: useTransform(smoothProgress1, [0, 0.12], [1, 10]),
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]), }}
}} >
> <h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6"> You've been carrying something
You've been carrying something </h1>
</h1> <h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse"> unsaid
unsaid </h2>
</h2> </motion.div>
</motion.div>
<motion.div
className="absolute text-center"
style={{
opacity: useTransform(smoothProgress1, [0, 0.15, 0.2], [0, 1, 0]),
y: useTransform(smoothProgress1, [0, 0.15, 0.2], [40, 0, -40]),
scale: useTransform(smoothProgress1, [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(
smoothProgress1,
[0.18, 0.25, 0.3],
[0, 1, 0],
),
y: useTransform(smoothProgress1, [0.18, 0.25, 0.3], [20, 0, -20]),
}}
transition={{ delay: 4 }}
>
<Logo scale={2} />
<motion.div <motion.div
className="absolute text-center" className="mt-6 text-4xl md:text-6xl text-base-content/60 "
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={{ style={{
opacity: useTransform( opacity: useTransform(
scrollYProgress, smoothProgress1,
[0.18, 0.25, 0.3], [0.22, 0.25, 0.35, 0.4],
[0, 1, 0], [0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
), ),
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
}} }}
transition={{ delay: 4 }}
> >
<Logo scale={2} /> is a{" "}
<motion.div <span className="font-display text-primary font-extralight">
className="mt-6 text-4xl md:text-6xl text-base-content/60 " safe space
style={{ </span>
opacity: useTransform( ,<br />
scrollYProgress, <motion.span
[0.22, 0.25, 0.35, 0.4], className="opacity-0 text-3xl md:text-5xl"
[0, 1, 1, 0], transition={{ delay: 3 }}
), whileInView={{ opacity: 1 }}
y: useTransform( viewport={{ once: false, amount: 0.3 }}
scrollYProgress,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
),
}}
> >
is a{" "} where you can
<span className="font-display text-primary font-extralight"> </motion.span>
safe space
</span>
,<br />
<motion.span
className="opacity-0 text-3xl md:text-5xl"
transition={{ delay: 3 }}
whileInView={{ opacity: 1 }}
viewport={{ once: false, amount: 0.3 }}
>
where you can
</motion.span>
</motion.div>
</motion.div> </motion.div>
</motion.div>
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20"> <div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
<motion.h2 <motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.3, 0.35, 0.4, 0.45],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[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(
smoothProgress1,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[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-secondary font-display italic font-extralight">
secure
</span>{" "}
and{" "}
<span className="text-secondary font-display font-extralight italic">
private
</span>
.
</motion.h2>
{/* Send / vault */}
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[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={{ style={{
opacity: useTransform( color: useTransform(
scrollYProgress, smoothProgress1,
[0.3, 0.35, 0.4, 0.45], [0.67, 1],
[0, 1, 1, 0], ["var(--color-accent)", "var(--color-neutral)"],
),
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{" "} someone dear
<span className="font-display text-primary font-extralight"> </motion.span>
letters <motion.span
</span>
.
</motion.h2>
{/* Seal */}
<motion.h2
style={{ style={{
opacity: useTransform( opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
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-secondary font-display italic font-extralight">
secure
</span>{" "}
and{" "}
<span className="text-secondary font-display font-extralight 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 <motion.span
className="font-display text-accent" className="font-display text-accent"
style={{ style={{
color: useTransform( color: useTransform(
scrollYProgress, smoothProgress1,
[0.67, 1], [0.67, 1],
["var(--color-accent)", "var(--color-neutral)"], ["var(--color-accent)", "var(--color-neutral)"],
), ),
}} }}
> >
someone dear {" "}
or{" "}
</motion.span> </motion.span>
<motion.span <span className="font-display text-success">
style={{ yourself in the future
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]), </span>
}} .
> </motion.span>
<motion.span </motion.h2>
className="font-display text-accent" {/* Burn */}
style={{ <motion.h2
color: useTransform( style={{
scrollYProgress, opacity: useTransform(
[0.67, 1], smoothProgress1,
["var(--color-accent)", "var(--color-neutral)"], [0.75, 0.8, 0.85, 0.9],
), [0, 1, 1, 0],
}} ),
> y: useTransform(
{" "} smoothProgress1,
or{" "} [0.75, 0.8, 0.85, 0.9],
</motion.span> [40, 0, 0, -40],
<span className="font-display text-success"> ),
yourself in the future }}
</span> className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
. >
</motion.span> and even <span className="font-display text-error">burn it</span> to
</motion.h2> release the burden.
{/* Burn */} </motion.h2>
<motion.h2 {/* Outro */}
style={{ <motion.h2
opacity: useTransform( className={
scrollYProgress, "italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
[0.75, 0.8, 0.85, 0.9], }
[0, 1, 1, 0], style={{
), opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
y: useTransform( y: useTransform(smoothProgress1, [0.9, 1], [80, 0]),
scrollYProgress, }}
[0.75, 0.8, 0.85, 0.9], >
[40, 0, 0, -40], You've been carrying it long enough.
), </motion.h2>
}} {/* CTA */}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight" <motion.div
> className={
and even <span className="font-display text-error">burn it</span>{" "} "z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
to release the burden. }
</motion.h2> style={{
{/* Outro */} opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
<motion.h2 y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
display: useTransform(
smoothProgress1,
[0.96, 1],
["none", "flex"],
),
}}
>
<button
className={ className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight" "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"
} }
style={{ type={"button"}
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]), onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
}}
> >
You've been carrying it long enough. <InfoIcon className={"text-primary"} />
</motion.h2> Tell me More
{/* CTA */} </button>
<motion.div <button
className={ className={
"z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center" "md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
} }
style={{ type={"button"}
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]), onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
display: useTransform(
scrollYProgress,
[0.96, 1],
["none", "flex"],
),
}}
> >
<button I'm ready
className={ </button>
"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" </motion.div>
}
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 hover: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="/screenshots/letter.webp" />
</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> </div>
</section>
</ReactLenis> <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(
smoothProgress1,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(smoothProgress1, [0.3, 0.45, 0.5], [300, 0, 200]),
scale: useTransform(
smoothProgress1,
[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="/screenshots/letter.webp" />
</div>
</div>
</motion.div>
{/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
smoothProgress1,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(smoothProgress1, [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(
smoothProgress1,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(smoothProgress1, [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(
smoothProgress1,
[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(smoothProgress1, [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>
); );
} }
+2 -2
View File
@@ -7,7 +7,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { z } from "zod"; import { z } from "zod";
import { api, publicApi } from "../api/apiClient"; import { api, publicApi } from "../api/apiClient";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import WelcomeModal from "../components/login/WelcomeModal"; import WelcomeModal from "../components/login/WelcomeModal.tsx";
import FormField from "../components/ui/FormField"; import FormField from "../components/ui/FormField";
import Saajan from "../components/ui/Saajan"; import Saajan from "../components/ui/Saajan";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
@@ -64,7 +64,7 @@ export default function Login() {
await setAuthStore(authData.access, userData, masterKey); await setAuthStore(authData.access, userData, masterKey);
navigate(nextRoute, { replace: true, state: location.state }); navigate(nextRoute, { replace: true });
} catch (err) { } catch (err) {
let message = let message =
"Sorry, we're experiencing technical issues.\nPlease try again later."; "Sorry, we're experiencing technical issues.\nPlease try again later.";
+2 -1
View File
@@ -56,7 +56,8 @@ done
export PIKU_ENV_FILE="$ENV_FILE" export PIKU_ENV_FILE="$ENV_FILE"
# NOTE: When running in Gitea Actions (within container), We must ponint DB and mail to the internal docker host instead. # NOTE: When running in Gitea Actions (within container), 127.0.0.1 is the actions instance.
# We must route DB and mail traffic to the docker host instead.
if [ "$GITEA_ACTIONS" = "true" ]; then if [ "$GITEA_ACTIONS" = "true" ]; then
sudo apt-get update && sudo apt-get install -y iproute2 sudo apt-get update && sudo apt-get install -y iproute2
# Sample: "default via <internal docker host IP> dev <network interface> proto dhcp src <IP> metric 100" # Sample: "default via <internal docker host IP> dev <network interface> proto dhcp src <IP> metric 100"