Compare commits
4 Commits
df56754c55
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d0ab979f5 | |||
| 8449377b6d | |||
| 3b5f140d21 | |||
| 740753cb33 |
+38
-23
@@ -19,11 +19,12 @@ jobs:
|
||||
mkcert -install
|
||||
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
||||
|
||||
- name: Cache certificates
|
||||
uses: actions/cache/save@v4
|
||||
- name: Upload certificates
|
||||
uses: christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
retention-days: 1
|
||||
|
||||
frontend:
|
||||
name: Frontend CI
|
||||
@@ -37,10 +38,10 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Restore certificates
|
||||
uses: actions/cache/restore@v4
|
||||
uses: christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -61,15 +62,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup-environment
|
||||
services:
|
||||
postgres:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: piku
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_DB: piku__test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: password123
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
- 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
|
||||
@@ -82,18 +83,28 @@ jobs:
|
||||
cache-dependency-glob: "backend/uv.lock"
|
||||
|
||||
- name: Restore certificates
|
||||
uses: actions/cache/restore@v4
|
||||
uses: christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
|
||||
- name: Setup Environment
|
||||
- name: Setup & Test
|
||||
run: |
|
||||
cp ../.env.example ../.env
|
||||
uv sync
|
||||
|
||||
- name: Lint & Test
|
||||
run: |
|
||||
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
|
||||
|
||||
@@ -101,23 +112,27 @@ jobs:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup-environment
|
||||
# Skipping on Gitea pushes until cache server is configured
|
||||
if: github.server_url == 'https://github.com' || github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Certificates
|
||||
uses: actions/cache/restore@v4
|
||||
uses: christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
|
||||
- name: Setup Tools
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
|
||||
- name: Cache Playwright
|
||||
id: playwright-cache
|
||||
# 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
|
||||
if: github.server_url == 'https://github.com'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
@@ -140,7 +155,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -208,7 +208,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
// Verify it opens the Reader without a hash
|
||||
logger.info(">> [Drawer] Verifying Reader page...");
|
||||
// Give it a bit more time for decryption
|
||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
|
||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
|
||||
// Reveal and check decrypted content in Reader
|
||||
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
||||
timeout: 10000,
|
||||
|
||||
@@ -54,6 +54,34 @@ async function registerAndLogin(
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/drawer/);
|
||||
await handleWelcomeLetter(page);
|
||||
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 };
|
||||
|
||||
@@ -15,7 +15,7 @@ const baseUrl = getBaseUrl(
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
timeout: 60000,
|
||||
timeout: 80000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -1,11 +1,5 @@
|
||||
import { lazy, Suspense, useEffect, useRef } from "react";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
ScrollRestoration,
|
||||
} from "react-router-dom";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||
import SplashScreen from "./components/SplashScreen";
|
||||
import { ROUTES } from "./config/routes";
|
||||
@@ -37,7 +31,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<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-10 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-50 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
|
||||
<Suspense fallback={<SplashScreen />}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.HOME} element={<Home />} />
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { LockKeyIcon } from "@phosphor-icons/react";
|
||||
import { HourglassSimpleMediumIcon } from "@phosphor-icons/react";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { Modal } from "../ui/Modal";
|
||||
|
||||
interface PasskeyModalProps {
|
||||
onUnlock: (password: string) => Promise<void>;
|
||||
}
|
||||
export function PasskeyModal() {
|
||||
const { unlock } = useAuth();
|
||||
|
||||
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
||||
return (
|
||||
<Modal isOpen={true}>
|
||||
<LockKeyIcon
|
||||
<HourglassSimpleMediumIcon
|
||||
size={48}
|
||||
className="text-primary mx-auto mb-8 animate-pulse"
|
||||
weight="duotone"
|
||||
/>
|
||||
<h3 className="font-bold text-lg font-display text-primary">
|
||||
Authentication Required
|
||||
You've been away a while.
|
||||
</h3>
|
||||
<p className="py-4 font-sans">
|
||||
We need your passkey to open your letters
|
||||
Your letters are still there. Just need the key once more.
|
||||
</p>
|
||||
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
||||
<p className="text-xs text-neutral-content/30 font-mono italic">
|
||||
Your passkey is used to decrypt your data locally.
|
||||
Nothing was lost.
|
||||
</p>
|
||||
<div className="modal-action items-center gap-4">
|
||||
<form
|
||||
@@ -30,7 +30,7 @@ export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const password = formData.get("password") as string;
|
||||
if (!password) return;
|
||||
await onUnlock(password);
|
||||
await unlock(password);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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,6 +2,12 @@ import * as fabric from "fabric";
|
||||
import type * as React 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 BASE_WIDTH = 680;
|
||||
const DEFAULT_LOGICAL_HEIGHT = 900;
|
||||
@@ -184,9 +190,7 @@ export function ComposeCanvas({
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fill: DEFAULT_FONT_COLOR,
|
||||
lineHeight: 1.5,
|
||||
// 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,
|
||||
splitByGrapheme: false,
|
||||
lockMovementX: true,
|
||||
lockMovementY: true,
|
||||
lockScalingX: true,
|
||||
@@ -220,6 +224,16 @@ 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();
|
||||
|
||||
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
||||
|
||||
@@ -34,8 +34,7 @@ export default function WelcomeModal({
|
||||
className="inline text-primary"
|
||||
weight="fill"
|
||||
/>
|
||||
<div className="divider my-0"></div>
|
||||
<br />
|
||||
<span className="divider my-0 block"></span>
|
||||
Everything you write here is sealed with your password,{" "}
|
||||
<span className="font-display text-success">cryptographically</span>
|
||||
, before it leaves your hands.
|
||||
@@ -44,11 +43,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">
|
||||
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
|
||||
<p className="text-sm font-medium text-primary-content">
|
||||
<div className="text-sm font-medium text-primary-content">
|
||||
If you ever happen to forget your password, your letters are lost
|
||||
to time, forever.
|
||||
<br />
|
||||
<span className="font-bold mt-2">
|
||||
<span className="font-bold mt-2 block">
|
||||
I highly, highly recommend storing this password in your{" "}
|
||||
<a
|
||||
href="https://www.privacyguides.org/en/passwords/"
|
||||
@@ -60,7 +59,7 @@ export default function WelcomeModal({
|
||||
</a>{" "}
|
||||
or somewhere safe to remember it.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action w-full">
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import {
|
||||
clearMasterKey,
|
||||
loadMasterKey,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { useAuth } from "./useAuth";
|
||||
|
||||
vi.mock("../utils/keystore");
|
||||
vi.mock("../utils/crypto");
|
||||
|
||||
const VITE_API_URL = "http://piku-server";
|
||||
|
||||
@@ -30,6 +32,11 @@ beforeEach(() => {
|
||||
isInitializing: true,
|
||||
});
|
||||
useKeyStore.setState({ masterKey: null });
|
||||
|
||||
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
|
||||
masterKey: mockMasterKey,
|
||||
authHash: "mock-hash",
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
@@ -201,3 +208,68 @@ describe("initialize", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,6 @@ export const useAuth = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// try session refresh
|
||||
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
|
||||
const { data: userData } = await api.get(endpoints.ME, {
|
||||
headers: { Authorization: `Bearer ${refreshData.access}` },
|
||||
@@ -71,16 +70,24 @@ export const useAuth = () => {
|
||||
}, [setMasterKey]);
|
||||
|
||||
const unlock = async (password: string) => {
|
||||
if (!user) return;
|
||||
if (!user) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { masterKey } = await CryptoUtils.deriveKeyBundle(
|
||||
password,
|
||||
user.email,
|
||||
);
|
||||
await saveMasterKey(masterKey);
|
||||
setMasterKey(masterKey);
|
||||
} catch {}
|
||||
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
||||
password,
|
||||
user.email,
|
||||
);
|
||||
|
||||
// Validate password by calling login endpoint
|
||||
await api.post(endpoints.LOGIN, {
|
||||
email: user.email,
|
||||
password: authHash,
|
||||
});
|
||||
|
||||
await saveMasterKey(masterKey);
|
||||
setMasterKey(masterKey);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
@@ -7,6 +7,19 @@ import { useAuthStore } from "../store/useAuthStore";
|
||||
import Drawer from "./Drawer";
|
||||
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
@@ -75,7 +88,36 @@ describe("Drawer Page", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/You've been away a while./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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FeatherIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
|
||||
import { LetterItem } from "../components/drawer/LetterItem.tsx";
|
||||
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
|
||||
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx";
|
||||
import Logo from "../components/Logo";
|
||||
import Saajan from "../components/ui/Saajan.tsx";
|
||||
import { PATHS } from "../config/routes";
|
||||
@@ -15,10 +16,14 @@ import {
|
||||
} from "../utils/dateFormat.ts";
|
||||
|
||||
export default function Drawer() {
|
||||
const { user, logout, unlock } = useAuth();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showWelcomeLetter, setShowWelcomeLetter] = useState(
|
||||
!!location.state?.firstTime,
|
||||
);
|
||||
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
|
||||
|
||||
if (!user) return null;
|
||||
@@ -30,7 +35,17 @@ 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="fixed inset-0 bg-vig pointer-events-none z-0" />
|
||||
|
||||
{isAuthRequired && <PasskeyModal onUnlock={unlock} />}
|
||||
{showWelcomeLetter && (
|
||||
<WelcomeLetterOverlay
|
||||
userName={user.full_name}
|
||||
onComplete={() => {
|
||||
setShowWelcomeLetter(false);
|
||||
navigate(location.pathname, { replace: true, state: {} });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAuthRequired && <PasskeyModal />}
|
||||
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<Logo />
|
||||
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
||||
@@ -166,12 +181,14 @@ export default function Drawer() {
|
||||
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
||||
For your unsaid.
|
||||
</footer>
|
||||
<div className="absolute bottom-0 z-50 font-sans">
|
||||
<Saajan
|
||||
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
||||
position="top"
|
||||
/>
|
||||
</div>
|
||||
{!showWelcomeLetter && (
|
||||
<div className="absolute bottom-0 z-50 font-sans">
|
||||
<Saajan
|
||||
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
||||
position="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,6 @@ import { CryptoUtils } from "../utils/crypto";
|
||||
import { formatRelativeDate } from "../utils/dateFormat";
|
||||
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";
|
||||
|
||||
const OVERLAY_FADE_MS = 250;
|
||||
@@ -268,7 +262,9 @@ export default function Editor() {
|
||||
await cryptoUtils.initialize();
|
||||
|
||||
try {
|
||||
const canvasData = canvasRef.current?.getData() || { objects: [] };
|
||||
const canvasData = (await canvasRef.current?.getData()) || {
|
||||
objects: [],
|
||||
};
|
||||
const canvasImages = canvasRef.current?.getImages() || [];
|
||||
|
||||
const { encryptedImageFiles, encryptedCanvasData } =
|
||||
|
||||
+310
-309
@@ -1,9 +1,9 @@
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { ReactLenis } from "lenis/react";
|
||||
import {
|
||||
motion,
|
||||
useMotionValueEvent,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { useRef, useState } from "react";
|
||||
@@ -16,14 +16,9 @@ import { formatDate } from "../utils/dateFormat.ts";
|
||||
|
||||
export default function Home() {
|
||||
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress: section1ScrollProgress } = useScroll({
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: sectionContainer1,
|
||||
});
|
||||
const smoothProgress1 = useSpring(section1ScrollProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
||||
const [flapOpen, setFlapOpen] = useState(false);
|
||||
const [recipient, setRecipient] = useState("someone dear");
|
||||
@@ -31,7 +26,7 @@ export default function Home() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useMotionValueEvent(section1ScrollProgress, "change", (latestScrollValue) => {
|
||||
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
|
||||
if (latestScrollValue > 0.54) {
|
||||
setFlapOpen(false);
|
||||
} else {
|
||||
@@ -55,342 +50,348 @@ export default function Home() {
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionContainer1}
|
||||
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">
|
||||
{/* Intro */}
|
||||
<motion.div
|
||||
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
||||
style={{
|
||||
opacity: useTransform(smoothProgress1, [0, 0.12, 1], [1, 0, 0]),
|
||||
scale: useTransform(smoothProgress1, [0, 0.12], [1, 10]),
|
||||
}}
|
||||
>
|
||||
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
|
||||
You've been carrying something
|
||||
</h1>
|
||||
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
|
||||
unsaid
|
||||
</h2>
|
||||
</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} />
|
||||
<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"
|
||||
>
|
||||
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
||||
{/* Intro */}
|
||||
<motion.div
|
||||
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
|
||||
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
smoothProgress1,
|
||||
[0.22, 0.25, 0.35, 0.4],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
smoothProgress1,
|
||||
[0.25, 0.3, 0.35, 0.4],
|
||||
[20, 0, 0, -20],
|
||||
),
|
||||
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
|
||||
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
|
||||
}}
|
||||
>
|
||||
is a{" "}
|
||||
<span className="font-display text-primary font-extralight">
|
||||
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>
|
||||
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
|
||||
You've been carrying something
|
||||
</h1>
|
||||
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
|
||||
unsaid
|
||||
</h2>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
||||
<motion.h2
|
||||
<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(
|
||||
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],
|
||||
scrollYProgress,
|
||||
[0.18, 0.25, 0.3],
|
||||
[0, 1, 0],
|
||||
),
|
||||
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
|
||||
}}
|
||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
transition={{ delay: 4 }}
|
||||
>
|
||||
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"
|
||||
<Logo scale={2} />
|
||||
<motion.div
|
||||
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
|
||||
style={{
|
||||
color: useTransform(
|
||||
smoothProgress1,
|
||||
[0.67, 1],
|
||||
["var(--color-accent)", "var(--color-neutral)"],
|
||||
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],
|
||||
),
|
||||
}}
|
||||
>
|
||||
someone dear
|
||||
</motion.span>
|
||||
<motion.span
|
||||
is a{" "}
|
||||
<span className="font-display text-primary font-extralight">
|
||||
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>
|
||||
|
||||
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
|
||||
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-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
|
||||
className="font-display text-accent"
|
||||
style={{
|
||||
color: useTransform(
|
||||
smoothProgress1,
|
||||
scrollYProgress,
|
||||
[0.67, 1],
|
||||
["var(--color-accent)", "var(--color-neutral)"],
|
||||
),
|
||||
}}
|
||||
>
|
||||
{" "}
|
||||
or{" "}
|
||||
someone dear
|
||||
</motion.span>
|
||||
<span className="font-display text-success">
|
||||
yourself in the future
|
||||
</span>
|
||||
.
|
||||
</motion.span>
|
||||
</motion.h2>
|
||||
{/* Burn */}
|
||||
<motion.h2
|
||||
style={{
|
||||
opacity: useTransform(
|
||||
smoothProgress1,
|
||||
[0.75, 0.8, 0.85, 0.9],
|
||||
[0, 1, 1, 0],
|
||||
),
|
||||
y: useTransform(
|
||||
smoothProgress1,
|
||||
[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"
|
||||
}
|
||||
style={{
|
||||
opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
|
||||
y: useTransform(smoothProgress1, [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-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
||||
}
|
||||
style={{
|
||||
opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
|
||||
y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
|
||||
display: useTransform(
|
||||
smoothProgress1,
|
||||
[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 })}
|
||||
<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"
|
||||
>
|
||||
<InfoIcon className={"text-primary"} />
|
||||
Tell me More
|
||||
</button>
|
||||
<button
|
||||
and even <span className="font-display text-error">burn it</span>{" "}
|
||||
to release the burden.
|
||||
</motion.h2>
|
||||
{/* Outro */}
|
||||
<motion.h2
|
||||
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"
|
||||
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||
}
|
||||
type={"button"}
|
||||
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
||||
style={{
|
||||
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
|
||||
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
|
||||
}}
|
||||
>
|
||||
I'm ready
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
You've been carrying it long enough.
|
||||
</motion.h2>
|
||||
{/* CTA */}
|
||||
<motion.div
|
||||
className={
|
||||
"z-100 absolute -bottom-12 md:bottom-0 font-display 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 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(
|
||||
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 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>
|
||||
</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>
|
||||
{/* 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]),
|
||||
}}
|
||||
/>
|
||||
</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 className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</ReactLenis>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import { api, publicApi } from "../api/apiClient";
|
||||
import Logo from "../components/Logo";
|
||||
import WelcomeModal from "../components/login/WelcomeModal.tsx";
|
||||
import WelcomeModal from "../components/login/WelcomeModal";
|
||||
import FormField from "../components/ui/FormField";
|
||||
import Saajan from "../components/ui/Saajan";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
@@ -64,7 +64,7 @@ export default function Login() {
|
||||
|
||||
await setAuthStore(authData.access, userData, masterKey);
|
||||
|
||||
navigate(nextRoute, { replace: true });
|
||||
navigate(nextRoute, { replace: true, state: location.state });
|
||||
} catch (err) {
|
||||
let message =
|
||||
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
||||
|
||||
@@ -55,6 +55,18 @@ until $CONTAINER_BIN exec "$DB_NAME" pg_isready -U "${DB_USER:-test}" >/dev/null
|
||||
done
|
||||
|
||||
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.
|
||||
if [ "$GITEA_ACTIONS" = "true" ]; then
|
||||
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"
|
||||
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
||||
echo "Running on Gitea. Internal Docker host... $HOST_IP"
|
||||
|
||||
export DB_HOST=$HOST_IP
|
||||
export EMAIL_HOST=$HOST_IP
|
||||
fi
|
||||
|
||||
echo "Starting Backend..."
|
||||
mkdir -p ./tmp/logs
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user