Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a415a964c | |||
| 601dbd5c9b | |||
| d2d8c88f69 | |||
| 283417fe24 | |||
| df56754c55 |
@@ -26,6 +26,59 @@ jobs:
|
|||||||
path: certs/
|
path: certs/
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
backend:
|
||||||
|
name: Backend CI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup-environment
|
||||||
|
services:
|
||||||
|
test-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: piku_test
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: password123
|
||||||
|
ports:
|
||||||
|
- 5442:5432
|
||||||
|
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: "backend/uv.lock"
|
||||||
|
|
||||||
|
- name: Restore certificates
|
||||||
|
uses: christopherHX/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ssl-certs
|
||||||
|
path: certs/
|
||||||
|
|
||||||
|
- name: Setup Environment
|
||||||
|
run: |
|
||||||
|
cp ../.env.example ../.env
|
||||||
|
uv sync
|
||||||
|
if [ "$GITEA_ACTIONS" = "true" ]; then
|
||||||
|
export DB_HOST="test-db"
|
||||||
|
export DB_PORT="5432"
|
||||||
|
else
|
||||||
|
export DB_HOST="127.0.0.1"
|
||||||
|
export DB_PORT="5442"
|
||||||
|
fi
|
||||||
|
export DB_PASSWORD='password123'
|
||||||
|
export DB_NAME="piku_test"
|
||||||
|
export DB_USER="test"
|
||||||
|
|
||||||
|
- name: Lint & Test
|
||||||
|
run: |
|
||||||
|
|
||||||
|
|
||||||
|
uv run ruff check
|
||||||
|
uv run python manage.py test
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend CI
|
name: Frontend CI
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -57,57 +110,6 @@ jobs:
|
|||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
run: bun run test
|
run: bun run test
|
||||||
|
|
||||||
backend:
|
|
||||||
name: Backend CI
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: setup-environment
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: piku__test
|
|
||||||
POSTGRES_USER: test
|
|
||||||
POSTGRES_PASSWORD: password123
|
|
||||||
ports:
|
|
||||||
- 5442:5432
|
|
||||||
options: --tmpfs /var/lib/postgresql/data --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: ./backend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: "backend/uv.lock"
|
|
||||||
|
|
||||||
- name: Restore certificates
|
|
||||||
uses: christopherHX/gitea-download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ssl-certs
|
|
||||||
path: certs/
|
|
||||||
|
|
||||||
- name: Setup & Test
|
|
||||||
run: |
|
|
||||||
cp ../.env.example ../.env
|
|
||||||
uv sync
|
|
||||||
|
|
||||||
export DB_NAME="piku__test"
|
|
||||||
export DB_USER="test"
|
|
||||||
export DB_PASSWORD="password123"
|
|
||||||
|
|
||||||
if [ "$GITEA_ACTIONS" = "true" ]; then
|
|
||||||
export DB_HOST="db"
|
|
||||||
export DB_PORT="5432"
|
|
||||||
else
|
|
||||||
export DB_HOST="127.0.0.1"
|
|
||||||
export DB_PORT="5442"
|
|
||||||
fi
|
|
||||||
|
|
||||||
uv run ruff check
|
|
||||||
uv run python manage.py test
|
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -133,7 +135,7 @@ jobs:
|
|||||||
# Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing
|
# Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing
|
||||||
# TODO: setup cache server in Gitea
|
# TODO: setup cache server in Gitea
|
||||||
if: github.server_url == 'https://github.com'
|
if: github.server_url == 'https://github.com'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/bun.lock') }}
|
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/bun.lock') }}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { AuthHelper } from "./utils/auth";
|
import { AuthHelper } from "./utils/auth";
|
||||||
import { revealEnvelope } from "./utils/envelope";
|
|
||||||
|
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
transport: {
|
transport: {
|
||||||
@@ -23,19 +22,20 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||||
|
|
||||||
logger.info(">> [Draft] Navigating to Editor via UI...");
|
logger.info(">> [Draft] Navigating to Editor via UI...");
|
||||||
await page.getByTestId("write-letter-btn").click();
|
await page.getByRole("button", { name: /write something/i }).click();
|
||||||
|
|
||||||
logger.info(`>> [Draft] Current URL after click: ${page.url()}`);
|
logger.info(`>> [Draft] Current URL after click: ${page.url()}`);
|
||||||
|
|
||||||
// Editor page
|
// Wait for the recipient input to be present in the DOM
|
||||||
await expect(page.getByTestId("recipient-input")).toBeVisible();
|
const recipientInput = page.locator("#recipient");
|
||||||
const recipientInput = page.getByTestId("recipient-input");
|
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
|
||||||
|
|
||||||
const recipientName = "Dear Friend";
|
const recipientName = "Dear Friend";
|
||||||
await recipientInput.fill(recipientName);
|
await recipientInput.fill(recipientName);
|
||||||
|
|
||||||
// Initial load: verify textarea value (populated by Fabric when focused)
|
// Initial load: verify textarea value (populated by Fabric when focused)
|
||||||
const canvasInput = page.locator("textarea");
|
const canvasInput = page.locator("textarea");
|
||||||
|
await canvasInput.waitFor({ state: "attached" });
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
await expect(canvasInput).toHaveValue(/Take a deep breath/i);
|
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.press("Enter");
|
||||||
await page.keyboard.type("It should persist.");
|
await page.keyboard.type("It should persist.");
|
||||||
logger.info(">> [Draft] Clicking Draft...");
|
logger.info(">> [Draft] Clicking Draft...");
|
||||||
await page.getByTestId("draft-btn").click();
|
await page.getByRole("button", { name: /draft/i }).click();
|
||||||
|
|
||||||
// Verify Success Modal/Alert
|
// Verify Success Modal/Alert
|
||||||
await expect(page.getByTestId("save-success-toast")).toBeVisible();
|
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
|
||||||
|
|
||||||
// Verify URL updated with a UUID
|
// Verify URL updated with a UUID
|
||||||
await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/);
|
await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/);
|
||||||
@@ -61,16 +61,24 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await page.goto(savedUrl);
|
await page.goto(savedUrl);
|
||||||
|
|
||||||
// Wait for initial load overlay to appear and then definitely disappear
|
// Wait for initial load overlay to appear and then definitely disappear
|
||||||
await expect(page.getByTestId("opening-draft-overlay")).toBeHidden();
|
await page
|
||||||
|
.getByText(/opening your draft/i)
|
||||||
|
.waitFor({ state: "visible", timeout: 2000 })
|
||||||
|
.catch(() => {});
|
||||||
|
await expect(page.getByText(/opening your draft/i)).toBeHidden({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
// Check recipient
|
// Check recipient
|
||||||
await expect(page.getByTestId("recipient-input")).toHaveValue(recipientName);
|
await expect(page.locator("#recipient")).toHaveValue(recipientName);
|
||||||
|
|
||||||
// Check canvas content
|
// Check canvas content
|
||||||
// We wait for the content to appear in the textarea.
|
// We wait for the content to appear in the textarea.
|
||||||
// toHaveValue will poll until it matches or timeouts.
|
// toHaveValue will poll until it matches or timeouts.
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
await expect(canvasInput).toHaveValue(/This is a secret draft/i);
|
await expect(canvasInput).toHaveValue(/This is a secret draft/i, {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
await expect(canvasInput).toHaveValue(/It should persist/i);
|
await expect(canvasInput).toHaveValue(/It should persist/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,9 +92,10 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||||
|
|
||||||
logger.info(">> [Seal] Navigating to Editor via UI...");
|
logger.info(">> [Seal] Navigating to Editor via UI...");
|
||||||
await page.getByTestId("write-letter-btn").click();
|
await page.locator("#write-letter-btn").click();
|
||||||
|
|
||||||
const recipientInput = page.getByTestId("recipient-input");
|
const recipientInput = page.locator("#recipient");
|
||||||
|
await recipientInput.waitFor({ state: "visible", timeout: 10000 });
|
||||||
await recipientInput.fill("A Secret Guest");
|
await recipientInput.fill("A Secret Guest");
|
||||||
|
|
||||||
const canvasInput = page.locator("textarea");
|
const canvasInput = page.locator("textarea");
|
||||||
@@ -95,41 +104,55 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
|
|
||||||
// Click Seal (open menu, then confirm)
|
// Click Seal (open menu, then confirm)
|
||||||
logger.info(">> [Seal] Clicking Seal...");
|
logger.info(">> [Seal] Clicking Seal...");
|
||||||
await page.getByTestId("seal-trigger-btn").click();
|
await page
|
||||||
await page.getByTestId("seal-confirm-btn").click();
|
.getByRole("button", { name: /seal/i })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /seal/i })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
// Should show sealed confirmation modal
|
// Should show sealed confirmation modal
|
||||||
logger.info(">> [Seal] Verifying sealed modal...");
|
logger.info(">> [Seal] Verifying sealed modal...");
|
||||||
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
|
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to Reader via "View letter"
|
// Navigate to Reader via "View letter"
|
||||||
await page.getByTestId("view-letter-btn").click();
|
await page.getByRole("button", { name: /view letter/i }).click();
|
||||||
|
|
||||||
// Should be on Reader URL
|
// Should be on Reader URL
|
||||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/);
|
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
|
||||||
|
|
||||||
// Open the envelope to reveal the letter
|
// Open the envelope to reveal the letter
|
||||||
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
|
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
||||||
// Flip the envelope to show the seal and reveal the letter
|
timeout: 10000,
|
||||||
await revealEnvelope(page);
|
});
|
||||||
await expect(page.getByTestId("envelope-letter")).toBeHidden();
|
// 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 });
|
||||||
|
|
||||||
// Share on demand
|
// Share on demand
|
||||||
logger.info(">> [Seal] Clicking Share button in Reader...");
|
logger.info(">> [Seal] Clicking Share button in Reader...");
|
||||||
await page.getByTestId("share-letter-btn").click();
|
await page.locator("#share-letter-btn").click();
|
||||||
|
|
||||||
// Verify share modal with a valid link
|
// Verify share modal with a valid link
|
||||||
await expect(page.getByTestId("share-letter-modal")).toBeVisible();
|
await expect(page.getByText(/send this letter/i)).toBeVisible();
|
||||||
const linkInput = page.locator("#share-link-input");
|
const linkInput = page.locator("#share-link-input");
|
||||||
const linkValue = await linkInput.inputValue();
|
const linkValue = await linkInput.inputValue();
|
||||||
expect(linkValue).toContain("/read/");
|
expect(linkValue).toContain("/read/");
|
||||||
expect(linkValue).toContain("#");
|
expect(linkValue).toContain("#");
|
||||||
logger.info(`>> [Seal] Sharing link: ${linkValue}`);
|
logger.info(`>> [Seal] Sharing link: ${linkValue}`);
|
||||||
|
|
||||||
await expect(page.getByTestId("copy-link-btn")).toBeVisible();
|
await expect(page.getByRole("button", { name: /copy/i })).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.getByRole("button", { name: /close/i }).click();
|
||||||
await page.getByTestId("modal-close-btn").click();
|
await expect(page.getByText(/send this letter/i)).toBeHidden();
|
||||||
await expect(page.getByTestId("share-letter-modal")).toBeHidden();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
||||||
@@ -144,9 +167,10 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||||
|
|
||||||
logger.info(">> [Drawer] Creating and sealing a letter...");
|
logger.info(">> [Drawer] Creating and sealing a letter...");
|
||||||
await page.getByTestId("write-letter-btn").click();
|
await page.getByRole("button", { name: /write something/i }).click();
|
||||||
|
|
||||||
const recipientInput = page.getByTestId("recipient-input");
|
const recipientInput = page.locator("#recipient");
|
||||||
|
await recipientInput.waitFor({ state: "visible" });
|
||||||
await recipientInput.fill(recipientName);
|
await recipientInput.fill(recipientName);
|
||||||
|
|
||||||
const canvasInput = page.locator("textarea");
|
const canvasInput = page.locator("textarea");
|
||||||
@@ -154,34 +178,59 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await canvasInput.fill(letterContent);
|
await canvasInput.fill(letterContent);
|
||||||
|
|
||||||
// Click Seal (open menu, then confirm)
|
// Click Seal (open menu, then confirm)
|
||||||
await page.getByTestId("seal-trigger-btn").click();
|
await page
|
||||||
await page.getByTestId("seal-confirm-btn").click();
|
.getByRole("button", { name: /seal/i })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /seal/i })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
// Sealed modal should appear — click "Keep it" to go to Drawer
|
// Sealed modal should appear — click "Keep it" to go to Drawer
|
||||||
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
|
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
||||||
await page.getByTestId("keep-it-btn").click();
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: /keep it to myself/i }).click();
|
||||||
|
|
||||||
// Open "Kept" section - search for the section with id='kept' and click its toggle button
|
// Open "Kept" section - search for the section with id='kept' and click its toggle button
|
||||||
logger.info(">> [Drawer] Opening Kept section...");
|
logger.info(">> [Drawer] Opening Kept section...");
|
||||||
await page.getByTestId("drawer-section-kept").click();
|
const keptSection = page.locator("#kept");
|
||||||
|
await keptSection.getByRole("button", { name: /kept/i }).click();
|
||||||
|
|
||||||
// Find the sealed letter in the drawer by recipient name and click it
|
// Find the sealed letter in the drawer by recipient name and click it
|
||||||
logger.info(">> [Drawer] Clicking sealed letter in drawer...");
|
logger.info(">> [Drawer] Clicking sealed letter in drawer...");
|
||||||
const sealedItem = page
|
const sealedItem = page
|
||||||
.getByTestId(/^letter-item-/)
|
.getByRole("button", { name: new RegExp(recipientName, "i") })
|
||||||
.filter({ hasText: recipientName })
|
|
||||||
.first();
|
.first();
|
||||||
await sealedItem.click();
|
await sealedItem.click();
|
||||||
|
|
||||||
// Verify it opens the Reader without a hash
|
// Verify it opens the Reader without a hash
|
||||||
logger.info(">> [Drawer] Verifying Reader page...");
|
logger.info(">> [Drawer] Verifying Reader page...");
|
||||||
// Give it a bit more time for decryption
|
// Give it a bit more time for decryption
|
||||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/);
|
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
|
||||||
// Reveal and check decrypted content in Reader
|
// Reveal and check decrypted content in Reader
|
||||||
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
|
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
||||||
// Flip the envelope and reveal the letter
|
timeout: 10000,
|
||||||
await revealEnvelope(page);
|
});
|
||||||
await expect(page.getByTestId("envelope-letter")).toBeHidden();
|
// 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 });
|
||||||
|
|
||||||
// Also check if we are redirected to the Reader if we manually go to the Editor URL
|
// Also check if we are redirected to the Reader if we manually go to the Editor URL
|
||||||
const readerUrl = page.url();
|
const readerUrl = page.url();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { MailpitHelper } from "./mailpit";
|
import { MailpitHelper } from "./mailpit";
|
||||||
import { handleWelcomeLetter } from "./envelope";
|
|
||||||
|
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
transport: {
|
transport: {
|
||||||
@@ -24,11 +23,11 @@ async function registerAndLogin(
|
|||||||
// Register the User
|
// Register the User
|
||||||
logger.info(`[Auth] Registering user: ${email}`);
|
logger.info(`[Auth] Registering user: ${email}`);
|
||||||
await page.goto("/onboard");
|
await page.goto("/onboard");
|
||||||
await page.getByTestId("pen-name-input").fill(fullName);
|
await page.getByLabel(/pen name/i).fill(fullName);
|
||||||
await page.getByTestId("email-input").fill(email);
|
await page.getByLabel("Email", { exact: true }).fill(email);
|
||||||
await page.getByTestId("password-input").fill(password);
|
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||||
await page.getByTestId("confirm-password-input").fill(password);
|
await page.getByLabel(/confirm password/i).fill(password);
|
||||||
await page.getByTestId("register-submit-btn").click();
|
await page.getByRole("button", { name: /^register$/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/verify-email/);
|
await expect(page).toHaveURL(/\/verify-email/);
|
||||||
|
|
||||||
@@ -38,23 +37,23 @@ async function registerAndLogin(
|
|||||||
|
|
||||||
await page.goto(activationLink);
|
await page.goto(activationLink);
|
||||||
|
|
||||||
await expect(page.getByTestId("activation-success-header")).toBeVisible();
|
await expect(page.getByText(/account activated/i)).toBeVisible();
|
||||||
await page.getByTestId("start-writing-btn").click();
|
await page.getByRole("button", { name: /start writing/i }).click();
|
||||||
|
|
||||||
// Dismiss the Welcom Modal and Perform Login
|
// Dismiss the Welcom Modal and Perform Login
|
||||||
logger.info(`[Auth] Logging in...`);
|
logger.info(`[Auth] Logging in...`);
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
await page.getByTestId("welcome-dismiss-btn").click();
|
const welcomeButton = page.getByRole("button", { name: /I'll remember/i });
|
||||||
await expect(page.getByTestId("welcome-dismiss-btn")).toBeHidden();
|
await welcomeButton.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await welcomeButton.click();
|
||||||
|
await expect(welcomeButton).toBeHidden();
|
||||||
|
|
||||||
await page.getByTestId("email-input").fill(email);
|
await page.getByLabel("Email", { exact: true }).fill(email);
|
||||||
await page.getByTestId("password-input").fill(password);
|
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||||
await page.getByTestId("login-submit-btn").click();
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/drawer/);
|
await expect(page).toHaveURL(/\/drawer/);
|
||||||
await handleWelcomeLetter(page);
|
|
||||||
logger.info(`[Auth] Successfully authenticated ${email}`);
|
logger.info(`[Auth] Successfully authenticated ${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthHelper = { registerAndLogin };
|
export const AuthHelper = { registerAndLogin };
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,70 +1,70 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b & vite build",
|
"build": "tsc -b & vite build",
|
||||||
"build:prod": "vite build --mode production",
|
"build:prod": "vite build --mode production",
|
||||||
"lint": "biome lint --write ./src",
|
"lint": "biome lint --write ./src",
|
||||||
"format": "biome format --write ./src",
|
"format": "biome format --write ./src",
|
||||||
"check": "biome check --write ./src",
|
"check": "biome check --write ./src",
|
||||||
"check-all": "biome check --write .",
|
"check-all": "biome check --write .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jost": "^5.2.8",
|
"@fontsource-variable/jost": "^5.2.8",
|
||||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||||
"@fontsource/architects-daughter": "^5.2.7",
|
"@fontsource/architects-daughter": "^5.2.7",
|
||||||
"@fontsource/cutive-mono": "^5.2.8",
|
"@fontsource/cutive-mono": "^5.2.8",
|
||||||
"@fontsource/kavivanar": "^5.2.8",
|
"@fontsource/kavivanar": "^5.2.8",
|
||||||
"@fontsource/knewave": "^5.2.7",
|
"@fontsource/knewave": "^5.2.7",
|
||||||
"@fontsource/redacted-script": "^5.2.8",
|
"@fontsource/redacted-script": "^5.2.8",
|
||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.19",
|
||||||
"fabric": "^7.2.0",
|
"fabric": "^7.2.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.11",
|
"@biomejs/biome": "^2.4.11",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.4",
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"msw": "^2.13.2",
|
"msw": "^2.13.2",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.0.4",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const baseUrl = getBaseUrl(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
timeout: 80000,
|
timeout: 60000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
},
|
},
|
||||||
@@ -60,8 +60,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
// NOTE: using npm here for docker compat mainly
|
command: "npm run dev -- --mode e2e",
|
||||||
command: "npm run build -- --mode e2e && npm run preview -- --mode e2e",
|
|
||||||
url: getBaseUrl(
|
url: getBaseUrl(
|
||||||
process.env.SSL_ENABLED === "true",
|
process.env.SSL_ENABLED === "true",
|
||||||
process.env.FRONTEND_DOMAIN,
|
process.env.FRONTEND_DOMAIN,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 755 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 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 |
@@ -1 +1,19 @@
|
|||||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
{
|
||||||
|
"name": "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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef } from "react";
|
import { lazy, Suspense, useEffect, useRef } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import {
|
||||||
import { AutoRedirectRoute, ProtectedRoute } from "./components/RouteGuards";
|
BrowserRouter,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
ScrollRestoration,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||||
import SplashScreen from "./components/SplashScreen";
|
import SplashScreen from "./components/SplashScreen";
|
||||||
import { ROUTES } from "./config/routes";
|
import { ROUTES } from "./config/routes";
|
||||||
import { useAuth } from "./hooks/useAuth";
|
import { useAuth } from "./hooks/useAuth";
|
||||||
@@ -31,48 +37,41 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-50 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]">
|
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
|
||||||
<Suspense fallback={<SplashScreen />}>
|
<Suspense fallback={<SplashScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path={ROUTES.HOME} element={<Home />} />
|
||||||
path={ROUTES.HOME}
|
|
||||||
element={
|
|
||||||
<AutoRedirectRoute>
|
|
||||||
<Home />
|
|
||||||
</AutoRedirectRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path={ROUTES.ONBOARD}
|
path={ROUTES.ONBOARD}
|
||||||
element={
|
element={
|
||||||
<AutoRedirectRoute>
|
<PublicRoute>
|
||||||
<Register />
|
<Register />
|
||||||
</AutoRedirectRoute>
|
</PublicRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={ROUTES.LOGIN}
|
path={ROUTES.LOGIN}
|
||||||
element={
|
element={
|
||||||
<AutoRedirectRoute>
|
<PublicRoute>
|
||||||
<Login />
|
<Login />
|
||||||
</AutoRedirectRoute>
|
</PublicRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={ROUTES.VERIFY_EMAIL}
|
path={ROUTES.VERIFY_EMAIL}
|
||||||
element={
|
element={
|
||||||
<AutoRedirectRoute>
|
<PublicRoute>
|
||||||
<VerifyEmail />
|
<VerifyEmail />
|
||||||
</AutoRedirectRoute>
|
</PublicRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={ROUTES.ACTIVATE}
|
path={ROUTES.ACTIVATE}
|
||||||
element={
|
element={
|
||||||
<AutoRedirectRoute>
|
<PublicRoute>
|
||||||
<Activate />
|
<Activate />
|
||||||
</AutoRedirectRoute>
|
</PublicRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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[];
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 738 KiB After Width: | Height: | Size: 738 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -1,22 +1,20 @@
|
|||||||
import { DotIcon } from "@phosphor-icons/react";
|
import { DotIcon } from "@phosphor-icons/react";
|
||||||
import logo from "../assets/logo.svg";
|
|
||||||
import "@fontsource/knewave/400.css";
|
import "@fontsource/knewave/400.css";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
scale?: number;
|
scale?: number;
|
||||||
type?: "inline" | "mono" | "logo" | null;
|
type?: "inline" | "mono" | "logo";
|
||||||
ul?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Logo({
|
export default function Logo({ scale = 1, type = "logo" }: LogoProps) {
|
||||||
scale = 1,
|
|
||||||
type = null,
|
|
||||||
ul = false,
|
|
||||||
}: LogoProps) {
|
|
||||||
if (type === "inline") {
|
if (type === "inline") {
|
||||||
return (
|
return (
|
||||||
<span className={"text-accent font-display italic "}>
|
<span
|
||||||
pi<span className="text-primary">.</span> ku
|
className={
|
||||||
|
"text-accent font-serif italic drop-shadow-xs drop-shadow-base-200/60 "
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Pi<span className="text-primary">.</span> Ku
|
||||||
<span className="text-primary">.</span>
|
<span className="text-primary">.</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -24,36 +22,30 @@ export default function Logo({
|
|||||||
|
|
||||||
if (type === "mono") {
|
if (type === "mono") {
|
||||||
return (
|
return (
|
||||||
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
|
<span className="font-mono italic font-bold border-b-3 border-dashed border-stone-800/50">
|
||||||
pi. ku.
|
pi. ku.
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "logo") {
|
|
||||||
return (
|
|
||||||
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Pi. Ku. logo"
|
aria-label="Pi. Ku. logo"
|
||||||
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
|
className="inline-flex items-baseline justify-center leading-none select-none"
|
||||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
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
|
<DotIcon
|
||||||
weight="fill"
|
weight="fill"
|
||||||
size={12}
|
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"> Ku</span>
|
<span className={`text-3xl font-light text-accent`}> Ku</span>
|
||||||
<DotIcon
|
<DotIcon
|
||||||
weight="fill"
|
weight="fill"
|
||||||
size={12}
|
size={12}
|
||||||
className="text-primary translate-y-1 -mx-px"
|
className={`text-primary translate-y-1 -mx-px`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,20 +3,14 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
import { AutoRedirectRoute, ProtectedRoute } from "./RouteGuards";
|
import { ProtectedRoute, PublicRoute } from "./RouteGuards";
|
||||||
|
|
||||||
function renderGuard(ui: React.ReactNode, mountPath: "/protected" | "/public") {
|
function renderGuard(ui: React.ReactNode, mountPath: "/protected" | "/public") {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[mountPath]}>
|
<MemoryRouter initialEntries={[mountPath]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/login" element={<div>Login Page</div>} />
|
||||||
path="/login"
|
<Route path="/drawer" element={<div>Drawer Page</div>} />
|
||||||
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="/protected" element={ui} />
|
||||||
<Route path="/public" element={ui} />
|
<Route path="/public" element={ui} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -41,13 +35,13 @@ describe("ProtectedRoute", () => {
|
|||||||
});
|
});
|
||||||
renderGuard(
|
renderGuard(
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div data-testid="secret-page">Secret</div>
|
<div>Secret</div>
|
||||||
</ProtectedRoute>,
|
</ProtectedRoute>,
|
||||||
"/protected",
|
"/protected",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("splash-screen")).toBeInTheDocument();
|
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("secret-page")).not.toBeInTheDocument();
|
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should redirect unauthenticated users to /login", () => {
|
it("should redirect unauthenticated users to /login", () => {
|
||||||
@@ -58,12 +52,12 @@ describe("ProtectedRoute", () => {
|
|||||||
});
|
});
|
||||||
renderGuard(
|
renderGuard(
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div data-testid="secret-page">Secret</div>
|
<div>Secret</div>
|
||||||
</ProtectedRoute>,
|
</ProtectedRoute>,
|
||||||
"/protected",
|
"/protected",
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
expect(screen.getByText("Login Page")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("secret-page")).not.toBeInTheDocument();
|
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render page for authenticated users", () => {
|
it("should render page for authenticated users", () => {
|
||||||
@@ -74,12 +68,12 @@ describe("ProtectedRoute", () => {
|
|||||||
});
|
});
|
||||||
renderGuard(
|
renderGuard(
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div data-testid="secret-page">Secret</div>
|
<div>Secret</div>
|
||||||
</ProtectedRoute>,
|
</ProtectedRoute>,
|
||||||
"/protected",
|
"/protected",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("secret-page")).toBeInTheDocument();
|
expect(screen.getByText("Secret")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,13 +85,13 @@ describe("PublicRoute", () => {
|
|||||||
user: null,
|
user: null,
|
||||||
});
|
});
|
||||||
renderGuard(
|
renderGuard(
|
||||||
<AutoRedirectRoute>
|
<PublicRoute>
|
||||||
<div data-testid="mock-login-page">Login Page</div>
|
<div>Login Page</div>
|
||||||
</AutoRedirectRoute>,
|
</PublicRoute>,
|
||||||
"/public",
|
"/public",
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("splash-screen")).toBeInTheDocument();
|
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("mock-login-page")).not.toBeInTheDocument();
|
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should redirect authenticated users to /drawer", () => {
|
it("should redirect authenticated users to /drawer", () => {
|
||||||
@@ -107,13 +101,13 @@ describe("PublicRoute", () => {
|
|||||||
user: mockUser,
|
user: mockUser,
|
||||||
});
|
});
|
||||||
renderGuard(
|
renderGuard(
|
||||||
<AutoRedirectRoute>
|
<PublicRoute>
|
||||||
<div data-testid="login-form">Login Form</div>
|
<div>Login Form</div>
|
||||||
</AutoRedirectRoute>,
|
</PublicRoute>,
|
||||||
"/public",
|
"/public",
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("drawer-page")).toBeInTheDocument();
|
expect(screen.getByText("Drawer Page")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("login-form")).not.toBeInTheDocument();
|
expect(screen.queryByText("Login Form")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render page for unauthenticated users", () => {
|
it("should render page for unauthenticated users", () => {
|
||||||
@@ -123,11 +117,11 @@ describe("PublicRoute", () => {
|
|||||||
user: null,
|
user: null,
|
||||||
});
|
});
|
||||||
renderGuard(
|
renderGuard(
|
||||||
<AutoRedirectRoute>
|
<PublicRoute>
|
||||||
<div data-testid="login-form">Login Form</div>
|
<div>Login Form</div>
|
||||||
</AutoRedirectRoute>,
|
</PublicRoute>,
|
||||||
"/public",
|
"/public",
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
expect(screen.getByText("Login Form")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-redirect - auth route guard.
|
* Public - auth route guard.
|
||||||
* If authenticated, redirect all the auth related flows to the drawer
|
* If authenticated, redirect all the auth related flows to the drawer
|
||||||
*/
|
*/
|
||||||
export function AutoRedirectRoute({ children }: { children: React.ReactNode }) {
|
export function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isInitializing } = useAuth();
|
const { isAuthenticated, isInitializing } = useAuth();
|
||||||
|
|
||||||
if (isInitializing) return <SplashScreen />;
|
if (isInitializing) return <SplashScreen />;
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import Logo from "./Logo";
|
|||||||
|
|
||||||
export default function SplashScreen() {
|
export default function SplashScreen() {
|
||||||
return (
|
return (
|
||||||
<div
|
<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')">
|
||||||
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">
|
<div className="flex flex-col items-center gap-6 animate-pulse">
|
||||||
<Logo />
|
<Logo />
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,19 @@ import { GearFineIcon } from "@phosphor-icons/react";
|
|||||||
interface DrawerSectionProps {
|
interface DrawerSectionProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
count: number;
|
count: string;
|
||||||
subtext: string;
|
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
icon: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawerSection({
|
export function DrawerSection({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
count,
|
count,
|
||||||
subtext,
|
|
||||||
isOpen,
|
isOpen,
|
||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
icon,
|
|
||||||
}: DrawerSectionProps) {
|
}: DrawerSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -27,36 +23,22 @@ export function DrawerSection({
|
|||||||
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
|
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`bg-neutral/10 transition-all duration-1000 ease-in-out overflow-visible ${isOpen ? "max-h-125" : "max-h-0 pointer-events-none"}`}
|
className={`transition-all duration-1500 ease-in-out bg-neutral/10 ${
|
||||||
|
isOpen
|
||||||
|
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible"
|
||||||
|
: "max-h-0 opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
{children}
|
||||||
className={`transition-opacity ease-in-out ${
|
|
||||||
isOpen
|
|
||||||
? "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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-testid={`drawer-section-${id}`}
|
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`}
|
||||||
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 className="flex-1">
|
||||||
<div
|
<div
|
||||||
data-testid="drawer-section-title"
|
|
||||||
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${
|
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${
|
||||||
isOpen
|
isOpen
|
||||||
? "text-base-content"
|
? "text-base-content"
|
||||||
@@ -65,15 +47,8 @@ export function DrawerSection({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-sans text-xs text-base-content/20 mt-1">
|
<div className="font-sans text-[0.6rem] text-base-content/20 mt-1">
|
||||||
<span className="font-mono text-xs md:text-base -mt-1 absolute text-primary/30">
|
{count}
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="ml-3">{subtext}</span>
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-5 -translate-y-15 text-base-content/4">
|
|
||||||
{icon}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,9 @@ export function LetterItem({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNavigate}
|
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`}
|
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-sm italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60">
|
<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]">
|
||||||
{preview}
|
{preview}
|
||||||
</div>
|
</div>
|
||||||
{unlock_at ? (
|
{unlock_at ? (
|
||||||
@@ -53,7 +52,7 @@ export function LetterItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="font-sans text-xs text-base-content/20">
|
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
|
||||||
{timestamp}
|
{timestamp}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
import { HourglassSimpleMediumIcon } from "@phosphor-icons/react";
|
import { LockKeyIcon } from "@phosphor-icons/react";
|
||||||
import { useAuth } from "../../hooks/useAuth";
|
|
||||||
import { Modal } from "../ui/Modal";
|
import { Modal } from "../ui/Modal";
|
||||||
|
|
||||||
export function PasskeyModal() {
|
interface PasskeyModalProps {
|
||||||
const { unlock } = useAuth();
|
onUnlock: (password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={true}>
|
<Modal isOpen={true}>
|
||||||
<HourglassSimpleMediumIcon
|
<LockKeyIcon
|
||||||
size={48}
|
size={48}
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
className="text-primary mx-auto mb-8 animate-pulse"
|
||||||
weight="duotone"
|
|
||||||
/>
|
/>
|
||||||
<h3
|
<h3 className="font-bold text-lg font-display text-primary">
|
||||||
data-testid="passkey-modal-title"
|
Authentication Required
|
||||||
className="font-bold text-lg font-display text-primary"
|
|
||||||
>
|
|
||||||
You've been away a while.
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="py-4 font-sans">
|
<p className="py-4 font-sans">
|
||||||
Your letters are still there. Just need the key once more.
|
We need your passkey to open your letters
|
||||||
</p>
|
</p>
|
||||||
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
||||||
<p className="text-xs text-neutral-content/30 font-mono italic">
|
<p className="text-xs text-neutral-content/30 font-mono italic">
|
||||||
Nothing was lost.
|
Your passkey is used to decrypt your data locally.
|
||||||
</p>
|
</p>
|
||||||
<div className="modal-action items-center gap-4">
|
<div className="modal-action items-center gap-4">
|
||||||
<form
|
<form
|
||||||
@@ -33,7 +30,7 @@ export function PasskeyModal() {
|
|||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
if (!password) return;
|
if (!password) return;
|
||||||
await unlock(password);
|
await onUnlock(password);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -41,15 +38,10 @@ export function PasskeyModal() {
|
|||||||
required
|
required
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="password"
|
placeholder="password"
|
||||||
data-testid="passkey-input"
|
|
||||||
className="font-sans validator input input-bordered rounded-r-none"
|
className="font-sans validator input input-bordered rounded-r-none"
|
||||||
/>
|
/>
|
||||||
<div className="validator-message text-xs text-error"></div>
|
<div className="validator-message text-xs text-error"></div>
|
||||||
<button
|
<button type="submit" className="btn btn-primary rounded-l-none">
|
||||||
type="submit"
|
|
||||||
data-testid="passkey-submit-btn"
|
|
||||||
className="btn btn-primary rounded-l-none"
|
|
||||||
>
|
|
||||||
Unlock
|
Unlock
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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,12 +2,6 @@ import * as fabric from "fabric";
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||||
|
|
||||||
import "@fontsource/kavivanar/index.css";
|
|
||||||
import "@fontsource/space-mono/index.css";
|
|
||||||
import "@fontsource/cutive-mono/index.css";
|
|
||||||
import "@fontsource/architects-daughter/index.css";
|
|
||||||
import "@fontsource/redacted-script/index.css";
|
|
||||||
|
|
||||||
const PAD = 36;
|
const PAD = 36;
|
||||||
const BASE_WIDTH = 680;
|
const BASE_WIDTH = 680;
|
||||||
const DEFAULT_LOGICAL_HEIGHT = 900;
|
const DEFAULT_LOGICAL_HEIGHT = 900;
|
||||||
@@ -122,7 +116,6 @@ export function ComposeCanvas({
|
|||||||
// re-calculates height based on content and applies the zoom transform
|
// re-calculates height based on content and applies the zoom transform
|
||||||
const syncViewport = useCallback(() => {
|
const syncViewport = useCallback(() => {
|
||||||
if (!(fabricRef.current && wrapperRef.current)) return;
|
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||||
textboxRef.current?.initDimensions();
|
|
||||||
|
|
||||||
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
|
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
|
||||||
logicalSizeRef.current.height = measureLogicalContentHeight(
|
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||||
@@ -191,7 +184,9 @@ export function ComposeCanvas({
|
|||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
fill: DEFAULT_FONT_COLOR,
|
fill: DEFAULT_FONT_COLOR,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
splitByGrapheme: false,
|
// NOTE: splitByGrapheme is required for word wrap and re-low
|
||||||
|
// but fabric asks to disable this for clear font?? So we disable it for read view
|
||||||
|
splitByGrapheme: !readOnly,
|
||||||
lockMovementX: true,
|
lockMovementX: true,
|
||||||
lockMovementY: true,
|
lockMovementY: true,
|
||||||
lockScalingX: true,
|
lockScalingX: true,
|
||||||
@@ -225,16 +220,6 @@ export function ComposeCanvas({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const img of canvas.getObjects("Image")) {
|
|
||||||
img.set({
|
|
||||||
hasControls: !readOnly,
|
|
||||||
hasBorders: !readOnly,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
|
|
||||||
await document.fonts.ready;
|
|
||||||
textbox.set("dirty", true);
|
|
||||||
syncViewport();
|
syncViewport();
|
||||||
|
|
||||||
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
||||||
@@ -260,35 +245,17 @@ export function ComposeCanvas({
|
|||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let lastWidth = 0;
|
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 () => {
|
const initCanvas = async () => {
|
||||||
// HACK: actual font may change the text-width - small ux improvement
|
// HACK: actual font may change the text-width - small ux improvement
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
|
|
||||||
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
||||||
|
|
||||||
const width = await getInitialWidth();
|
let width = wrapperRef.current.clientWidth;
|
||||||
|
if (width === 0) {
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
// init the fabric instance
|
// init the fabric instance
|
||||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||||
@@ -319,7 +286,13 @@ export function ComposeCanvas({
|
|||||||
|
|
||||||
// auto window resizing based width
|
// auto window resizing based width
|
||||||
lastWidth = wrapperRef.current.clientWidth;
|
lastWidth = wrapperRef.current.clientWidth;
|
||||||
resizeObserver = initResizeOberver();
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
const nextWidth = wrapperRef.current?.clientWidth;
|
||||||
|
if (!nextWidth || nextWidth === lastWidth) return;
|
||||||
|
lastWidth = nextWidth;
|
||||||
|
syncViewport();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(wrapperRef.current!);
|
||||||
};
|
};
|
||||||
|
|
||||||
initCanvas().then();
|
initCanvas().then();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function PostSealModal({
|
|||||||
type = "KEPT",
|
type = "KEPT",
|
||||||
}: PostSealModalProps) {
|
}: PostSealModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={!!sealedTargetId} data-testid="post-seal-modal">
|
<Modal isOpen={!!sealedTargetId}>
|
||||||
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
|
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
|
||||||
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
|
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
|
||||||
<p className="text-base-content/60">
|
<p className="text-base-content/60">
|
||||||
@@ -25,11 +25,10 @@ export function PostSealModal({
|
|||||||
<p className="text-base-content/80 text-sm font-sans">
|
<p className="text-base-content/80 text-sm font-sans">
|
||||||
When you're ready,
|
When you're ready,
|
||||||
<br />
|
<br />
|
||||||
you can
|
you can{" "}
|
||||||
<span className="text-primary font-bold font-display">read</span>
|
<span className="text-primary font-bold font-display">read</span> it,{" "}
|
||||||
it,
|
|
||||||
<span className="text-accent font-bold font-display">send</span> it to
|
<span className="text-accent font-bold font-display">send</span> it to
|
||||||
someone, or
|
someone, or{" "}
|
||||||
<span className="text-error font-bold font-display">burn</span> it to
|
<span className="text-error font-bold font-display">burn</span> it to
|
||||||
release
|
release
|
||||||
</p>
|
</p>
|
||||||
@@ -37,12 +36,12 @@ export function PostSealModal({
|
|||||||
<p className="text-base-content/80 text-sm font-sans">
|
<p className="text-base-content/80 text-sm font-sans">
|
||||||
Be assured that the letter will find you when the time is right.
|
Be assured that the letter will find you when the time is right.
|
||||||
<br />
|
<br />
|
||||||
Till then,
|
Till then,{" "}
|
||||||
<span className="font-bold font-display text-primary">
|
<span className="font-bold font-display text-primary">
|
||||||
take a deep breath
|
take a deep breath
|
||||||
</span>
|
</span>
|
||||||
, <span className="font-bold font-display text-accent">manifest</span>
|
, <span className="font-bold font-display text-accent">manifest</span>
|
||||||
, and
|
, and{" "}
|
||||||
<span className="font-bold font-display text-success">
|
<span className="font-bold font-display text-success">
|
||||||
let it rest
|
let it rest
|
||||||
</span>
|
</span>
|
||||||
@@ -54,7 +53,6 @@ export function PostSealModal({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="keep-it-btn"
|
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
>
|
>
|
||||||
@@ -62,13 +60,8 @@ export function PostSealModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="view-letter-btn"
|
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => {
|
onClick={() => navigate(PATHS.read(sealedTargetId!))}
|
||||||
if (sealedTargetId) {
|
|
||||||
navigate(PATHS.read(sealedTargetId));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
View letter
|
View letter
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Modal } from "../ui/Modal";
|
import { Modal } from "../ui/Modal";
|
||||||
import type { CanvasStyle } from "./ComposeCanvas";
|
import type { CanvasStyle } from "./ComposeCanvas.tsx";
|
||||||
|
|
||||||
interface ToolBarProps {
|
interface ToolBarProps {
|
||||||
onAddImage: () => void;
|
onAddImage: () => void;
|
||||||
@@ -30,7 +30,7 @@ const FONT_FAMILIES: Map<string, string> = new Map([
|
|||||||
["Handwriting", "Architects Daughter"],
|
["Handwriting", "Architects Daughter"],
|
||||||
["Slab", "Cutive Mono"],
|
["Slab", "Cutive Mono"],
|
||||||
["Mono", "Space Mono"],
|
["Mono", "Space Mono"],
|
||||||
["Ink", "Kavivanar"],
|
["Tamil", "Kavivanar"],
|
||||||
["Crazy(pls no)", "Redacted Script"],
|
["Crazy(pls no)", "Redacted Script"],
|
||||||
]);
|
]);
|
||||||
const FONT_COLORS: Map<string, string> = new Map([
|
const FONT_COLORS: Map<string, string> = new Map([
|
||||||
@@ -140,7 +140,6 @@ export function ToolBar({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
title="Store in your private drawer"
|
||||||
onClick={() => onSave("DRAFT")}
|
onClick={() => onSave("DRAFT")}
|
||||||
@@ -156,7 +155,6 @@ export function ToolBar({
|
|||||||
{/*Seal */}
|
{/*Seal */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="seal-trigger-btn"
|
|
||||||
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
|
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
|
||||||
onClick={() => setSealBtnClicked(true)}
|
onClick={() => setSealBtnClicked(true)}
|
||||||
>
|
>
|
||||||
@@ -178,7 +176,6 @@ export function ToolBar({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="seal-confirm-btn"
|
|
||||||
className="btn btn-accent btn-sm rounded-full px-6 group"
|
className="btn btn-accent btn-sm rounded-full px-6 group"
|
||||||
onClick={() => onSave("SEALED")}
|
onClick={() => onSave("SEALED")}
|
||||||
>
|
>
|
||||||
@@ -194,7 +191,6 @@ export function ToolBar({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="vault-trigger-btn"
|
|
||||||
className="btn btn-neutral btn-sm rounded-full px-6 group"
|
className="btn btn-neutral btn-sm rounded-full px-6 group"
|
||||||
onClick={() => setConfirmModal("VAULT")}
|
onClick={() => setConfirmModal("VAULT")}
|
||||||
>
|
>
|
||||||
@@ -270,7 +266,8 @@ export function VaultConfirmModal({
|
|||||||
I'll remember to mail you this on the unlock date.
|
I'll remember to mail you this on the unlock date.
|
||||||
<br />
|
<br />
|
||||||
<span className={"font-bold text-primary"}>
|
<span className={"font-bold text-primary"}>
|
||||||
But I won't let you read or rewrite this letter until then.
|
{" "}
|
||||||
|
But I won't let you read or rewrite this letter until then.
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
</p>
|
</p>
|
||||||
@@ -299,7 +296,6 @@ export function VaultConfirmModal({
|
|||||||
<div className="w-full flex justify-center gap-8 mt-4">
|
<div className="w-full flex justify-center gap-8 mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="vault-cancel-btn"
|
|
||||||
className="btn btn-ghost btn-sm mt-4"
|
className="btn btn-ghost btn-sm mt-4"
|
||||||
onClick={() => setConfirmModal(null)}
|
onClick={() => setConfirmModal(null)}
|
||||||
>
|
>
|
||||||
@@ -308,7 +304,6 @@ export function VaultConfirmModal({
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-sm mt-4"
|
className="btn btn-primary btn-sm mt-4"
|
||||||
type="submit"
|
type="submit"
|
||||||
data-testid="vault-confirm-btn"
|
|
||||||
form="vault-form"
|
form="vault-form"
|
||||||
>
|
>
|
||||||
Take it
|
Take it
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
WarningIcon,
|
WarningIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import Logo from "../Logo";
|
import Logo from "../Logo.tsx";
|
||||||
import { Modal } from "../ui/Modal";
|
import { Modal } from "../ui/Modal";
|
||||||
import Saajan from "../ui/Saajan";
|
import Saajan from "../ui/Saajan.tsx";
|
||||||
|
|
||||||
export default function WelcomeModal({
|
export default function WelcomeModal({
|
||||||
setShowWelcome,
|
setShowWelcome,
|
||||||
@@ -15,7 +15,7 @@ export default function WelcomeModal({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={true}>
|
<Modal isOpen={true}>
|
||||||
<div className="flex flex-col items-center text-center gap-2 md:gap-4">
|
<div className="flex flex-col items-center text-center gap-4">
|
||||||
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
|
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
|
||||||
<ShieldCheckIcon
|
<ShieldCheckIcon
|
||||||
size={48}
|
size={48}
|
||||||
@@ -24,50 +24,48 @@ export default function WelcomeModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-display text-2xl font-bold text-primary">
|
<h3 className="font-display text-2xl font-bold text-primary">
|
||||||
Welcome to
|
Welcome to
|
||||||
<Logo type="inline" />
|
<Logo /> !
|
||||||
</h3>
|
</h3>
|
||||||
<p className="inline text-sm md:text-base text-base-content/80">
|
<p className="text-base-content/80 leading-relaxed">
|
||||||
Before we begin, let me make a small promise.
|
Before we begin, let me make a small promise.
|
||||||
<HandPalmIcon
|
<HandPalmIcon
|
||||||
size={18}
|
size={18}
|
||||||
className="inline text-primary"
|
className="inline text-primary"
|
||||||
weight="fill"
|
weight="fill"
|
||||||
/>
|
/>
|
||||||
<span className="divider my-0"></span>
|
<div className="divider my-0"></div>
|
||||||
Everything you write here is sealed with your password,
|
<br />
|
||||||
|
Everything you write here is sealed with your password,{" "}
|
||||||
<span className="font-display text-success">cryptographically</span>
|
<span className="font-display text-success">cryptographically</span>
|
||||||
, before it leaves your hands.
|
, before it leaves your hands.
|
||||||
<br />
|
<br />A fancy way of saying, I couldn't if I tried.
|
||||||
<br />A fancy way of saying, no one else can read them without your
|
|
||||||
key—not even me.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="alert alert-warning flex items-start gap-3 text-left py-3">
|
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
|
||||||
<WarningIcon size={24} weight="fill" className="shrink-0" />
|
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
|
||||||
<div className="text-xs md:text-sm font-medium text-primary-content tracking-tight">
|
<p className="text-sm font-medium text-primary-content">
|
||||||
If you ever happen to forget your password, your letters are lost
|
If you ever happen to forget your password, your letters are lost
|
||||||
to time, forever.
|
to time, forever.
|
||||||
<span className="mt-2 block">
|
<br />
|
||||||
I highly, <span className="font-bold italic">highly</span>
|
<span className="font-bold mt-2">
|
||||||
recommend storing this password in your
|
I highly, highly recommend storing this password in your{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.privacyguides.org/en/passwords/"
|
href="https://www.privacyguides.org/en/passwords/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="link link-neutral!"
|
className="link link-primary-content"
|
||||||
rel="noopener noreferrer"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
password manager
|
password manager
|
||||||
</a>
|
</a>{" "}
|
||||||
or somewhere safe to remember it.
|
or somewhere safe to remember it.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-action w-full">
|
<div className="modal-action w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="welcome-dismiss-btn"
|
|
||||||
onClick={() => setShowWelcome(false)}
|
onClick={() => setShowWelcome(false)}
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
>
|
>
|
||||||
@@ -76,9 +74,9 @@ export default function WelcomeModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className="absolute bottom-0 md:right-5/12 z-1000 font-sans w-full flex justify-center">
|
<div className="absolute bottom-0 right-0 z-1000 font-sans w-full">
|
||||||
<Saajan
|
<Saajan
|
||||||
position="left"
|
position="top"
|
||||||
message={"I've lost words before.\nI know what it feels like."}
|
message={"I've lost words before.\nI know what it feels like."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export function BurnModal({
|
|||||||
Let the echoes of your unsaid be finally released.
|
Let the echoes of your unsaid be finally released.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 font-sans text-sm">
|
<div className="mt-4 font-sans text-sm">
|
||||||
<span className="text-error">Press</span> and
|
<span className="text-error">Press</span> and{" "}
|
||||||
<span className="text-error">hold</span> the
|
<span className="text-error">hold</span> the{" "}
|
||||||
<span className="text-amber-300">flame</span> to proceed.
|
<span className="text-amber-300">flame</span> to proceed.
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-action w-full justify-center gap-3 mt-2">
|
<div className="modal-action w-full justify-center gap-3 mt-2">
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export function EnvelopeReveal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
data-testid="wax-seal"
|
|
||||||
className={
|
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"
|
"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"
|
||||||
}
|
}
|
||||||
@@ -92,7 +91,6 @@ export function EnvelopeReveal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="letter"
|
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"}`}
|
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}
|
onClick={handleClick}
|
||||||
></button>
|
></button>
|
||||||
@@ -114,7 +112,6 @@ export function EnvelopeReveal({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
id="env-front"
|
id="env-front"
|
||||||
data-testid="envelope-front"
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isInteractive}
|
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" : ""}`}
|
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" : ""}`}
|
||||||
@@ -123,12 +120,7 @@ export function EnvelopeReveal({
|
|||||||
<span className={"text-neutral-content/60 font-xs font-display"}>
|
<span className={"text-neutral-content/60 font-xs font-display"}>
|
||||||
to
|
to
|
||||||
</span>
|
</span>
|
||||||
<h1
|
<h1 className="text-3xl font-bold text-base-content">{recipient}</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>
|
<p className="text-base-content/60 font-display mt-8">{date}</p>
|
||||||
<img
|
<img
|
||||||
src={stamp}
|
src={stamp}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export function PostActionOverlay({ revealState }: PostActionOverlayProps) {
|
|||||||
May your <span className="italic text-primary">soul</span> find
|
May your <span className="italic text-primary">soul</span> find
|
||||||
solace,
|
solace,
|
||||||
<br />
|
<br />
|
||||||
just like your <span className="text-accent italic">unsaid</span>
|
just like your <span className="text-accent italic">unsaid</span>{" "}
|
||||||
words did.
|
words did.
|
||||||
</p>
|
</p>
|
||||||
<div className="divider mx-auto w-24 text-center"></div>
|
<div className="divider mx-auto w-24 text-center"></div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal isOpen={!!shareLink} onClose={() => setShareLink(null)}>
|
||||||
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="flex flex-col items-center justify-center text-center gap-6 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<PaperPlaneTiltIcon
|
<PaperPlaneTiltIcon
|
||||||
@@ -30,7 +26,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
<p className="text-base-content/80 text-sm font-sans mt-4">
|
<p className="text-base-content/80 text-sm font-sans mt-4">
|
||||||
You've carried these words long enough.
|
You've carried these words long enough.
|
||||||
<br />
|
<br />
|
||||||
Send your letter now, and let the
|
Send your letter now, and let the{" "}
|
||||||
<span className="text-accent font-display">unsaid</span> finally
|
<span className="text-accent font-display">unsaid</span> finally
|
||||||
find its home.
|
find its home.
|
||||||
</p>
|
</p>
|
||||||
@@ -51,7 +47,6 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyToClipboard}
|
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"
|
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
@@ -59,8 +54,8 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 uppercase tracking-widest text-base-content/30 font-sans">
|
<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">
|
<p className="textarea-xs flex items-center justify-center">
|
||||||
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />
|
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />{" "}
|
||||||
Zero-Knowledge Share:
|
Zero-Knowledge Share:
|
||||||
</p>
|
</p>
|
||||||
<p className="textarea-xs font-mono text-center">
|
<p className="textarea-xs font-mono text-center">
|
||||||
The key never leaves your or the recipient's browser.
|
The key never leaves your or the recipient's browser.
|
||||||
@@ -68,7 +63,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
<div className="absolute bottom-0 md:right-5/11 z-1000 font-sans w-full">
|
<div className="absolute bottom-0 z-1000 font-sans w-full">
|
||||||
<Saajan
|
<Saajan
|
||||||
position="top"
|
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.`}
|
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.`}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface FormFieldProps {
|
|||||||
registration: UseFormRegisterReturn;
|
registration: UseFormRegisterReturn;
|
||||||
error?: string;
|
error?: string;
|
||||||
handleFocus?: () => void;
|
handleFocus?: () => void;
|
||||||
"data-testid"?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormField({
|
export default function FormField({
|
||||||
@@ -17,20 +16,18 @@ export default function FormField({
|
|||||||
registration,
|
registration,
|
||||||
error,
|
error,
|
||||||
handleFocus,
|
handleFocus,
|
||||||
"data-testid": testId,
|
|
||||||
}: FormFieldProps) {
|
}: FormFieldProps) {
|
||||||
return (
|
return (
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<label
|
<label
|
||||||
htmlFor={registration.name}
|
htmlFor={registration.name}
|
||||||
className="field-label font-display text-neutral-content/80 font-medium"
|
className="field-label font-display text-base-content/90 font-medium"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...registration}
|
{...registration}
|
||||||
id={registration.name}
|
id={registration.name}
|
||||||
data-testid={testId}
|
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={`input input-bordered focus:input-primary ${
|
className={`input input-bordered focus:input-primary ${
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const LogModal = ({
|
|||||||
{status === "WARN" && (
|
{status === "WARN" && (
|
||||||
<WarningIcon className="text-warning" size={16} weight="duotone" />
|
<WarningIcon className="text-warning" size={16} weight="duotone" />
|
||||||
)}
|
)}
|
||||||
<span data-testid="log-modal-message">{message}</span>
|
{message}
|
||||||
{log && (
|
{log && (
|
||||||
<>
|
<>
|
||||||
<div className="divider text-primary-content text-xs uppercase tracking-widest">
|
<div className="divider text-primary-content text-xs uppercase tracking-widest">
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
import { XCircleIcon } from "@phosphor-icons/react";
|
import { XCircleIcon } from "@phosphor-icons/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
"data-testid"?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({
|
export function Modal({ isOpen, onClose, children }: ModalProps) {
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
"data-testid": testId,
|
|
||||||
}: ModalProps) {
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
// 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.
|
return (
|
||||||
const mainContainer = document.querySelector("main") || document.body;
|
<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')]">
|
||||||
return createPortal(
|
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
|
||||||
<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 && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="modal-close-btn"
|
|
||||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
@@ -38,7 +25,6 @@ export function Modal({
|
|||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>
|
||||||
mainContainer,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import { mockUser } from "../../test/fixtures/user.fixture";
|
|||||||
import { server } from "../../test/mocks/server";
|
import { server } from "../../test/mocks/server";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
|
||||||
import {
|
import {
|
||||||
clearMasterKey,
|
clearMasterKey,
|
||||||
loadMasterKey,
|
loadMasterKey,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
import { useAuth } from "./useAuth";
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
vi.mock("../utils/keystore");
|
vi.mock("../utils/keystore");
|
||||||
vi.mock("../utils/crypto");
|
|
||||||
|
|
||||||
const VITE_API_URL = "http://piku-server";
|
const VITE_API_URL = "http://piku-server";
|
||||||
|
|
||||||
@@ -32,11 +30,6 @@ beforeEach(() => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
});
|
});
|
||||||
useKeyStore.setState({ masterKey: null });
|
useKeyStore.setState({ masterKey: null });
|
||||||
|
|
||||||
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
|
|
||||||
masterKey: mockMasterKey,
|
|
||||||
authHash: "mock-hash",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isAuthenticated", () => {
|
describe("isAuthenticated", () => {
|
||||||
@@ -208,68 +201,3 @@ describe("initialize", () => {
|
|||||||
expect(useKeyStore.getState().masterKey).not.toBeNull();
|
expect(useKeyStore.getState().masterKey).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("unlock", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useAuthStore.setState({
|
|
||||||
accessToken: "valid-token",
|
|
||||||
user: mockUser,
|
|
||||||
isInitializing: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should derive the master key from the user password, validate it via API, and persist it", async () => {
|
|
||||||
let loginCalled = false;
|
|
||||||
server.use(
|
|
||||||
http.post(`${VITE_API_URL}/api/auth/login/`, async () => {
|
|
||||||
loginCalled = true;
|
|
||||||
return HttpResponse.json({ access: "token", user: mockUser });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const { result } = renderHook(() => useAuth());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.unlock("password");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith(
|
|
||||||
"password",
|
|
||||||
mockUser.email,
|
|
||||||
);
|
|
||||||
expect(loginCalled).toBe(true);
|
|
||||||
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
|
|
||||||
expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should logout if user is not present", async () => {
|
|
||||||
useAuthStore.setState({ user: null });
|
|
||||||
const { result } = renderHook(() => useAuth());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.unlock("password");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled();
|
|
||||||
expect(saveMasterKey).not.toHaveBeenCalled();
|
|
||||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
||||||
expect(clearMasterKey).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw an error and not persist the key if validation fails", async () => {
|
|
||||||
server.use(
|
|
||||||
http.post(
|
|
||||||
`${VITE_API_URL}/api/auth/login/`,
|
|
||||||
() => new HttpResponse(null, { status: 400 }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await expect(result.current.unlock("wrong-password")).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(saveMasterKey).not.toHaveBeenCalled();
|
|
||||||
expect(useKeyStore.getState().masterKey).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const useAuth = () => {
|
|||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await api.post(endpoints.LOGOUT);
|
await api.post(endpoints.LOGOUT);
|
||||||
} catch {
|
} catch (_error) {
|
||||||
} finally {
|
} finally {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
setMasterKey(null);
|
setMasterKey(null);
|
||||||
@@ -57,6 +57,7 @@ export const useAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// try session refresh
|
||||||
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
|
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
|
||||||
const { data: userData } = await api.get(endpoints.ME, {
|
const { data: userData } = await api.get(endpoints.ME, {
|
||||||
headers: { Authorization: `Bearer ${refreshData.access}` },
|
headers: { Authorization: `Bearer ${refreshData.access}` },
|
||||||
@@ -70,24 +71,16 @@ export const useAuth = () => {
|
|||||||
}, [setMasterKey]);
|
}, [setMasterKey]);
|
||||||
|
|
||||||
const unlock = async (password: string) => {
|
const unlock = async (password: string) => {
|
||||||
if (!user) {
|
if (!user) return;
|
||||||
await logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
try {
|
||||||
password,
|
const { masterKey } = await CryptoUtils.deriveKeyBundle(
|
||||||
user.email,
|
password,
|
||||||
);
|
user.email,
|
||||||
|
);
|
||||||
// Validate password by calling login endpoint
|
await saveMasterKey(masterKey);
|
||||||
await api.post(endpoints.LOGIN, {
|
setMasterKey(masterKey);
|
||||||
email: user.email,
|
} catch {}
|
||||||
password: authHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
await saveMasterKey(masterKey);
|
|
||||||
setMasterKey(masterKey);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import type { LetterMetadata, LetterResponseData } from "../api/response";
|
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
|
||||||
export interface ProcessedLetter extends LetterResponseData {
|
export interface Letter {
|
||||||
|
public_id: string;
|
||||||
|
type: "KEPT" | "VAULT" | "SENT";
|
||||||
|
status: "DRAFT" | "SEALED" | "BURNED";
|
||||||
|
updated_at: string;
|
||||||
|
sealed_at?: string;
|
||||||
|
unlock_at: string;
|
||||||
|
encrypted_metadata: string;
|
||||||
|
encrypted_content: string;
|
||||||
|
encrypted_dek: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LetterMetadata {
|
||||||
|
recipient: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedLetter extends Letter {
|
||||||
metadata: LetterMetadata;
|
metadata: LetterMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptLettersMetadata(
|
async function decryptLettersMetadata(
|
||||||
letters: LetterResponseData[],
|
letters: Letter[],
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<ProcessedLetter[]> {
|
): Promise<ProcessedLetter[]> {
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
@@ -27,7 +43,7 @@ async function decryptLettersMetadata(
|
|||||||
)) as LetterMetadata;
|
)) as LetterMetadata;
|
||||||
|
|
||||||
return { ...letter, metadata };
|
return { ...letter, metadata };
|
||||||
} catch {
|
} catch (_err) {
|
||||||
return {
|
return {
|
||||||
...letter,
|
...letter,
|
||||||
metadata: { recipient: "Encrypted Letter" },
|
metadata: { recipient: "Encrypted Letter" },
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
--color-accent: oklch(55% 0.06 325);
|
--color-accent: oklch(55% 0.06 325);
|
||||||
--color-accent-content: oklch(18% 0.03 295);
|
--color-accent-content: oklch(18% 0.03 295);
|
||||||
|
|
||||||
--color-neutral: oklch(38% 0.02 45);
|
--color-neutral: oklch(28% 0.02 45);
|
||||||
--color-neutral-content: oklch(80% 0.015 60);
|
--color-neutral-content: oklch(80% 0.015 60);
|
||||||
|
|
||||||
--color-info: oklch(60% 0.06 250);
|
--color-info: oklch(60% 0.07 240);
|
||||||
--color-info-content: oklch(95% 0.01 240);
|
--color-info-content: oklch(95% 0.01 240);
|
||||||
--color-success: oklch(65% 0.05 140);
|
--color-success: oklch(60% 0.08 150);
|
||||||
--color-success-content: oklch(16% 0.03 150);
|
--color-success-content: oklch(16% 0.03 150);
|
||||||
--color-warning: oklch(68% 0.08 72);
|
--color-warning: oklch(68% 0.08 72);
|
||||||
--color-warning-content: oklch(18% 0.03 60);
|
--color-warning-content: oklch(18% 0.03 60);
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
--font-sans: "Jost Variable", sans-serif;
|
--font-sans: "Jost Variable", sans-serif;
|
||||||
--font-serif: "Playfair Display Variable", serif;
|
--font-serif: "Playfair Display Variable", serif;
|
||||||
--font-mono: "Space Mono", monospace;
|
--font-mono: "Space Mono", monospace;
|
||||||
--font-ink: "Kavivanar", sans-serif;
|
--font-tamil: "Kavivanar", sans-serif;
|
||||||
--font-redact: "Redacted Script", cursive;
|
--font-redact: "Redacted Script", cursive;
|
||||||
--font-slab: "Cutive Mono", monospace;
|
--font-slab: "Cutive Mono", monospace;
|
||||||
--font-hand: "Architects Daughter", cursive;
|
--font-hand: "Architects Daughter", cursive;
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@apply bg-glass-bg max-w-xs md:max-w-sm backdrop-blur-xl border border-neutral-content/10 shadow-warm rounded-xl m-4;
|
@apply bg-glass-bg backdrop-blur-xl border border-white/5 shadow-warm rounded-xl m-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ul-wavy {
|
.ul-wavy {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
|
|||||||
import "@fontsource-variable/jost/wght.css";
|
import "@fontsource-variable/jost/wght.css";
|
||||||
import "@fontsource-variable/playfair-display/wght.css";
|
import "@fontsource-variable/playfair-display/wght.css";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App.tsx";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (root) {
|
if (root) {
|
||||||
|
|||||||
@@ -4,18 +4,14 @@ import {
|
|||||||
ArrowBendDownRightIcon,
|
ArrowBendDownRightIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
CaretUpIcon,
|
CaretUpIcon,
|
||||||
DetectiveIcon,
|
|
||||||
FlowerTulipIcon,
|
FlowerTulipIcon,
|
||||||
GhostIcon,
|
GhostIcon,
|
||||||
GithubLogoIcon,
|
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LockKeyOpenIcon,
|
|
||||||
LockLaminatedIcon,
|
LockLaminatedIcon,
|
||||||
|
LockOpenIcon,
|
||||||
PasswordIcon,
|
PasswordIcon,
|
||||||
PeaceIcon,
|
|
||||||
PersonArmsSpreadIcon,
|
PersonArmsSpreadIcon,
|
||||||
PersonIcon,
|
PersonIcon,
|
||||||
QuotesIcon,
|
|
||||||
ScrollIcon,
|
ScrollIcon,
|
||||||
SmileyIcon,
|
SmileyIcon,
|
||||||
SparkleIcon,
|
SparkleIcon,
|
||||||
@@ -25,9 +21,7 @@ import { ReactLenis } from "lenis/react";
|
|||||||
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import stamp from "../assets/envelope/stamp.png";
|
import stamp from "../assets/envelope/stamp.png";
|
||||||
import e2eDiag from "../assets/screenshots/e2e.svg";
|
import Logo from "../components/Logo.tsx";
|
||||||
import saajan from "../assets/sf.png";
|
|
||||||
import Logo from "../components/Logo";
|
|
||||||
import { Modal } from "../components/ui/Modal";
|
import { Modal } from "../components/ui/Modal";
|
||||||
|
|
||||||
import "@fontsource/kavivanar/index.css";
|
import "@fontsource/kavivanar/index.css";
|
||||||
@@ -37,7 +31,7 @@ import "@fontsource/architects-daughter/index.css";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
function HorizontalScroll({ children }: { children: React.ReactNode }) {
|
function HorizontalScroll({ children }: { children: React.ReactNode }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef(null);
|
||||||
const { scrollYProgress } = useScroll({ target: ref });
|
const { scrollYProgress } = useScroll({ target: ref });
|
||||||
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
|
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
|
||||||
|
|
||||||
@@ -96,17 +90,16 @@ function PrivacySection() {
|
|||||||
weight="bold"
|
weight="bold"
|
||||||
/>
|
/>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-col items-center shrink-0 gap-8 max-w-11/12 w-220">
|
<div className="flex flex-col items-center shrink-0 gap-8 max-w-11/12 w-200">
|
||||||
<p className="text-xxs md:text-sm tracking-widester text-neutral-content/80 font-semibold uppercase mt-6">
|
<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>{" "}
|
||||||
<span className="text-error">Nobody else's.</span>
|
<span className="text-error">Nobody else's.</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm md:text-lg text-neutral">
|
<p className="text-sm md:text-lg">
|
||||||
When you write or upload anything
|
When you write something here, it gets encrypted in your browser
|
||||||
<span className="font-hand">(yes, even images)</span> here, it gets
|
before anything leaves your device. What reaches the server isn't your
|
||||||
encrypted in your browser before anything leaves your device. What
|
letter. It's something unreadable — and the server has no way to
|
||||||
reaches the server is something unreadable—and the server has no
|
change that, because the key never left you.
|
||||||
way to change that, because the key never left you.
|
|
||||||
</p>
|
</p>
|
||||||
<figure className="diff aspect-3/4 touch-pan-y select-none">
|
<figure className="diff aspect-3/4 touch-pan-y select-none">
|
||||||
<div className="diff-item-1 z-1" role="img">
|
<div className="diff-item-1 z-1" role="img">
|
||||||
@@ -127,12 +120,8 @@ function PrivacySection() {
|
|||||||
B@z1ng4A
|
B@z1ng4A
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider divider-neutral w-1/2 mx-auto">
|
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
|
||||||
<LockKeyOpenIcon
|
<LockOpenIcon size={48} />
|
||||||
weight="duotone"
|
|
||||||
className="-scale-x-100 text-base-200 w-full rounded-full bg-info/50"
|
|
||||||
size={36}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center md:gap-2">
|
<div className="flex flex-col items-center md:gap-2">
|
||||||
<ScrollIcon
|
<ScrollIcon
|
||||||
@@ -144,7 +133,7 @@ function PrivacySection() {
|
|||||||
</h2>
|
</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">
|
<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 className="wrap-anywhere">Hello friend,</p>
|
||||||
<p>I've never told anyone this...</p>
|
<p>I've never told this to anyone...</p>
|
||||||
<p className="font-redact">
|
<p className="font-redact">
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut
|
||||||
semper, justo eget vehicula vestibulum, enim enim suscipit
|
semper, justo eget vehicula vestibulum, enim enim suscipit
|
||||||
@@ -168,7 +157,7 @@ function PrivacySection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="diff-item-2" role="img">
|
<div className="diff-item-2" role="img">
|
||||||
<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="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="flex flex-col gap-2">
|
<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">
|
<h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8">
|
||||||
server see
|
server see
|
||||||
@@ -184,12 +173,8 @@ function PrivacySection() {
|
|||||||
9e54d05f88bdd67a675b03bf1cd0a1647e2109b5aa18185ff6a9ba4c6959a19d
|
9e54d05f88bdd67a675b03bf1cd0a1647e2109b5aa18185ff6a9ba4c6959a19d
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider divider-neutral w-1/2 mx-auto">
|
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
|
||||||
<LockLaminatedIcon
|
<LockLaminatedIcon size={48} />
|
||||||
weight="duotone"
|
|
||||||
className="text-success-content w-full rounded-full bg-success"
|
|
||||||
size={36}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center md:gap-2">
|
<div className="flex flex-col items-center md:gap-2">
|
||||||
<ScrollIcon
|
<ScrollIcon
|
||||||
@@ -219,108 +204,62 @@ function SpecsSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
|
<section className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
|
||||||
<h1 className="flex tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-display z-10">
|
<h1 className="relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-display z-10">
|
||||||
S'more
|
S'more Specs
|
||||||
<DetectiveIcon weight="duotone" className="text-neutral" />
|
|
||||||
Specs
|
|
||||||
</h1>
|
</h1>
|
||||||
<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">
|
<div className="flex flex-col items-start shrink-0 gap-6 max-w-11/12 w-200 mt-4 md:mt-12">
|
||||||
<h2 className="text-xl md:text-3xl text-center mx-auto">
|
<h2 className="text-xl md:text-3xl text-center mx-auto">
|
||||||
<Logo type={"inline"} /> uses
|
<Logo type={"inline"} /> uses{" "}
|
||||||
<span className="text-accent font-mono">Zero Knowledge</span>
|
<span className="text-accent font-mono">Zero Knowledge</span>{" "}
|
||||||
<button
|
<span className="group ul-wavy font-mono text-primary">
|
||||||
type="button"
|
|
||||||
className="group ul-wavy font-mono text-success"
|
|
||||||
>
|
|
||||||
E
|
E
|
||||||
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
nd—
|
nd
|
||||||
</span>
|
</span>
|
||||||
2
|
2
|
||||||
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
—
|
|
||||||
</span>
|
</span>
|
||||||
E
|
E
|
||||||
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
nd
|
nd
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden group-hover:inline group-focus-within:inline">
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
<span>E</span>
|
<span>E</span>
|
||||||
<span className="hidden group-hover:inline group-focus-within:inline text-neutral">
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
ncryption
|
ncryption
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</span>{" "}
|
||||||
for your
|
with{" "}
|
||||||
<span className="font-hand text-primary">letters</span>, with
|
<span className="font-mono text-primary">Envelope Encryption</span>
|
||||||
<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>
|
|
||||||
for the <span className="font-hand text-primary">keys</span>.
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="text-sm md:text-xl leading-relaxed">
|
<p className="text-sm md:text-xl leading-relaxed">
|
||||||
This means, both the
|
This means, both the encryption and decryption runs on your device, in
|
||||||
<span className="font-display text-info">encryption</span> and
|
your browser.
|
||||||
<span className="font-display text-info">decryption</span> runs on
|
<br />
|
||||||
your device, in your browser.
|
Every letter has a{" "}
|
||||||
<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">
|
<span className="font-mono text-primary">unique key</span> which is
|
||||||
<li>
|
derived from your original password.
|
||||||
Every letter has a
|
<br />
|
||||||
<span className="font-mono text-primary/50 font-bold">
|
Both the letter and the key are encrypted securely and sent to the
|
||||||
unique key
|
server.
|
||||||
</span>
|
<br />
|
||||||
which is derived from your original password.
|
Now, the server holds{" "}
|
||||||
</li>
|
<span className="text-primary font-bold">the envelope</span>,{" "}
|
||||||
<li>
|
<span className="text-primary font-bold">the seal</span> and{" "}
|
||||||
Both the letter and the key are encrypted securely and sent to the
|
<span className="text-primary font-bold">another locked box</span>{" "}
|
||||||
server.
|
with a key inside that unseals your letter. But you,{" "}
|
||||||
</li>
|
<span className="italic text-primary">only you</span>, hold the only
|
||||||
<li>
|
thing that opens the box —{" "}
|
||||||
Now, the server holds
|
|
||||||
<span className="text-primary/50 font-bold">the envelope</span>
|
|
||||||
,
|
|
||||||
<span className="text-primary/50 font-bold">the seal</span>
|
|
||||||
and
|
|
||||||
<span className="text-primary/50 font-bold">
|
|
||||||
another locked box
|
|
||||||
</span>
|
|
||||||
—with a key inside that unseals your letter.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
But you—
|
|
||||||
<span className="italic">only you</span>—hold the very thing
|
|
||||||
that opens that box,
|
|
||||||
<span className="font-mono text-accent">your password</span>.
|
<span className="font-mono text-accent">your password</span>.
|
||||||
</div>
|
</p>
|
||||||
<div className="text-xs md:text-lg text-right w-full flex items-center justify-end gap-4 leading-relaxed text-neutral-content/80">
|
<p className="text-sm md:text-xl text-right w-full flex items-center justify-end gap-4 leading-relaxed">
|
||||||
<span>
|
Nothing on the server is readable without your actual password.
|
||||||
Nothing on the server is readable without your actual password.
|
<br />
|
||||||
<br />
|
Even if someone were to breach in, all they'd find is encrypted noise.
|
||||||
Even if someone were to breach in, all they'd find is encrypted
|
<VaultIcon size={48} weight="duotone" />
|
||||||
noise and ain't no way they crackin'
|
</p>
|
||||||
<br />
|
|
||||||
<a
|
|
||||||
href="https://xkcd.com/538/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xxs md:text-sm text-neutral! font-hand"
|
|
||||||
>
|
|
||||||
(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
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
@@ -335,20 +274,18 @@ function SpecsSection() {
|
|||||||
|
|
||||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
|
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
|
||||||
<div className="w-full bg-paper rounded-md p-6">
|
<div className="w-full bg-paper rounded-md p-6">
|
||||||
<img src={e2eDiag} width={"100%"} alt="pi ku e2e diagram" />
|
<img src="/screenshots/e2e.svg" alt="pi ku e2e diagram" />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<p className="text-sm md:text-lg">
|
<p className="text-sm md:text-lg">
|
||||||
Of course, this level of
|
This level of privacy comes with a catch.{" "}
|
||||||
<span className="text-success font-bold">privacy</span> comes with a
|
<span className="text-error font-bold">No password reset.</span>
|
||||||
catch. <span className="text-error font-bold">No password reset</span>
|
|
||||||
for you.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs md:text-base alert alert-warning font-medium">
|
<p className="text-sm md:text-lg alert alert-warning font-semibold">
|
||||||
<InfoIcon weight="duotone" /> Your original password is never stored
|
<InfoIcon weight="duotone" /> Your original password is never stored
|
||||||
on the server. So, if it's forgotten, the letters stay sealed
|
on the server. Which means if it's lost, the letters stay sealed
|
||||||
foreeeeveer.
|
forever.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -363,35 +300,30 @@ function OSSSection() {
|
|||||||
"relative tracking-tighter text-4xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif text-center"
|
"relative tracking-tighter text-4xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif text-center"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Logo type={"inline"} />
|
<span className="hidden absolute -translate-y-24 translate-x-45 font-display text-3xl md:text-6xl opacity-70 rotate-8">
|
||||||
is
|
only for
|
||||||
<span className="line-through decoration-6 text-neutral-content/50 decoration-error">
|
<br />
|
||||||
private
|
<span className="text-primary">your letters</span> <SmileyIcon />
|
||||||
<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">
|
<ArrowArcLeftIcon className="inline rotate-45 -translate-y-8" />
|
||||||
only for
|
|
||||||
<span className="text-primary"> your letters </span>
|
|
||||||
<SmileyIcon weight="duotone" className="text-primary" />
|
|
||||||
<ArrowArcLeftIcon className="text-accent inline rotate-45 -translate-y" />
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<Logo type={"inline"} /> is{" "}
|
||||||
<span className="text-success -rotate-3">open source !</span>
|
<span className="line-through decoration-6 decoration-error">
|
||||||
|
private
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-success">open source !</span>
|
||||||
</h1>
|
</h1>
|
||||||
<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">
|
<div className="flex flex-col items-center shrink-0 max-w-11/12 w-200 gap-4 p-4 md:p-6">
|
||||||
<p className="text-sm md:text-xl">
|
<p className="text-sm md:text-xl">
|
||||||
<Logo type={"mono"} /> is
|
<Logo type={"mono"} /> is fully open source. Every claim about privacy
|
||||||
<span className="font-hand"> ...uhhh... pretty </span>
|
and encryption is publicly available in the code so you don't have to
|
||||||
<span className="text-success font-display">secure</span>. Every claim
|
take anyone's word for it.
|
||||||
about privacy and encryption is publicly available in the code so you
|
|
||||||
don't have to take my word at it.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm md:text-lg">
|
<p className="text-sm md:text-lg">
|
||||||
You can also
|
You can also{" "}
|
||||||
<span className="uppercase font-mono text-primary">Self-host</span>
|
<span className="uppercase font-bold text-primary">Self-host</span>{" "}
|
||||||
|
|
||||||
<Logo type={"inline"} /> in just 4 steps.
|
<Logo type={"inline"} /> in just 4 steps.
|
||||||
</p>
|
</p>
|
||||||
<div className="mockup-code w-110 max-w-11/12 text-xs">
|
<div className="mockup-code w-full text-xs">
|
||||||
<pre data-prefix="$">
|
<pre data-prefix="$">
|
||||||
<code>git clone https://git.ramvignesh.dev/me/pi-ku.git</code>
|
<code>git clone https://git.ramvignesh.dev/me/pi-ku.git</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -411,13 +343,12 @@ function OSSSection() {
|
|||||||
href="https://git.ramvignesh.dev/me/pi-ku"
|
href="https://git.ramvignesh.dev/me/pi-ku"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary flex items-center gap-2"
|
className="text-primary"
|
||||||
>
|
>
|
||||||
<GithubLogoIcon weight="duotone" /> View Source
|
View on GitHub
|
||||||
</a>
|
</a>
|
||||||
.
|
|
||||||
<p className="text-xs md:text-base opacity-70">
|
<p className="text-xs md:text-base opacity-70">
|
||||||
Found something to report or request?
|
Found something to report or request?{" "}
|
||||||
<a
|
<a
|
||||||
href="https://git.ramvignesh.dev/me/pi-ku/issues"
|
href="https://git.ramvignesh.dev/me/pi-ku/issues"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -438,7 +369,7 @@ function OSSSection() {
|
|||||||
<Logo type={"mono"} /> wouldn't exist without the work of people who
|
<Logo type={"mono"} /> wouldn't exist without the work of people who
|
||||||
chose to build in the open.
|
chose to build in the open.
|
||||||
</p>
|
</p>
|
||||||
<p className="divider font-display opacity-30 my-0">a big thanks to</p>
|
|
||||||
<p className="text-sm md:text-lg">
|
<p className="text-sm md:text-lg">
|
||||||
<a
|
<a
|
||||||
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API"
|
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API"
|
||||||
@@ -446,9 +377,10 @@ function OSSSection() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Web Crypto API
|
Web Crypto API
|
||||||
</a>
|
</a>{" "}
|
||||||
: Browser-native cryptography that runs entirely on your device. The
|
— the backbone of everything promised. Browser-native
|
||||||
backbone of everything secure—your letters, keys—here.
|
cryptography that runs entirely on your device. Without it, none of
|
||||||
|
the privacy here would be possible — or credible.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm md:text-lg">
|
<p className="text-sm md:text-lg">
|
||||||
@@ -458,30 +390,30 @@ function OSSSection() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
DaisyUI
|
DaisyUI
|
||||||
</a>
|
</a>{" "}
|
||||||
·
|
·{" "}
|
||||||
<a
|
<a
|
||||||
href="http://fabricjs.com"
|
href="http://fabricjs.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Fabric.js
|
Fabric.js
|
||||||
</a>
|
</a>{" "}
|
||||||
·
|
·{" "}
|
||||||
<a
|
<a
|
||||||
href="https://phosphoricons.com"
|
href="https://phosphoricons.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Phosphor Icons
|
Phosphor Icons
|
||||||
</a>
|
</a>{" "}
|
||||||
: The brilliant work by others that let me focus on the core
|
— the beautiful work by others that let me focus on the core
|
||||||
experience instead of re-inventing the wheel.
|
experience.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm md:text-lg mt-2 md:mt-4 text-neutral">
|
<p className="text-sm md:text-lg mt-4">
|
||||||
Open source is what made <Logo type={"inline"} /> possible. It always
|
Open source is what made this possible. It felt right to give it back
|
||||||
feels right to give it back the same way.
|
the same way.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -502,8 +434,8 @@ function StorySection() {
|
|||||||
<div className="translate-x-2">
|
<div className="translate-x-2">
|
||||||
<Logo />
|
<Logo />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex ml-10 font-ink text-2xl md:text-3xl group">
|
<div className="flex ml-10 font-tamil text-2xl md:text-3xl group">
|
||||||
<button type="button" className={"flex flex-col flex-wrap ul-wavy"}>
|
<div className={"flex flex-col flex-wrap ul-wavy"}>
|
||||||
பின்
|
பின்
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
@@ -512,7 +444,7 @@ function StorySection() {
|
|||||||
>
|
>
|
||||||
after
|
after
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
<ArrowBendDownLeftIcon className={"text-primary"} />
|
<ArrowBendDownLeftIcon className={"text-primary"} />
|
||||||
<ArrowBendDownRightIcon className="ml-8 text-primary" />
|
<ArrowBendDownRightIcon className="ml-8 text-primary" />
|
||||||
<div className={"flex flex-col flex-wrap group ul-wavy"}>
|
<div className={"flex flex-col flex-wrap group ul-wavy"}>
|
||||||
@@ -542,8 +474,8 @@ function StorySection() {
|
|||||||
postscript; a note written after the letter is signed.
|
postscript; a note written after the letter is signed.
|
||||||
<br />
|
<br />
|
||||||
<blockquote className="text-primary/50 italic mt-2 ml-2 border-l-primary/20 leading-none border-l">
|
<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
|
"the most honest thing was always in the{" "}
|
||||||
<span className="font-ink">பி. கு.</span>"
|
<span className="font-tamil">பி. கு.</span>"
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</li>
|
</li>
|
||||||
<li>the thing you almost didn't say.</li>
|
<li>the thing you almost didn't say.</li>
|
||||||
@@ -561,16 +493,13 @@ function StorySection() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"max-w-220 md:text-xl p-6 flex flex-col gap-4 md:gap-8 text-base-content/70 leading-relaxed"
|
"max-w-200 md:text-xl p-6 flex flex-col gap-4 md:gap-8 text-base-content/70 leading-relaxed"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className={""}>
|
<p className={""}>
|
||||||
<Logo type={"inline"} /> is an abbreviated transliteration of the
|
<Logo type={"inline"} /> is an abbreviated transliteration of the
|
||||||
<span className="font-ink text-accent"> தமிழ் </span>
|
Tamil word for{" "}
|
||||||
<span className="italic text-xs md:text-base">(Tamil) </span>word
|
<span
|
||||||
for
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={
|
className={
|
||||||
"group italic text-primary font-serif inline underline decoration-dotted underline-offset-2 decoration-primary/40"
|
"group italic text-primary font-serif inline underline decoration-dotted underline-offset-2 decoration-primary/40"
|
||||||
}
|
}
|
||||||
@@ -592,10 +521,9 @@ function StorySection() {
|
|||||||
cript
|
cript
|
||||||
</span>
|
</span>
|
||||||
.
|
.
|
||||||
</button>
|
</span>{" "}
|
||||||
—the thing you add after you've already signed your
|
— the thing you add after you've already signed your name,
|
||||||
name, what you write when you thought you were finished, but
|
what you write when you thought you were finished, but weren't.
|
||||||
weren't.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className={"font-medium text-primary"}>
|
<span className={"font-medium text-primary"}>
|
||||||
@@ -604,25 +532,18 @@ function StorySection() {
|
|||||||
<br />
|
<br />
|
||||||
It sits in drafts , in half-written notes, in the pause before we
|
It sits in drafts , in half-written notes, in the pause before we
|
||||||
change the subject. <br />
|
change the subject. <br />
|
||||||
Those words
|
Those words{" "}
|
||||||
<button
|
<span
|
||||||
type="button"
|
|
||||||
className={
|
className={
|
||||||
"blur-sm hover:blur-none active:blur-none focus:blur-none focus:outline-none transition-all duration-500"
|
"blur-sm hover:blur-none active:blur-none focus:blur-none focus:outline-none transition-all duration-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
don't just disappear. They
|
don't just disappear. They
|
||||||
</button>
|
</span>{" "}
|
||||||
stay
|
stay <span className={"text-primary font-hand"}>unsaid</span>{" "}
|
||||||
<span className={"text-primary font-hand font-extrabold"}>
|
— a quiet weight difficult to bear.
|
||||||
unsaid
|
|
||||||
</span>
|
|
||||||
—
|
|
||||||
<span className="italic">a quiet weight difficult to bear</span>.
|
|
||||||
</p>
|
|
||||||
<p className={"italic text-neutral text-center"}>
|
|
||||||
And that's okay...
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className={"italic text-primary"}>And that's okay...</p>
|
||||||
<p>
|
<p>
|
||||||
<Logo type={"inline"} />
|
<Logo type={"inline"} />
|
||||||
<span className={"text-primary"}>
|
<span className={"text-primary"}>
|
||||||
@@ -645,14 +566,15 @@ function ForWhoSection() {
|
|||||||
Who is <br /> this for?
|
Who is <br /> this for?
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-6 max-w-220 p-4 text-base-200 text-xl md:text-2xl leading-relaxed">
|
<div className="space-y-6 max-w-200 p-4 text-base-200 text-xl md:text-2xl leading-relaxed">
|
||||||
<p>
|
<p>
|
||||||
<Logo type={"mono"} /> wasn't built for one kind of person, but a
|
<Logo type={"mono"} /> wasn't built for one kind of person, but a
|
||||||
particular kind of feeling—
|
particular kind of feeling —
|
||||||
<span className="italic font-serif text-stone-900">
|
<span className="italic font-serif text-stone-900">
|
||||||
the one that lingers very quietly
|
{" "}
|
||||||
</span>
|
the one that lingers very quietly
|
||||||
—fragile, yet never breaks.
|
</span>{" "}
|
||||||
|
— fragile, yet never breaks.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="pt-8 flex items-center gap-4">
|
<div className="pt-8 flex items-center gap-4">
|
||||||
@@ -681,8 +603,7 @@ function ArchetypesSection() {
|
|||||||
>
|
>
|
||||||
The Archetypes
|
The Archetypes
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-hand text-xs md:text-xl">of writing</p>
|
<div className="flex flex-col items-center shrink-0 w-200 max-w-11/12 gap-2 md:gap-8 my-4">
|
||||||
<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">
|
<div className="relative w-full">
|
||||||
<details
|
<details
|
||||||
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
|
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
|
||||||
@@ -690,8 +611,8 @@ function ArchetypesSection() {
|
|||||||
open
|
open
|
||||||
>
|
>
|
||||||
<summary className="collapse-title md:text-xl leading-tight font-hand flex items-center gap-4">
|
<summary className="collapse-title md:text-xl leading-tight font-hand flex items-center gap-4">
|
||||||
<GhostIcon weight="duotone" className="text-accent" size={32} />
|
<GhostIcon weight="duotone" className="text-accent" size={32} />{" "}
|
||||||
To someone you can't reach anymore.
|
To someone you can't reach anymore.
|
||||||
</summary>
|
</summary>
|
||||||
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
||||||
<p>
|
<p>
|
||||||
@@ -701,7 +622,7 @@ function ArchetypesSection() {
|
|||||||
finished.
|
finished.
|
||||||
<br />
|
<br />
|
||||||
</p>
|
</p>
|
||||||
<p className="font-ink font-medium opacity-70">
|
<p className="font-serif font-medium opacity-70">
|
||||||
Write the letter anyway. Keep it close.
|
Write the letter anyway. Keep it close.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -721,17 +642,17 @@ function ArchetypesSection() {
|
|||||||
weight="duotone"
|
weight="duotone"
|
||||||
className="text-accent"
|
className="text-accent"
|
||||||
size={32}
|
size={32}
|
||||||
/>
|
/>{" "}
|
||||||
To someone who's still here.
|
To someone who's still here.
|
||||||
</summary>
|
</summary>
|
||||||
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
||||||
<p>
|
<p>
|
||||||
Not every letter is about distance. Sometimes you just need to
|
Not every letter is about distance. Sometimes you just need to
|
||||||
say something properly—without a text thread, without the
|
say something properly — without a text thread, without
|
||||||
noise of a conversation already in motion. A letter slows it
|
the noise of a conversation already in motion. A letter slows it
|
||||||
down.
|
down.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-ink font-medium opacity-70">
|
<p className="font-serif font-medium opacity-70">
|
||||||
Give people their due flowers while they can still smell them.
|
Give people their due flowers while they can still smell them.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -751,8 +672,7 @@ function ArchetypesSection() {
|
|||||||
weight="duotone"
|
weight="duotone"
|
||||||
className="text-accent"
|
className="text-accent"
|
||||||
size={14}
|
size={14}
|
||||||
/>
|
/>{" "}
|
||||||
|
|
||||||
<PersonArmsSpreadIcon
|
<PersonArmsSpreadIcon
|
||||||
weight="duotone"
|
weight="duotone"
|
||||||
className="text-accent"
|
className="text-accent"
|
||||||
@@ -763,13 +683,13 @@ function ArchetypesSection() {
|
|||||||
</summary>
|
</summary>
|
||||||
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
||||||
<p>
|
<p>
|
||||||
Not a journal. Not a note-to-self. A proper letter—to
|
Not a journal. Not a note-to-self. A proper letter — to
|
||||||
whoever you'll be in a year, or five, or ten.
|
whoever you'll be in a year, or five, or ten.
|
||||||
<br />
|
<br />
|
||||||
Ask yourself of the healed wounds, forgotten fears, or the
|
Ask yourself of the healed wounds, forgotten fears, or the
|
||||||
things you finally learned to live with.
|
things you finally learned to live with.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-ink font-medium opacity-70">
|
<p className="font-serif font-medium opacity-70">
|
||||||
Set a date and let a letter surprise you when you've long
|
Set a date and let a letter surprise you when you've long
|
||||||
forgotten writing it.
|
forgotten writing it.
|
||||||
</p>
|
</p>
|
||||||
@@ -785,8 +705,8 @@ function ArchetypesSection() {
|
|||||||
name="my-accordion-det-1"
|
name="my-accordion-det-1"
|
||||||
>
|
>
|
||||||
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-center gap-4">
|
<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} />
|
<SparkleIcon weight="duotone" className="text-accent" size={32} />{" "}
|
||||||
For liberation.
|
For liberation.
|
||||||
</summary>
|
</summary>
|
||||||
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
|
||||||
<p>
|
<p>
|
||||||
@@ -795,7 +715,7 @@ function ArchetypesSection() {
|
|||||||
putting it somewhere outside of yourself. <br />
|
putting it somewhere outside of yourself. <br />
|
||||||
That's sometimes enough.
|
That's sometimes enough.
|
||||||
</p>
|
</p>
|
||||||
<p className="font-ink font-medium opacity-70">
|
<p className="font-serif font-medium opacity-70">
|
||||||
Say it once. All of it. Then let it fade.
|
Say it once. All of it. Then let it fade.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -804,14 +724,11 @@ function ArchetypesSection() {
|
|||||||
04
|
04
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center justify-center gap-2 group mt-12">
|
||||||
className="flex items-center justify-center gap-2 group mt-12 text-left"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={stamp}
|
src={stamp}
|
||||||
alt="stamp"
|
alt="stamp"
|
||||||
className="rotate-6 group-hover:rotate-0 group-focus-within:rotate-0 transition-all duration-1000 focus:outline-none"
|
className="rotate-6 group-hover:rotate-0 group-focus-within:rotate-0 transition-all duration-1000"
|
||||||
/>
|
/>
|
||||||
<p className="md:text-xl mt-4">
|
<p className="md:text-xl mt-4">
|
||||||
If any of these felt familiar,
|
If any of these felt familiar,
|
||||||
@@ -820,7 +737,7 @@ function ArchetypesSection() {
|
|||||||
<br />
|
<br />
|
||||||
this is for you.
|
this is for you.
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -836,12 +753,12 @@ function AttributionSection() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen w-screen items-center py-18 bg-accent text-neutral-content">
|
<div className="flex flex-col min-h-screen w-screen items-center py-18">
|
||||||
{/* Saajan hover image */}
|
{/* Saajan hover image */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hover.visible && (
|
{hover.visible && (
|
||||||
<motion.img
|
<motion.img
|
||||||
src={saajan}
|
src="/saajan.png"
|
||||||
alt="Saajan Fernandes from The Lunchbox, cutout"
|
alt="Saajan Fernandes from The Lunchbox, cutout"
|
||||||
initial={{ opacity: 0, scale: 0.5 }}
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
@@ -858,7 +775,7 @@ function AttributionSection() {
|
|||||||
|
|
||||||
<h1
|
<h1
|
||||||
className={
|
className={
|
||||||
"relative tracking-tighter text-5xl md:text-8xl text-base-200 font-extrabold italic font-serif"
|
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Honest Speak
|
Honest Speak
|
||||||
@@ -866,7 +783,7 @@ function AttributionSection() {
|
|||||||
<div className="flex flex-col items-center shrink-0">
|
<div className="flex flex-col items-center shrink-0">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"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"
|
"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"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Hi.
|
Hi.
|
||||||
@@ -874,40 +791,24 @@ function AttributionSection() {
|
|||||||
<p>
|
<p>
|
||||||
<Logo type={"inline"} /> took a while to exist.
|
<Logo type={"inline"} /> took a while to exist.
|
||||||
<br />
|
<br />
|
||||||
This started as a
|
This started as a{" "}
|
||||||
<a
|
<a
|
||||||
href="https://cs50.harvard.edu/web/"
|
href="https://cs50.harvard.edu/web/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
CS50W
|
CS50W
|
||||||
</a>
|
</a>{" "}
|
||||||
capstone—one I kept postponing until I ran out of
|
capstone, one I kept postponing until I ran out of reasons not to.
|
||||||
excuses. When I sat down to build it, it felt heavier than a typical
|
When I eventually sat down to build, I knew it had to be more than a
|
||||||
assignment—not just because things were difficult. It had to
|
deadline; it had to be something that outlasted the grade. I wanted
|
||||||
be something that outlasted the grade. I wanted to make this one
|
to create a space for the feelings we usually keep to ourselves and
|
||||||
count more than anything else I'd ever made. Something as close to
|
every hour spent on it was worth it. I've shared the edges of{" "}
|
||||||
perfect as I could get it. Something to be remembered for—a
|
<Logo type={"inline"} /> here, but the heart of it is best found by
|
||||||
Swan Song if you will.
|
exploring it yourself.
|
||||||
</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
|
|
||||||
<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"} />
|
|
||||||
here—the core philosophies, how it all works—but
|
|
||||||
the heart of it is really something you have to find by exploring it
|
|
||||||
yourself.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
The "why" behind all of this didn't just appear out of nowhere. For
|
I kept coming back to{" "}
|
||||||
a while, I kept coming back to
|
|
||||||
<span
|
<span
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
className="cursor-default ul-wavy text-accent"
|
className="cursor-default ul-wavy text-accent"
|
||||||
@@ -928,87 +829,56 @@ function AttributionSection() {
|
|||||||
onMouseLeave={() => setHover((h) => ({ ...h, visible: false }))}
|
onMouseLeave={() => setHover((h) => ({ ...h, visible: false }))}
|
||||||
>
|
>
|
||||||
Saajan
|
Saajan
|
||||||
</span>
|
</span>{" "}
|
||||||
from
|
from{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.themoviedb.org/movie/191714-the-lunchbox"
|
href="https://www.imdb.com/title/tt2350496/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
The Lunchbox
|
The Lunchbox
|
||||||
</a>
|
</a>{" "}
|
||||||
—brought to life with such subtle brilliance by
|
—{" "}
|
||||||
<a
|
<span className="italic">
|
||||||
className="text-accent!"
|
one of the most subtle yet brilliant portrayals by Irrfan Khan
|
||||||
href="https://www.themoviedb.org/person/76793-irrfan-khan"
|
</span>{" "}
|
||||||
rel="noopener noreferrer"
|
— the quiet emotional weight he carries throughout the film,
|
||||||
>
|
going through the motions of a lonely life, until those letters
|
||||||
Irrfan Khan
|
arrive and something inside him finally loosens. Of course, the
|
||||||
</a>
|
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
|
||||||
<PeaceIcon weight="duotone" className="inline text-accent" />
|
briefly. I think about that a lot.
|
||||||
—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
|
|
||||||
<span className="font-hand font-bold text-accent">
|
|
||||||
"it is what it is"
|
|
||||||
</span>
|
|
||||||
, but the simple act of writing—of letting the unsaid
|
|
||||||
out—offered him a brief, yet necessary ease. I think about
|
|
||||||
that a lot.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
There's a lot that goes
|
There's a lot that goes{" "}
|
||||||
<span className={"text-primary font-hand text-lg md:text-xl"}>
|
<span className={"text-primary font-hand text-lg md:text-xl"}>
|
||||||
unsaid
|
unsaid
|
||||||
</span>
|
</span>{" "}
|
||||||
these days. Not for a lack of feeling, not for the lack of
|
now. Not that people feel less or for the lack of time, but because
|
||||||
time, but because the ways we reach each other have quietly changed.
|
the ways we reach each other have quietly changed. We're always
|
||||||
We're always reachable <span className="italic">digitally,</span>
|
reachable <span className="italic">digitally,</span> yet somehow the
|
||||||
yet somehow the things that actually matter most end up
|
things that matter most end up staying inside.
|
||||||
staying inside—a trapped one at that.
|
|
||||||
<br />
|
<br />
|
||||||
Maybe writing can/will help. Maybe putting words somewhere
|
Maybe writing will help with that. Maybe something about putting
|
||||||
deliberate makes them feel less like a weight you're carrying alone.
|
words somewhere deliberate makes them feel less like something
|
||||||
|
you're carrying.
|
||||||
</p>
|
</p>
|
||||||
<p>Or maybe it won't—but it's worth a try.</p>
|
<p>Or maybe it won't, but it's worth a try.</p>
|
||||||
<p>
|
<p>
|
||||||
<Logo type={"inline"} /> is for that try. I hope it helps. Really.
|
<Logo type={"inline"} /> is for that try. I hope it helps.
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
"text-right font-hand text-base-content text-lg md:text-xl"
|
"text-right font-hand text-base-content text-lg md:text-xl"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
—Ram
|
— Ram
|
||||||
</p>
|
|
||||||
<p className="text-xs md:text-sm opacity-75 font-mono">
|
|
||||||
P.S. And just so we're clear—I wrote every word of this
|
|
||||||
myself—as I continue to back
|
|
||||||
<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?
|
|
||||||
<span className="font-hand">(get it?)</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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">
|
||||||
<QuotesIcon
|
"I think we forget things if there is nobody to tell them."
|
||||||
weight="duotone"
|
<span className="block mt-2 text-sm not-italic text-base-content/30 w-full text-right">
|
||||||
size={48}
|
~ Saajan Fernandes, <span className="italic">The Lunchbox</span>
|
||||||
className="rotate-180 text-neutral-content"
|
|
||||||
/>
|
|
||||||
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,
|
|
||||||
<span className="italic underline decoration-dotted">
|
|
||||||
The Lunchbox
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
@@ -1016,7 +886,7 @@ function AttributionSection() {
|
|||||||
<button
|
<button
|
||||||
type={"button"}
|
type={"button"}
|
||||||
onClick={() => navigate("/onboard")}
|
onClick={() => navigate("/onboard")}
|
||||||
className="btn btn-base-100 btn-wide rounded-full px-14 font-mono"
|
className="btn btn-primary btn-wide rounded-full px-14 font-mono"
|
||||||
>
|
>
|
||||||
Begin
|
Begin
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function Activate() {
|
|||||||
});
|
});
|
||||||
await publicApi.get(url);
|
await publicApi.get(url);
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
} catch {
|
} catch (_err) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -52,22 +52,17 @@ export default function Activate() {
|
|||||||
className="text-success"
|
className="text-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2
|
<h2 className="font-display text-xl text-success">
|
||||||
data-testid="activation-success-header"
|
Account Activated!
|
||||||
className="font-display text-xl text-success"
|
|
||||||
>
|
|
||||||
You're in.
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="opacity-70 leading-relaxed">
|
<p className="opacity-70 leading-relaxed">
|
||||||
Welcome to
|
Welcome to <Logo scale={1} />
|
||||||
<Logo type="inline" />
|
|
||||||
<br />
|
<br />
|
||||||
Just one more step and you can start writing timeless letters.
|
Your identity is now verified and ready for timeless letters.
|
||||||
</p>
|
</p>
|
||||||
<div className="divider opacity-10 my-0"></div>
|
<div className="divider opacity-10 my-0"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="start-writing-btn"
|
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(ROUTES.LOGIN, {
|
navigate(ROUTES.LOGIN, {
|
||||||
@@ -76,7 +71,7 @@ export default function Activate() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
I'm ready
|
Start Writing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||||
import type { WelcomeLetterOverlayProps } from "../components/drawer/WelcomeLetterOverlay";
|
|
||||||
import { useLetters } from "../hooks/useLetters";
|
import { useLetters } from "../hooks/useLetters";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
import Drawer from "./Drawer";
|
import Drawer from "./Drawer";
|
||||||
|
|
||||||
vi.mock("../hooks/useLetters");
|
vi.mock("../hooks/useLetters");
|
||||||
vi.mock("../components/drawer/WelcomeLetterOverlay", () => ({
|
|
||||||
WelcomeLetterOverlay: ({ onComplete }: 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", () => {
|
describe("Drawer Page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -41,21 +27,17 @@ describe("Drawer Page", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the drawer sections and empty state message", () => {
|
it("renders the cabinet sections and empty state message", () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Drawer />
|
<Drawer />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("drawer-section-drafts")).toBeInTheDocument();
|
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
|
||||||
expect(
|
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
|
||||||
screen.getAllByTestId("drawer-section-title").length,
|
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
|
||||||
).toBeGreaterThanOrEqual(1);
|
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("drawer-section-vault")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByTestId("empty-drawer-message-drafts"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the loading state", () => {
|
it("renders the loading state", () => {
|
||||||
@@ -74,7 +56,7 @@ describe("Drawer Page", () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("drawer-loading-state")).toBeInTheDocument();
|
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the authentication required modal when api requires auth", () => {
|
it("renders the authentication required modal when api requires auth", () => {
|
||||||
@@ -93,36 +75,7 @@ describe("Drawer Page", () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("passkey-modal-title")).toBeInTheDocument();
|
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("passkey-input")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the welcome letter when firstTime state is present", () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter
|
|
||||||
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
|
||||||
>
|
|
||||||
<Drawer />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("welcome-letter-overlay")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the drawer content when the letter is closed", () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter
|
|
||||||
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
|
||||||
>
|
|
||||||
<Drawer />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const completeButton = screen.getByTestId("overlay-exit-button");
|
|
||||||
fireEvent.click(completeButton);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.queryByTestId("welcome-letter-overlay"),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
import {
|
import { FeatherIcon } from "@phosphor-icons/react";
|
||||||
ArchiveIcon,
|
|
||||||
FeatherIcon,
|
|
||||||
FileDashedIcon,
|
|
||||||
PaperPlaneTiltIcon,
|
|
||||||
VaultIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { DrawerSection } from "../components/drawer/DrawerSection";
|
import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
|
||||||
import { LetterItem } from "../components/drawer/LetterItem";
|
import { LetterItem } from "../components/drawer/LetterItem.tsx";
|
||||||
import { PasskeyModal } from "../components/drawer/PasskeyModal";
|
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
|
||||||
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay";
|
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import Saajan from "../components/ui/Saajan";
|
import Saajan from "../components/ui/Saajan.tsx";
|
||||||
import { PATHS } from "../config/routes";
|
import { PATHS } from "../config/routes";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useLetters } from "../hooks/useLetters";
|
import { useLetters } from "../hooks/useLetters";
|
||||||
import {
|
import {
|
||||||
formatRelativeDate,
|
formatRelativeDate,
|
||||||
formatRelativeDateWithoutTime,
|
formatRelativeDateWithoutTime,
|
||||||
} from "../utils/dateFormat";
|
} from "../utils/dateFormat.ts";
|
||||||
|
|
||||||
export default function Drawer() {
|
export default function Drawer() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout, unlock } = useAuth();
|
||||||
|
|
||||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const [showWelcomeLetter, setShowWelcomeLetter] = useState(
|
|
||||||
!!location.state?.firstTime,
|
|
||||||
);
|
|
||||||
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
|
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@@ -41,24 +30,14 @@ export default function Drawer() {
|
|||||||
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
|
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
||||||
|
|
||||||
{showWelcomeLetter && (
|
{isAuthRequired && <PasskeyModal onUnlock={unlock} />}
|
||||||
<WelcomeLetterOverlay
|
|
||||||
userName={user.full_name}
|
|
||||||
onComplete={() => {
|
|
||||||
setShowWelcomeLetter(false);
|
|
||||||
navigate(location.pathname, { replace: true, state: {} });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAuthRequired && <PasskeyModal />}
|
|
||||||
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
<Logo />
|
<Logo />
|
||||||
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
||||||
Personal Archive
|
Personal Archive
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
|
<div className="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{" "}
|
||||||
<span className="font-semibold text-primary">{user.full_name}</span>
|
<span className="font-semibold text-primary">{user.full_name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -74,10 +53,7 @@ export default function Drawer() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
|
<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="loading loading-ring loading-lg text-primary opacity-20"></span>
|
||||||
<span
|
<span className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse">
|
||||||
data-testid="drawer-loading-state"
|
|
||||||
className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse"
|
|
||||||
>
|
|
||||||
Opening your cabinet...
|
Opening your cabinet...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,11 +62,9 @@ export default function Drawer() {
|
|||||||
<DrawerSection
|
<DrawerSection
|
||||||
id="drafts"
|
id="drafts"
|
||||||
title="Drafts"
|
title="Drafts"
|
||||||
count={drafts.length}
|
count={`${drafts.length} unfinished whispers`}
|
||||||
subtext="unfinished whispers"
|
|
||||||
isOpen={openSection === "drafts"}
|
isOpen={openSection === "drafts"}
|
||||||
onClick={() => toggleSection("drafts")}
|
onClick={() => toggleSection("drafts")}
|
||||||
icon={<FileDashedIcon weight="thin" size={128} />}
|
|
||||||
>
|
>
|
||||||
{drafts.map((draft) => (
|
{drafts.map((draft) => (
|
||||||
<LetterItem
|
<LetterItem
|
||||||
@@ -106,11 +80,9 @@ export default function Drawer() {
|
|||||||
<DrawerSection
|
<DrawerSection
|
||||||
id="kept"
|
id="kept"
|
||||||
title="Kept"
|
title="Kept"
|
||||||
count={kept.length}
|
count={`${kept.length} private letters`}
|
||||||
subtext="private letters"
|
|
||||||
isOpen={openSection === "kept"}
|
isOpen={openSection === "kept"}
|
||||||
onClick={() => toggleSection("kept")}
|
onClick={() => toggleSection("kept")}
|
||||||
icon={<ArchiveIcon weight="thin" size={128} />}
|
|
||||||
>
|
>
|
||||||
{kept.map((letter) => (
|
{kept.map((letter) => (
|
||||||
<LetterItem
|
<LetterItem
|
||||||
@@ -125,11 +97,9 @@ export default function Drawer() {
|
|||||||
<DrawerSection
|
<DrawerSection
|
||||||
id="sent"
|
id="sent"
|
||||||
title="Sent"
|
title="Sent"
|
||||||
count={sent.length}
|
count={`${sent.length} shared truths`}
|
||||||
subtext="shared truths"
|
|
||||||
isOpen={openSection === "sent"}
|
isOpen={openSection === "sent"}
|
||||||
onClick={() => toggleSection("sent")}
|
onClick={() => toggleSection("sent")}
|
||||||
icon={<PaperPlaneTiltIcon weight="thin" size={128} />}
|
|
||||||
>
|
>
|
||||||
{sent.map((letter) => (
|
{sent.map((letter) => (
|
||||||
<LetterItem
|
<LetterItem
|
||||||
@@ -140,15 +110,18 @@ export default function Drawer() {
|
|||||||
timestamp={formatRelativeDate(letter.updated_at)}
|
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>
|
||||||
<DrawerSection
|
<DrawerSection
|
||||||
id="vault"
|
id="vault"
|
||||||
title="Vault"
|
title="Vault"
|
||||||
count={vault.length}
|
count={`${vault.length} things locked;not lost;in time`}
|
||||||
subtext="things locked—not lost—in time"
|
|
||||||
isOpen={openSection === "vault"}
|
isOpen={openSection === "vault"}
|
||||||
onClick={() => toggleSection("vault")}
|
onClick={() => toggleSection("vault")}
|
||||||
icon={<VaultIcon weight="thin" size={128} />}
|
|
||||||
>
|
>
|
||||||
{vault.map((letter) => (
|
{vault.map((letter) => (
|
||||||
<LetterItem
|
<LetterItem
|
||||||
@@ -171,7 +144,6 @@ export default function Drawer() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="write-letter-btn"
|
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"
|
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(""))}
|
onClick={() => navigate(PATHS.write(""))}
|
||||||
>
|
>
|
||||||
@@ -180,7 +152,7 @@ export default function Drawer() {
|
|||||||
weight="duotone"
|
weight="duotone"
|
||||||
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
|
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
|
||||||
/>
|
/>
|
||||||
Write something
|
Write something{" "}
|
||||||
<span className="relative inline-flex">
|
<span className="relative inline-flex">
|
||||||
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
|
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
|
||||||
. . . . . .
|
. . . . . .
|
||||||
@@ -194,14 +166,12 @@ export default function Drawer() {
|
|||||||
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
||||||
For your unsaid.
|
For your unsaid.
|
||||||
</footer>
|
</footer>
|
||||||
{!showWelcomeLetter && (
|
<div className="absolute bottom-0 z-50 font-sans">
|
||||||
<div className="absolute bottom-0 z-50 font-sans">
|
<Saajan
|
||||||
<Saajan
|
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
||||||
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
position="top"
|
||||||
position="top"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitForElementToBeRemoved,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -84,9 +79,9 @@ describe("Editor Page", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Wait for initial load to complete
|
// Wait for initial load to complete
|
||||||
await waitForElementToBeRemoved(() =>
|
await waitFor(() => {
|
||||||
screen.queryByTestId("opening-draft-overlay"),
|
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
|
||||||
);
|
});
|
||||||
|
|
||||||
const canvas = screen.getByTestId("canvas");
|
const canvas = screen.getByTestId("canvas");
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("false");
|
expect(canvas.getAttribute("data-readonly")).toBe("false");
|
||||||
@@ -97,22 +92,27 @@ describe("Editor Page", () => {
|
|||||||
fireEvent.click(sealBtn);
|
fireEvent.click(sealBtn);
|
||||||
|
|
||||||
// Click Vault to show confirm modal
|
// Click Vault to show confirm modal
|
||||||
const vaultBtn = screen.getByTestId("vault-trigger-btn");
|
const vaultBtn = screen.getByRole("button", { name: /vault/i });
|
||||||
fireEvent.click(vaultBtn);
|
fireEvent.click(vaultBtn);
|
||||||
|
|
||||||
// Set date and submit vault form
|
// Set date and submit vault form
|
||||||
const dateInput = document.body.querySelector('input[name="vault-date"]');
|
const dateInput = container.querySelector('input[name="vault-date"]');
|
||||||
if (!dateInput) throw new Error("Date input not found");
|
if (!dateInput) throw new Error("Date input not found");
|
||||||
fireEvent.change(dateInput, { target: { value: "2026-12-31" } });
|
fireEvent.change(dateInput, { target: { value: "2026-12-31" } });
|
||||||
|
|
||||||
const confirmVaultBtn = screen.getByTestId("vault-confirm-btn");
|
const confirmVaultBtn = container.querySelector(
|
||||||
|
'button[form="vault-form"]',
|
||||||
|
);
|
||||||
|
if (!confirmVaultBtn) throw new Error("Confirm vault button not found");
|
||||||
fireEvent.click(confirmVaultBtn);
|
fireEvent.click(confirmVaultBtn);
|
||||||
|
|
||||||
// Wait for save to complete and check readOnly
|
// Wait for save to complete and check readOnly
|
||||||
expect(await screen.findByTestId("save-success-toast")).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
||||||
expect(screen.getByTestId("recipient-input")).toBeDisabled();
|
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set canvas to readOnly when status is SEALED", async () => {
|
it("should set canvas to readOnly when status is SEALED", async () => {
|
||||||
@@ -132,7 +132,7 @@ describe("Editor Page", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(
|
const { container } = render(
|
||||||
<MemoryRouter initialEntries={["/write/test-id"]}>
|
<MemoryRouter initialEntries={["/write/test-id"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/write/:public_id" element={<Editor />} />
|
<Route path="/write/:public_id" element={<Editor />} />
|
||||||
@@ -140,23 +140,27 @@ describe("Editor Page", () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitForElementToBeRemoved(() =>
|
await waitFor(() => {
|
||||||
screen.queryByTestId("opening-draft-overlay"),
|
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
|
||||||
);
|
});
|
||||||
|
|
||||||
const canvas = screen.getByTestId("canvas");
|
const canvas = screen.getByTestId("canvas");
|
||||||
|
|
||||||
const sealBtn = screen.getByTestId("seal-trigger-btn");
|
const toolbar = container.querySelector("#writer-toolbar");
|
||||||
|
const sealBtn = toolbar?.querySelector(".btn-primary");
|
||||||
|
if (!sealBtn) throw new Error("Seal button not found");
|
||||||
fireEvent.click(sealBtn);
|
fireEvent.click(sealBtn);
|
||||||
|
|
||||||
// The secondary seal button appears (it has btn-accent class)
|
// The secondary seal button appears (it has btn-accent class)
|
||||||
const secondarySealBtn = screen.getByTestId("seal-confirm-btn");
|
const secondarySealBtn = container.querySelector(".btn-accent");
|
||||||
if (!secondarySealBtn) throw new Error("Secondary seal button not found");
|
if (!secondarySealBtn) throw new Error("Secondary seal button not found");
|
||||||
fireEvent.click(secondarySealBtn);
|
fireEvent.click(secondarySealBtn);
|
||||||
|
|
||||||
expect(await screen.findByTestId("save-success-toast")).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
||||||
expect(screen.getByTestId("recipient-input")).toBeDisabled();
|
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import type { LetterResponseData } from "../api/response";
|
|
||||||
import {
|
import {
|
||||||
type CanvasStyle,
|
type CanvasStyle,
|
||||||
type CanvasTools,
|
type CanvasTools,
|
||||||
@@ -27,6 +26,7 @@ import DateDisplay from "../components/ui/DateDisplay";
|
|||||||
import { LogModal } from "../components/ui/LogModal";
|
import { LogModal } from "../components/ui/LogModal";
|
||||||
import { Modal } from "../components/ui/Modal";
|
import { Modal } from "../components/ui/Modal";
|
||||||
import { Navbar } from "../components/ui/Navbar";
|
import { Navbar } from "../components/ui/Navbar";
|
||||||
|
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { PATHS } from "../config/routes";
|
import { PATHS } from "../config/routes";
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
@@ -34,6 +34,12 @@ import { CryptoUtils } from "../utils/crypto";
|
|||||||
import { formatRelativeDate } from "../utils/dateFormat";
|
import { formatRelativeDate } from "../utils/dateFormat";
|
||||||
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
||||||
|
|
||||||
|
import "@fontsource/kavivanar/index.css";
|
||||||
|
import "@fontsource/space-mono/index.css";
|
||||||
|
import "@fontsource/cutive-mono/index.css";
|
||||||
|
import "@fontsource/architects-daughter/index.css";
|
||||||
|
import "@fontsource/redacted-script/index.css";
|
||||||
|
|
||||||
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
|
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
|
||||||
|
|
||||||
const OVERLAY_FADE_MS = 250;
|
const OVERLAY_FADE_MS = 250;
|
||||||
@@ -116,54 +122,10 @@ export default function Editor() {
|
|||||||
justSavedRef.current = false;
|
justSavedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const decryptAndLoadLetter = async (
|
|
||||||
letterData: LetterResponseData,
|
|
||||||
masterKey: CryptoKey,
|
|
||||||
) => {
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
|
||||||
const metadata = await cryptoUtils.decryptMetadata(
|
|
||||||
{
|
|
||||||
encrypted_content: letterData.encrypted_metadata,
|
|
||||||
encrypted_dek: letterData.encrypted_dek,
|
|
||||||
},
|
|
||||||
masterKey,
|
|
||||||
);
|
|
||||||
setRecipient(metadata.recipient || "");
|
|
||||||
|
|
||||||
const decryptedJsonStr = await cryptoUtils.decryptLetter(
|
|
||||||
{
|
|
||||||
encrypted_content: letterData.encrypted_content,
|
|
||||||
encrypted_dek: letterData.encrypted_dek,
|
|
||||||
},
|
|
||||||
masterKey,
|
|
||||||
);
|
|
||||||
const canvasData = JSON.parse(decryptedJsonStr);
|
|
||||||
|
|
||||||
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
|
|
||||||
await decryptCanvasImages(
|
|
||||||
canvasData,
|
|
||||||
letterData.images ?? [],
|
|
||||||
letterData.encrypted_dek,
|
|
||||||
masterKey,
|
|
||||||
cryptoUtils,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isPartialFailure) {
|
|
||||||
setDecryptionStatus({
|
|
||||||
status: "WARN",
|
|
||||||
message: "Failed to decrypt some elements. Please check the render.",
|
|
||||||
log: errors.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canvasRef.current) {
|
|
||||||
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadExistingLetter = async () => {
|
const loadExistingLetter = async () => {
|
||||||
setIsInitialLoading(true);
|
setIsInitialLoading(true);
|
||||||
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||||
const letterData = res.data;
|
const letterData = res.data;
|
||||||
@@ -176,14 +138,55 @@ export default function Editor() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (letterData.encrypted_dek && masterKey) {
|
if (!letterData.encrypted_dek) {
|
||||||
await decryptAndLoadLetter(letterData, masterKey);
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
|
const metadata = await cryptoUtils.decryptMetadata(
|
||||||
|
{
|
||||||
|
encrypted_content: letterData.encrypted_metadata,
|
||||||
|
encrypted_dek: letterData.encrypted_dek,
|
||||||
|
},
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
setRecipient(metadata.recipient || "");
|
||||||
|
|
||||||
|
const decryptedJsonStr = await cryptoUtils.decryptLetter(
|
||||||
|
{
|
||||||
|
encrypted_content: letterData.encrypted_content,
|
||||||
|
encrypted_dek: letterData.encrypted_dek,
|
||||||
|
},
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
const canvasData = JSON.parse(decryptedJsonStr);
|
||||||
|
|
||||||
|
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
|
||||||
|
await decryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
|
letterData.images ?? [],
|
||||||
|
letterData.encrypted_dek,
|
||||||
|
masterKey,
|
||||||
|
cryptoUtils,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPartialFailure) {
|
||||||
|
setDecryptionStatus({
|
||||||
|
status: "WARN",
|
||||||
|
message:
|
||||||
|
"Failed to decrypt some elements. Please check the render.",
|
||||||
|
log: errors.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvasRef.current) {
|
||||||
|
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
setDecryptionStatus({
|
setDecryptionStatus({
|
||||||
status: "ERROR",
|
status: "ERROR",
|
||||||
message: "Failed to decrypt letter. Please try again later.",
|
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 {
|
} finally {
|
||||||
setIsInitialLoading(false);
|
setIsInitialLoading(false);
|
||||||
@@ -245,79 +248,74 @@ export default function Editor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRequestData = async (
|
|
||||||
targetId: string,
|
|
||||||
status: string,
|
|
||||||
vaultDate?: Date,
|
|
||||||
): Promise<FormData> => {
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
|
||||||
await cryptoUtils.initialize();
|
|
||||||
|
|
||||||
const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
|
|
||||||
const canvasImages = canvasRef.current?.getImages() || [];
|
|
||||||
|
|
||||||
const { encryptedImageFiles, encryptedCanvasData } =
|
|
||||||
await encryptCanvasImages(
|
|
||||||
canvasData,
|
|
||||||
canvasImages,
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
|
||||||
masterKey!,
|
|
||||||
cryptoUtils,
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted_letter = await cryptoUtils.encryptLetter(
|
|
||||||
JSON.stringify(encryptedCanvasData),
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
|
|
||||||
masterKey!,
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
|
||||||
{ recipient, tags: [] },
|
|
||||||
// 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());
|
|
||||||
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);
|
|
||||||
|
|
||||||
encryptedImageFiles.forEach((blob, filename) => {
|
|
||||||
formData.append("image_files", blob, filename);
|
|
||||||
});
|
|
||||||
|
|
||||||
return formData;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (
|
const handleSave = async (
|
||||||
status: "SEALED" | "DRAFT" | "VAULT",
|
status: "SEALED" | "DRAFT" | "VAULT",
|
||||||
vaultDate?: Date,
|
vaultDate?: Date,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
setSealBtnClicked(false);
|
setSealBtnClicked(false);
|
||||||
// use the letter's id if an existing letter or create a new id
|
|
||||||
const targetId = public_id || letterIdRef.current || crypto.randomUUID();
|
let targetId = public_id || letterIdRef.current;
|
||||||
|
if (!targetId) {
|
||||||
|
targetId = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
if (saveOverlay === "SAVING" || !masterKey) return;
|
if (saveOverlay === "SAVING" || !masterKey) return;
|
||||||
|
|
||||||
setSaveOverlay("SAVING");
|
setSaveOverlay("SAVING");
|
||||||
setShowSaveOverlay(true);
|
setShowSaveOverlay(true);
|
||||||
|
|
||||||
try {
|
const cryptoUtils = new CryptoUtils();
|
||||||
const formData = await getRequestData(targetId, status, vaultDate);
|
await cryptoUtils.initialize();
|
||||||
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvasData = canvasRef.current?.getData() || { objects: [] };
|
||||||
|
const canvasImages = canvasRef.current?.getImages() || [];
|
||||||
|
|
||||||
|
const { encryptedImageFiles, encryptedCanvasData } =
|
||||||
|
await encryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
|
canvasImages,
|
||||||
|
masterKey,
|
||||||
|
cryptoUtils,
|
||||||
|
);
|
||||||
|
|
||||||
|
const encrypted_letter = await cryptoUtils.encryptLetter(
|
||||||
|
JSON.stringify(encryptedCanvasData),
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
||||||
|
{ recipient, tags: [] },
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
if (status === "VAULT") {
|
||||||
|
const finalDate = vaultDate || unlockDate;
|
||||||
|
formData.append("type", "VAULT");
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
encryptedImageFiles.forEach((blob, filename) => {
|
||||||
|
formData.append("image_files", blob, filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
|
||||||
justSavedRef.current = true;
|
justSavedRef.current = true;
|
||||||
|
|
||||||
if (!public_id) {
|
if (!public_id) {
|
||||||
letterIdRef.current = targetId;
|
letterIdRef.current = targetId;
|
||||||
navigate(PATHS.write(targetId), { replace: true });
|
navigate(PATHS.write(targetId), { replace: true });
|
||||||
@@ -332,7 +330,7 @@ export default function Editor() {
|
|||||||
}
|
}
|
||||||
setSaveOverlay("SAVED");
|
setSaveOverlay("SAVED");
|
||||||
setShowSaveOverlay(true);
|
setShowSaveOverlay(true);
|
||||||
} catch {
|
} catch (_error) {
|
||||||
setSaveOverlay("ERROR");
|
setSaveOverlay("ERROR");
|
||||||
setShowSaveOverlay(true);
|
setShowSaveOverlay(true);
|
||||||
}
|
}
|
||||||
@@ -382,10 +380,7 @@ export default function Editor() {
|
|||||||
weight="bold"
|
weight="bold"
|
||||||
className="animate-spin text-primary"
|
className="animate-spin text-primary"
|
||||||
/>
|
/>
|
||||||
<p
|
<p className="text-xxs uppercase tracking-widester font-bold text-base-content/40">
|
||||||
data-testid="opening-draft-overlay"
|
|
||||||
className="text-xxs uppercase tracking-widester font-bold text-base-content/40"
|
|
||||||
>
|
|
||||||
Opening your draft...
|
Opening your draft...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,7 +410,6 @@ export default function Editor() {
|
|||||||
{saveOverlay === "SAVED" && (
|
{saveOverlay === "SAVED" && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
data-testid="save-success-toast"
|
|
||||||
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
|
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
|
||||||
showSaveOverlay
|
showSaveOverlay
|
||||||
? "opacity-100 scale-100 translate-y-0"
|
? "opacity-100 scale-100 translate-y-0"
|
||||||
@@ -469,7 +463,6 @@ export default function Editor() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="recipient"
|
id="recipient"
|
||||||
data-testid="recipient-input"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={toPlaceholderList[placeholderIndex]}
|
placeholder={toPlaceholderList[placeholderIndex]}
|
||||||
value={recipient}
|
value={recipient}
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { InfoIcon } from "@phosphor-icons/react";
|
import { InfoIcon } from "@phosphor-icons/react";
|
||||||
import { ReactLenis } from "lenis/react";
|
|
||||||
import {
|
import {
|
||||||
motion,
|
motion,
|
||||||
useMotionValueEvent,
|
useMotionValueEvent,
|
||||||
useScroll,
|
useScroll,
|
||||||
|
useSpring,
|
||||||
useTransform,
|
useTransform,
|
||||||
} from "motion/react";
|
} from "motion/react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import letterSample from "../assets/screenshots/letter.webp";
|
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
|
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
|
||||||
import Saajan from "../components/ui/Saajan";
|
import Saajan from "../components/ui/Saajan.tsx";
|
||||||
import { ROUTES } from "../config/routes";
|
import { ROUTES } from "../config/routes.ts";
|
||||||
import { formatDate } from "../utils/dateFormat";
|
import { formatDate } from "../utils/dateFormat.ts";
|
||||||
|
|
||||||
import "@fontsource/space-mono/index.css";
|
|
||||||
import "@fontsource/architects-daughter/index.css";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
||||||
const { scrollYProgress } = useScroll({
|
const { scrollYProgress: section1ScrollProgress } = useScroll({
|
||||||
target: sectionContainer1,
|
target: sectionContainer1,
|
||||||
});
|
});
|
||||||
|
const smoothProgress1 = useSpring(section1ScrollProgress, {
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 30,
|
||||||
|
restDelta: 0.001,
|
||||||
|
});
|
||||||
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
||||||
const [flapOpen, setFlapOpen] = useState(false);
|
const [flapOpen, setFlapOpen] = useState(false);
|
||||||
const [recipient, setRecipient] = useState("someone dear");
|
const [recipient, setRecipient] = useState("someone dear");
|
||||||
@@ -30,7 +31,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
|
useMotionValueEvent(section1ScrollProgress, "change", (latestScrollValue) => {
|
||||||
if (latestScrollValue > 0.54) {
|
if (latestScrollValue > 0.54) {
|
||||||
setFlapOpen(false);
|
setFlapOpen(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -54,347 +55,342 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
|
<section
|
||||||
<section
|
ref={sectionContainer1}
|
||||||
ref={sectionContainer1}
|
className="relative w-full h-[850vh] bg-base-100 font-serif"
|
||||||
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
|
>
|
||||||
>
|
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
||||||
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
{/* Intro */}
|
||||||
{/* Intro */}
|
<motion.div
|
||||||
<motion.div
|
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
||||||
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
style={{
|
||||||
style={{
|
opacity: useTransform(smoothProgress1, [0, 0.12, 1], [1, 0, 0]),
|
||||||
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
|
scale: useTransform(smoothProgress1, [0, 0.12], [1, 10]),
|
||||||
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
|
||||||
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6">
|
You've been carrying something
|
||||||
You've been carrying something
|
</h1>
|
||||||
</h1>
|
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
|
||||||
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
|
unsaid
|
||||||
unsaid
|
</h2>
|
||||||
</motion.h2>
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="absolute text-center"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(smoothProgress1, [0, 0.15, 0.2], [0, 1, 0]),
|
||||||
|
y: useTransform(smoothProgress1, [0, 0.15, 0.2], [40, 0, -40]),
|
||||||
|
scale: useTransform(smoothProgress1, [0, 0.15, 0.2], [0.8, 1, 3]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
|
||||||
|
and that's okay...
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
{/* pi. ku. */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute text-center px-6"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.18, 0.25, 0.3],
|
||||||
|
[0, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.18, 0.25, 0.3], [20, 0, -20]),
|
||||||
|
}}
|
||||||
|
transition={{ delay: 4 }}
|
||||||
|
>
|
||||||
|
<Logo scale={2} />
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute text-center"
|
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
|
||||||
style={{
|
|
||||||
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
|
|
||||||
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
|
|
||||||
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
|
|
||||||
and that's okay...
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
{/* pi. ku. */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute text-center px-6"
|
|
||||||
style={{
|
style={{
|
||||||
opacity: useTransform(
|
opacity: useTransform(
|
||||||
scrollYProgress,
|
smoothProgress1,
|
||||||
[0.18, 0.25, 0.3],
|
[0.22, 0.25, 0.35, 0.4],
|
||||||
[0, 1, 0],
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.25, 0.3, 0.35, 0.4],
|
||||||
|
[20, 0, 0, -20],
|
||||||
),
|
),
|
||||||
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
|
|
||||||
}}
|
}}
|
||||||
transition={{ delay: 4 }}
|
|
||||||
>
|
>
|
||||||
<Logo type="logo" scale={1.5} ul={true} />
|
is a{" "}
|
||||||
<motion.div
|
<span className="font-display text-primary font-extralight">
|
||||||
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
|
safe space
|
||||||
style={{
|
</span>
|
||||||
opacity: useTransform(
|
,<br />
|
||||||
scrollYProgress,
|
<motion.span
|
||||||
[0.22, 0.25, 0.35, 0.4],
|
className="opacity-0 text-3xl md:text-5xl"
|
||||||
[0, 1, 1, 0],
|
transition={{ delay: 3 }}
|
||||||
),
|
whileInView={{ opacity: 1 }}
|
||||||
y: useTransform(
|
viewport={{ once: false, amount: 0.3 }}
|
||||||
scrollYProgress,
|
|
||||||
[0.25, 0.3, 0.35, 0.4],
|
|
||||||
[20, 0, 0, -20],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
is a{" "}
|
where you can
|
||||||
<span className="font-display text-primary font-extralight">
|
</motion.span>
|
||||||
safe space
|
|
||||||
</span>
|
|
||||||
,<br />
|
|
||||||
<motion.span
|
|
||||||
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
|
|
||||||
transition={{ delay: 5 }}
|
|
||||||
whileInView={{ opacity: 1 }}
|
|
||||||
viewport={{ once: false, amount: 0.3 }}
|
|
||||||
>
|
|
||||||
where you can
|
|
||||||
</motion.span>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
||||||
<motion.h2
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.35, 0.4, 0.45],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.35, 0.4, 0.45],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
pen down your unsaid words into{" "}
|
||||||
|
<span className="font-display text-primary font-extralight">
|
||||||
|
letters
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</motion.h2>
|
||||||
|
{/* Seal */}
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.45, 0.5, 0.55, 0.6],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.45, 0.5, 0.55, 0.6],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
seal it{" "}
|
||||||
|
<span className="text-secondary font-display italic font-extralight">
|
||||||
|
secure
|
||||||
|
</span>{" "}
|
||||||
|
and{" "}
|
||||||
|
<span className="text-secondary font-display font-extralight italic">
|
||||||
|
private
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</motion.h2>
|
||||||
|
{/* Send / vault */}
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.6, 0.63, 0.72, 0.75],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.6, 0.63, 0.72, 0.75],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
send it to{" "}
|
||||||
|
<motion.span
|
||||||
|
className="font-display text-accent"
|
||||||
style={{
|
style={{
|
||||||
opacity: useTransform(
|
color: useTransform(
|
||||||
scrollYProgress,
|
smoothProgress1,
|
||||||
[0.3, 0.35, 0.4, 0.45],
|
[0.67, 1],
|
||||||
[0, 1, 1, 0],
|
["var(--color-accent)", "var(--color-neutral)"],
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.3, 0.35, 0.4, 0.45],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
>
|
||||||
pen down your unsaid words into
|
someone dear
|
||||||
<span className="font-display text-primary font-extralight">
|
</motion.span>
|
||||||
letters
|
<motion.span
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</motion.h2>
|
|
||||||
{/* Seal */}
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
style={{
|
||||||
opacity: useTransform(
|
opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
|
||||||
scrollYProgress,
|
|
||||||
[0.45, 0.5, 0.55, 0.6],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.45, 0.5, 0.55, 0.6],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
>
|
||||||
seal it
|
|
||||||
<span className="text-success font-mono tracking-tighter font-extrabold">
|
|
||||||
secure
|
|
||||||
</span>
|
|
||||||
and
|
|
||||||
<span className="text-info font-mono tracking-tighter italic">
|
|
||||||
private
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</motion.h2>
|
|
||||||
{/* Send / vault */}
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.6, 0.63, 0.72, 0.75],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.6, 0.63, 0.72, 0.75],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
|
||||||
send it to
|
|
||||||
<motion.span
|
<motion.span
|
||||||
className="font-display text-accent"
|
className="font-display text-accent"
|
||||||
style={{
|
style={{
|
||||||
color: useTransform(
|
color: useTransform(
|
||||||
scrollYProgress,
|
smoothProgress1,
|
||||||
[0.67, 1],
|
[0.67, 1],
|
||||||
["var(--color-accent)", "var(--color-neutral)"],
|
["var(--color-accent)", "var(--color-neutral)"],
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
someone dear
|
{" "}
|
||||||
|
or{" "}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
<motion.span
|
<span className="font-display text-success">
|
||||||
style={{
|
yourself in the future
|
||||||
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
|
</span>
|
||||||
}}
|
.
|
||||||
>
|
</motion.span>
|
||||||
<motion.span
|
</motion.h2>
|
||||||
className="font-display text-accent"
|
{/* Burn */}
|
||||||
style={{
|
<motion.h2
|
||||||
color: useTransform(
|
style={{
|
||||||
scrollYProgress,
|
opacity: useTransform(
|
||||||
[0.67, 1],
|
smoothProgress1,
|
||||||
["var(--color-accent)", "var(--color-neutral)"],
|
[0.75, 0.8, 0.85, 0.9],
|
||||||
),
|
[0, 1, 1, 0],
|
||||||
}}
|
),
|
||||||
>
|
y: useTransform(
|
||||||
or
|
smoothProgress1,
|
||||||
</motion.span>
|
[0.75, 0.8, 0.85, 0.9],
|
||||||
<span className="font-display text-success">
|
[40, 0, 0, -40],
|
||||||
yourself in the future
|
),
|
||||||
</span>
|
}}
|
||||||
.
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
</motion.span>
|
>
|
||||||
</motion.h2>
|
and even <span className="font-display text-error">burn it</span> to
|
||||||
{/* Burn */}
|
release the burden.
|
||||||
<motion.h2
|
</motion.h2>
|
||||||
style={{
|
{/* Outro */}
|
||||||
opacity: useTransform(
|
<motion.h2
|
||||||
scrollYProgress,
|
className={
|
||||||
[0.75, 0.8, 0.85, 0.9],
|
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
[0, 1, 1, 0],
|
}
|
||||||
),
|
style={{
|
||||||
y: useTransform(
|
opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
|
||||||
scrollYProgress,
|
y: useTransform(smoothProgress1, [0.9, 1], [80, 0]),
|
||||||
[0.75, 0.8, 0.85, 0.9],
|
}}
|
||||||
[40, 0, 0, -40],
|
>
|
||||||
),
|
You've been carrying it long enough.
|
||||||
}}
|
</motion.h2>
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
{/* CTA */}
|
||||||
>
|
<motion.div
|
||||||
and even <span className="font-display text-error">burn it</span>
|
className={
|
||||||
to release the burden.
|
"z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
||||||
</motion.h2>
|
}
|
||||||
{/* Outro */}
|
style={{
|
||||||
<motion.h2
|
opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
|
||||||
|
y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
|
||||||
|
display: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.96, 1],
|
||||||
|
["none", "flex"],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
className={
|
className={
|
||||||
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
|
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||||
}
|
}
|
||||||
style={{
|
type={"button"}
|
||||||
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
|
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
|
||||||
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
You've been carrying it long enough.
|
<InfoIcon className={"text-primary"} />
|
||||||
</motion.h2>
|
Tell me More
|
||||||
{/* CTA */}
|
</button>
|
||||||
<motion.div
|
<button
|
||||||
className={
|
className={
|
||||||
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||||
}
|
}
|
||||||
style={{
|
type={"button"}
|
||||||
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
|
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
||||||
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
|
|
||||||
display: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.96, 1],
|
|
||||||
["none", "flex"],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
I'm ready
|
||||||
className={
|
</button>
|
||||||
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
</motion.div>
|
||||||
}
|
|
||||||
type={"button"}
|
|
||||||
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
|
|
||||||
>
|
|
||||||
<InfoIcon className={"text-primary"} />
|
|
||||||
Tell me More
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
|
||||||
}
|
|
||||||
type={"button"}
|
|
||||||
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
|
||||||
>
|
|
||||||
I'm ready
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
|
|
||||||
<motion.div
|
|
||||||
className={"z-21 absolute"}
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.3, 0.4, 0.5, 0.52],
|
|
||||||
[0, 1, 0.1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.3, 0.45, 0.5],
|
|
||||||
[300, 0, 200],
|
|
||||||
),
|
|
||||||
scale: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.3, 0.4, 0.5],
|
|
||||||
[1, 1, 0.6],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="mockup-phone w-[75vw] border-primary">
|
|
||||||
<div className="mockup-phone-camera"></div>
|
|
||||||
<div className="mockup-phone-display">
|
|
||||||
<img alt="letter" src={letterSample} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
{/* Envelope */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute scale-50 md:scale-80 z-10"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
|
|
||||||
[0, 0.6, 1, 1, 0.3, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EnvelopeReveal
|
|
||||||
isInteractive={false}
|
|
||||||
ignite={ignite}
|
|
||||||
recipient={recipient}
|
|
||||||
date={formatDate(new Date().toISOString())}
|
|
||||||
onRevealComplete={() => {}}
|
|
||||||
isFlip={isEnvelopeFlipped}
|
|
||||||
openFlap={flapOpen}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
{/* Saajan */}
|
|
||||||
<motion.div
|
|
||||||
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.98, 0.995, 1],
|
|
||||||
[0, 0.5, 1],
|
|
||||||
),
|
|
||||||
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Saajan
|
|
||||||
message={
|
|
||||||
"I think we forget things\nif there is nobody to tell them."
|
|
||||||
}
|
|
||||||
position={"top"}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
{/* Orb */}
|
|
||||||
<motion.div
|
|
||||||
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
|
|
||||||
transition={{
|
|
||||||
backgroundColor: { ease: "easeIn", duration: 2 },
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
backgroundColor: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.45, 0.5, 0.7, 0.75, 1],
|
|
||||||
[
|
|
||||||
"var(--color-primary)",
|
|
||||||
"var(--color-secondary)",
|
|
||||||
"var(--color-accent)",
|
|
||||||
"var(--color-success)",
|
|
||||||
"var(--color-error)",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</ReactLenis>
|
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<motion.div
|
||||||
|
className={"z-21 absolute"}
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.4, 0.5, 0.52],
|
||||||
|
[0, 1, 0.1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.3, 0.45, 0.5], [300, 0, 200]),
|
||||||
|
scale: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.4, 0.5],
|
||||||
|
[1, 1, 0.6],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mockup-phone w-[75vw] border-primary">
|
||||||
|
<div className="mockup-phone-camera"></div>
|
||||||
|
<div className="mockup-phone-display">
|
||||||
|
<img alt="letter" src="/screenshots/letter.webp" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
{/* Envelope */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute scale-50 md:scale-80 z-10"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
|
||||||
|
[0, 0.6, 1, 1, 0.3, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.45, 0.5, 1], [600, 200, 0]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EnvelopeReveal
|
||||||
|
isInteractive={false}
|
||||||
|
ignite={ignite}
|
||||||
|
recipient={recipient}
|
||||||
|
date={formatDate(new Date().toISOString())}
|
||||||
|
onRevealComplete={() => {}}
|
||||||
|
isFlip={isEnvelopeFlipped}
|
||||||
|
openFlap={flapOpen}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
{/* Saajan */}
|
||||||
|
<motion.div
|
||||||
|
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.98, 0.995, 1],
|
||||||
|
[0, 0.5, 1],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.98, 1], [50, -10]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Saajan
|
||||||
|
message={
|
||||||
|
"I think we forget things\nif there is nobody to tell them."
|
||||||
|
}
|
||||||
|
position={"top"}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
{/* Orb */}
|
||||||
|
<motion.div
|
||||||
|
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
|
||||||
|
transition={{
|
||||||
|
backgroundColor: { ease: "easeIn", duration: 2 },
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.45, 0.5, 0.7, 0.75, 1],
|
||||||
|
[
|
||||||
|
"var(--color-primary)",
|
||||||
|
"var(--color-secondary)",
|
||||||
|
"var(--color-accent)",
|
||||||
|
"var(--color-success)",
|
||||||
|
"var(--color-error)",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
scale: useTransform(smoothProgress1, [0, 1], [0.6, 2.5]),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,11 @@ describe("Login Page", () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByTestId("email-input"), "test@example.com");
|
await userEvent.type(screen.getByLabelText(/email/i), "test@example.com");
|
||||||
await userEvent.type(screen.getByTestId("password-input"), "password123");
|
await userEvent.type(screen.getByLabelText(/password/i), "password123");
|
||||||
await userEvent.click(screen.getByTestId("login-submit-btn"));
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
|
||||||
expect(await screen.findByTestId("login-error-message")).toHaveTextContent(
|
expect(await screen.findByText(/technical issues/i)).toBeInTheDocument();
|
||||||
/technical issues/i,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -75,24 +73,16 @@ describe("Login Page", () => {
|
|||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route
|
<Route path="/drawer" element={<div>Drawer</div>} />
|
||||||
path="/drawer"
|
<Route path="/read/:publicId" element={<div>Reader</div>} />
|
||||||
element={<div data-testid="drawer-page">Drawer</div>}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/read/:publicId"
|
|
||||||
element={<div data-testid="reader-page">Reader</div>}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.type(screen.getByTestId("email-input"), "test@example.com");
|
await userEvent.type(screen.getByLabelText(/email/i), "test@example.com");
|
||||||
await userEvent.type(screen.getByTestId("password-input"), "password123");
|
await userEvent.type(screen.getByLabelText(/password/i), "password123");
|
||||||
await userEvent.click(screen.getByTestId("login-submit-btn"));
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
|
||||||
const expectedTestId =
|
expect(await screen.findByText(nextRoute)).toBeInTheDocument();
|
||||||
nextRoute.toLowerCase() === "drawer" ? "drawer-page" : "reader-page";
|
|
||||||
expect(await screen.findByTestId(expectedTestId)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { api, publicApi } from "../api/apiClient";
|
import { api, publicApi } from "../api/apiClient";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import WelcomeModal from "../components/login/WelcomeModal";
|
import WelcomeModal from "../components/login/WelcomeModal.tsx";
|
||||||
import FormField from "../components/ui/FormField";
|
import FormField from "../components/ui/FormField";
|
||||||
import Saajan from "../components/ui/Saajan";
|
import Saajan from "../components/ui/Saajan";
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
@@ -64,7 +64,7 @@ export default function Login() {
|
|||||||
|
|
||||||
await setAuthStore(authData.access, userData, masterKey);
|
await setAuthStore(authData.access, userData, masterKey);
|
||||||
|
|
||||||
navigate(nextRoute, { replace: true, state: location.state });
|
navigate(nextRoute, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message =
|
let message =
|
||||||
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
||||||
@@ -83,13 +83,13 @@ export default function Login() {
|
|||||||
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
|
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
|
||||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||||
<h1 className="flex items-center font-display text-2xl justify-center text-primary/80 tracking-tight">
|
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
|
||||||
Enter <Logo type="logo" scale={0.7} /> Archive
|
Enter <Logo /> Archive
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||||
<span data-testid="login-error-message">{apiError}</span>
|
<span>{apiError}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -97,7 +97,6 @@ export default function Login() {
|
|||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="f.kafka@wrongtrain.com"
|
placeholder="f.kafka@wrongtrain.com"
|
||||||
data-testid="email-input"
|
|
||||||
registration={register("email")}
|
registration={register("email")}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
handleFocus={() => setSaajanMessage("I remember you.")}
|
handleFocus={() => setSaajanMessage("I remember you.")}
|
||||||
@@ -107,7 +106,6 @@ export default function Login() {
|
|||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
data-testid="password-input"
|
|
||||||
registration={register("password")}
|
registration={register("password")}
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
handleFocus={() =>
|
handleFocus={() =>
|
||||||
@@ -120,29 +118,27 @@ export default function Login() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
name="login"
|
name="login"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
data-testid="login-submit-btn"
|
aria-label="Sign In"
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
) : (
|
) : (
|
||||||
"Continue"
|
"Sign In"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider text-neutral my-0">or</div>
|
|
||||||
<div className="text-center text-sm font-medium text-neutral">
|
<div className="text-center text-sm font-medium text-base-content/70">
|
||||||
New to <Logo type="inline" />
|
Don't have an account?{" "}
|
||||||
?
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
name="register"
|
name="register"
|
||||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||||
className="link link-primary"
|
className="link link-primary no-underline hover:underline font-bold"
|
||||||
>
|
>
|
||||||
Start here
|
Register
|
||||||
</button>
|
</button>
|
||||||
.
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -76,9 +76,9 @@ describe("Reader Page", () => {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
expect(await screen.findByTestId("envelope-recipient")).toHaveTextContent(
|
await waitFor(() => {
|
||||||
/Guest/i,
|
expect(screen.getByText(/Guest/i)).toBeInTheDocument();
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display an error message if the server request fails", async () => {
|
it("should display an error message if the server request fails", async () => {
|
||||||
@@ -99,9 +99,9 @@ describe("Reader Page", () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByTestId("log-modal-message")).toHaveTextContent(
|
expect(
|
||||||
/Failed to load letter/i,
|
await screen.findByText(/Failed to load letter/i),
|
||||||
);
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
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 () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
type NavigateFunction,
|
type NavigateFunction,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { api } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import type { LetterImageData, LetterResponseData } from "../api/response";
|
|
||||||
import {
|
import {
|
||||||
type CanvasJSON,
|
type CanvasJSON,
|
||||||
type CanvasTools,
|
type CanvasTools,
|
||||||
@@ -73,8 +71,7 @@ export default function Reader() {
|
|||||||
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
|
||||||
try {
|
try {
|
||||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
|
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
|
||||||
} catch {
|
} catch (_err) {
|
||||||
// shouldn't obstruct share if api operation fails (since it's client side share)
|
|
||||||
} finally {
|
} finally {
|
||||||
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
|
||||||
}
|
}
|
||||||
@@ -87,10 +84,7 @@ export default function Reader() {
|
|||||||
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
|
||||||
status: "BURNED",
|
status: "BURNED",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (_err) {
|
||||||
// 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 {
|
} finally {
|
||||||
setIsBurning(false);
|
setIsBurning(false);
|
||||||
setShowBurnModal(false);
|
setShowBurnModal(false);
|
||||||
@@ -109,109 +103,89 @@ export default function Reader() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptImages = async (
|
const loadAndDecrypt = async () => {
|
||||||
canvasData: CanvasJSON,
|
|
||||||
images: LetterImageData[],
|
|
||||||
encrypted_dek: string,
|
|
||||||
cryptoUtils: CryptoUtils,
|
|
||||||
) => {
|
|
||||||
if (!images?.length) return;
|
|
||||||
const isShared = !!sharingKey;
|
|
||||||
try {
|
try {
|
||||||
if (isShared) {
|
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||||
await decryptCanvasImagesWithSharingKey(
|
const {
|
||||||
canvasData,
|
encrypted_content,
|
||||||
images,
|
encrypted_metadata,
|
||||||
sharingKey,
|
encrypted_dek,
|
||||||
cryptoUtils,
|
images,
|
||||||
);
|
updated_at,
|
||||||
} else {
|
status,
|
||||||
await decryptCanvasImages(
|
} = response.data;
|
||||||
canvasData,
|
|
||||||
images,
|
if (status === "BURNED")
|
||||||
encrypted_dek,
|
throw new Error("This letter has been burned.");
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
|
||||||
masterKey!,
|
if (encrypted_dek) setEncryptedDek(encrypted_dek);
|
||||||
cryptoUtils,
|
|
||||||
);
|
const cryptoUtils = new CryptoUtils();
|
||||||
}
|
const isShared = !!sharingKey;
|
||||||
} catch (err) {
|
|
||||||
setLogTrace({
|
if (isShared && !encrypted_content) throw new Error("Content missing");
|
||||||
message:
|
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
|
||||||
"Failed to decrypt elements. Images might not render in the letter as intended.",
|
if (!(isShared || isDecryptionKeyAvailable))
|
||||||
log: err instanceof Error ? err.message : "Unknown error",
|
throw new Error("Auth required: Decryption key is not available");
|
||||||
type: "WARN",
|
|
||||||
|
// Decrypt Metadata
|
||||||
|
const decryptedMetadata = isShared
|
||||||
|
? await cryptoUtils.decryptMetadataWithSharingKey(
|
||||||
|
encrypted_metadata,
|
||||||
|
sharingKey,
|
||||||
|
)
|
||||||
|
: await cryptoUtils.decryptMetadata(
|
||||||
|
{ encrypted_content: encrypted_metadata, encrypted_dek },
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
|
masterKey!,
|
||||||
|
);
|
||||||
|
setMetadata({
|
||||||
|
...(decryptedMetadata as LetterMetadata),
|
||||||
|
updated_at,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptLetterData = async (
|
// Decrypt Content
|
||||||
data: LetterResponseData,
|
const decryptedContent = isShared
|
||||||
cryptoUtils: CryptoUtils,
|
? await cryptoUtils.decryptLetterWithSharingKey(
|
||||||
) => {
|
encrypted_content,
|
||||||
const isShared = !!sharingKey;
|
sharingKey,
|
||||||
const {
|
)
|
||||||
encrypted_content,
|
: await cryptoUtils.decryptLetter(
|
||||||
encrypted_metadata,
|
{ encrypted_content, encrypted_dek },
|
||||||
encrypted_dek,
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
images,
|
masterKey!,
|
||||||
updated_at,
|
);
|
||||||
} = data;
|
|
||||||
|
|
||||||
// Decrypt Metadata
|
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
||||||
const decryptedMetadata = isShared
|
|
||||||
? await cryptoUtils.decryptMetadataWithSharingKey(
|
|
||||||
encrypted_metadata,
|
|
||||||
sharingKey,
|
|
||||||
)
|
|
||||||
: await cryptoUtils.decryptMetadata(
|
|
||||||
{ encrypted_content: encrypted_metadata, encrypted_dek },
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
|
||||||
masterKey!,
|
|
||||||
);
|
|
||||||
setMetadata({
|
|
||||||
...(decryptedMetadata as LetterMetadata),
|
|
||||||
updated_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Decrypt Content
|
try {
|
||||||
const decryptedContent = isShared
|
// Decrypt Images
|
||||||
? await cryptoUtils.decryptLetterWithSharingKey(
|
if (images?.length > 0) {
|
||||||
encrypted_content,
|
isShared
|
||||||
sharingKey,
|
? await decryptCanvasImagesWithSharingKey(
|
||||||
)
|
canvasData,
|
||||||
: await cryptoUtils.decryptLetter(
|
images,
|
||||||
{ encrypted_content, encrypted_dek },
|
sharingKey,
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
cryptoUtils,
|
||||||
masterKey!,
|
)
|
||||||
);
|
: await decryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
images,
|
||||||
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
|
encrypted_dek,
|
||||||
setDecryptedCanvasData(canvasData);
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
};
|
masterKey!,
|
||||||
|
cryptoUtils,
|
||||||
const processLetterData = async (data: LetterResponseData) => {
|
);
|
||||||
if (data.status === "BURNED")
|
}
|
||||||
throw new Error("This letter has been burned.");
|
} catch (err) {
|
||||||
|
setLogTrace({
|
||||||
if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek);
|
message:
|
||||||
|
"Failed to decrypt elements. Images might not render in the letter as intended.",
|
||||||
const isDecryptionKeyAvailable = data.encrypted_dek && masterKey;
|
log: err instanceof Error ? err.message : "Unknown error",
|
||||||
if (!(!!sharingKey || isDecryptionKeyAvailable)) {
|
type: "WARN",
|
||||||
throw new Error("Auth required: Decryption key is not available");
|
});
|
||||||
}
|
}
|
||||||
|
setDecryptedCanvasData(canvasData);
|
||||||
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) {
|
} catch (err) {
|
||||||
setLogTrace({
|
setLogTrace({
|
||||||
message: `Failed to load letter ☹`,
|
message: `Failed to load letter ☹`,
|
||||||
@@ -221,7 +195,7 @@ export default function Reader() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadAndDecryptLetter().then(() => setIsDecrypting(false));
|
loadAndDecrypt().then(() => setIsDecrypting(false));
|
||||||
}, [public_id, sharingKey, masterKey]);
|
}, [public_id, sharingKey, masterKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -243,10 +217,7 @@ export default function Reader() {
|
|||||||
<Logo />
|
<Logo />
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="loading loading-ring loading-md text-primary/40"></span>
|
<span className="loading loading-ring loading-md text-primary/40"></span>
|
||||||
<p
|
<p className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse">
|
||||||
data-testid="decryption-overlay"
|
|
||||||
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse"
|
|
||||||
>
|
|
||||||
Breaking the seal...
|
Breaking the seal...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +306,6 @@ export default function Reader() {
|
|||||||
<div className="flex justify-center gap-2 mt-8 z-10 relative">
|
<div className="flex justify-center gap-2 mt-8 z-10 relative">
|
||||||
<button
|
<button
|
||||||
id="share-letter-btn"
|
id="share-letter-btn"
|
||||||
data-testid="share-letter-btn"
|
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
|
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
@@ -347,7 +317,6 @@ export default function Reader() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="burn-letter-btn"
|
id="burn-letter-btn"
|
||||||
data-testid="burn-letter-btn"
|
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
|
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
|
||||||
onClick={() => setShowBurnModal(true)}
|
onClick={() => setShowBurnModal(true)}
|
||||||
|
|||||||
@@ -77,8 +77,7 @@ export default function Register() {
|
|||||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||||
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
||||||
Create a<Logo type="logo" scale={0.7} />
|
Create a <Logo /> Account
|
||||||
Account
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
@@ -90,7 +89,6 @@ export default function Register() {
|
|||||||
<FormField
|
<FormField
|
||||||
label="Pen Name"
|
label="Pen Name"
|
||||||
placeholder="Word Smith"
|
placeholder="Word Smith"
|
||||||
data-testid="pen-name-input"
|
|
||||||
registration={register("full_name")}
|
registration={register("full_name")}
|
||||||
error={errors.full_name?.message}
|
error={errors.full_name?.message}
|
||||||
handleFocus={() =>
|
handleFocus={() =>
|
||||||
@@ -102,7 +100,6 @@ export default function Register() {
|
|||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="f.kafka@wrongtrain.com"
|
placeholder="f.kafka@wrongtrain.com"
|
||||||
data-testid="email-input"
|
|
||||||
registration={register("email")}
|
registration={register("email")}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
handleFocus={() =>
|
handleFocus={() =>
|
||||||
@@ -116,7 +113,6 @@ export default function Register() {
|
|||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
data-testid="password-input"
|
|
||||||
registration={register("password")}
|
registration={register("password")}
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
handleFocus={() =>
|
handleFocus={() =>
|
||||||
@@ -130,7 +126,6 @@ export default function Register() {
|
|||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
data-testid="confirm-password-input"
|
|
||||||
registration={register("confirm_password")}
|
registration={register("confirm_password")}
|
||||||
error={errors.confirm_password?.message}
|
error={errors.confirm_password?.message}
|
||||||
handleFocus={() =>
|
handleFocus={() =>
|
||||||
@@ -144,9 +139,9 @@ export default function Register() {
|
|||||||
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
Choose a password you won't forget. <br />
|
Choose a password you won't forget. <br />
|
||||||
Just like life,
|
Just like life,{" "}
|
||||||
<span className="underline decoration-2">there is no reset</span>
|
<span className="underline decoration-2">there is no reset</span>{" "}
|
||||||
here. If you lose it, your letters cannot be recovered.
|
here. If you lose it, your letters cannot be recovered.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,29 +150,15 @@ export default function Register() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="Register"
|
aria-label="Register"
|
||||||
data-testid="register-submit-btn"
|
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
) : (
|
) : (
|
||||||
"Begin"
|
"Register"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider text-neutral my-0">or</div>
|
|
||||||
<div className="text-center text-sm font-medium text-neutral">
|
|
||||||
Been here before?
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
name="register"
|
|
||||||
onClick={() => navigate(ROUTES.LOGIN)}
|
|
||||||
className="link link-primary"
|
|
||||||
>
|
|
||||||
Continue where you left off
|
|
||||||
</button>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export default function VerifyEmail() {
|
|||||||
Check Your Mailbox
|
Check Your Mailbox
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
|
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
|
||||||
You're one train away from starting your <Logo scale={0.8} />
|
You're one train away from starting your <Logo scale={0.8} />{" "}
|
||||||
journey.
|
journey.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { LetterMetadata } from "../api/response";
|
|
||||||
|
|
||||||
export interface EncryptedLetter {
|
export interface EncryptedLetter {
|
||||||
encrypted_content: string;
|
encrypted_content: string;
|
||||||
encrypted_dek: string;
|
encrypted_dek: string;
|
||||||
@@ -277,7 +275,7 @@ export class CryptoUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async encryptMetadata(
|
public async encryptMetadata(
|
||||||
metadata: LetterMetadata,
|
metadata: Record<string, any>,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<EncryptedLetterMetadata> {
|
): Promise<EncryptedLetterMetadata> {
|
||||||
const { encryptedContent, encrypted_dek, sharingKey } =
|
const { encryptedContent, encrypted_dek, sharingKey } =
|
||||||
@@ -292,7 +290,7 @@ export class CryptoUtils {
|
|||||||
public async decryptMetadata(
|
public async decryptMetadata(
|
||||||
encrypted_metadata: EncryptedLetter,
|
encrypted_metadata: EncryptedLetter,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<LetterMetadata> {
|
): Promise<Record<string, any>> {
|
||||||
const bytes = await this.openEnvelope(
|
const bytes = await this.openEnvelope(
|
||||||
encrypted_metadata.encrypted_content,
|
encrypted_metadata.encrypted_content,
|
||||||
encrypted_metadata.encrypted_dek,
|
encrypted_metadata.encrypted_dek,
|
||||||
@@ -305,7 +303,7 @@ export class CryptoUtils {
|
|||||||
public async decryptMetadataWithSharingKey(
|
public async decryptMetadataWithSharingKey(
|
||||||
encrypted_content: string,
|
encrypted_content: string,
|
||||||
sharingKey: string,
|
sharingKey: string,
|
||||||
): Promise<LetterMetadata> {
|
): Promise<Record<string, any>> {
|
||||||
const bytes = await this.openEnvelopeWithSharingKey(
|
const bytes = await this.openEnvelopeWithSharingKey(
|
||||||
encrypted_content,
|
encrypted_content,
|
||||||
sharingKey,
|
sharingKey,
|
||||||
|
|||||||
@@ -221,11 +221,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const remoteImages = [
|
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"]) });
|
vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) });
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { api, apiServerUrl, publicApi } from "../api/apiClient";
|
import { api, apiServerUrl, publicApi } from "../api/apiClient";
|
||||||
import type { LetterImageData } from "../api/response";
|
|
||||||
import type {
|
import type {
|
||||||
CanvasJSON,
|
CanvasJSON,
|
||||||
FabricImageJSON,
|
FabricImageJSON,
|
||||||
@@ -112,7 +111,7 @@ export async function decryptCanvasImages(
|
|||||||
|
|
||||||
export async function decryptCanvasImagesWithSharingKey(
|
export async function decryptCanvasImagesWithSharingKey(
|
||||||
canvasData: CanvasJSON,
|
canvasData: CanvasJSON,
|
||||||
remoteImages: LetterImageData[],
|
remoteImages: { file_name: string; file: string }[],
|
||||||
sharingKey: string,
|
sharingKey: string,
|
||||||
cryptoUtils: CryptoUtils,
|
cryptoUtils: CryptoUtils,
|
||||||
): Promise<DecryptionResult> {
|
): Promise<DecryptionResult> {
|
||||||
|
|||||||
@@ -46,10 +46,5 @@ export default defineConfig(({ mode }) => {
|
|||||||
host: env.FRONTEND_DOMAIN,
|
host: env.FRONTEND_DOMAIN,
|
||||||
https: isSslEnabled ? sslCerts : undefined,
|
https: isSslEnabled ? sslCerts : undefined,
|
||||||
},
|
},
|
||||||
preview: {
|
|
||||||
port: Number(env.FRONTEND_PORT),
|
|
||||||
host: env.FRONTEND_DOMAIN,
|
|
||||||
https: isSslEnabled ? sslCerts : undefined,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ done
|
|||||||
|
|
||||||
export PIKU_ENV_FILE="$ENV_FILE"
|
export PIKU_ENV_FILE="$ENV_FILE"
|
||||||
|
|
||||||
# NOTE: When running in Gitea Actions (within container), We must ponint DB and mail to the internal docker host instead.
|
# NOTE: When running in Gitea Actions (within container), 127.0.0.1 is the actions instance.
|
||||||
|
# We must route DB and mail traffic to the docker host instead.
|
||||||
if [ "$GITEA_ACTIONS" = "true" ]; then
|
if [ "$GITEA_ACTIONS" = "true" ]; then
|
||||||
sudo apt-get update && sudo apt-get install -y iproute2
|
sudo apt-get update && sudo apt-get install -y iproute2
|
||||||
# Sample: "default via <internal docker host IP> dev <network interface> proto dhcp src <IP> metric 100"
|
# Sample: "default via <internal docker host IP> dev <network interface> proto dhcp src <IP> metric 100"
|
||||||
|
|||||||