22 Commits

Author SHA1 Message Date
me fff90902b5 refactor: fix whitespace indents in copy
CI / Generate Certificates (pull_request) Successful in 37s
CI / Frontend CI (pull_request) Successful in 1m8s
CI / Backend CI (pull_request) Successful in 1m8s
CI / E2E Tests (pull_request) Successful in 6m53s
2026-05-08 23:20:27 +05:30
me a3a56d4316 chore: replace empty jsx space braces with nbsp
CI / Generate Certificates (pull_request) Successful in 41s
CI / Frontend CI (pull_request) Successful in 1m7s
CI / Backend CI (pull_request) Successful in 1m8s
CI / E2E Tests (pull_request) Successful in 6m55s
2026-05-08 22:17:50 +05:30
me 2ba5d6964f test: replace role texts by testids
CI / Generate Certificates (pull_request) Successful in 38s
CI / Frontend CI (pull_request) Successful in 1m6s
CI / Backend CI (pull_request) Successful in 1m18s
CI / E2E Tests (pull_request) Successful in 6m49s
2026-05-08 21:21:30 +05:30
me ffe588c3ec fix: add fallback to Modal portal container 2026-05-08 21:19:54 +05:30
me a7cced71ee chore: update copy of register and login 2026-05-08 21:00:18 +05:30
me c6545a11b1 style: improve mobile responsiveness and a11y for WelcomeModal 2026-05-08 20:16:09 +05:30
me 2419b73b15 fix: update modal positioning to the viewport instead of parent container 2026-05-08 20:04:33 +05:30
me a599dbeb30 refactor: lint formatting and fixes (#6)
CI / Backend CI (push) Successful in 1m8s
CI / E2E Tests (push) Has been skipped
CI / Generate Certificates (push) Successful in 38s
CI / Frontend CI (push) Successful in 1m4s
![image.png](/attachments/182d430f-7b29-48f7-aad6-0aa3ed1708c4)

---------

Co-authored-by: me <ramvignesh-b@github.com>
Reviewed-on: #6
2026-05-08 06:13:25 +00:00
me 143b992391 style: revamp homepage and drawer
CI / Generate Certificates (push) Successful in 38s
CI / Frontend CI (push) Successful in 1m5s
CI / Backend CI (push) Successful in 1m8s
CI / E2E Tests (push) Has been skipped
Co-authored-by: me <ramvignesh-b@github.com>
Reviewed-on: #4
2026-05-08 03:16:16 +00:00
me d625cbb1fb test: use testids instead of text queries for unit tests (#5)
CI / Generate Certificates (push) Successful in 28s
CI / Frontend CI (push) Successful in 1m3s
CI / E2E Tests (push) Has been cancelled
CI / Backend CI (push) Has been cancelled
Co-authored-by: me <ramvignesh-b@github.com>
Reviewed-on: #5
2026-05-07 23:54:12 +00:00
me 7e53229308 refactor: rename tamil font family to Ink (because it resembles fountain pen)
CI / Generate Certificates (push) Successful in 37s
CI / Frontend CI (push) Successful in 1m7s
CI / Backend CI (push) Successful in 1m9s
CI / E2E Tests (push) Has been skipped
2026-05-07 04:49:28 +05:30
me ca352fa88b styling: icons makeover 2026-05-07 04:39:08 +05:30
me 7eb19788e7 style: refined blockquote styling
CI / Generate Certificates (push) Successful in 36s
CI / Frontend CI (push) Successful in 1m10s
CI / Backend CI (push) Successful in 1m9s
CI / E2E Tests (push) Has been skipped
2026-05-07 04:20:15 +05:30
me 3a56d9fd77 refactor: whitespace fixes
CI / E2E Tests (push) Has been skipped
CI / Generate Certificates (push) Successful in 34s
CI / Frontend CI (push) Successful in 1m15s
CI / Backend CI (push) Successful in 1m10s
2026-05-07 04:06:49 +05:30
me fe94047f18 feat: update About page design and content 2026-05-07 04:06:36 +05:30
me f5e1813ec3 chore: update about page content
CI / Generate Certificates (push) Successful in 1m39s
CI / Frontend CI (push) Successful in 1m11s
CI / Backend CI (push) Successful in 1m18s
CI / E2E Tests (push) Has been skipped
2026-05-07 02:17:26 +05:30
me 3ec8bb2226 feat: update logo typography styles 2026-05-07 02:16:55 +05:30
me ac2f541ebe refactor/optimize e2e test (#3)
CI / Frontend CI (push) Successful in 1m10s
CI / Backend CI (push) Successful in 1m9s
CI / E2E Tests (push) Has been skipped
CI / Generate Certificates (push) Successful in 36s
how fast i'll go 🏄‍♂️

---------

Co-authored-by: me <ramvignesh-b@github.com>
Reviewed-on: #3
2026-05-06 18:04:11 +00:00
me 8d0ab979f5 feat/welcome-letter integration (#2)
CI / Generate Certificates (push) Successful in 42s
CI / Frontend CI (push) Successful in 1m8s
CI / Backend CI (push) Successful in 1m7s
CI / E2E Tests (push) Has been skipped
Co-authored-by: me <ramvignesh-b@github.com>
Reviewed-on: #2
2026-05-06 16:46:53 +00:00
me 8449377b6d refactor: implement authentication flow using authHash in unlock hook and update PasskeyModal UI
CI / Generate Certificates (push) Successful in 1m52s
CI / Frontend CI (push) Successful in 1m13s
CI / Backend CI (push) Successful in 1m15s
CI / E2E Tests (push) Has been skipped
2026-05-06 13:45:30 +05:30
me 3b5f140d21 feat: use ReactLenis and simplify motion animations in Home page
CI / Generate Certificates (push) Successful in 1m40s
CI / Frontend CI (push) Successful in 1m7s
CI / Backend CI (push) Successful in 1m9s
CI / E2E Tests (push) Has been skipped
2026-05-05 21:43:47 +05:30
me 740753cb33 CI/gitiea ci compatibility (#1)
CI / Generate Certificates (push) Successful in 26s
CI / Frontend CI (push) Successful in 1m16s
CI / Backend CI (push) Successful in 1m9s
CI / E2E Tests (push) Has been skipped
Co-authored-by: me <ramvignesh-b@github.com>
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #1
2026-05-05 14:46:00 +00:00
65 changed files with 1889 additions and 1203 deletions
+38 -23
View File
@@ -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/
+41 -90
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import pino from "pino";
import { AuthHelper } from "./utils/auth";
import { revealEnvelope } from "./utils/envelope";
const logger = pino({
transport: {
@@ -22,20 +23,19 @@ test.describe("Letter Drafting (Real Backend)", () => {
await AuthHelper.registerAndLogin(page, email, name, password);
logger.info(">> [Draft] Navigating to Editor via UI...");
await page.getByRole("button", { name: /write something/i }).click();
await page.getByTestId("write-letter-btn").click();
logger.info(`>> [Draft] Current URL after click: ${page.url()}`);
// Wait for the recipient input to be present in the DOM
const recipientInput = page.locator("#recipient");
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
// Editor page
await expect(page.getByTestId("recipient-input")).toBeVisible();
const recipientInput = page.getByTestId("recipient-input");
const recipientName = "Dear Friend";
await recipientInput.fill(recipientName);
// Initial load: verify textarea value (populated by Fabric when focused)
const canvasInput = page.locator("textarea");
await canvasInput.waitFor({ state: "attached" });
await canvasInput.focus();
await expect(canvasInput).toHaveValue(/Take a deep breath/i);
@@ -46,10 +46,10 @@ test.describe("Letter Drafting (Real Backend)", () => {
await page.keyboard.press("Enter");
await page.keyboard.type("It should persist.");
logger.info(">> [Draft] Clicking Draft...");
await page.getByRole("button", { name: /draft/i }).click();
await page.getByTestId("draft-btn").click();
// Verify Success Modal/Alert
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
await expect(page.getByTestId("save-success-toast")).toBeVisible();
// Verify URL updated with a UUID
await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/);
@@ -61,24 +61,16 @@ test.describe("Letter Drafting (Real Backend)", () => {
await page.goto(savedUrl);
// Wait for initial load overlay to appear and then definitely disappear
await page
.getByText(/opening your draft/i)
.waitFor({ state: "visible", timeout: 2000 })
.catch(() => {});
await expect(page.getByText(/opening your draft/i)).toBeHidden({
timeout: 10000,
});
await expect(page.getByTestId("opening-draft-overlay")).toBeHidden();
// Check recipient
await expect(page.locator("#recipient")).toHaveValue(recipientName);
await expect(page.getByTestId("recipient-input")).toHaveValue(recipientName);
// Check canvas content
// We wait for the content to appear in the textarea.
// toHaveValue will poll until it matches or timeouts.
await canvasInput.focus();
await expect(canvasInput).toHaveValue(/This is a secret draft/i, {
timeout: 10000,
});
await expect(canvasInput).toHaveValue(/This is a secret draft/i);
await expect(canvasInput).toHaveValue(/It should persist/i);
});
@@ -92,10 +84,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
await AuthHelper.registerAndLogin(page, email, name, password);
logger.info(">> [Seal] Navigating to Editor via UI...");
await page.locator("#write-letter-btn").click();
await page.getByTestId("write-letter-btn").click();
const recipientInput = page.locator("#recipient");
await recipientInput.waitFor({ state: "visible", timeout: 10000 });
const recipientInput = page.getByTestId("recipient-input");
await recipientInput.fill("A Secret Guest");
const canvasInput = page.locator("textarea");
@@ -104,55 +95,41 @@ test.describe("Letter Drafting (Real Backend)", () => {
// Click Seal (open menu, then confirm)
logger.info(">> [Seal] Clicking Seal...");
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
await page.getByTestId("seal-trigger-btn").click();
await page.getByTestId("seal-confirm-btn").click();
// Should show sealed confirmation modal
logger.info(">> [Seal] Verifying sealed modal...");
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
// Navigate to Reader via "View letter"
await page.getByRole("button", { name: /view letter/i }).click();
await page.getByTestId("view-letter-btn").click();
// Should be on Reader URL
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/);
// Open the envelope to reveal the letter
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
timeout: 10000,
});
// Flip the envelope to show the seal
await page.locator("#env-front").click();
await page.waitForTimeout(2500); // Wait for flip transition
await page.getByAltText("Seal").click();
await page.waitForTimeout(1500);
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
// Flip the envelope to show the seal and reveal the letter
await revealEnvelope(page);
await expect(page.getByTestId("envelope-letter")).toBeHidden();
// Share on demand
logger.info(">> [Seal] Clicking Share button in Reader...");
await page.locator("#share-letter-btn").click();
await page.getByTestId("share-letter-btn").click();
// Verify share modal with a valid link
await expect(page.getByText(/send this letter/i)).toBeVisible();
await expect(page.getByTestId("share-letter-modal")).toBeVisible();
const linkInput = page.locator("#share-link-input");
const linkValue = await linkInput.inputValue();
expect(linkValue).toContain("/read/");
expect(linkValue).toContain("#");
logger.info(`>> [Seal] Sharing link: ${linkValue}`);
await expect(page.getByRole("button", { name: /copy/i })).toBeVisible();
await page.getByRole("button", { name: /close/i }).click();
await expect(page.getByText(/send this letter/i)).toBeHidden();
await expect(page.getByTestId("copy-link-btn")).toBeVisible();
// Assuming Close button in ShareModal might need a testid too, but for now let's use text if unique or add testid
await page.getByTestId("modal-close-btn").click();
await expect(page.getByTestId("share-letter-modal")).toBeHidden();
});
test("should allow author to access sealed letter from drawer without sharing key", async ({
@@ -167,10 +144,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
await AuthHelper.registerAndLogin(page, email, name, password);
logger.info(">> [Drawer] Creating and sealing a letter...");
await page.getByRole("button", { name: /write something/i }).click();
await page.getByTestId("write-letter-btn").click();
const recipientInput = page.locator("#recipient");
await recipientInput.waitFor({ state: "visible" });
const recipientInput = page.getByTestId("recipient-input");
await recipientInput.fill(recipientName);
const canvasInput = page.locator("textarea");
@@ -178,59 +154,34 @@ test.describe("Letter Drafting (Real Backend)", () => {
await canvasInput.fill(letterContent);
// Click Seal (open menu, then confirm)
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
await page.getByTestId("seal-trigger-btn").click();
await page.getByTestId("seal-confirm-btn").click();
// Sealed modal should appear — click "Keep it" to go to Drawer
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: /keep it to myself/i }).click();
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
await page.getByTestId("keep-it-btn").click();
// Open "Kept" section - search for the section with id='kept' and click its toggle button
logger.info(">> [Drawer] Opening Kept section...");
const keptSection = page.locator("#kept");
await keptSection.getByRole("button", { name: /kept/i }).click();
await page.getByTestId("drawer-section-kept").click();
// Find the sealed letter in the drawer by recipient name and click it
logger.info(">> [Drawer] Clicking sealed letter in drawer...");
const sealedItem = page
.getByRole("button", { name: new RegExp(recipientName, "i") })
.getByTestId(/^letter-item-/)
.filter({ hasText: recipientName })
.first();
await sealedItem.click();
// 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}$/);
// Reveal and check decrypted content in Reader
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
timeout: 10000,
});
// Check recipient on the front of the envelope
await expect(page.getByText(new RegExp(recipientName, "i"))).toBeVisible();
// Flip the envelope to the back
await page.getByText(new RegExp(recipientName, "i")).click();
// Wait for flip transition (2s)
await page.waitForTimeout(2500);
// Reveal the letter: click seal then click letter
await page.getByAltText("Seal").click();
// Wait for flap transition
await page.waitForTimeout(1500);
// Click the letter to pull it out
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
// Wait for reveal transition
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
// Flip the envelope and reveal the letter
await revealEnvelope(page);
await expect(page.getByTestId("envelope-letter")).toBeHidden();
// Also check if we are redirected to the Reader if we manually go to the Editor URL
const readerUrl = page.url();
+15 -14
View File
@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
import pino from "pino";
import { MailpitHelper } from "./mailpit";
import { handleWelcomeLetter } from "./envelope";
const logger = pino({
transport: {
@@ -23,11 +24,11 @@ async function registerAndLogin(
// Register the User
logger.info(`[Auth] Registering user: ${email}`);
await page.goto("/onboard");
await page.getByLabel(/pen name/i).fill(fullName);
await page.getByLabel("Email", { exact: true }).fill(email);
await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByLabel(/confirm password/i).fill(password);
await page.getByRole("button", { name: /^register$/i }).click();
await page.getByTestId("pen-name-input").fill(fullName);
await page.getByTestId("email-input").fill(email);
await page.getByTestId("password-input").fill(password);
await page.getByTestId("confirm-password-input").fill(password);
await page.getByTestId("register-submit-btn").click();
await expect(page).toHaveURL(/\/verify-email/);
@@ -37,23 +38,23 @@ async function registerAndLogin(
await page.goto(activationLink);
await expect(page.getByText(/account activated/i)).toBeVisible();
await page.getByRole("button", { name: /start writing/i }).click();
await expect(page.getByTestId("activation-success-header")).toBeVisible();
await page.getByTestId("start-writing-btn").click();
// Dismiss the Welcom Modal and Perform Login
logger.info(`[Auth] Logging in...`);
await expect(page).toHaveURL(/\/login/);
const welcomeButton = page.getByRole("button", { name: /I'll remember/i });
await welcomeButton.waitFor({ state: "visible", timeout: 10000 });
await welcomeButton.click();
await expect(welcomeButton).toBeHidden();
await page.getByTestId("welcome-dismiss-btn").click();
await expect(page.getByTestId("welcome-dismiss-btn")).toBeHidden();
await page.getByLabel("Email", { exact: true }).fill(email);
await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();
await page.getByTestId("email-input").fill(email);
await page.getByTestId("password-input").fill(password);
await page.getByTestId("login-submit-btn").click();
await expect(page).toHaveURL(/\/drawer/);
await handleWelcomeLetter(page);
logger.info(`[Auth] Successfully authenticated ${email}`);
}
export const AuthHelper = { registerAndLogin };
+38
View File
@@ -0,0 +1,38 @@
import { type Page, expect } from "@playwright/test";
import pino from "pino";
const logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});
/**
* Reveal a letter from an envelope.
*/
export async function revealEnvelope(page: Page) {
logger.info("[Envelope] Revealing envelope...");
// Click envelope to flip
await page.getByTestId("envelope-front").click();
// Click seal to open flap
await page.getByTestId("wax-seal").click();
// Click letter to reveal
await page.getByTestId("envelope-letter").click({ position: { x: 30, y: 15 } });
}
/**
* Handles and dismisses the first welcome letter
*/
export async function handleWelcomeLetter(page: Page) {
logger.info("[Envelope] Handling Welcome Letter...");
await revealEnvelope(page);
// Click "I'll see you" button
await page.getByTestId("dismiss-welcome-letter-btn").click();
await expect(page.getByTestId("dismiss-welcome-letter-btn")).toBeHidden();
}
+3 -2
View File
@@ -15,7 +15,7 @@ const baseUrl = getBaseUrl(
);
export default defineConfig({
timeout: 60000,
timeout: 80000,
expect: {
timeout: 10000,
},
@@ -60,7 +60,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev -- --mode e2e",
// NOTE: using npm here for docker compat mainly
command: "npm run build -- --mode e2e && npm run preview -- --mode e2e",
url: getBaseUrl(
process.env.SSL_ENABLED === "true",
process.env.FRONTEND_DOMAIN,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -19
View File
@@ -1,19 +1 @@
{
"name": "Pi. Ku.",
"short_name": "Pi. Ku.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#d4a24f",
"background_color": "#3b1d13",
"display": "standalone"
}
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+19 -18
View File
@@ -1,12 +1,6 @@
import { lazy, Suspense, useEffect, useRef } from "react";
import {
BrowserRouter,
Navigate,
Route,
Routes,
ScrollRestoration,
} from "react-router-dom";
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AutoRedirectRoute, ProtectedRoute } from "./components/RouteGuards";
import SplashScreen from "./components/SplashScreen";
import { ROUTES } from "./config/routes";
import { useAuth } from "./hooks/useAuth";
@@ -37,41 +31,48 @@ 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/textures/noise.gif')]">
<Suspense fallback={<SplashScreen />}>
<Routes>
<Route path={ROUTES.HOME} element={<Home />} />
<Route
path={ROUTES.HOME}
element={
<AutoRedirectRoute>
<Home />
</AutoRedirectRoute>
}
/>
<Route
path={ROUTES.ONBOARD}
element={
<PublicRoute>
<AutoRedirectRoute>
<Register />
</PublicRoute>
</AutoRedirectRoute>
}
/>
<Route
path={ROUTES.LOGIN}
element={
<PublicRoute>
<AutoRedirectRoute>
<Login />
</PublicRoute>
</AutoRedirectRoute>
}
/>
<Route
path={ROUTES.VERIFY_EMAIL}
element={
<PublicRoute>
<AutoRedirectRoute>
<VerifyEmail />
</PublicRoute>
</AutoRedirectRoute>
}
/>
<Route
path={ROUTES.ACTIVATE}
element={
<PublicRoute>
<AutoRedirectRoute>
<Activate />
</PublicRoute>
</AutoRedirectRoute>
}
/>
+24
View File
@@ -0,0 +1,24 @@
export interface LetterResponseData {
public_id: string;
type: "KEPT" | "SENT" | "VAULT";
status: "DRAFT" | "SEALED" | "BURNED";
encrypted_content: string;
encrypted_metadata: string;
encrypted_dek: string;
unlock_at: string | null;
sealed_at: string | null;
created_at: string;
updated_at: string;
images: LetterImageData[];
}
export interface LetterImageData {
public_id: string;
file: string;
file_name: string;
}
export interface LetterMetadata {
recipient: string;
tags?: string[];
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Before

Width:  |  Height:  |  Size: 738 KiB

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+22 -14
View File
@@ -1,20 +1,22 @@
import { DotIcon } from "@phosphor-icons/react";
import logo from "../assets/logo.svg";
import "@fontsource/knewave/400.css";
interface LogoProps {
scale?: number;
type?: "inline" | "mono" | "logo";
type?: "inline" | "mono" | "logo" | null;
ul?: boolean;
}
export default function Logo({ scale = 1, type = "logo" }: LogoProps) {
export default function Logo({
scale = 1,
type = null,
ul = false,
}: LogoProps) {
if (type === "inline") {
return (
<span
className={
"text-accent font-serif italic drop-shadow-xs drop-shadow-base-200/60 "
}
>
Pi<span className="text-primary">.</span>&nbsp;Ku
<span className={"text-accent font-display italic "}>
pi<span className="text-primary">.</span>&nbsp;ku
<span className="text-primary">.</span>&nbsp;
</span>
);
@@ -22,30 +24,36 @@ export default function Logo({ scale = 1, type = "logo" }: LogoProps) {
if (type === "mono") {
return (
<span className="font-mono italic font-bold border-b-3 border-dashed border-stone-800/50">
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
pi. ku.
</span>
);
}
if (type === "logo") {
return (
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
);
}
return (
<div
role="img"
aria-label="Pi. Ku. logo"
className="inline-flex items-baseline justify-center leading-none select-none"
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
style={{ fontFamily: "'Knewave', serif", scale }}
>
<span className={`text-3xl font-light text-accent`}>Pi</span>
<span className="text-3xl font-light text-accent">Pi</span>
<DotIcon
weight="fill"
size={12}
className={`text-primary translate-y-1 -mx-px`}
className="text-primary translate-y-1 -mx-px"
/>
<span className={`text-3xl font-light text-accent`}>&nbsp;Ku</span>
<span className="text-3xl font-light text-accent">&nbsp;Ku</span>
<DotIcon
weight="fill"
size={12}
className={`text-primary translate-y-1 -mx-px`}
className="text-primary translate-y-1 -mx-px"
/>
</div>
);
+31 -25
View File
@@ -3,14 +3,20 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it } from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture";
import { useAuthStore } from "../store/useAuthStore";
import { ProtectedRoute, PublicRoute } from "./RouteGuards";
import { AutoRedirectRoute, ProtectedRoute } from "./RouteGuards";
function renderGuard(ui: React.ReactNode, mountPath: "/protected" | "/public") {
return render(
<MemoryRouter initialEntries={[mountPath]}>
<Routes>
<Route path="/login" element={<div>Login Page</div>} />
<Route path="/drawer" element={<div>Drawer Page</div>} />
<Route
path="/login"
element={<div data-testid="login-page">Login Page</div>}
/>
<Route
path="/drawer"
element={<div data-testid="drawer-page">Drawer Page</div>}
/>
<Route path="/protected" element={ui} />
<Route path="/public" element={ui} />
</Routes>
@@ -35,13 +41,13 @@ describe("ProtectedRoute", () => {
});
renderGuard(
<ProtectedRoute>
<div>Secret</div>
<div data-testid="secret-page">Secret</div>
</ProtectedRoute>,
"/protected",
);
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
expect(screen.getByTestId("splash-screen")).toBeInTheDocument();
expect(screen.queryByTestId("secret-page")).not.toBeInTheDocument();
});
it("should redirect unauthenticated users to /login", () => {
@@ -52,12 +58,12 @@ describe("ProtectedRoute", () => {
});
renderGuard(
<ProtectedRoute>
<div>Secret</div>
<div data-testid="secret-page">Secret</div>
</ProtectedRoute>,
"/protected",
);
expect(screen.getByText("Login Page")).toBeInTheDocument();
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
expect(screen.getByTestId("login-page")).toBeInTheDocument();
expect(screen.queryByTestId("secret-page")).not.toBeInTheDocument();
});
it("should render page for authenticated users", () => {
@@ -68,12 +74,12 @@ describe("ProtectedRoute", () => {
});
renderGuard(
<ProtectedRoute>
<div>Secret</div>
<div data-testid="secret-page">Secret</div>
</ProtectedRoute>,
"/protected",
);
expect(screen.getByText("Secret")).toBeInTheDocument();
expect(screen.getByTestId("secret-page")).toBeInTheDocument();
});
});
@@ -85,13 +91,13 @@ describe("PublicRoute", () => {
user: null,
});
renderGuard(
<PublicRoute>
<div>Login Page</div>
</PublicRoute>,
<AutoRedirectRoute>
<div data-testid="mock-login-page">Login Page</div>
</AutoRedirectRoute>,
"/public",
);
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
expect(screen.getByTestId("splash-screen")).toBeInTheDocument();
expect(screen.queryByTestId("mock-login-page")).not.toBeInTheDocument();
});
it("should redirect authenticated users to /drawer", () => {
@@ -101,13 +107,13 @@ describe("PublicRoute", () => {
user: mockUser,
});
renderGuard(
<PublicRoute>
<div>Login Form</div>
</PublicRoute>,
<AutoRedirectRoute>
<div data-testid="login-form">Login Form</div>
</AutoRedirectRoute>,
"/public",
);
expect(screen.getByText("Drawer Page")).toBeInTheDocument();
expect(screen.queryByText("Login Form")).not.toBeInTheDocument();
expect(screen.getByTestId("drawer-page")).toBeInTheDocument();
expect(screen.queryByTestId("login-form")).not.toBeInTheDocument();
});
it("should render page for unauthenticated users", () => {
@@ -117,11 +123,11 @@ describe("PublicRoute", () => {
user: null,
});
renderGuard(
<PublicRoute>
<div>Login Form</div>
</PublicRoute>,
<AutoRedirectRoute>
<div data-testid="login-form">Login Form</div>
</AutoRedirectRoute>,
"/public",
);
expect(screen.getByText("Login Form")).toBeInTheDocument();
expect(screen.getByTestId("login-form")).toBeInTheDocument();
});
});
+2 -2
View File
@@ -22,10 +22,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
}
/**
* Public - auth route guard.
* Auto-redirect - auth route guard.
* If authenticated, redirect all the auth related flows to the drawer
*/
export function PublicRoute({ children }: { children: React.ReactNode }) {
export function AutoRedirectRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isInitializing } = useAuth();
if (isInitializing) return <SplashScreen />;
+4 -1
View File
@@ -3,7 +3,10 @@ import Logo from "./Logo";
export default function SplashScreen() {
return (
<div className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')">
<div
data-testid="splash-screen"
className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')"
>
<div className="flex flex-col items-center gap-6 animate-pulse">
<Logo />
@@ -3,19 +3,23 @@ import { GearFineIcon } from "@phosphor-icons/react";
interface DrawerSectionProps {
id: string;
title: string;
count: string;
count: number;
subtext: string;
isOpen: boolean;
onClick: () => void;
children: React.ReactNode;
icon: React.ReactNode;
}
export function DrawerSection({
id,
title,
count,
subtext,
isOpen,
onClick,
children,
icon,
}: DrawerSectionProps) {
return (
<div
@@ -23,22 +27,36 @@ export function DrawerSection({
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
>
<div
className={`transition-all duration-1500 ease-in-out bg-neutral/10 ${
className={`bg-neutral/10 transition-all duration-1000 ease-in-out overflow-visible ${isOpen ? "max-h-125" : "max-h-0 pointer-events-none"}`}
>
<div
className={`transition-opacity ease-in-out ${
isOpen
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible"
: "max-h-0 opacity-0 pointer-events-none"
? "opacity-100 py-3 border-b border-base-content/5 duration-700 delay-500"
: "opacity-0 duration-100"
}`}
>
{children}
{count === 0 && (
<p
data-testid={`empty-drawer-message-${id}`}
className="text-center text-base-content/20 mt-4"
>
This drawer remains silent
</p>
)}
</div>
</div>
<button
type="button"
onClick={onClick}
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
data-testid={`drawer-section-${id}`}
className="w-full relative p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 overflow-hidden focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40"
>
<div className="flex-1">
<div
data-testid="drawer-section-title"
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${
isOpen
? "text-base-content"
@@ -47,8 +65,15 @@ export function DrawerSection({
>
{title}
</div>
<div className="font-sans text-[0.6rem] text-base-content/20 mt-1">
<div className="font-sans text-xs text-base-content/20 mt-1">
<span className="font-mono text-xs md:text-base -mt-1 absolute text-primary/30">
{count}
</span>
&nbsp;
<span className="ml-3">{subtext}</span>
</div>
<div className="absolute right-5 -translate-y-15 text-base-content/4">
{icon}
</div>
</div>
@@ -33,9 +33,10 @@ export function LetterItem({
<button
type="button"
onClick={handleNavigate}
data-testid={`letter-item-${id}`}
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all delay-75 duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
>
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
<div className="text-sm italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60">
{preview}
</div>
{unlock_at ? (
@@ -52,7 +53,7 @@ export function LetterItem({
)}
</div>
) : (
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
<div className="font-sans text-xs text-base-content/20">
{timestamp}
</div>
)}
+20 -12
View File
@@ -1,26 +1,29 @@
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
<h3
data-testid="passkey-modal-title"
className="font-bold text-lg font-display text-primary"
>
You've been away a while.
</h3>
<p className="py-4 font-sans">
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 +33,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
@@ -38,10 +41,15 @@ export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
required
type="password"
placeholder="password"
data-testid="passkey-input"
className="font-sans validator input input-bordered rounded-r-none"
/>
<div className="validator-message text-xs text-error"></div>
<button type="submit" className="btn btn-primary rounded-l-none">
<button
type="submit"
data-testid="passkey-submit-btn"
className="btn btn-primary rounded-l-none"
>
Unlock
</button>
</form>
@@ -0,0 +1,78 @@
import { AnimatePresence, motion } from "motion/react";
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";
export 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"
data-testid="dismiss-welcome-letter-btn"
onClick={onComplete}
className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider"
>
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;
@@ -116,6 +122,7 @@ export function ComposeCanvas({
// re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return;
textboxRef.current?.initDimensions();
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
@@ -184,9 +191,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 +225,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.
@@ -245,17 +260,35 @@ export function ComposeCanvas({
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
const getInitialWidth = async () => {
if (!wrapperRef.current) return BASE_WIDTH;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
return width;
};
const initResizeOberver = () => {
if (!wrapperRef.current) return null;
const observer = new ResizeObserver(() => {
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
observer.observe(wrapperRef.current);
return observer;
};
const initCanvas = async () => {
// HACK: actual font may change the text-width - small ux improvement
await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
const width = await getInitialWidth();
// init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, {
@@ -286,13 +319,7 @@ export function ComposeCanvas({
// auto window resizing based width
lastWidth = wrapperRef.current.clientWidth;
resizeObserver = new ResizeObserver(() => {
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
resizeObserver.observe(wrapperRef.current!);
resizeObserver = initResizeOberver();
};
initCanvas().then();
@@ -15,7 +15,7 @@ export function PostSealModal({
type = "KEPT",
}: PostSealModalProps) {
return (
<Modal isOpen={!!sealedTargetId}>
<Modal isOpen={!!sealedTargetId} data-testid="post-seal-modal">
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
<p className="text-base-content/60">
@@ -25,10 +25,11 @@ export function PostSealModal({
<p className="text-base-content/80 text-sm font-sans">
When you're ready,
<br />
you can{" "}
<span className="text-primary font-bold font-display">read</span> it,{" "}
you can&nbsp;
<span className="text-primary font-bold font-display">read</span>
&nbsp; it,&nbsp;
<span className="text-accent font-bold font-display">send</span> it to
someone, or{" "}
someone, or&nbsp;
<span className="text-error font-bold font-display">burn</span> it to
release
</p>
@@ -36,12 +37,12 @@ export function PostSealModal({
<p className="text-base-content/80 text-sm font-sans">
Be assured that the letter will find you when the time is right.
<br />
Till then,{" "}
Till then,&nbsp;
<span className="font-bold font-display text-primary">
take a deep breath
</span>
, <span className="font-bold font-display text-accent">manifest</span>
, and{" "}
, and&nbsp;
<span className="font-bold font-display text-success">
let it rest
</span>
@@ -53,6 +54,7 @@ export function PostSealModal({
<>
<button
type="button"
data-testid="keep-it-btn"
className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)}
>
@@ -60,8 +62,13 @@ export function PostSealModal({
</button>
<button
type="button"
data-testid="view-letter-btn"
className="btn btn-primary btn-sm"
onClick={() => navigate(PATHS.read(sealedTargetId!))}
onClick={() => {
if (sealedTargetId) {
navigate(PATHS.read(sealedTargetId));
}
}}
>
View letter
</button>
+9 -4
View File
@@ -11,7 +11,7 @@ import {
XCircleIcon,
} from "@phosphor-icons/react";
import { Modal } from "../ui/Modal";
import type { CanvasStyle } from "./ComposeCanvas.tsx";
import type { CanvasStyle } from "./ComposeCanvas";
interface ToolBarProps {
onAddImage: () => void;
@@ -30,7 +30,7 @@ const FONT_FAMILIES: Map<string, string> = new Map([
["Handwriting", "Architects Daughter"],
["Slab", "Cutive Mono"],
["Mono", "Space Mono"],
["Tamil", "Kavivanar"],
["Ink", "Kavivanar"],
["Crazy(pls no)", "Redacted Script"],
]);
const FONT_COLORS: Map<string, string> = new Map([
@@ -140,6 +140,7 @@ export function ToolBar({
<div className="flex items-center gap-2">
<button
type="button"
data-testid="draft-btn"
className="btn btn-ghost btn-sm text-xxs group tracking-widester uppercase font-bold text-base-content/60 hover:text-base-content"
title="Store in your private drawer"
onClick={() => onSave("DRAFT")}
@@ -155,6 +156,7 @@ export function ToolBar({
{/*Seal */}
<button
type="button"
data-testid="seal-trigger-btn"
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
onClick={() => setSealBtnClicked(true)}
>
@@ -176,6 +178,7 @@ export function ToolBar({
>
<button
type="button"
data-testid="seal-confirm-btn"
className="btn btn-accent btn-sm rounded-full px-6 group"
onClick={() => onSave("SEALED")}
>
@@ -191,6 +194,7 @@ export function ToolBar({
</div>
<button
type="button"
data-testid="vault-trigger-btn"
className="btn btn-neutral btn-sm rounded-full px-6 group"
onClick={() => setConfirmModal("VAULT")}
>
@@ -266,8 +270,7 @@ export function VaultConfirmModal({
I'll remember to mail you this on the unlock date.
<br />
<span className={"font-bold text-primary"}>
{" "}
But I won't let you read or rewrite this letter until then.
&nbsp; But I won't let you read or rewrite this letter until then.
</span>
<br />
</p>
@@ -296,6 +299,7 @@ export function VaultConfirmModal({
<div className="w-full flex justify-center gap-8 mt-4">
<button
type="button"
data-testid="vault-cancel-btn"
className="btn btn-ghost btn-sm mt-4"
onClick={() => setConfirmModal(null)}
>
@@ -304,6 +308,7 @@ export function VaultConfirmModal({
<button
className="btn btn-primary btn-sm mt-4"
type="submit"
data-testid="vault-confirm-btn"
form="vault-form"
>
Take it
+24 -22
View File
@@ -3,9 +3,9 @@ import {
ShieldCheckIcon,
WarningIcon,
} from "@phosphor-icons/react";
import Logo from "../Logo.tsx";
import Logo from "../Logo";
import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan.tsx";
import Saajan from "../ui/Saajan";
export default function WelcomeModal({
setShowWelcome,
@@ -15,7 +15,7 @@ export default function WelcomeModal({
return (
<>
<Modal isOpen={true}>
<div className="flex flex-col items-center text-center gap-4">
<div className="flex flex-col items-center text-center gap-2 md:gap-4">
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
<ShieldCheckIcon
size={48}
@@ -25,47 +25,49 @@ export default function WelcomeModal({
</div>
<h3 className="font-display text-2xl font-bold text-primary">
Welcome to&nbsp;
<Logo /> &nbsp;!
<Logo type="inline" />
</h3>
<p className="text-base-content/80 leading-relaxed">
<p className="inline text-sm md:text-base text-base-content/80">
Before we begin, let me make a small promise.
<HandPalmIcon
size={18}
className="inline text-primary"
weight="fill"
/>
<div className="divider my-0"></div>
<br />
Everything you write here is sealed with your password,{" "}
<span className="divider my-0"></span>
Everything you write here is sealed with your password,&nbsp;
<span className="font-display text-success">cryptographically</span>
, before it leaves your hands.
<br />A fancy way of saying, I couldn't if I tried.
<br />
<br />A fancy way of saying, no one else can read them without your
key&mdash;not even me.
</p>
<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="alert alert-warning flex items-start gap-3 text-left py-3">
<WarningIcon size={24} weight="fill" className="shrink-0" />
<div className="text-xs md:text-sm font-medium text-primary-content tracking-tight">
If you ever happen to forget your password, your letters are lost
to time, forever.
<br />
<span className="font-bold mt-2">
I highly, highly recommend storing this password in your{" "}
<span className="mt-2 block">
I highly, <span className="font-bold italic">highly</span>&nbsp;
recommend storing this password in your&nbsp;
<a
href="https://www.privacyguides.org/en/passwords/"
target="_blank"
className="link link-primary-content"
rel="noopener"
className="link link-neutral!"
rel="noopener noreferrer"
>
password manager
</a>{" "}
or somewhere safe to remember it.
</a>
&nbsp; or somewhere safe to remember it.
</span>
</p>
</div>
</div>
<div className="modal-action w-full">
<button
type="button"
data-testid="welcome-dismiss-btn"
onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg"
>
@@ -74,9 +76,9 @@ export default function WelcomeModal({
</div>
</div>
</Modal>
<div className="absolute bottom-0 right-0 z-1000 font-sans w-full">
<div className="absolute bottom-0 md:right-5/12 z-1000 font-sans w-full flex justify-center">
<Saajan
position="top"
position="left"
message={"I've lost words before.\nI know what it feels like."}
/>
</div>
+2 -2
View File
@@ -58,8 +58,8 @@ export function BurnModal({
Let the echoes of your unsaid be finally released.
</p>
<div className="mt-4 font-sans text-sm">
<span className="text-error">Press</span> and{" "}
<span className="text-error">hold</span> the{" "}
<span className="text-error">Press</span> and&nbsp;
<span className="text-error">hold</span> the&nbsp;
<span className="text-amber-300">flame</span> to proceed.
</div>
<div className="modal-action w-full justify-center gap-3 mt-2">
@@ -80,6 +80,7 @@ export function EnvelopeReveal({
/>
</div>
<img
data-testid="wax-seal"
className={
"translate-y-24 delay-2000 absolute z-6 peer-has-checked:pointer-events-none peer-has-checked:opacity-0 peer-has-checked:delay-0 transition-opacity duration-1000 cursor-pointer"
}
@@ -91,6 +92,7 @@ export function EnvelopeReveal({
<button
type="button"
id="letter"
data-testid="envelope-letter"
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-3000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
onClick={handleClick}
></button>
@@ -112,6 +114,7 @@ export function EnvelopeReveal({
<button
id="env-front"
data-testid="envelope-front"
type="button"
disabled={!isInteractive}
className={`text-left p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2 ${isFlipped ? "pointer-events-none" : ""}`}
@@ -120,7 +123,12 @@ export function EnvelopeReveal({
<span className={"text-neutral-content/60 font-xs font-display"}>
to
</span>
<h1 className="text-3xl font-bold text-base-content">{recipient}</h1>
<h1
data-testid="envelope-recipient"
className="text-3xl font-bold text-base-content"
>
{recipient}
</h1>
<p className="text-base-content/60 font-display mt-8">{date}</p>
<img
src={stamp}
@@ -23,8 +23,8 @@ export function PostActionOverlay({ revealState }: PostActionOverlayProps) {
May your <span className="italic text-primary">soul</span> find
solace,
<br />
just like your <span className="text-accent italic">unsaid</span>{" "}
words did.
just like your <span className="text-accent italic">unsaid</span>
&nbsp; words did.
</p>
<div className="divider mx-auto w-24 text-center"></div>
<button
+10 -5
View File
@@ -14,7 +14,11 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
};
return (
<>
<Modal isOpen={!!shareLink} onClose={() => setShareLink(null)}>
<Modal
isOpen={!!shareLink}
onClose={() => setShareLink(null)}
data-testid="share-letter-modal"
>
<div className="flex flex-col items-center justify-center text-center gap-6 py-4">
<div className="space-y-2">
<PaperPlaneTiltIcon
@@ -26,7 +30,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
<p className="text-base-content/80 text-sm font-sans mt-4">
You've carried these words long enough.
<br />
Send your letter now, and let the{" "}
Send your letter now, and let the&nbsp;
<span className="text-accent font-display">unsaid</span> finally
find its home.
</p>
@@ -47,6 +51,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
<button
type="button"
onClick={copyToClipboard}
data-testid="copy-link-btn"
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
>
Copy
@@ -54,8 +59,8 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
</div>
<div className="flex flex-col gap-1 uppercase tracking-widest text-base-content/30 font-sans">
<p className="textarea-xs flex items-center justify-center">
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />{" "}
Zero-Knowledge Share:
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />
&nbsp; Zero-Knowledge Share:
</p>
<p className="textarea-xs font-mono text-center">
The key never leaves your or the recipient's browser.
@@ -63,7 +68,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
</div>
</div>
</Modal>
<div className="absolute bottom-0 z-1000 font-sans w-full">
<div className="absolute bottom-0 md:right-5/11 z-1000 font-sans w-full">
<Saajan
position="top"
message={`Someone once said,\n"To send a letter is a good way to go somewhere without moving anything but your heart."\nThey were not wrong.`}
+4 -1
View File
@@ -7,6 +7,7 @@ interface FormFieldProps {
registration: UseFormRegisterReturn;
error?: string;
handleFocus?: () => void;
"data-testid"?: string;
}
export default function FormField({
@@ -16,18 +17,20 @@ export default function FormField({
registration,
error,
handleFocus,
"data-testid": testId,
}: FormFieldProps) {
return (
<div className="form-control">
<label
htmlFor={registration.name}
className="field-label font-display text-base-content/90 font-medium"
className="field-label font-display text-neutral-content/80 font-medium"
>
{label}
</label>
<input
{...registration}
id={registration.name}
data-testid={testId}
type={type}
placeholder={placeholder}
className={`input input-bordered focus:input-primary ${
+1 -1
View File
@@ -24,7 +24,7 @@ export const LogModal = ({
{status === "WARN" && (
<WarningIcon className="text-warning" size={16} weight="duotone" />
)}
{message}
<span data-testid="log-modal-message">{message}</span>
{log && (
<>
<div className="divider text-primary-content text-xs uppercase tracking-widest">
+20 -6
View File
@@ -1,21 +1,34 @@
import { XCircleIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
onClose?: () => void;
children: ReactNode;
"data-testid"?: string;
}
export function Modal({ isOpen, onClose, children }: ModalProps) {
export function Modal({
isOpen,
onClose,
children,
"data-testid": testId,
}: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
// render the modal top of all elements and position them to document viewport (/ the main wrapper).
// NOTE: this is recommended approach for modals as it shouldn't be bound to the parent box.
const mainContainer = document.querySelector("main") || document.body;
return createPortal(
<div
data-testid={testId}
className="modal modal-open modal-middle backdrop-blur-md 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/textures/noise.gif')]"
>
<div className="modal-box border border-neutral/60 relative bg-base-100/60 flex flex-col items-center text-center gap-6">
{onClose && (
<button
type="button"
data-testid="modal-close-btn"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
onClick={onClose}
aria-label="Close"
@@ -25,6 +38,7 @@ export function Modal({ isOpen, onClose, children }: ModalProps) {
)}
{children}
</div>
</div>
</div>,
mainContainer,
);
}
+102
View File
@@ -0,0 +1,102 @@
import trainImage from "../assets/screenshots/train.png";
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: 720,
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: trainImage,
crossOrigin: null,
filters: [],
},
],
canvasWidth: 700,
canvasHeight: 900,
};
}
+72
View File
@@ -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();
});
});
+13 -6
View File
@@ -32,7 +32,7 @@ export const useAuth = () => {
const logout = async () => {
try {
await api.post(endpoints.LOGOUT);
} catch (_error) {
} catch {
} finally {
clearAuth();
setMasterKey(null);
@@ -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(
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);
} catch {}
};
return {
+4 -20
View File
@@ -1,32 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api/apiClient";
import type { LetterMetadata, LetterResponseData } from "../api/response";
import { endpoints } from "../config/endpoints";
import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
export interface Letter {
public_id: string;
type: "KEPT" | "VAULT" | "SENT";
status: "DRAFT" | "SEALED" | "BURNED";
updated_at: string;
sealed_at?: string;
unlock_at: string;
encrypted_metadata: string;
encrypted_content: string;
encrypted_dek: string;
}
export interface LetterMetadata {
recipient: string;
tags?: string[];
}
export interface ProcessedLetter extends Letter {
export interface ProcessedLetter extends LetterResponseData {
metadata: LetterMetadata;
}
async function decryptLettersMetadata(
letters: Letter[],
letters: LetterResponseData[],
masterKey: CryptoKey,
): Promise<ProcessedLetter[]> {
const cryptoUtils = new CryptoUtils();
@@ -43,7 +27,7 @@ async function decryptLettersMetadata(
)) as LetterMetadata;
return { ...letter, metadata };
} catch (_err) {
} catch {
return {
...letter,
metadata: { recipient: "Encrypted Letter" },
+5 -5
View File
@@ -21,12 +21,12 @@
--color-accent: oklch(55% 0.06 325);
--color-accent-content: oklch(18% 0.03 295);
--color-neutral: oklch(28% 0.02 45);
--color-neutral: oklch(38% 0.02 45);
--color-neutral-content: oklch(80% 0.015 60);
--color-info: oklch(60% 0.07 240);
--color-info: oklch(60% 0.06 250);
--color-info-content: oklch(95% 0.01 240);
--color-success: oklch(60% 0.08 150);
--color-success: oklch(65% 0.05 140);
--color-success-content: oklch(16% 0.03 150);
--color-warning: oklch(68% 0.08 72);
--color-warning-content: oklch(18% 0.03 60);
@@ -48,7 +48,7 @@
--font-sans: "Jost Variable", sans-serif;
--font-serif: "Playfair Display Variable", serif;
--font-mono: "Space Mono", monospace;
--font-tamil: "Kavivanar", sans-serif;
--font-ink: "Kavivanar", sans-serif;
--font-redact: "Redacted Script", cursive;
--font-slab: "Cutive Mono", monospace;
--font-hand: "Architects Daughter", cursive;
@@ -66,7 +66,7 @@
}
.glass-card {
@apply bg-glass-bg backdrop-blur-xl border border-white/5 shadow-warm rounded-xl m-4;
@apply bg-glass-bg max-w-xs md:max-w-sm backdrop-blur-xl border border-neutral-content/10 shadow-warm rounded-xl m-4;
}
.ul-wavy {
+1 -1
View File
@@ -7,7 +7,7 @@ import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
import "@fontsource-variable/jost/wght.css";
import "@fontsource-variable/playfair-display/wght.css";
import App from "./App.tsx";
import App from "./App";
const root = document.getElementById("root");
if (root) {
+306 -176
View File
@@ -4,14 +4,18 @@ import {
ArrowBendDownRightIcon,
ArrowRightIcon,
CaretUpIcon,
DetectiveIcon,
FlowerTulipIcon,
GhostIcon,
GithubLogoIcon,
InfoIcon,
LockKeyOpenIcon,
LockLaminatedIcon,
LockOpenIcon,
PasswordIcon,
PeaceIcon,
PersonArmsSpreadIcon,
PersonIcon,
QuotesIcon,
ScrollIcon,
SmileyIcon,
SparkleIcon,
@@ -21,7 +25,9 @@ import { ReactLenis } from "lenis/react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import stamp from "../assets/envelope/stamp.png";
import Logo from "../components/Logo.tsx";
import e2eDiag from "../assets/screenshots/e2e.svg";
import saajan from "../assets/sf.png";
import Logo from "../components/Logo";
import { Modal } from "../components/ui/Modal";
import "@fontsource/kavivanar/index.css";
@@ -31,7 +37,7 @@ import "@fontsource/architects-daughter/index.css";
import { useNavigate } from "react-router-dom";
function HorizontalScroll({ children }: { children: React.ReactNode }) {
const ref = useRef(null);
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref });
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
@@ -90,16 +96,17 @@ function PrivacySection() {
weight="bold"
/>
</h1>
<div className="flex flex-col items-center shrink-0 gap-8 max-w-11/12 w-200">
<div className="flex flex-col items-center shrink-0 gap-8 max-w-11/12 w-220">
<p className="text-xxs md:text-sm tracking-widester text-neutral-content/80 font-semibold uppercase mt-6">
<span className="text-accent">Your letters.</span>{" "}
<span className="text-accent">Your letters.</span>&nbsp;
<span className="text-error">Nobody else's.</span>
</p>
<p className="text-sm md:text-lg">
When you write something here, it gets encrypted in your browser
before anything leaves your device. What reaches the server isn't your
letter. It's something unreadable &mdash; and the server has no way to
change that, because the key never left you.
<p className="text-sm md:text-lg text-neutral">
When you write or upload anything&nbsp;
<span className="font-hand">(yes, even images)</span> here, it gets
encrypted in your browser before anything leaves your device. What
reaches the server is something unreadable&mdash;and the server has no
way to change that, because the key never left you.
</p>
<figure className="diff aspect-3/4 touch-pan-y select-none">
<div className="diff-item-1 z-1" role="img">
@@ -120,8 +127,12 @@ function PrivacySection() {
B@z1ng4A
</p>
</div>
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
<LockOpenIcon size={48} />
<div className="divider divider-neutral w-1/2 mx-auto">
<LockKeyOpenIcon
weight="duotone"
className="-scale-x-100 text-base-200 w-full rounded-full bg-info/50"
size={36}
/>
</div>
<div className="flex flex-col items-center md:gap-2">
<ScrollIcon
@@ -133,7 +144,7 @@ function PrivacySection() {
</h2>
<div className="p-6 bg-paper w-82 md:w-150 h-200 flex flex-col gap-4 text-xs md:text-lg overflow-hidden max-h-68 md:max-h-full">
<p className="wrap-anywhere">Hello friend,</p>
<p>I've never told this to anyone...</p>
<p>I've never told anyone this...</p>
<p className="font-redact">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut
semper, justo eget vehicula vestibulum, enim enim suscipit
@@ -157,7 +168,7 @@ function PrivacySection() {
</div>
</div>
<div className="diff-item-2" role="img">
<div className="bg-neutral-content bg-[url('https://www.transparenttextures.com/patterns/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4">
<div className="bg-neutral-content bg-[url('assets/textures/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8">
server see
@@ -173,8 +184,12 @@ function PrivacySection() {
9e54d05f88bdd67a675b03bf1cd0a1647e2109b5aa18185ff6a9ba4c6959a19d
</p>
</div>
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
<LockLaminatedIcon size={48} />
<div className="divider divider-neutral w-1/2 mx-auto">
<LockLaminatedIcon
weight="duotone"
className="text-success-content w-full rounded-full bg-success"
size={36}
/>
</div>
<div className="flex flex-col items-center md:gap-2">
<ScrollIcon
@@ -204,62 +219,108 @@ function SpecsSection() {
return (
<section className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
<h1 className="relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-display z-10">
S'more Specs
<h1 className="flex tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-display z-10">
S'more&nbsp;
<DetectiveIcon weight="duotone" className="text-neutral" />
Specs
</h1>
<div className="flex flex-col items-start shrink-0 gap-6 max-w-11/12 w-200 mt-4 md:mt-12">
<div className="flex flex-col items-center shrink-0 gap-6 max-w-11/12 w-220 mt-4 md:mt-12 text-neutral-content/80">
<h2 className="text-xl md:text-3xl text-center mx-auto">
<Logo type={"inline"} /> uses{" "}
<span className="text-accent font-mono">Zero Knowledge</span>{" "}
<span className="group ul-wavy font-mono text-primary">
<Logo type={"inline"} /> uses&nbsp;
<span className="text-accent font-mono">Zero Knowledge</span>&nbsp;
<button
type="button"
className="group ul-wavy font-mono text-success"
>
E
<span className="hidden group-hover:inline group-focus-within:inline">
nd&nbsp;
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
nd&mdash;
</span>
2
<span className="hidden group-hover:inline group-focus-within:inline">
&nbsp;
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
&mdash;
</span>
E
<span className="hidden group-hover:inline group-focus-within:inline">
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
nd
</span>
<span className="hidden group-hover:inline group-focus-within:inline">
&nbsp;<span>E</span>
<span className="hidden group-hover:inline group-focus-within:inline">
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
ncryption
</span>
</span>
</span>{" "}
with{" "}
<span className="font-mono text-primary">Envelope Encryption</span>
</button>
&nbsp; for your&nbsp;
<span className="font-hand text-primary">letters</span>, with&nbsp;
<a
href="https://hackernoon.com/what-the-heck-is-envelope-encryption-in-cloud-security"
target="_blank"
rel="noopener noreferrer"
className="font-mono text-neutral!"
>
Envelope Encryption
</a>
&nbsp; for the <span className="font-hand text-primary">keys</span>.
</h2>
<p className="text-sm md:text-xl leading-relaxed">
This means, both the encryption and decryption runs on your device, in
your browser.
<br />
Every letter has a{" "}
<span className="font-mono text-primary">unique key</span> which is
derived from your original password.
<br />
<div className="text-sm md:text-xl leading-relaxed">
This means, both the&nbsp;
<span className="font-display text-info">encryption</span> and&nbsp;
<span className="font-display text-info">decryption</span> runs on
your device, in your browser.
<ul className="list-decimal ml-6 md:ml-10 list-outside text-neutral marker:text-primary/30 marker:font-mono marker:text-xs marker:md:text-base">
<li>
Every letter has a&nbsp;
<span className="font-mono text-primary/50 font-bold">
unique key
</span>
&nbsp; which is derived from your original password.
</li>
<li>
Both the letter and the key are encrypted securely and sent to the
server.
<br />
Now, the server holds{" "}
<span className="text-primary font-bold">the envelope</span>,{" "}
<span className="text-primary font-bold">the seal</span> and{" "}
<span className="text-primary font-bold">another locked box</span>{" "}
with a key inside that unseals your letter. But you,{" "}
<span className="italic text-primary">only you</span>, hold the only
thing that opens the box &mdash;{" "}
</li>
<li>
Now, the server holds&nbsp;
<span className="text-primary/50 font-bold">the envelope</span>
,&nbsp;
<span className="text-primary/50 font-bold">the seal</span>&nbsp;
and&nbsp;
<span className="text-primary/50 font-bold">
another locked box
</span>
&mdash;with a key inside that unseals your letter.
</li>
</ul>
But you&mdash;
<span className="italic">only you</span>&mdash;hold the very thing
that opens that box,&nbsp;
<span className="font-mono text-accent">your password</span>.
</p>
<p className="text-sm md:text-xl text-right w-full flex items-center justify-end gap-4 leading-relaxed">
</div>
<div className="text-xs md:text-lg text-right w-full flex items-center justify-end gap-4 leading-relaxed text-neutral-content/80">
<span>
Nothing on the server is readable without your actual password.
<br />
Even if someone were to breach in, all they'd find is encrypted noise.
<VaultIcon size={48} weight="duotone" />
</p>
Even if someone were to breach in, all they'd find is encrypted
noise and ain't no way they crackin'
<br />
<a
href="https://xkcd.com/538/"
target="_blank"
rel="noopener noreferrer"
className="text-xxs md:text-sm text-neutral! font-hand"
>
&nbsp;(unless this happens)
</a>
</span>
<div className="flex shrink-0 items-center justify-end bg-success/20 rounded-full p-4 ">
<VaultIcon
size={36}
weight="duotone"
className="text-neutral-content"
/>
</div>
</div>
<button
type={"button"}
@@ -274,18 +335,20 @@ function SpecsSection() {
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<div className="w-full bg-paper rounded-md p-6">
<img src="/screenshots/e2e.svg" alt="pi ku e2e diagram" />
<img src={e2eDiag} width={"100%"} alt="pi ku e2e diagram" />
</div>
</Modal>
<p className="text-sm md:text-lg">
This level of privacy comes with a catch.{" "}
<span className="text-error font-bold">No password reset.</span>
Of course, this level of&nbsp;
<span className="text-success font-bold">privacy</span> comes with a
catch. <span className="text-error font-bold">No password reset</span>
&nbsp; for you.
</p>
<p className="text-sm md:text-lg alert alert-warning font-semibold">
<p className="text-xs md:text-base alert alert-warning font-medium">
<InfoIcon weight="duotone" /> Your original password is never stored
on the server. Which means if it's lost, the letters stay sealed
forever.
on the server. So, if it's forgotten, the letters stay sealed
foreeeeveer.
</p>
</div>
</section>
@@ -300,30 +363,35 @@ function OSSSection() {
"relative tracking-tighter text-4xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif text-center"
}
>
<span className="hidden absolute -translate-y-24 translate-x-45 font-display text-3xl md:text-6xl opacity-70 rotate-8">
only for
<br />
<span className="text-primary">your letters</span> <SmileyIcon />
<ArrowArcLeftIcon className="inline rotate-45 -translate-y-8" />
</span>
<Logo type={"inline"} /> is{" "}
<span className="line-through decoration-6 decoration-error">
<Logo type={"inline"} />
is&nbsp;
<span className="line-through decoration-6 text-neutral-content/50 decoration-error">
&nbsp;private
</span>{" "}
<span className="text-success">open source !</span>
<span className="absolute -translate-y-2 -translate-x-42 md:-translate-x-72 font-hand text-xs md:text-xl opacity-70 rotate-8 tracking-normal inline-flex items-center not-italic w-48 md:w-100 flex-wrap">
only for
<span className="text-primary">&nbsp;your letters&nbsp;</span>&nbsp;
<SmileyIcon weight="duotone" className="text-primary" />
<ArrowArcLeftIcon className="text-accent inline rotate-45 -translate-y" />
</span>
</span>
&nbsp;
<span className="text-success -rotate-3">open source !</span>
</h1>
<div className="flex flex-col items-center shrink-0 max-w-11/12 w-200 gap-4 p-4 md:p-6">
<div className="flex flex-col items-center shrink-0 max-w-11/12 w-220 gap-4 p-4 md:p-6 text-neutral-content/80">
<p className="text-sm md:text-xl">
<Logo type={"mono"} /> is fully open source. Every claim about privacy
and encryption is publicly available in the code so you don't have to
take anyone's word for it.
<Logo type={"mono"} /> is
<span className="font-hand"> ...uhhh... pretty </span>
<span className="text-success font-display">secure</span>. Every claim
about privacy and encryption is publicly available in the code so you
don't have to take my word at it.
</p>
<p className="text-sm md:text-lg">
You can also{" "}
<span className="uppercase font-bold text-primary">Self-host</span>{" "}
You can also&nbsp;
<span className="uppercase font-mono text-primary">Self-host</span>
&nbsp;
<Logo type={"inline"} /> in just 4 steps.
</p>
<div className="mockup-code w-full text-xs">
<div className="mockup-code w-110 max-w-11/12 text-xs">
<pre data-prefix="$">
<code>git clone https://git.ramvignesh.dev/me/pi-ku.git</code>
</pre>
@@ -343,12 +411,13 @@ function OSSSection() {
href="https://git.ramvignesh.dev/me/pi-ku"
target="_blank"
rel="noopener noreferrer"
className="text-primary"
className="text-primary flex items-center gap-2"
>
View on GitHub
<GithubLogoIcon weight="duotone" /> View Source
</a>
.
<p className="text-xs md:text-base opacity-70">
Found something to report or request?{" "}
Found something to report or request?&nbsp;
<a
href="https://git.ramvignesh.dev/me/pi-ku/issues"
target="_blank"
@@ -369,7 +438,7 @@ function OSSSection() {
<Logo type={"mono"} /> wouldn't exist without the work of people who
chose to build in the open.
</p>
<p className="divider font-display opacity-30 my-0">a big thanks to</p>
<p className="text-sm md:text-lg">
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API"
@@ -377,10 +446,9 @@ function OSSSection() {
rel="noopener noreferrer"
>
Web Crypto API
</a>{" "}
&mdash; the backbone of everything promised. Browser-native
cryptography that runs entirely on your device. Without it, none of
the privacy here would be possible &mdash; or credible.
</a>
: Browser-native cryptography that runs entirely on your device. The
backbone of everything secure&mdash;your letters, keys&mdash;here.
</p>
<p className="text-sm md:text-lg">
@@ -390,30 +458,30 @@ function OSSSection() {
rel="noopener noreferrer"
>
DaisyUI
</a>{" "}
·{" "}
</a>
&nbsp; ·&nbsp;
<a
href="http://fabricjs.com"
target="_blank"
rel="noopener noreferrer"
>
Fabric.js
</a>{" "}
·{" "}
</a>
&nbsp; ·&nbsp;
<a
href="https://phosphoricons.com"
target="_blank"
rel="noopener noreferrer"
>
Phosphor Icons
</a>{" "}
&mdash; the beautiful work by others that let me focus on the core
experience.
</a>
: The brilliant work by others that let me focus on the core
experience instead of re-inventing the wheel.
</p>
<p className="text-sm md:text-lg mt-4">
Open source is what made this possible. It felt right to give it back
the same way.
<p className="text-sm md:text-lg mt-2 md:mt-4 text-neutral">
Open source is what made <Logo type={"inline"} /> possible. It always
feels right to give it back the same way.
</p>
</div>
</section>
@@ -434,8 +502,8 @@ function StorySection() {
<div className="translate-x-2">
<Logo />
</div>
<div className="flex ml-10 font-tamil text-2xl md:text-3xl group">
<div className={"flex flex-col flex-wrap ul-wavy"}>
<div className="flex ml-10 font-ink text-2xl md:text-3xl group">
<button type="button" className={"flex flex-col flex-wrap ul-wavy"}>
பின்
<span
className={
@@ -444,7 +512,7 @@ function StorySection() {
>
after
</span>
</div>
</button>
<ArrowBendDownLeftIcon className={"text-primary"} />
<ArrowBendDownRightIcon className="ml-8 text-primary" />
<div className={"flex flex-col flex-wrap group ul-wavy"}>
@@ -474,8 +542,8 @@ function StorySection() {
postscript; a note written after the letter is signed.
<br />
<blockquote className="text-primary/50 italic mt-2 ml-2 border-l-primary/20 leading-none border-l">
"the most honest thing was always in the{" "}
<span className="font-tamil">பி. கு.</span>"
"the most honest thing was always in the&nbsp;
<span className="font-ink">பி. கு.</span>"
</blockquote>
</li>
<li>the thing you almost didn't say.</li>
@@ -493,13 +561,16 @@ function StorySection() {
</div>
<div
className={
"max-w-200 md:text-xl p-6 flex flex-col gap-4 md:gap-8 text-base-content/70 leading-relaxed"
"max-w-220 md:text-xl p-6 flex flex-col gap-4 md:gap-8 text-base-content/70 leading-relaxed"
}
>
<p className={""}>
<Logo type={"inline"} /> is an abbreviated transliteration of the
Tamil word for{" "}
<span
<span className="font-ink text-accent"> ி </span>
<span className="italic text-xs md:text-base">(Tamil) </span>word
for&nbsp;
<button
type="button"
className={
"group italic text-primary font-serif inline underline decoration-dotted underline-offset-2 decoration-primary/40"
}
@@ -521,9 +592,10 @@ function StorySection() {
cript
</span>
.
</span>{" "}
&mdash; the thing you add after you've already signed your name,
what you write when you thought you were finished, but weren't.
</button>
&nbsp; &mdash;the thing you add after you've already signed your
name, what you write when you thought you were finished, but
weren't.
</p>
<p>
<span className={"font-medium text-primary"}>
@@ -532,18 +604,25 @@ function StorySection() {
<br />
It sits in drafts , in half-written notes, in the pause before we
change the subject. <br />
Those words{" "}
<span
Those words&nbsp;
<button
type="button"
className={
"blur-sm hover:blur-none active:blur-none focus:blur-none focus:outline-none transition-all duration-500"
}
>
don't just disappear. They
</span>{" "}
stay <span className={"text-primary font-hand"}>unsaid</span>{" "}
&mdash; a quiet weight difficult to bear.
</button>
&nbsp; stay&nbsp;
<span className={"text-primary font-hand font-extrabold"}>
unsaid
</span>
&mdash;
<span className="italic">a quiet weight difficult to bear</span>.
</p>
<p className={"italic text-neutral text-center"}>
And that's okay...
</p>
<p className={"italic text-primary"}>And that's okay...</p>
<p>
<Logo type={"inline"} />
<span className={"text-primary"}>
@@ -566,15 +645,14 @@ function ForWhoSection() {
Who is <br /> this for?
</h2>
<div className="space-y-6 max-w-200 p-4 text-base-200 text-xl md:text-2xl leading-relaxed">
<div className="space-y-6 max-w-220 p-4 text-base-200 text-xl md:text-2xl leading-relaxed">
<p>
<Logo type={"mono"} /> wasn't built for one kind of person, but a
particular kind of feeling&mdash;
<span className="italic font-serif text-stone-900">
{" "}
the one that lingers very quietly
</span>{" "}
&mdash; fragile, yet never breaks.
&nbsp; the one that lingers very quietly
</span>
&nbsp; &mdash;fragile, yet never breaks.
</p>
<div className="pt-8 flex items-center gap-4">
@@ -603,7 +681,8 @@ function ArchetypesSection() {
>
The Archetypes
</h1>
<div className="flex flex-col items-center shrink-0 w-200 max-w-11/12 gap-2 md:gap-8 my-4">
<p className="font-hand text-xs md:text-xl">of writing</p>
<div className="flex flex-col items-center shrink-0 w-220 max-w-11/12 gap-2 md:gap-8 my-4">
<div className="relative w-full">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
@@ -611,8 +690,8 @@ function ArchetypesSection() {
open
>
<summary className="collapse-title md:text-xl leading-tight font-hand flex items-center gap-4">
<GhostIcon weight="duotone" className="text-accent" size={32} />{" "}
To someone you can't reach anymore.
<GhostIcon weight="duotone" className="text-accent" size={32} />
&nbsp; To someone you can't reach anymore.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
@@ -622,7 +701,7 @@ function ArchetypesSection() {
finished.
<br />
</p>
<p className="font-serif font-medium opacity-70">
<p className="font-ink font-medium opacity-70">
Write the letter anyway. Keep it close.
</p>
</div>
@@ -642,17 +721,17 @@ function ArchetypesSection() {
weight="duotone"
className="text-accent"
size={32}
/>{" "}
To someone who's still here.
/>
&nbsp; To someone who's still here.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
Not every letter is about distance. Sometimes you just need to
say something properly &mdash; without a text thread, without
the noise of a conversation already in motion. A letter slows it
say something properly&mdash;without a text thread, without the
noise of a conversation already in motion. A letter slows it
down.
</p>
<p className="font-serif font-medium opacity-70">
<p className="font-ink font-medium opacity-70">
Give people their due flowers while they can still smell them.
</p>
</div>
@@ -672,7 +751,8 @@ function ArchetypesSection() {
weight="duotone"
className="text-accent"
size={14}
/>{" "}
/>
&nbsp;
<PersonArmsSpreadIcon
weight="duotone"
className="text-accent"
@@ -689,7 +769,7 @@ function ArchetypesSection() {
Ask yourself of the healed wounds, forgotten fears, or the
things you finally learned to live with.
</p>
<p className="font-serif font-medium opacity-70">
<p className="font-ink font-medium opacity-70">
Set a date and let a letter surprise you when you've long
forgotten writing it.
</p>
@@ -705,8 +785,8 @@ function ArchetypesSection() {
name="my-accordion-det-1"
>
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-center gap-4">
<SparkleIcon weight="duotone" className="text-accent" size={32} />{" "}
For liberation.
<SparkleIcon weight="duotone" className="text-accent" size={32} />
&nbsp; For liberation.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
@@ -715,7 +795,7 @@ function ArchetypesSection() {
putting it somewhere outside of yourself. <br />
That's sometimes enough.
</p>
<p className="font-serif font-medium opacity-70">
<p className="font-ink font-medium opacity-70">
Say it once. All of it. Then let it fade.
</p>
</div>
@@ -724,11 +804,14 @@ function ArchetypesSection() {
04
</span>
</div>
<div className="flex items-center justify-center gap-2 group mt-12">
<button
className="flex items-center justify-center gap-2 group mt-12 text-left"
type="button"
>
<img
src={stamp}
alt="stamp"
className="rotate-6 group-hover:rotate-0 group-focus-within:rotate-0 transition-all duration-1000"
className="rotate-6 group-hover:rotate-0 group-focus-within:rotate-0 transition-all duration-1000 focus:outline-none"
/>
<p className="md:text-xl mt-4">
If any of these felt familiar,
@@ -737,7 +820,7 @@ function ArchetypesSection() {
<br />
this is for you.
</p>
</div>
</button>
</div>
</div>
);
@@ -753,12 +836,12 @@ function AttributionSection() {
const navigate = useNavigate();
return (
<div className="flex flex-col min-h-screen w-screen items-center py-18">
<div className="flex flex-col min-h-screen w-screen items-center py-18 bg-accent text-neutral-content">
{/* Saajan hover image */}
<AnimatePresence>
{hover.visible && (
<motion.img
src="/saajan.png"
src={saajan}
alt="Saajan Fernandes from The Lunchbox, cutout"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
@@ -775,7 +858,7 @@ function AttributionSection() {
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif"
"relative tracking-tighter text-5xl md:text-8xl text-base-200 font-extrabold italic font-serif"
}
>
Honest Speak
@@ -783,7 +866,7 @@ function AttributionSection() {
<div className="flex flex-col items-center shrink-0">
<div
className={
"max-w-200 m-2 md:m-8 text-sm md:text-lg px-4 md:px-8 py-6 md:py-12 flex flex-col gap-4 md:gap-8 text-base-100 leading-relaxed bg-paper font-mono tracking-tight"
"max-w-220 m-2 md:m-8 text-sm md:text-lg p-6 md:p-12 flex flex-col gap-4 md:gap-8 text-base-100 leading-relaxed bg-paper font-sans tracking-tight"
}
>
Hi.
@@ -791,24 +874,40 @@ function AttributionSection() {
<p>
<Logo type={"inline"} /> took a while to exist.
<br />
This started as a{" "}
This started as a&nbsp;
<a
href="https://cs50.harvard.edu/web/"
target="_blank"
rel="noopener noreferrer"
>
CS50W
</a>{" "}
capstone, one I kept postponing until I ran out of reasons not to.
When I eventually sat down to build, I knew it had to be more than a
deadline; it had to be something that outlasted the grade. I wanted
to create a space for the feelings we usually keep to ourselves and
every hour spent on it was worth it. I've shared the edges of{" "}
<Logo type={"inline"} /> here, but the heart of it is best found by
exploring it yourself.
</a>
&nbsp; capstone&mdash;one I kept postponing until I ran out of
excuses. When I sat down to build it, it felt heavier than a typical
assignment&mdash;not just because things were difficult. It had to
be something that outlasted the grade. I wanted to make this one
count more than anything else I'd ever made. Something as close to
perfect as I could get it. Something to be remembered for&mdash;a
Swan Song if you will.
</p>
<p>So, I gave it all I've got.</p>
<p>
Of course, frustrations, id-exisi crises, crept in from time to
time. But <Logo type="inline" /> helped me re-kindle the love for
the odd hours spent obsessing over the tiniest UX decisions and
endlessly polishing the UI&nbsp;
<span className="font-hand">
(only if I could've just made my mind up on one design system
sooner, instead of paddling in a sea of muses, muses everywhere)
</span>
. I know I've shared the nuts and bolts of <Logo type={"inline"} />
&nbsp; here&mdash;the core philosophies, how it all works&mdash;but
the heart of it is really something you have to find by exploring it
yourself.
</p>
<p>
I kept coming back to{" "}
The "why" behind all of this didn't just appear out of nowhere. For
a while, I kept coming back to&nbsp;
<span
role="tooltip"
className="cursor-default ul-wavy text-accent"
@@ -829,43 +928,53 @@ function AttributionSection() {
onMouseLeave={() => setHover((h) => ({ ...h, visible: false }))}
>
Saajan
</span>{" "}
from{" "}
</span>
&nbsp; from&nbsp;
<a
href="https://www.imdb.com/title/tt2350496/"
href="https://www.themoviedb.org/movie/191714-the-lunchbox"
target="_blank"
rel="noopener noreferrer"
>
The Lunchbox
</a>{" "}
&mdash;{" "}
<span className="italic">
one of the most subtle yet brilliant portrayals by Irrfan Khan
</span>{" "}
&mdash; the quiet emotional weight he carries throughout the film,
going through the motions of a lonely life, until those letters
arrive and something inside him finally loosens. Of course, the
ending felt like a deep sigh of "it is what it is". But something
about the act of writing and letting the unsaid out eased it, even
briefly. I think about that a lot.
</a>
&nbsp; &mdash;brought to life with such subtle brilliance by&nbsp;
<a
className="text-accent!"
href="https://www.themoviedb.org/person/76793-irrfan-khan"
rel="noopener noreferrer"
>
Irrfan Khan
</a>
&nbsp;
<PeaceIcon weight="duotone" className="inline text-accent" />
&mdash;the quiet emotional weight he carries through a lonely and
mechanized life, right up until those letters arrive and something
inside him finally loosens. The ending feels like a deep sigh
of&nbsp;
<span className="font-hand font-bold text-accent">
"it is what it is"
</span>
, but the simple act of writing&mdash;of letting the unsaid
out&mdash;offered him a brief, yet necessary ease. I think about
that a lot.
</p>
<p>
There's a lot that goes{" "}
There's a lot that goes&nbsp;
<span className={"text-primary font-hand text-lg md:text-xl"}>
unsaid
</span>{" "}
now. Not that people feel less or for the lack of time, but because
the ways we reach each other have quietly changed. We're always
reachable <span className="italic">digitally,</span> yet somehow the
things that matter most end up staying inside.
</span>
&nbsp; these days. Not for a lack of feeling, not for the lack of
time, but because the ways we reach each other have quietly changed.
We're always reachable <span className="italic">digitally,</span>
&nbsp; yet somehow the things that actually matter most end up
staying inside&mdash;a trapped one at that.
<br />
Maybe writing will help with that. Maybe something about putting
words somewhere deliberate makes them feel less like something
you're carrying.
Maybe writing can/will help. Maybe putting words somewhere
deliberate makes them feel less like a weight you're carrying alone.
</p>
<p>Or maybe it won't, but it's worth a try.</p>
<p>Or maybe it won't&mdash;but it's worth a try.</p>
<p>
<Logo type={"inline"} /> is for that try. I hope it helps.
<Logo type={"inline"} /> is for that try. I hope it helps. Really.
</p>
<p
className={
@@ -874,11 +983,32 @@ function AttributionSection() {
>
&mdash;Ram
</p>
<p className="text-xs md:text-sm opacity-75 font-mono">
P.S. And just so we're clear&mdash;I wrote every word of this
myself&mdash;as I continue to back&nbsp;
<a
href="https://em-dash-appreciation.org/"
target="_blank"
rel="noopener noreferrer"
>
Em DASH
</a>
. Why should AI get to have all the fun with 'em em dashes?&nbsp;
<span className="font-hand">(get it?)</span>
</p>
</div>
<blockquote className="text-primary/50 italic mt-8 md:mt-12 mx-auto border-l-primary/20 leading-relaxed border-l pl-4 max-w-11/12 text-lg">
"I think we forget things if there is nobody to tell them."
<span className="block mt-2 text-sm not-italic text-base-content/30 w-full text-right">
~ Saajan Fernandes, <span className="italic">The Lunchbox</span>
<blockquote className="text-base-300 italic mt-8 md:mt-12 mx-auto border-l-neutral-content/50 leading-relaxed border-l-4 pl-4 max-w-11/12 text-lg">
<QuotesIcon
weight="duotone"
size={48}
className="rotate-180 text-neutral-content"
/>
&nbsp; I think we forget things if there is nobody to tell them.
<span className="block mt-2 text-sm not-italic text-base-200/70 w-full text-right">
~ Saajan Fernandes,&nbsp;
<span className="italic underline decoration-dotted">
The Lunchbox
</span>
</span>
</blockquote>
</div>
@@ -886,7 +1016,7 @@ function AttributionSection() {
<button
type={"button"}
onClick={() => navigate("/onboard")}
className="btn btn-primary btn-wide rounded-full px-14 font-mono"
className="btn btn-base-100 btn-wide rounded-full px-14 font-mono"
>
Begin
</button>
+11 -6
View File
@@ -26,7 +26,7 @@ export default function Activate() {
});
await publicApi.get(url);
setStatus("success");
} catch (_err) {
} catch {
setStatus("error");
}
};
@@ -52,17 +52,22 @@ export default function Activate() {
className="text-success"
/>
</div>
<h2 className="font-display text-xl text-success">
Account Activated!
<h2
data-testid="activation-success-header"
className="font-display text-xl text-success"
>
You're in.
</h2>
<p className="opacity-70 leading-relaxed">
Welcome to <Logo scale={1} />
Welcome to&nbsp;
<Logo type="inline" />
<br />
Your identity is now verified and ready for timeless letters.
Just one more step and you can start writing timeless letters.
</p>
<div className="divider opacity-10 my-0"></div>
<button
type="button"
data-testid="start-writing-btn"
className="btn btn-primary w-full shadow-lg"
onClick={() =>
navigate(ROUTES.LOGIN, {
@@ -71,7 +76,7 @@ export default function Activate() {
})
}
>
Start Writing
I'm ready
</button>
</div>
)}
+56 -9
View File
@@ -1,12 +1,26 @@
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";
import type { WelcomeLetterOverlayProps } from "../components/drawer/WelcomeLetterOverlay";
import { useLetters } from "../hooks/useLetters";
import { useAuthStore } from "../store/useAuthStore";
import Drawer from "./Drawer";
vi.mock("../hooks/useLetters");
vi.mock("../components/drawer/WelcomeLetterOverlay", () => ({
WelcomeLetterOverlay: ({ onComplete }: WelcomeLetterOverlayProps) => (
<div data-testid="welcome-letter-overlay">
<button
type="button"
data-testid="overlay-exit-button"
onClick={onComplete}
>
I'll see you
</button>
</div>
),
}));
describe("Drawer Page", () => {
beforeEach(() => {
@@ -27,17 +41,21 @@ describe("Drawer Page", () => {
});
});
it("renders the cabinet sections and empty state message", () => {
it("renders the drawer sections and empty state message", () => {
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
expect(screen.getByTestId("drawer-section-drafts")).toBeInTheDocument();
expect(
screen.getAllByTestId("drawer-section-title").length,
).toBeGreaterThanOrEqual(1);
expect(screen.getByTestId("drawer-section-vault")).toBeInTheDocument();
expect(
screen.getByTestId("empty-drawer-message-drafts"),
).toBeInTheDocument();
});
it("renders the loading state", () => {
@@ -56,7 +74,7 @@ describe("Drawer Page", () => {
</MemoryRouter>,
);
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
expect(screen.getByTestId("drawer-loading-state")).toBeInTheDocument();
});
it("renders the authentication required modal when api requires auth", () => {
@@ -75,7 +93,36 @@ describe("Drawer Page", () => {
</MemoryRouter>,
);
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
expect(screen.getByTestId("passkey-modal-title")).toBeInTheDocument();
expect(screen.getByTestId("passkey-input")).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();
});
});
+51 -21
View File
@@ -1,24 +1,35 @@
import { FeatherIcon } from "@phosphor-icons/react";
import {
ArchiveIcon,
FeatherIcon,
FileDashedIcon,
PaperPlaneTiltIcon,
VaultIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import { 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 { useLocation, useNavigate } from "react-router-dom";
import { DrawerSection } from "../components/drawer/DrawerSection";
import { LetterItem } from "../components/drawer/LetterItem";
import { PasskeyModal } from "../components/drawer/PasskeyModal";
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay";
import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan.tsx";
import Saajan from "../components/ui/Saajan";
import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters";
import {
formatRelativeDate,
formatRelativeDateWithoutTime,
} from "../utils/dateFormat.ts";
} from "../utils/dateFormat";
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,14 +41,24 @@ 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">
Personal Archive
</div>
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
Welcome Back{" "}
Welcome Back&nbsp;
<span className="font-semibold text-primary">{user.full_name}</span>
<button
type="button"
@@ -53,7 +74,10 @@ export default function Drawer() {
{loading ? (
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
<span className="loading loading-ring loading-lg text-primary opacity-20"></span>
<span className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse">
<span
data-testid="drawer-loading-state"
className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse"
>
Opening your cabinet...
</span>
</div>
@@ -62,9 +86,11 @@ export default function Drawer() {
<DrawerSection
id="drafts"
title="Drafts"
count={`${drafts.length} unfinished whispers`}
count={drafts.length}
subtext="unfinished whispers"
isOpen={openSection === "drafts"}
onClick={() => toggleSection("drafts")}
icon={<FileDashedIcon weight="thin" size={128} />}
>
{drafts.map((draft) => (
<LetterItem
@@ -80,9 +106,11 @@ export default function Drawer() {
<DrawerSection
id="kept"
title="Kept"
count={`${kept.length} private letters`}
count={kept.length}
subtext="private letters"
isOpen={openSection === "kept"}
onClick={() => toggleSection("kept")}
icon={<ArchiveIcon weight="thin" size={128} />}
>
{kept.map((letter) => (
<LetterItem
@@ -97,9 +125,11 @@ export default function Drawer() {
<DrawerSection
id="sent"
title="Sent"
count={`${sent.length} shared truths`}
count={sent.length}
subtext="shared truths"
isOpen={openSection === "sent"}
onClick={() => toggleSection("sent")}
icon={<PaperPlaneTiltIcon weight="thin" size={128} />}
>
{sent.map((letter) => (
<LetterItem
@@ -110,18 +140,15 @@ export default function Drawer() {
timestamp={formatRelativeDate(letter.updated_at)}
/>
))}
{sent.length === 0 && (
<p className="text-center text-base-content/20 mt-4">
This drawer remains silent
</p>
)}
</DrawerSection>
<DrawerSection
id="vault"
title="Vault"
count={`${vault.length} things locked;not lost;in time`}
count={vault.length}
subtext="things locked—not lost—in time"
isOpen={openSection === "vault"}
onClick={() => toggleSection("vault")}
icon={<VaultIcon weight="thin" size={128} />}
>
{vault.map((letter) => (
<LetterItem
@@ -144,6 +171,7 @@ export default function Drawer() {
<button
type="button"
id="write-letter-btn"
data-testid="write-letter-btn"
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
onClick={() => navigate(PATHS.write(""))}
>
@@ -152,7 +180,7 @@ export default function Drawer() {
weight="duotone"
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
/>
Write something{" "}
Write something&nbsp;
<span className="relative inline-flex">
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
. . . . . .
@@ -166,12 +194,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>
{!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>
);
}
+22 -26
View File
@@ -1,4 +1,9 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitForElementToBeRemoved,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -79,9 +84,9 @@ describe("Editor Page", () => {
);
// Wait for initial load to complete
await waitFor(() => {
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
});
await waitForElementToBeRemoved(() =>
screen.queryByTestId("opening-draft-overlay"),
);
const canvas = screen.getByTestId("canvas");
expect(canvas.getAttribute("data-readonly")).toBe("false");
@@ -92,27 +97,22 @@ describe("Editor Page", () => {
fireEvent.click(sealBtn);
// Click Vault to show confirm modal
const vaultBtn = screen.getByRole("button", { name: /vault/i });
const vaultBtn = screen.getByTestId("vault-trigger-btn");
fireEvent.click(vaultBtn);
// Set date and submit vault form
const dateInput = container.querySelector('input[name="vault-date"]');
const dateInput = document.body.querySelector('input[name="vault-date"]');
if (!dateInput) throw new Error("Date input not found");
fireEvent.change(dateInput, { target: { value: "2026-12-31" } });
const confirmVaultBtn = container.querySelector(
'button[form="vault-form"]',
);
if (!confirmVaultBtn) throw new Error("Confirm vault button not found");
const confirmVaultBtn = screen.getByTestId("vault-confirm-btn");
fireEvent.click(confirmVaultBtn);
// Wait for save to complete and check readOnly
await waitFor(() => {
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
});
expect(await screen.findByTestId("save-success-toast")).toBeInTheDocument();
expect(canvas.getAttribute("data-readonly")).toBe("true");
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
expect(screen.getByTestId("recipient-input")).toBeDisabled();
});
it("should set canvas to readOnly when status is SEALED", async () => {
@@ -132,7 +132,7 @@ describe("Editor Page", () => {
}),
);
const { container } = render(
render(
<MemoryRouter initialEntries={["/write/test-id"]}>
<Routes>
<Route path="/write/:public_id" element={<Editor />} />
@@ -140,27 +140,23 @@ describe("Editor Page", () => {
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
});
await waitForElementToBeRemoved(() =>
screen.queryByTestId("opening-draft-overlay"),
);
const canvas = screen.getByTestId("canvas");
const toolbar = container.querySelector("#writer-toolbar");
const sealBtn = toolbar?.querySelector(".btn-primary");
if (!sealBtn) throw new Error("Seal button not found");
const sealBtn = screen.getByTestId("seal-trigger-btn");
fireEvent.click(sealBtn);
// The secondary seal button appears (it has btn-accent class)
const secondarySealBtn = container.querySelector(".btn-accent");
const secondarySealBtn = screen.getByTestId("seal-confirm-btn");
if (!secondarySealBtn) throw new Error("Secondary seal button not found");
fireEvent.click(secondarySealBtn);
await waitFor(() => {
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
});
expect(await screen.findByTestId("save-success-toast")).toBeInTheDocument();
expect(canvas.getAttribute("data-readonly")).toBe("true");
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
expect(screen.getByTestId("recipient-input")).toBeDisabled();
});
});
+68 -61
View File
@@ -11,6 +11,7 @@ import {
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient";
import type { LetterResponseData } from "../api/response";
import {
type CanvasStyle,
type CanvasTools,
@@ -26,7 +27,6 @@ import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal";
import { Modal } from "../components/ui/Modal";
import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore";
@@ -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;
@@ -122,26 +116,11 @@ export default function Editor() {
justSavedRef.current = false;
return;
}
const loadExistingLetter = async () => {
setIsInitialLoading(true);
const decryptAndLoadLetter = async (
letterData: LetterResponseData,
masterKey: CryptoKey,
) => {
const cryptoUtils = new CryptoUtils();
try {
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
const letterData = res.data;
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
setLetterStatus(letterData.status);
if (letterData.status === "SEALED") {
navigateRef.current(PATHS.read(public_id), { replace: true });
return;
}
if (!letterData.encrypted_dek) {
return;
}
const metadata = await cryptoUtils.decryptMetadata(
{
encrypted_content: letterData.encrypted_metadata,
@@ -173,8 +152,7 @@ export default function Editor() {
if (isPartialFailure) {
setDecryptionStatus({
status: "WARN",
message:
"Failed to decrypt some elements. Please check the render.",
message: "Failed to decrypt some elements. Please check the render.",
log: errors.toString(),
});
}
@@ -182,11 +160,30 @@ export default function Editor() {
if (canvasRef.current) {
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
}
} catch (_err) {
};
const loadExistingLetter = async () => {
setIsInitialLoading(true);
try {
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
const letterData = res.data;
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
setLetterStatus(letterData.status);
if (letterData.status === "SEALED") {
navigateRef.current(PATHS.read(public_id), { replace: true });
return;
}
if (letterData.encrypted_dek && masterKey) {
await decryptAndLoadLetter(letterData, masterKey);
}
} catch (err) {
setDecryptionStatus({
status: "ERROR",
message: "Failed to decrypt letter. Please try again later.",
log: _err instanceof Error ? _err.message : "Unknown error",
log: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsInitialLoading(false);
@@ -248,74 +245,79 @@ export default function Editor() {
}
};
const handleSave = async (
status: "SEALED" | "DRAFT" | "VAULT",
const getRequestData = async (
targetId: string,
status: string,
vaultDate?: Date,
): Promise<void> => {
setSealBtnClicked(false);
let targetId = public_id || letterIdRef.current;
if (!targetId) {
targetId = crypto.randomUUID();
}
if (saveOverlay === "SAVING" || !masterKey) return;
setSaveOverlay("SAVING");
setShowSaveOverlay(true);
): Promise<FormData> => {
const cryptoUtils = new CryptoUtils();
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 } =
await encryptCanvasImages(
canvasData,
canvasImages,
masterKey,
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
cryptoUtils,
);
const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(encryptedCanvasData),
masterKey,
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
);
const encrypted_metadata = await cryptoUtils.encryptMetadata(
{ recipient, tags: [] },
masterKey,
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
);
const formData = new FormData();
if (status === "VAULT") {
const finalDate = vaultDate || unlockDate;
formData.append("type", "VAULT");
if (finalDate) {
formData.append("unlock_at", finalDate.toISOString());
}
if (finalDate) formData.append("unlock_at", finalDate.toISOString());
formData.append("status", "SEALED");
} else {
formData.append("type", "KEPT");
formData.append("status", status);
}
formData.append("public_id", targetId);
formData.append("encrypted_content", encrypted_letter.encrypted_content);
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
formData.append(
"encrypted_metadata",
encrypted_metadata.encrypted_content,
);
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
encryptedImageFiles.forEach((blob, filename) => {
formData.append("image_files", blob, filename);
});
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
justSavedRef.current = true;
return formData;
};
const handleSave = async (
status: "SEALED" | "DRAFT" | "VAULT",
vaultDate?: Date,
): Promise<void> => {
setSealBtnClicked(false);
// use the letter's id if an existing letter or create a new id
const targetId = public_id || letterIdRef.current || crypto.randomUUID();
if (saveOverlay === "SAVING" || !masterKey) return;
setSaveOverlay("SAVING");
setShowSaveOverlay(true);
try {
const formData = await getRequestData(targetId, status, vaultDate);
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
justSavedRef.current = true;
if (!public_id) {
letterIdRef.current = targetId;
navigate(PATHS.write(targetId), { replace: true });
@@ -330,7 +332,7 @@ export default function Editor() {
}
setSaveOverlay("SAVED");
setShowSaveOverlay(true);
} catch (_error) {
} catch {
setSaveOverlay("ERROR");
setShowSaveOverlay(true);
}
@@ -380,7 +382,10 @@ export default function Editor() {
weight="bold"
className="animate-spin text-primary"
/>
<p className="text-xxs uppercase tracking-widester font-bold text-base-content/40">
<p
data-testid="opening-draft-overlay"
className="text-xxs uppercase tracking-widester font-bold text-base-content/40"
>
Opening your draft...
</p>
</div>
@@ -410,6 +415,7 @@ export default function Editor() {
{saveOverlay === "SAVED" && (
<div
role="alert"
data-testid="save-success-toast"
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
showSaveOverlay
? "opacity-100 scale-100 translate-y-0"
@@ -463,6 +469,7 @@ export default function Editor() {
</label>
<input
id="recipient"
data-testid="recipient-input"
type="text"
placeholder={toPlaceholderList[placeholderIndex]}
value={recipient}
+72 -68
View File
@@ -1,29 +1,28 @@
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";
import { useNavigate } from "react-router-dom";
import letterSample from "../assets/screenshots/letter.webp";
import Logo from "../components/Logo";
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
import Saajan from "../components/ui/Saajan.tsx";
import { ROUTES } from "../config/routes.ts";
import { formatDate } from "../utils/dateFormat.ts";
import Saajan from "../components/ui/Saajan";
import { ROUTES } from "../config/routes";
import { formatDate } from "../utils/dateFormat";
import "@fontsource/space-mono/index.css";
import "@fontsource/architects-daughter/index.css";
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 +30,7 @@ export default function Home() {
const navigate = useNavigate();
useMotionValueEvent(section1ScrollProgress, "change", (latestScrollValue) => {
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
if (latestScrollValue > 0.54) {
setFlapOpen(false);
} else {
@@ -55,33 +54,34 @@ export default function Home() {
});
return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
<section
ref={sectionContainer1}
className="relative w-full h-[850vh] bg-base-100 font-serif"
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
>
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
{/* Intro */}
<motion.div
className="absolute flex flex-col items-center justify-center pointer-events-none"
style={{
opacity: useTransform(smoothProgress1, [0, 0.12, 1], [1, 0, 0]),
scale: useTransform(smoothProgress1, [0, 0.12], [1, 10]),
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
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 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">
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
unsaid
</h2>
</motion.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]),
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">
@@ -93,25 +93,25 @@ export default function Home() {
className="absolute text-center px-6"
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[0.18, 0.25, 0.3],
[0, 1, 0],
),
y: useTransform(smoothProgress1, [0.18, 0.25, 0.3], [20, 0, -20]),
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
}}
transition={{ delay: 4 }}
>
<Logo scale={2} />
<Logo type="logo" scale={1.5} ul={true} />
<motion.div
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[0.22, 0.25, 0.35, 0.4],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
scrollYProgress,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
),
@@ -123,8 +123,8 @@ export default function Home() {
</span>
,<br />
<motion.span
className="opacity-0 text-3xl md:text-5xl"
transition={{ delay: 3 }}
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
transition={{ delay: 5 }}
whileInView={{ opacity: 1 }}
viewport={{ once: false, amount: 0.3 }}
>
@@ -137,19 +137,19 @@ export default function Home() {
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[0.3, 0.35, 0.4, 0.45],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
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{" "}
pen down your unsaid words into&nbsp;
<span className="font-display text-primary font-extralight">
letters
</span>
@@ -159,24 +159,24 @@ export default function Home() {
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
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">
seal it&nbsp;
<span className="text-success font-mono tracking-tighter font-extrabold">
secure
</span>{" "}
and{" "}
<span className="text-secondary font-display font-extralight italic">
</span>
&nbsp; and&nbsp;
<span className="text-info font-mono tracking-tighter italic">
private
</span>
.
@@ -185,24 +185,24 @@ export default function Home() {
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
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{" "}
send it to&nbsp;
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
smoothProgress1,
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
@@ -212,21 +212,20 @@ export default function Home() {
</motion.span>
<motion.span
style={{
opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
}}
>
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
smoothProgress1,
scrollYProgress,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
{" "}
or{" "}
&nbsp; or&nbsp;
</motion.span>
<span className="font-display text-success">
yourself in the future
@@ -238,29 +237,29 @@ export default function Home() {
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
scrollYProgress,
[0.75, 0.8, 0.85, 0.9],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
and even <span className="font-display text-error">burn it</span> to
release the burden.
and even <span className="font-display text-error">burn it</span>
&nbsp; to release the burden.
</motion.h2>
{/* Outro */}
<motion.h2
className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
}
style={{
opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
y: useTransform(smoothProgress1, [0.9, 1], [80, 0]),
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
}}
>
You've been carrying it long enough.
@@ -268,13 +267,13 @@ export default function Home() {
{/* 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"
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
}
style={{
opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
display: useTransform(
smoothProgress1,
scrollYProgress,
[0.96, 1],
["none", "flex"],
),
@@ -292,7 +291,7 @@ export default function Home() {
</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"
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
@@ -307,13 +306,17 @@ export default function Home() {
className={"z-21 absolute"}
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[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]),
y: useTransform(
scrollYProgress,
[0.3, 0.45, 0.5],
[300, 0, 200],
),
scale: useTransform(
smoothProgress1,
scrollYProgress,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
@@ -322,7 +325,7 @@ export default function Home() {
<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" />
<img alt="letter" src={letterSample} />
</div>
</div>
</motion.div>
@@ -331,11 +334,11 @@ export default function Home() {
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[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]),
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
@@ -353,11 +356,11 @@ export default function Home() {
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
smoothProgress1,
scrollYProgress,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(smoothProgress1, [0.98, 1], [50, -10]),
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
}}
>
<Saajan
@@ -375,7 +378,7 @@ export default function Home() {
}}
style={{
backgroundColor: useTransform(
smoothProgress1,
scrollYProgress,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
@@ -385,12 +388,13 @@ export default function Home() {
"var(--color-error)",
],
),
scale: useTransform(smoothProgress1, [0, 1], [0.6, 2.5]),
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div>
</section>
</ReactLenis>
);
}
+20 -10
View File
@@ -27,11 +27,13 @@ describe("Login Page", () => {
</MemoryRouter>,
);
await userEvent.type(screen.getByLabelText(/email/i), "test@example.com");
await userEvent.type(screen.getByLabelText(/password/i), "password123");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await userEvent.type(screen.getByTestId("email-input"), "test@example.com");
await userEvent.type(screen.getByTestId("password-input"), "password123");
await userEvent.click(screen.getByTestId("login-submit-btn"));
expect(await screen.findByText(/technical issues/i)).toBeInTheDocument();
expect(await screen.findByTestId("login-error-message")).toHaveTextContent(
/technical issues/i,
);
});
it.each([
@@ -73,16 +75,24 @@ describe("Login Page", () => {
>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/drawer" element={<div>Drawer</div>} />
<Route path="/read/:publicId" element={<div>Reader</div>} />
<Route
path="/drawer"
element={<div data-testid="drawer-page">Drawer</div>}
/>
<Route
path="/read/:publicId"
element={<div data-testid="reader-page">Reader</div>}
/>
</Routes>
</MemoryRouter>,
);
await userEvent.type(screen.getByLabelText(/email/i), "test@example.com");
await userEvent.type(screen.getByLabelText(/password/i), "password123");
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
await userEvent.type(screen.getByTestId("email-input"), "test@example.com");
await userEvent.type(screen.getByTestId("password-input"), "password123");
await userEvent.click(screen.getByTestId("login-submit-btn"));
expect(await screen.findByText(nextRoute)).toBeInTheDocument();
const expectedTestId =
nextRoute.toLowerCase() === "drawer" ? "drawer-page" : "reader-page";
expect(await screen.findByTestId(expectedTestId)).toBeInTheDocument();
});
});
+16 -12
View File
@@ -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.";
@@ -83,13 +83,13 @@ export default function Login() {
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
Enter <Logo /> Archive
<h1 className="flex items-center font-display text-2xl justify-center text-primary/80 tracking-tight">
&nbsp;&nbsp;Enter <Logo type="logo" scale={0.7} /> Archive
</h1>
{apiError && (
<div className="alert alert-error text-xs py-2 rounded-md">
<span>{apiError}</span>
<span data-testid="login-error-message">{apiError}</span>
</div>
)}
@@ -97,6 +97,7 @@ export default function Login() {
label="Email"
type="email"
placeholder="f.kafka@wrongtrain.com"
data-testid="email-input"
registration={register("email")}
error={errors.email?.message}
handleFocus={() => setSaajanMessage("I remember you.")}
@@ -106,6 +107,7 @@ export default function Login() {
label="Password"
type="password"
placeholder="••••••••"
data-testid="password-input"
registration={register("password")}
error={errors.password?.message}
handleFocus={() =>
@@ -118,27 +120,29 @@ export default function Login() {
type="submit"
name="login"
disabled={isLoading}
aria-label="Sign In"
data-testid="login-submit-btn"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Sign In"
"Continue"
)}
</button>
</div>
<div className="text-center text-sm font-medium text-base-content/70">
Don't have an account?{" "}
<div className="divider text-neutral my-0">or</div>
<div className="text-center text-sm font-medium text-neutral">
New to <Logo type="inline" />
?&nbsp;
<button
type="button"
name="register"
onClick={() => navigate(ROUTES.ONBOARD)}
className="link link-primary no-underline hover:underline font-bold"
className="link link-primary"
>
Register
Start here
</button>
.
</div>
</form>
</div>
+7 -7
View File
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -76,9 +76,9 @@ describe("Reader Page", () => {
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/Guest/i)).toBeInTheDocument();
});
expect(await screen.findByTestId("envelope-recipient")).toHaveTextContent(
/Guest/i,
);
});
it("should display an error message if the server request fails", async () => {
@@ -99,9 +99,9 @@ describe("Reader Page", () => {
</MemoryRouter>,
);
expect(
await screen.findByText(/Failed to load letter/i),
).toBeInTheDocument();
expect(await screen.findByTestId("log-modal-message")).toHaveTextContent(
/Failed to load letter/i,
);
});
it("should navigate to the login page with redirect url when the letter has no sharing key and the user is not logged in", async () => {
+80 -49
View File
@@ -1,4 +1,5 @@
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
import type { AxiosResponse } from "axios";
import { useEffect, useRef, useState } from "react";
import {
type NavigateFunction,
@@ -7,6 +8,7 @@ import {
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient";
import type { LetterImageData, LetterResponseData } from "../api/response";
import {
type CanvasJSON,
type CanvasTools,
@@ -71,7 +73,8 @@ export default function Reader() {
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
} catch (_err) {
} catch {
// shouldn't obstruct share if api operation fails (since it's client side share)
} finally {
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
}
@@ -84,7 +87,10 @@ export default function Reader() {
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
status: "BURNED",
});
} catch (_err) {
} catch {
// should not obstruct burn if api operation fails
// WHY?: it disconnects the UX. if you want to burn the letter, you should be able to burn the letter
// TODO: maybe say something like: "the wind is strong today, let's try again"? or maybe something less stupid :3
} finally {
setIsBurning(false);
setShowBurnModal(false);
@@ -103,30 +109,54 @@ export default function Reader() {
return;
}
const loadAndDecrypt = async () => {
const decryptImages = async (
canvasData: CanvasJSON,
images: LetterImageData[],
encrypted_dek: string,
cryptoUtils: CryptoUtils,
) => {
if (!images?.length) return;
const isShared = !!sharingKey;
try {
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
if (isShared) {
await decryptCanvasImagesWithSharingKey(
canvasData,
images,
sharingKey,
cryptoUtils,
);
} else {
await decryptCanvasImages(
canvasData,
images,
encrypted_dek,
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
cryptoUtils,
);
}
} catch (err) {
setLogTrace({
message:
"Failed to decrypt elements. Images might not render in the letter as intended.",
log: err instanceof Error ? err.message : "Unknown error",
type: "WARN",
});
}
};
const decryptLetterData = async (
data: LetterResponseData,
cryptoUtils: CryptoUtils,
) => {
const isShared = !!sharingKey;
const {
encrypted_content,
encrypted_metadata,
encrypted_dek,
images,
updated_at,
status,
} = response.data;
if (status === "BURNED")
throw new Error("This letter has been burned.");
if (encrypted_dek) setEncryptedDek(encrypted_dek);
const cryptoUtils = new CryptoUtils();
const isShared = !!sharingKey;
if (isShared && !encrypted_content) throw new Error("Content missing");
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
if (!(isShared || isDecryptionKeyAvailable))
throw new Error("Auth required: Decryption key is not available");
} = data;
// Decrypt Metadata
const decryptedMetadata = isShared
@@ -157,35 +187,31 @@ export default function Reader() {
);
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
try {
// Decrypt Images
if (images?.length > 0) {
isShared
? await decryptCanvasImagesWithSharingKey(
canvasData,
images,
sharingKey,
cryptoUtils,
)
: await decryptCanvasImages(
canvasData,
images,
encrypted_dek,
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
cryptoUtils,
);
}
} catch (err) {
setLogTrace({
message:
"Failed to decrypt elements. Images might not render in the letter as intended.",
log: err instanceof Error ? err.message : "Unknown error",
type: "WARN",
});
}
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
setDecryptedCanvasData(canvasData);
};
const processLetterData = async (data: LetterResponseData) => {
if (data.status === "BURNED")
throw new Error("This letter has been burned.");
if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek);
const isDecryptionKeyAvailable = data.encrypted_dek && masterKey;
if (!(!!sharingKey || isDecryptionKeyAvailable)) {
throw new Error("Auth required: Decryption key is not available");
}
const cryptoUtils = new CryptoUtils();
await decryptLetterData(data, cryptoUtils);
};
const loadAndDecryptLetter = async () => {
try {
const response: AxiosResponse<LetterResponseData> = await api.get(
`${endpoints.LETTERS}${public_id}/`,
);
await processLetterData(response.data);
} catch (err) {
setLogTrace({
message: `Failed to load letter ☹`,
@@ -195,7 +221,7 @@ export default function Reader() {
}
};
loadAndDecrypt().then(() => setIsDecrypting(false));
loadAndDecryptLetter().then(() => setIsDecrypting(false));
}, [public_id, sharingKey, masterKey]);
useEffect(() => {
@@ -217,7 +243,10 @@ export default function Reader() {
<Logo />
<div className="flex flex-col items-center gap-2">
<span className="loading loading-ring loading-md text-primary/40"></span>
<p className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse">
<p
data-testid="decryption-overlay"
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse"
>
Breaking the seal...
</p>
</div>
@@ -306,6 +335,7 @@ export default function Reader() {
<div className="flex justify-center gap-2 mt-8 z-10 relative">
<button
id="share-letter-btn"
data-testid="share-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
onClick={handleShare}
@@ -317,6 +347,7 @@ export default function Reader() {
</button>
<button
id="burn-letter-btn"
data-testid="burn-letter-btn"
type="button"
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
onClick={() => setShowBurnModal(true)}
+24 -5
View File
@@ -77,7 +77,8 @@ export default function Register() {
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
Create a <Logo /> Account
Create a<Logo type="logo" scale={0.7} />
Account
</div>
{apiError && (
@@ -89,6 +90,7 @@ export default function Register() {
<FormField
label="Pen Name"
placeholder="Word Smith"
data-testid="pen-name-input"
registration={register("full_name")}
error={errors.full_name?.message}
handleFocus={() =>
@@ -100,6 +102,7 @@ export default function Register() {
label="Email"
type="email"
placeholder="f.kafka@wrongtrain.com"
data-testid="email-input"
registration={register("email")}
error={errors.email?.message}
handleFocus={() =>
@@ -113,6 +116,7 @@ export default function Register() {
label="Password"
type="password"
placeholder="••••••••"
data-testid="password-input"
registration={register("password")}
error={errors.password?.message}
handleFocus={() =>
@@ -126,6 +130,7 @@ export default function Register() {
label="Confirm Password"
type="password"
placeholder="••••••••"
data-testid="confirm-password-input"
registration={register("confirm_password")}
error={errors.confirm_password?.message}
handleFocus={() =>
@@ -139,9 +144,9 @@ export default function Register() {
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
<p className="text-sm font-semibold">
Choose a password you won't forget. <br />
Just like life,{" "}
<span className="underline decoration-2">there is no reset</span>{" "}
here. If you lose it, your letters cannot be recovered.
Just like life,&nbsp;
<span className="underline decoration-2">there is no reset</span>
&nbsp; here. If you lose it, your letters cannot be recovered.
</p>
</div>
@@ -150,15 +155,29 @@ export default function Register() {
type="submit"
disabled={isLoading}
aria-label="Register"
data-testid="register-submit-btn"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Register"
"Begin"
)}
</button>
</div>
<div className="divider text-neutral my-0">or</div>
<div className="text-center text-sm font-medium text-neutral">
Been here before?&nbsp;
<button
type="button"
name="register"
onClick={() => navigate(ROUTES.LOGIN)}
className="link link-primary"
>
Continue where you left off
</button>
.
</div>
</form>
</div>
</div>
+2 -2
View File
@@ -23,8 +23,8 @@ export default function VerifyEmail() {
Check Your Mailbox
</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
You're one train away from starting your <Logo scale={0.8} />{" "}
journey.
You're one train away from starting your <Logo scale={0.8} />
&nbsp; journey.
</p>
</div>
+5 -3
View File
@@ -1,3 +1,5 @@
import type { LetterMetadata } from "../api/response";
export interface EncryptedLetter {
encrypted_content: string;
encrypted_dek: string;
@@ -275,7 +277,7 @@ export class CryptoUtils {
}
public async encryptMetadata(
metadata: Record<string, any>,
metadata: LetterMetadata,
masterKey: CryptoKey,
): Promise<EncryptedLetterMetadata> {
const { encryptedContent, encrypted_dek, sharingKey } =
@@ -290,7 +292,7 @@ export class CryptoUtils {
public async decryptMetadata(
encrypted_metadata: EncryptedLetter,
masterKey: CryptoKey,
): Promise<Record<string, any>> {
): Promise<LetterMetadata> {
const bytes = await this.openEnvelope(
encrypted_metadata.encrypted_content,
encrypted_metadata.encrypted_dek,
@@ -303,7 +305,7 @@ export class CryptoUtils {
public async decryptMetadataWithSharingKey(
encrypted_content: string,
sharingKey: string,
): Promise<Record<string, any>> {
): Promise<LetterMetadata> {
const bytes = await this.openEnvelopeWithSharingKey(
encrypted_content,
sharingKey,
+5 -1
View File
@@ -221,7 +221,11 @@ describe("letterLogic image helpers", () => {
],
};
const remoteImages = [
{ file_name: "photo.png.bin", file: "https://remote/photo.png.bin" },
{
public_id: "1234",
file_name: "photo.png.bin",
file: "https://remote/photo.png.bin",
},
];
vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) });
+2 -1
View File
@@ -1,4 +1,5 @@
import { api, apiServerUrl, publicApi } from "../api/apiClient";
import type { LetterImageData } from "../api/response";
import type {
CanvasJSON,
FabricImageJSON,
@@ -111,7 +112,7 @@ export async function decryptCanvasImages(
export async function decryptCanvasImagesWithSharingKey(
canvasData: CanvasJSON,
remoteImages: { file_name: string; file: string }[],
remoteImages: LetterImageData[],
sharingKey: string,
cryptoUtils: CryptoUtils,
): Promise<DecryptionResult> {
+5
View File
@@ -46,5 +46,10 @@ export default defineConfig(({ mode }) => {
host: env.FRONTEND_DOMAIN,
https: isSslEnabled ? sslCerts : undefined,
},
preview: {
port: Number(env.FRONTEND_PORT),
host: env.FRONTEND_DOMAIN,
https: isSslEnabled ? sslCerts : undefined,
},
};
});
+12
View File
@@ -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
(