Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ab2cad53 | |||
| b1a32512ab | |||
| 357df454d0 | |||
| 43e5e5ed5b | |||
| f242977be3 | |||
| b1d466fb11 |
@@ -3,7 +3,7 @@ DB_NAME=piku_test_db
|
|||||||
DB_USER=test
|
DB_USER=test
|
||||||
DB_PASSWORD=password123
|
DB_PASSWORD=password123
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=5443
|
DB_PORT=5433
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
SSL_ENABLED=true
|
SSL_ENABLED=true
|
||||||
@@ -12,7 +12,7 @@ SSL_ENABLED=true
|
|||||||
DEBUG=True
|
DEBUG=True
|
||||||
SECRET_KEY=django-insecure-initial-key
|
SECRET_KEY=django-insecure-initial-key
|
||||||
BACKEND_DOMAIN=127.0.0.1
|
BACKEND_DOMAIN=127.0.0.1
|
||||||
BACKEND_PORT=8101
|
BACKEND_PORT=8001
|
||||||
|
|
||||||
# EMAIL
|
# EMAIL
|
||||||
EMAIL_HOST=127.0.0.1
|
EMAIL_HOST=127.0.0.1
|
||||||
@@ -25,4 +25,4 @@ EMAIL_API_PORT=8026
|
|||||||
# FRONTEND
|
# FRONTEND
|
||||||
FRONTEND_PORT=5199
|
FRONTEND_PORT=5199
|
||||||
FRONTEND_DOMAIN=127.0.0.1
|
FRONTEND_DOMAIN=127.0.0.1
|
||||||
VITE_API_URL=https://127.0.0.1:8101
|
VITE_API_URL=https://127.0.0.1:8001
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ DB_NAME=piku
|
|||||||
DB_USER=user
|
DB_USER=user
|
||||||
DB_PASSWORD=password123
|
DB_PASSWORD=password123
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=5442
|
DB_PORT=5432
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
SSL_ENABLED=true
|
SSL_ENABLED=true
|
||||||
@@ -13,7 +13,7 @@ S3_ENABLED=false
|
|||||||
DEBUG=True
|
DEBUG=True
|
||||||
SECRET_KEY=django-secret-key
|
SECRET_KEY=django-secret-key
|
||||||
BACKEND_DOMAIN=127.0.0.1
|
BACKEND_DOMAIN=127.0.0.1
|
||||||
BACKEND_PORT=8100
|
BACKEND_PORT=8000
|
||||||
# S3
|
# S3
|
||||||
R2_ACCESS_KEY_ID=
|
R2_ACCESS_KEY_ID=
|
||||||
R2_SECRET_ACCESS_KEY=
|
R2_SECRET_ACCESS_KEY=
|
||||||
@@ -31,4 +31,4 @@ FROM_EMAIL="Pi Ku <no-reply@test.com>"
|
|||||||
# FRONTEND
|
# FRONTEND
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
FRONTEND_DOMAIN=127.0.0.1
|
FRONTEND_DOMAIN=127.0.0.1
|
||||||
VITE_API_URL=https://127.0.0.1:8100
|
VITE_API_URL=https://127.0.0.1:8000
|
||||||
|
|||||||
@@ -19,12 +19,11 @@ jobs:
|
|||||||
mkcert -install
|
mkcert -install
|
||||||
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
||||||
|
|
||||||
- name: Upload certificates
|
- name: Cache certificates
|
||||||
uses: christopherHX/gitea-upload-artifact@v4
|
uses: actions/cache/save@v4
|
||||||
with:
|
with:
|
||||||
name: ssl-certs
|
path: certs
|
||||||
path: certs/
|
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend CI
|
name: Frontend CI
|
||||||
@@ -38,10 +37,10 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Restore certificates
|
- name: Restore certificates
|
||||||
uses: christopherHX/gitea-download-artifact@v4
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
name: ssl-certs
|
path: certs
|
||||||
path: certs/
|
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
@@ -62,15 +61,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup-environment
|
needs: setup-environment
|
||||||
services:
|
services:
|
||||||
db:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: piku__test
|
POSTGRES_DB: piku
|
||||||
POSTGRES_USER: test
|
POSTGRES_USER: user
|
||||||
POSTGRES_PASSWORD: password123
|
POSTGRES_PASSWORD: password123
|
||||||
ports:
|
ports:
|
||||||
- 5442:5432
|
- 5432:5432
|
||||||
options: --tmpfs /var/lib/postgresql/data --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
@@ -83,28 +82,18 @@ jobs:
|
|||||||
cache-dependency-glob: "backend/uv.lock"
|
cache-dependency-glob: "backend/uv.lock"
|
||||||
|
|
||||||
- name: Restore certificates
|
- name: Restore certificates
|
||||||
uses: christopherHX/gitea-download-artifact@v4
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
name: ssl-certs
|
path: certs
|
||||||
path: certs/
|
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||||
|
|
||||||
- name: Setup & Test
|
- name: Setup Environment
|
||||||
run: |
|
run: |
|
||||||
cp ../.env.example ../.env
|
cp ../.env.example ../.env
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
export DB_NAME="piku__test"
|
- name: Lint & Test
|
||||||
export DB_USER="test"
|
run: |
|
||||||
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 ruff check
|
||||||
uv run python manage.py test
|
uv run python manage.py test
|
||||||
|
|
||||||
@@ -112,27 +101,23 @@ jobs:
|
|||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup-environment
|
needs: setup-environment
|
||||||
# Skipping on Gitea pushes until cache server is configured
|
|
||||||
if: github.server_url == 'https://github.com' || github.event_name == 'pull_request'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore Certificates
|
- name: Restore Certificates
|
||||||
uses: christopherHX/gitea-download-artifact@v4
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
name: ssl-certs
|
path: certs
|
||||||
path: certs/
|
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||||
|
|
||||||
- name: Setup Tools
|
- name: Setup Tools
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
|
||||||
- name: Cache Playwright
|
- name: Cache Playwright
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
# Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing
|
|
||||||
# TODO: setup cache server in Gitea
|
|
||||||
if: github.server_url == 'https://github.com'
|
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
@@ -155,7 +140,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Playwright Report
|
- name: Upload Playwright Report
|
||||||
if: always()
|
if: always()
|
||||||
uses: christopherHX/gitea-upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: frontend/playwright-report/
|
path: frontend/playwright-report/
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ __pycache__/
|
|||||||
|
|
||||||
docs/
|
docs/
|
||||||
encrypted-images/
|
encrypted-images/
|
||||||
logs/
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ RUN uv sync --frozen --no-dev
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Make the temp log dir writable since server is running rootless
|
||||||
|
RUN mkdir -p /app/logs && chmod -R 777 /app/logs
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# NOTE: Exporting env var 'UVICORN_MAIN=true' is required for the scheduler to run on app start.
|
CMD ["sh", "-c", "uv run manage.py migrate && uv run gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - --capture-output --log-level debug config.wsgi:application"]
|
||||||
CMD ["sh", "-c", "uv run manage.py migrate && UVICORN_MAIN=true uv run gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - --capture-output --log-level debug config.wsgi:application"]
|
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
LOGS_DIR = BASE_DIR / "logs"
|
|
||||||
|
|
||||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
structlog.configure(
|
structlog.configure(
|
||||||
processors=[
|
processors=[
|
||||||
structlog.contextvars.merge_contextvars,
|
structlog.contextvars.merge_contextvars,
|
||||||
@@ -48,22 +41,22 @@ LOGGING = {
|
|||||||
},
|
},
|
||||||
"json_file": {
|
"json_file": {
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
"class": "logging.handlers.WatchedFileHandler",
|
||||||
"filename": LOGS_DIR / "json.log",
|
"filename": "logs/json.log",
|
||||||
"formatter": "json_formatter",
|
"formatter": "json_formatter",
|
||||||
},
|
},
|
||||||
"flat_line_file": {
|
"flat_line_file": {
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
"class": "logging.handlers.WatchedFileHandler",
|
||||||
"filename": LOGS_DIR / "flat_line.log",
|
"filename": "logs/flat_line.log",
|
||||||
"formatter": "key_value",
|
"formatter": "key_value",
|
||||||
},
|
},
|
||||||
"letters_log": {
|
"letters_log": {
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
"class": "logging.handlers.WatchedFileHandler",
|
||||||
"filename": LOGS_DIR / "letters.log",
|
"filename": "logs/letters.log",
|
||||||
"formatter": "key_value",
|
"formatter": "key_value",
|
||||||
},
|
},
|
||||||
"scheduler_log": {
|
"scheduler_log": {
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
"class": "logging.handlers.WatchedFileHandler",
|
||||||
"filename": LOGS_DIR / "scheduler.log",
|
"filename": "logs/scheduler.log",
|
||||||
"formatter": "key_value",
|
"formatter": "key_value",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -78,18 +71,18 @@ LOGGING = {
|
|||||||
"level": "DEBUG",
|
"level": "DEBUG",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
"letters.tasks": {
|
|
||||||
"handlers": ["console", "scheduler_log"],
|
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
"letters": {
|
"letters": {
|
||||||
"handlers": ["console", "flat_line_file", "json_file", "letters_log"],
|
"handlers": ["console", "flat_line_file", "json_file", "letters_log"],
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"handlers": ["console", "scheduler_log"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
"": {
|
"": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console", "flat_line_file", "json_file"],
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
import environ
|
import environ
|
||||||
|
|
||||||
from .logging import LOGGING
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
@@ -27,12 +25,9 @@ env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env")
|
|||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
environ.Env.read_env(env_file, overwrite=False)
|
environ.Env.read_env(env_file, overwrite=False)
|
||||||
|
|
||||||
# Security Settings
|
|
||||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
|
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
|
||||||
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
|
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
|
||||||
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
|
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
|
||||||
# NOTE: Set to forward https when using reverse proxy
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||||
|
|
||||||
@@ -56,7 +51,6 @@ SECRET_KEY = env("SECRET_KEY")
|
|||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env.bool("DEBUG", default=False)
|
DEBUG = env.bool("DEBUG", default=False)
|
||||||
|
|
||||||
LOGGING = LOGGING
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@@ -87,21 +81,6 @@ MIDDLEWARE = [
|
|||||||
"django_structlog.middlewares.RequestMiddleware",
|
"django_structlog.middlewares.RequestMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ class LettersConfig(AppConfig):
|
|||||||
"""
|
"""
|
||||||
Start the scheduler only when the server is starting.
|
Start the scheduler only when the server is starting.
|
||||||
NOTE: If we don't check for RUN_MAIN, the scheduler triggers for all django operations (migration, test etc.)
|
NOTE: If we don't check for RUN_MAIN, the scheduler triggers for all django operations (migration, test etc.)
|
||||||
NOTE++: For uvicorn, we make sure to set the env var `UVICORN_MAIN` to `true` in the docker command.
|
|
||||||
"""
|
"""
|
||||||
if not (
|
|
||||||
os.environ.get("RUN_MAIN") == "true"
|
if not (os.environ.get("RUN_MAIN") == "true" or os.environ.get("WERKZEUG_RUN_MAIN") == "true"):
|
||||||
or os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
|
||||||
or os.environ.get("UVICORN_MAIN") == "true"
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
from .tasks import start_scheduler
|
from .tasks import start_scheduler
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ from datetime import UTC, datetime
|
|||||||
import structlog
|
import structlog
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.template.loader import render_to_string
|
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from config.settings import FRONTEND_URLS
|
|
||||||
from letters.models import Letter
|
from letters.models import Letter
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
@@ -25,26 +23,9 @@ def notify_unlocked_letter(letter):
|
|||||||
"""
|
"""
|
||||||
author = letter.user.get_username()
|
author = letter.user.get_username()
|
||||||
try:
|
try:
|
||||||
letter_link = f"{FRONTEND_URLS[0]}/read/{letter.public_id}"
|
send_mail(subject="", message="", from_email=settings.FROM_EMAIL, recipient_list=[author], fail_silently=False)
|
||||||
subject = "A letter. Written for this exact moment."
|
|
||||||
context = {
|
|
||||||
"pen_name": letter.user.first_name,
|
|
||||||
"cta": {"title": "View what you wrote", "link": letter_link},
|
|
||||||
"footnote": True,
|
|
||||||
}
|
|
||||||
plaint_content = render_to_string("email/vault_unlock.txt", context=context)
|
|
||||||
html_content = render_to_string("email/vault_unlock.html", context=context)
|
|
||||||
send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message=plaint_content,
|
|
||||||
from_email=settings.FROM_EMAIL,
|
|
||||||
recipient_list=[author],
|
|
||||||
fail_silently=False,
|
|
||||||
html_message=html_content,
|
|
||||||
)
|
|
||||||
letter.notified_at = datetime.now(UTC)
|
letter.notified_at = datetime.now(UTC)
|
||||||
letter.save()
|
letter.save()
|
||||||
logger.info(f"Successfully notified {author} of unlocked letter")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Failed to notify {author} of unlocked letter")
|
logger.exception(f"Failed to notify {author} of unlocked letter")
|
||||||
|
|
||||||
|
|||||||
@@ -396,7 +396,6 @@ class LetterTaskTest(TestCase):
|
|||||||
from_email=settings.FROM_EMAIL,
|
from_email=settings.FROM_EMAIL,
|
||||||
recipient_list=[self.user.email],
|
recipient_list=[self.user.email],
|
||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
html_message=ANY,
|
|
||||||
)
|
)
|
||||||
self.assertIsNotNone(letter_to_notify1.notified_at)
|
self.assertIsNotNone(letter_to_notify1.notified_at)
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{% extends 'email/base.html' %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div style="padding: 15px; font-style: italic">
|
|
||||||
<p>{{ pen_name }},</p>
|
|
||||||
<p>
|
|
||||||
Your destination is one train away.
|
|
||||||
</p>
|
|
||||||
<p>I've been keeping a place for your words.<br/>
|
|
||||||
Come when you're ready.</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footnote %}
|
|
||||||
This link expires in 24 hours.<br/>
|
|
||||||
I'm patient, but not endlessly so.
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer %}
|
|
||||||
Didn't write to me? Then someone else did.<br/>
|
|
||||||
Ignore this. I'll forget you were ever here.
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
pi. ku.
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
{{pen_name}},
|
|
||||||
|
|
||||||
Your destination is one train away.
|
|
||||||
|
|
||||||
I've been keeping a place for your words.
|
|
||||||
Come when you're ready.
|
|
||||||
|
|
||||||
{{ cta.title }} -> {{ cta.link }}
|
|
||||||
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
This link expires in 24 hours.
|
|
||||||
I'm patient, but not endlessly so.
|
|
||||||
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
Didn't write to me? Then someone else did.
|
|
||||||
Ignore this. I'll forget you were ever here.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<title>pi. ku.</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body style="margin:0; padding:0; background-color:#1a1712;">
|
|
||||||
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
|
||||||
style="background-color:#1a1712; font-family: 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding: 48px 16px;">
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
|
||||||
style="max-width:480px; width:100%;">
|
|
||||||
|
|
||||||
{# Logo #}
|
|
||||||
<tr>
|
|
||||||
<td align="left" style="padding-bottom: 24px;">
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/ramvignesh-b/cdn@main/pi-ku_logo.png" width="80"
|
|
||||||
alt="Pi.Ku" style="display:block; border:0;">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{# Body #}
|
|
||||||
<tr>
|
|
||||||
<td style="font-family: 'Trebuchet MS', 'Lucida Sans Unicode', Arial, sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.9;
|
|
||||||
color: #cdccca;
|
|
||||||
font-style: italic;
|
|
||||||
padding-bottom: 24px;">
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{# CTA #}
|
|
||||||
{% if cta %}
|
|
||||||
<tr>
|
|
||||||
<td align="left" style="padding-bottom: 24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color: #301e19; border-radius: 3px;">
|
|
||||||
<a href='{{ cta.link }}' style="display: inline-block;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-family: 'Trebuchet MS', Arial, sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #f5e6c8;
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
font-weight: bold;">
|
|
||||||
{{ cta.title }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if footnote %}
|
|
||||||
<tr>
|
|
||||||
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
|
|
||||||
font-size: 12px;
|
|
||||||
font-style: italic;
|
|
||||||
color: #7a7974;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
line-height: 1.8;">
|
|
||||||
{% block footnote %}
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Footer #}
|
|
||||||
<tr>
|
|
||||||
<td style="border-top: 1px solid #2e2c29; padding-bottom: 24px; font-size: 0; line-height: 0;">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
|
|
||||||
font-size: 12px;
|
|
||||||
font-style: italic;
|
|
||||||
color: #5a5957;
|
|
||||||
line-height: 1.8;">
|
|
||||||
{% block footer %}
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends 'email/base.html' %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p>
|
|
||||||
Time has a way of making things clearer.<br/>
|
|
||||||
Or heavier. Sometimes both.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You had something to say at this exact moment.<br/>
|
|
||||||
I kept it exactly as you left it. <br/>
|
|
||||||
Not a word changed. Not a word read.
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footnote %}
|
|
||||||
<p>
|
|
||||||
You're ready now. Or maybe you're still not.<br/>
|
|
||||||
Open it anyway. You won't regret it.
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
pi. ku.
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
{{pen_name}},
|
|
||||||
|
|
||||||
Time has a way of making things clearer.
|
|
||||||
Or heavier. Sometimes both.
|
|
||||||
|
|
||||||
You had something to say at this exact moment.
|
|
||||||
I kept it exactly as you left it.
|
|
||||||
Not a word changed. Not a word read.
|
|
||||||
|
|
||||||
{{ cta.title }} -> {{ cta.link }}
|
|
||||||
|
|
||||||
-------------------------------------------
|
|
||||||
You're ready now. Or maybe you're still not.
|
|
||||||
Open it anyway. You won't regret it.
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.tokens import default_token_generator
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.encoding import force_bytes
|
from django.utils.encoding import force_bytes
|
||||||
from django.utils.http import urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_encode
|
||||||
|
|
||||||
@@ -10,25 +9,16 @@ def send_activation_email(user):
|
|||||||
token = default_token_generator.make_token(user)
|
token = default_token_generator.make_token(user)
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.public_id))
|
uid = urlsafe_base64_encode(force_bytes(user.public_id))
|
||||||
activation_url = f"{settings.FRONTEND_URLS[0]}/activate/{uid}/{token}"
|
activation_url = f"{settings.FRONTEND_URLS[0]}/activate/{uid}/{token}"
|
||||||
subject = "Activate your pi. ku. account"
|
subject = "Activate Your Piku Account"
|
||||||
context = {
|
message = f"""Hi {user.full_name},
|
||||||
"pen_name": user.full_name,
|
|
||||||
"footnote": True,
|
Welcome to Pi Ku.
|
||||||
"cta": {
|
|
||||||
"title": "Onboard",
|
Please click the link below to activate your account:
|
||||||
"link": activation_url,
|
>> {activation_url}
|
||||||
},
|
|
||||||
}
|
If you did not create this account, please ignore this email."""
|
||||||
html_content = render_to_string("email/activation.html", context)
|
send_mail(subject, message, settings.FROM_EMAIL, [user.email], fail_silently=False)
|
||||||
plain_content = render_to_string("email/activation.txt", context)
|
|
||||||
send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message=plain_content,
|
|
||||||
from_email=settings.FROM_EMAIL,
|
|
||||||
recipient_list=[user.email],
|
|
||||||
fail_silently=False,
|
|
||||||
html_message=html_content,
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ COPY package.json bun.lock* ./
|
|||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG BACKEND_DOMAIN
|
||||||
|
ARG BACKEND_PORT
|
||||||
|
ARG SSL_ENABLED
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL
|
||||||
|
|
||||||
|
ENV BACKEND_DOMAIN=$BACKEND_DOMAIN
|
||||||
|
ENV BACKEND_PORT=$BACKEND_PORT
|
||||||
|
ENV SSL_ENABLED=$SSL_ENABLED
|
||||||
|
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
RUN bun run build:prod
|
RUN bun run build:prod
|
||||||
|
|||||||
@@ -8,12 +8,8 @@
|
|||||||
"@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/cutive-mono": "^5.2.8",
|
"@fontsource/cutive-mono": "^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/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",
|
||||||
@@ -21,8 +17,6 @@
|
|||||||
"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",
|
|
||||||
"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",
|
||||||
@@ -124,18 +118,10 @@
|
|||||||
|
|
||||||
"@fontsource-variable/playwrite-hr-lijeva": ["@fontsource-variable/playwrite-hr-lijeva@5.2.7", "", {}, "sha512-cQqbD8HHZDpiKdtgwUxgwAY76TC+GI9iZOxHSW0XkV/L8lA0X18z1wzR+J8yv9XZQYgLJ5WfzBGwzMSLnSLdPA=="],
|
"@fontsource-variable/playwrite-hr-lijeva": ["@fontsource-variable/playwrite-hr-lijeva@5.2.7", "", {}, "sha512-cQqbD8HHZDpiKdtgwUxgwAY76TC+GI9iZOxHSW0XkV/L8lA0X18z1wzR+J8yv9XZQYgLJ5WfzBGwzMSLnSLdPA=="],
|
||||||
|
|
||||||
"@fontsource/architects-daughter": ["@fontsource/architects-daughter@5.2.7", "", {}, "sha512-W7tHXduV9kRQZDTqcU4Rnc/GtSq9cYUHOnhvcRPjy87u5x/oRqKXPU2PghqbktTECOIh1N0qVZLt9rwqa+aWhg=="],
|
|
||||||
|
|
||||||
"@fontsource/cutive-mono": ["@fontsource/cutive-mono@5.2.8", "", {}, "sha512-Y8PKAYfbpl9Empbb1HZBoirlj4W7RtU+G4EhvX27pHzO6RE1sO0I1ElZQH5DMCTS+MSJkMmQT33sJ0+Ji9U8eQ=="],
|
"@fontsource/cutive-mono": ["@fontsource/cutive-mono@5.2.8", "", {}, "sha512-Y8PKAYfbpl9Empbb1HZBoirlj4W7RtU+G4EhvX27pHzO6RE1sO0I1ElZQH5DMCTS+MSJkMmQT33sJ0+Ji9U8eQ=="],
|
||||||
|
|
||||||
"@fontsource/kavivanar": ["@fontsource/kavivanar@5.2.8", "", {}, "sha512-wbr/9vQ2da9aabUngCpWLbbHM08XZK3nkLDuQ0eX/BhdVvoJx0MSPzaKJ0WIiKpVHy3fUL8ewOqpCyidGZlvEg=="],
|
|
||||||
|
|
||||||
"@fontsource/knewave": ["@fontsource/knewave@5.2.7", "", {}, "sha512-uzx8jgcTiQgAwKvQ/hWdX7lOQPwS+K74Eij/WCVzYvAkCX7GRTnWnbxXXx0XsKR6UIN16kH/u40LW4K8aHJb1w=="],
|
"@fontsource/knewave": ["@fontsource/knewave@5.2.7", "", {}, "sha512-uzx8jgcTiQgAwKvQ/hWdX7lOQPwS+K74Eij/WCVzYvAkCX7GRTnWnbxXXx0XsKR6UIN16kH/u40LW4K8aHJb1w=="],
|
||||||
|
|
||||||
"@fontsource/redacted-script": ["@fontsource/redacted-script@5.2.8", "", {}, "sha512-NOEGJyurXvCx5egCha9yUQB+Tt0IxXriacykYiRlohUvhdbKvisHbucAHQaK8N5/LLB6rlX62SrX8C9+t41PYQ=="],
|
|
||||||
|
|
||||||
"@fontsource/space-mono": ["@fontsource/space-mono@5.2.9", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="],
|
|
||||||
|
|
||||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||||
|
|
||||||
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
||||||
@@ -416,8 +402,6 @@
|
|||||||
|
|
||||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||||
|
|
||||||
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
|
||||||
|
|
||||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
@@ -492,8 +476,6 @@
|
|||||||
|
|
||||||
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
||||||
|
|
||||||
"lenis": ["lenis@1.3.23", "", { "peerDependencies": { "@nuxt/kit": ">=3.0.0", "react": ">=17.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "react", "vue"] }, "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg=="],
|
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
@@ -544,12 +526,6 @@
|
|||||||
|
|
||||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
|
|
||||||
|
|
||||||
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
|
||||||
|
|
||||||
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"msw": ["msw@2.13.2", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A=="],
|
"msw": ["msw@2.13.2", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A=="],
|
||||||
@@ -736,8 +712,6 @@
|
|||||||
|
|
||||||
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||||
|
|||||||
@@ -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.getByLabel("Canvas text input");
|
||||||
|
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}/);
|
||||||
@@ -60,17 +60,19 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
logger.info(">> [Draft] Reloading to verify persistence...");
|
logger.info(">> [Draft] Reloading to verify persistence...");
|
||||||
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 disappear
|
||||||
await expect(page.getByTestId("opening-draft-overlay")).toBeHidden();
|
await expect(page.getByText(/opening your draft/i)).toBeHidden();
|
||||||
|
|
||||||
// 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,52 +86,67 @@ 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.getByLabel("Canvas text input");
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
await canvasInput.fill("This letter will be sealed and shared.");
|
await canvasInput.fill("This letter will be sealed and shared.");
|
||||||
|
|
||||||
// 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,44 +161,70 @@ 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.getByLabel("Canvas text input");
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
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/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: {
|
||||||
@@ -15,46 +14,48 @@ const logger = pino({
|
|||||||
/**
|
/**
|
||||||
* Completes the full registration -> activation -> login cycle.
|
* Completes the full registration -> activation -> login cycle.
|
||||||
*/
|
*/
|
||||||
async function registerAndLogin(
|
export async function registerAndLogin(
|
||||||
page: Page,
|
page: Page,
|
||||||
email: string,
|
email: string,
|
||||||
fullName: string,
|
fullName: string,
|
||||||
password: string,
|
password: string,
|
||||||
) {
|
) {
|
||||||
// Register the User
|
// 1. Registration
|
||||||
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/);
|
||||||
|
|
||||||
// Get activation URL from Mailpit and activate user
|
// 2. Activation via Mailpit
|
||||||
logger.info(`[Auth] Polling Mailpit for activation email...`);
|
logger.info(`[Auth] Polling Mailpit for activation email...`);
|
||||||
const activationLink = await MailpitHelper.getActivationLink(email);
|
const activationLink = await MailpitHelper.getActivationLink(email);
|
||||||
|
|
||||||
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
|
// 3. 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 understand/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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maintain backward compatibility if needed, or update callers
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -31,8 +31,8 @@ export const MailpitHelper = {
|
|||||||
);
|
);
|
||||||
const details = await detailRes.json();
|
const details = await detailRes.json();
|
||||||
|
|
||||||
const body = details.Text || "";
|
const body = details.HTML || details.Text || "";
|
||||||
const match = body.match(/https?:\/\/\S*activate\S*/);
|
const match = body.match(/https?:\/\/\S+activate\/\S+/);
|
||||||
|
|
||||||
if (match) return match[0];
|
if (match) return match[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Pi. Ku. | A safe haven for your unsaid and unsent letters</title>
|
<title>Pi. Ku. | A safe haven for your unsent letters</title>
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal your unsaid words into digital letters." />
|
content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal digital letters." />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -22,12 +22,8 @@
|
|||||||
"@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/cutive-mono": "^5.2.8",
|
"@fontsource/cutive-mono": "^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/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",
|
||||||
@@ -35,8 +31,6 @@
|
|||||||
"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",
|
|
||||||
"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",
|
||||||
|
|||||||
@@ -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 |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 755 B |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef } from "react";
|
import { lazy, Suspense, useEffect } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { AutoRedirectRoute, ProtectedRoute } from "./components/RouteGuards";
|
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||||
import SplashScreen from "./components/SplashScreen";
|
import SplashScreen from "./components/SplashScreen";
|
||||||
import { ROUTES } from "./config/routes";
|
import { ROUTES } from "./config/routes";
|
||||||
import { useAuth } from "./hooks/useAuth";
|
import { useAuth } from "./hooks/useAuth";
|
||||||
|
|
||||||
|
let authInitialized = false;
|
||||||
|
|
||||||
const Activate = lazy(() => import("./pages/Activate"));
|
const Activate = lazy(() => import("./pages/Activate"));
|
||||||
const Drawer = lazy(() => import("./pages/Drawer"));
|
const Drawer = lazy(() => import("./pages/Drawer"));
|
||||||
const Editor = lazy(() => import("./pages/Editor"));
|
const Editor = lazy(() => import("./pages/Editor"));
|
||||||
@@ -13,16 +15,14 @@ const Login = lazy(() => import("./pages/Login"));
|
|||||||
const Reader = lazy(() => import("./pages/Reader"));
|
const Reader = lazy(() => import("./pages/Reader"));
|
||||||
const Register = lazy(() => import("./pages/Register"));
|
const Register = lazy(() => import("./pages/Register"));
|
||||||
const VerifyEmail = lazy(() => import("./pages/VerifyEmail"));
|
const VerifyEmail = lazy(() => import("./pages/VerifyEmail"));
|
||||||
const About = lazy(() => import("./pages/About"));
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { initialize, isInitializing } = useAuth();
|
const { initialize, isInitializing } = useAuth();
|
||||||
const authInitialized = useRef<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authInitialized.current) return;
|
if (authInitialized) return;
|
||||||
authInitialized.current = true;
|
authInitialized = true;
|
||||||
initialize().then();
|
initialize();
|
||||||
}, [initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
@@ -31,48 +31,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="min-h-screen bg-base-200 flex items-center justify-center w-full">
|
||||||
<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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -93,7 +86,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path={ROUTES.READ} element={<Reader />} />
|
<Route path={ROUTES.READ} element={<Reader />} />
|
||||||
<Route path={ROUTES.ABOUT} element={<About />} />
|
|
||||||
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import axios from "axios";
|
|||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
|
|
||||||
export const apiServerUrl = import.meta.env.VITE_API_URL;
|
|
||||||
|
|
||||||
// publicApi for endpoints that don't need authentication (login, refresh, register)
|
// publicApi for endpoints that don't need authentication (login, refresh, register)
|
||||||
export const publicApi = axios.create({
|
export const publicApi = axios.create({
|
||||||
baseURL: apiServerUrl,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// api for all authenticated requests
|
// api for all authenticated requests
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: apiServerUrl,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// auto-attach access token to authenticated requests
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -22,28 +22,29 @@ api.interceptors.request.use((config) => {
|
|||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
// auto handle 401 errors by attempting a silent refresh
|
|
||||||
|
// Handle 401 errors by attempting a silent refresh
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
|
||||||
// if first time 401 and we haven't tried refreshing yet, we proceed with silent refresh
|
// If 401 and we haven't tried refreshing yet
|
||||||
// else it could mean the refresh also 401'd
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Attempt silent refresh
|
||||||
const { data } = await publicApi.post(endpoints.REFRESH);
|
const { data } = await publicApi.post(endpoints.REFRESH);
|
||||||
const newAccessToken = data.access;
|
const newAccessToken = data.access;
|
||||||
|
|
||||||
// Update store with the latest accesstoken
|
// Update store
|
||||||
const { user, setAuth } = useAuthStore.getState();
|
const { user, setAuth } = useAuthStore.getState();
|
||||||
if (user) {
|
if (user) {
|
||||||
setAuth(newAccessToken, user);
|
setAuth(newAccessToken, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// retry the original request with the new token
|
// Retry the original request with the new token
|
||||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
return api(originalRequest);
|
return api(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
|||||||
@@ -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: 4.5 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 738 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -1,59 +1,25 @@
|
|||||||
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 {
|
export default function Logo({ scale = 2 }) {
|
||||||
scale?: number;
|
|
||||||
type?: "inline" | "mono" | "logo" | null;
|
|
||||||
ul?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Logo({
|
|
||||||
scale = 1,
|
|
||||||
type = null,
|
|
||||||
ul = false,
|
|
||||||
}: LogoProps) {
|
|
||||||
if (type === "inline") {
|
|
||||||
return (
|
|
||||||
<span className={"text-accent font-display italic "}>
|
|
||||||
pi<span className="text-primary">.</span> ku
|
|
||||||
<span className="text-primary">.</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "mono") {
|
|
||||||
return (
|
|
||||||
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
|
|
||||||
pi. ku.
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "logo") {
|
|
||||||
return (
|
|
||||||
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Pi. Ku. logo"
|
aria-label="Pi Ku"
|
||||||
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-xl font-light text-accent`}> Pi</span>
|
||||||
<DotIcon
|
<DotIcon
|
||||||
weight="fill"
|
weight="fill"
|
||||||
size={12}
|
size={6}
|
||||||
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-xl font-light text-accent`}> Ku</span>
|
||||||
<DotIcon
|
<DotIcon
|
||||||
weight="fill"
|
weight="fill"
|
||||||
size={12}
|
size={6}
|
||||||
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { useAuth } from "../hooks/useAuth";
|
|||||||
import SplashScreen from "./SplashScreen";
|
import SplashScreen from "./SplashScreen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private route guard.
|
* Post-login routes.
|
||||||
* If not authenticated, capture the current url in route
|
* Redirects to /login if not already authenticated.
|
||||||
* state so the Login component can link them back after sign-in
|
|
||||||
*/
|
*/
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isInitializing } = useAuth();
|
const { isAuthenticated, isInitializing } = useAuth();
|
||||||
@@ -15,6 +14,7 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
if (isInitializing) return <SplashScreen />;
|
if (isInitializing) return <SplashScreen />;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
// Save the intended location to redirect back after login
|
||||||
return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
|
return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,10 +22,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-redirect - auth route guard.
|
* Pre-login flows.
|
||||||
* If authenticated, redirect all the auth related flows to the drawer
|
* Redirects to /drawer if already authenticated.
|
||||||
*/
|
*/
|
||||||
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 bg-base-100 flex flex-col items-center justify-center z-9999">
|
||||||
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 />
|
||||||
|
|
||||||
@@ -18,7 +15,7 @@ export default function SplashScreen() {
|
|||||||
/>
|
/>
|
||||||
<span className="loading loading-ring loading-xl text-primary"></span>
|
<span className="loading loading-ring loading-xl text-primary"></span>
|
||||||
...
|
...
|
||||||
<p className="text-xs uppercase font-sans tracking-widester opacity-40">
|
<p className="text-xs uppercase font-sans tracking-[1em] opacity-40">
|
||||||
Unsealing
|
Unsealing
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,37 +23,23 @@ 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 ${
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`transition-opacity ease-in-out ${
|
|
||||||
isOpen
|
isOpen
|
||||||
? "opacity-100 py-3 border-b border-base-content/5 duration-700 delay-500"
|
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible"
|
||||||
: "opacity-0 duration-100"
|
: "max-h-0 opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{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-[0.2em] 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"
|
||||||
: "text-base-content/40 group-hover:text-base-content/80"
|
: "text-base-content/40 group-hover:text-base-content/80"
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,6 @@ import { LockIcon, LockKeyOpenIcon } from "@phosphor-icons/react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PATHS } from "../../config/routes";
|
import { PATHS } from "../../config/routes";
|
||||||
|
|
||||||
interface LetterItemProps {
|
|
||||||
preview: string;
|
|
||||||
timestamp: string;
|
|
||||||
id: string;
|
|
||||||
status: "DRAFT" | "SEALED" | "BURNED";
|
|
||||||
unlock_at?: string;
|
|
||||||
isLocked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LetterItem({
|
export function LetterItem({
|
||||||
preview,
|
preview,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -18,7 +9,14 @@ export function LetterItem({
|
|||||||
status,
|
status,
|
||||||
unlock_at,
|
unlock_at,
|
||||||
isLocked = false,
|
isLocked = false,
|
||||||
}: LetterItemProps) {
|
}: {
|
||||||
|
preview: string;
|
||||||
|
timestamp: string;
|
||||||
|
id: string;
|
||||||
|
status: "DRAFT" | "SEALED" | "BURNED";
|
||||||
|
unlock_at?: string;
|
||||||
|
isLocked?: boolean;
|
||||||
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
function handleNavigate(): void {
|
function handleNavigate(): void {
|
||||||
if (isLocked) return;
|
if (isLocked) return;
|
||||||
@@ -33,10 +31,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 +50,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";
|
|
||||||
|
|
||||||
export function PasskeyModal() {
|
interface PasskeyModalProps {
|
||||||
const { unlock } = useAuth();
|
onUnlock: (password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={true}>
|
<div className="modal modal-open bg-base-100/20 backdrop-blur-md z-100">
|
||||||
<HourglassSimpleMediumIcon
|
<div className="modal-box p-12 flex flex-col items-center">
|
||||||
|
<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,19 +38,15 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
import * as fabric from "fabric";
|
import * as fabric from "fabric";
|
||||||
import type * as React from "react";
|
import {
|
||||||
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
import "@fontsource/kavivanar/index.css";
|
useEffect,
|
||||||
import "@fontsource/space-mono/index.css";
|
useImperativeHandle,
|
||||||
import "@fontsource/cutive-mono/index.css";
|
useRef,
|
||||||
import "@fontsource/architects-daughter/index.css";
|
} from "react";
|
||||||
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;
|
||||||
const DEFAULT_FONT_FAMILY = "Playfair Display Variable";
|
|
||||||
const DEFAULT_FONT_COLOR = "#000";
|
|
||||||
|
|
||||||
export interface FabricObjectJSON {
|
export interface FabricObjectJSON {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -21,7 +18,6 @@ export interface FabricObjectJSON {
|
|||||||
left: number;
|
left: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,149 +33,34 @@ export interface CanvasJSON {
|
|||||||
canvasHeight?: number;
|
canvasHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasStyle {
|
|
||||||
fontFamily: string;
|
|
||||||
fontColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CanvasTools = {
|
export type CanvasTools = {
|
||||||
addImage: (url: string, file: File) => void;
|
addImage: (url: string, file: File) => void;
|
||||||
getData: () => CanvasJSON;
|
getData: () => CanvasJSON;
|
||||||
|
getJsonData: () => string;
|
||||||
getImages: () => { src: string; file: File }[];
|
getImages: () => { src: string; file: File }[];
|
||||||
loadData: (data: CanvasJSON) => Promise<void>;
|
loadData: (data: CanvasJSON) => Promise<void>;
|
||||||
getStyle: () => CanvasStyle;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FabricImageWithFile extends fabric.FabricImage {
|
export interface FabricImageWithFile extends fabric.FabricImage {
|
||||||
_customRawFile: File;
|
_customRawFile: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: We use the same canvasData to render on both mobile and desktop viewports.
|
const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
|
||||||
// Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up)
|
return new Promise((resolve) => {
|
||||||
// over the last saved canvas size.
|
const check = () => {
|
||||||
const applyResponsiveViewport = (
|
const width = wrapper.clientWidth || 0;
|
||||||
canvas: fabric.Canvas,
|
if (width > 0) resolve(width);
|
||||||
wrapper: HTMLDivElement,
|
else requestAnimationFrame(check);
|
||||||
logicalWidth: number,
|
|
||||||
logicalHeight: number,
|
|
||||||
) => {
|
|
||||||
const physicalWidth = wrapper.clientWidth || logicalWidth;
|
|
||||||
const zoomMultiplier = physicalWidth / logicalWidth;
|
|
||||||
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
|
|
||||||
|
|
||||||
canvas.setDimensions({
|
|
||||||
width: physicalWidth,
|
|
||||||
height: physicalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.style.height = `${physicalHeight}px`;
|
|
||||||
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
// to find the maximum height of the content to dynamically resize the canvas
|
|
||||||
// would've been wayyy easier only if canvas supported fit-content like CSS property :)
|
|
||||||
const measureLogicalContentHeight = (
|
|
||||||
canvas: fabric.Canvas,
|
|
||||||
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
) => {
|
|
||||||
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
|
|
||||||
const top = currObj.top;
|
|
||||||
const height = currObj.getScaledHeight();
|
|
||||||
return Math.max(maxHeight, top + height);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return Math.max(minimumHeight, maxBottom + PAD);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_INIT_TEXT = "Take a deep breath...";
|
|
||||||
|
|
||||||
interface ComposeCanvasProps {
|
|
||||||
readOnly?: boolean;
|
|
||||||
initialData?: CanvasJSON | null;
|
|
||||||
style?: CanvasStyle;
|
|
||||||
ref?: React.Ref<CanvasTools>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComposeCanvas({
|
|
||||||
readOnly = false,
|
|
||||||
initialData = null,
|
|
||||||
style,
|
|
||||||
ref,
|
|
||||||
}: ComposeCanvasProps) {
|
|
||||||
// wrapper is the parent div box
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
|
||||||
|
|
||||||
const textboxRef = useRef<fabric.Textbox | null>(null);
|
|
||||||
const deferredDataRef = useRef<CanvasJSON | null>(null);
|
|
||||||
const logicalSizeRef = useRef({
|
|
||||||
width: BASE_WIDTH,
|
|
||||||
height: DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
});
|
|
||||||
|
|
||||||
// re-calculates height based on content and applies the zoom transform
|
|
||||||
const syncViewport = useCallback(() => {
|
|
||||||
if (!(fabricRef.current && wrapperRef.current)) return;
|
|
||||||
textboxRef.current?.initDimensions();
|
|
||||||
|
|
||||||
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
|
|
||||||
logicalSizeRef.current.height = measureLogicalContentHeight(
|
|
||||||
fabricRef.current,
|
|
||||||
minHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
applyResponsiveViewport(
|
|
||||||
fabricRef.current,
|
|
||||||
wrapperRef.current,
|
|
||||||
logicalSizeRef.current.width,
|
|
||||||
logicalSizeRef.current.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
fabricRef.current.requestRenderAll();
|
|
||||||
}, [initialData]);
|
|
||||||
|
|
||||||
// auto focus the cursor into the main textbox no matter the latest element added
|
|
||||||
const focusTextbox = useCallback(
|
|
||||||
(textbox: fabric.Textbox) => {
|
|
||||||
if (readOnly || !fabricRef.current) return;
|
|
||||||
|
|
||||||
fabricRef.current.setActiveObject(textbox);
|
|
||||||
textbox.enterEditing();
|
|
||||||
|
|
||||||
// move the cursor to the end of the text
|
|
||||||
const textLength = textbox.text?.length ?? 0;
|
|
||||||
textbox.selectionStart = textLength;
|
|
||||||
textbox.selectionEnd = textLength;
|
|
||||||
|
|
||||||
fabricRef.current.requestRenderAll();
|
|
||||||
},
|
|
||||||
[readOnly],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadContent = useCallback(
|
|
||||||
async (data: CanvasJSON | null) => {
|
|
||||||
const canvas = fabricRef.current;
|
|
||||||
const wrapper = wrapperRef.current;
|
|
||||||
if (!(canvas && wrapper)) return;
|
|
||||||
|
|
||||||
// clean the canvas everytime and set fresh
|
|
||||||
canvas.clear();
|
|
||||||
let textbox: fabric.Textbox | null = null;
|
|
||||||
|
|
||||||
// restore logical size from prev saved data if available (in case of existing letter)
|
|
||||||
logicalSizeRef.current = {
|
|
||||||
width: data?.canvasWidth ?? BASE_WIDTH,
|
|
||||||
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
};
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (data?.objects?.length) {
|
const createMainTextbox = (
|
||||||
await canvas.loadFromJSON(data);
|
text: string,
|
||||||
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
|
isReadOnly = false,
|
||||||
} else {
|
): fabric.Textbox => {
|
||||||
// Create a fresh letter if no data exists
|
return new fabric.Textbox(text, {
|
||||||
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
|
|
||||||
name: "main-textbox",
|
name: "main-textbox",
|
||||||
originX: "left",
|
originX: "left",
|
||||||
originY: "top",
|
originY: "top",
|
||||||
@@ -188,112 +69,44 @@ export function ComposeCanvas({
|
|||||||
width: BASE_WIDTH - PAD * 2,
|
width: BASE_WIDTH - PAD * 2,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
fontFamily: "Playfair Display Variable",
|
||||||
fill: DEFAULT_FONT_COLOR,
|
fill: "#000",
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
editable: !isReadOnly,
|
||||||
|
selectable: false,
|
||||||
|
evented: !isReadOnly,
|
||||||
|
hasControls: false,
|
||||||
|
hasBorders: false,
|
||||||
|
objectCaching: false,
|
||||||
splitByGrapheme: false,
|
splitByGrapheme: false,
|
||||||
lockMovementX: true,
|
lockMovementX: true,
|
||||||
lockMovementY: true,
|
lockMovementY: true,
|
||||||
lockScalingX: true,
|
lockScalingX: true,
|
||||||
lockScalingY: true,
|
lockScalingY: true,
|
||||||
lockRotation: true,
|
lockRotation: true,
|
||||||
hasControls: false,
|
|
||||||
hasBorders: false,
|
|
||||||
objectCaching: false,
|
|
||||||
noScaleCache: false,
|
|
||||||
});
|
});
|
||||||
canvas.add(textbox);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
if (!textbox) return;
|
const fixFabricA11y = () => {
|
||||||
|
const textAreas = document.querySelectorAll(
|
||||||
// readonly contraints applicable for post seal view
|
'textarea[data-fabric="textarea"]',
|
||||||
textbox.selectable = !readOnly;
|
|
||||||
textbox.evented = !readOnly;
|
|
||||||
textbox.editable = !readOnly;
|
|
||||||
textbox.hasBorders = false;
|
|
||||||
|
|
||||||
textboxRef.current = textbox;
|
|
||||||
|
|
||||||
// observe and auto-resize the canvas height whenever typed
|
|
||||||
textbox.on("changed", syncViewport);
|
|
||||||
|
|
||||||
// trapping the focus into the textbox wherever clicked on canvas (except images)
|
|
||||||
canvas.on("mouse:down", (e) => {
|
|
||||||
if (!e.target || e.target === textbox) {
|
|
||||||
focusTextbox(textbox);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const img of canvas.getObjects("Image")) {
|
|
||||||
img.set({
|
|
||||||
hasControls: !readOnly,
|
|
||||||
hasBorders: !readOnly,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
|
|
||||||
await document.fonts.ready;
|
|
||||||
textbox.set("dirty", true);
|
|
||||||
syncViewport();
|
|
||||||
|
|
||||||
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
|
||||||
// otherwise it goes to the front
|
|
||||||
if (!readOnly) {
|
|
||||||
setTimeout(() => focusTextbox(textbox), 200);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[readOnly, syncViewport, focusTextbox],
|
|
||||||
);
|
);
|
||||||
|
for (const area of textAreas) {
|
||||||
useEffect(() => {
|
if (!area.getAttribute("aria-label")) {
|
||||||
if (style && textboxRef.current) {
|
area.setAttribute("aria-label", "Canvas text input");
|
||||||
const textBox = textboxRef.current;
|
|
||||||
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
|
|
||||||
textBox.fill = style.fontColor || textBox.fill;
|
|
||||||
syncViewport();
|
|
||||||
}
|
}
|
||||||
}, [style, syncViewport]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
let lastWidth = 0;
|
|
||||||
|
|
||||||
const getInitialWidth = async () => {
|
|
||||||
if (!wrapperRef.current) return BASE_WIDTH;
|
|
||||||
let width = wrapperRef.current.clientWidth;
|
|
||||||
if (width === 0) {
|
|
||||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
||||||
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
|
|
||||||
}
|
}
|
||||||
return width;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const initResizeOberver = () => {
|
const initializeCanvas = (
|
||||||
if (!wrapperRef.current) return null;
|
el: HTMLCanvasElement,
|
||||||
const observer = new ResizeObserver(() => {
|
width: number,
|
||||||
const nextWidth = wrapperRef.current?.clientWidth;
|
height: number,
|
||||||
if (!nextWidth || nextWidth === lastWidth) return;
|
readOnly: boolean,
|
||||||
lastWidth = nextWidth;
|
) => {
|
||||||
syncViewport();
|
const canvas = new fabric.Canvas(el, {
|
||||||
});
|
|
||||||
observer.observe(wrapperRef.current);
|
|
||||||
return observer;
|
|
||||||
};
|
|
||||||
|
|
||||||
const initCanvas = async () => {
|
|
||||||
// HACK: actual font may change the text-width - small ux improvement
|
|
||||||
await document.fonts.ready;
|
|
||||||
|
|
||||||
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
|
||||||
|
|
||||||
const width = await getInitialWidth();
|
|
||||||
|
|
||||||
// init the fabric instance
|
|
||||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
|
||||||
width,
|
width,
|
||||||
height: DEFAULT_LOGICAL_HEIGHT,
|
height,
|
||||||
selection: !readOnly,
|
selection: !readOnly,
|
||||||
preserveObjectStacking: true,
|
preserveObjectStacking: true,
|
||||||
allowTouchScrolling: true,
|
allowTouchScrolling: true,
|
||||||
@@ -301,41 +114,278 @@ export function ComposeCanvas({
|
|||||||
objectCaching: false,
|
objectCaching: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// remove default fabric background to let our CSS show through
|
|
||||||
// TODO: provision custom bg (color in scope, but how does img fit?)
|
|
||||||
const wrapperEl = canvas.getElement().parentElement;
|
const wrapperEl = canvas.getElement().parentElement;
|
||||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
if (wrapperEl) wrapperEl.style.background = "transparent";
|
||||||
|
|
||||||
fabricRef.current = canvas;
|
return canvas;
|
||||||
|
};
|
||||||
|
|
||||||
await loadContent(initialData);
|
const getLogicalSize = (data: CanvasJSON | null) => {
|
||||||
|
return {
|
||||||
|
width: data?.canvasWidth ?? BASE_WIDTH,
|
||||||
|
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// sometimes loadData() may be called before the canvas finished the init render
|
const getObjectBottom = (obj: fabric.FabricObject) => {
|
||||||
// so we retry that stashed render right after the init
|
const top = obj.top ?? 0;
|
||||||
if (deferredDataRef.current) {
|
const height =
|
||||||
await loadContent(deferredDataRef.current);
|
typeof obj.getScaledHeight === "function"
|
||||||
deferredDataRef.current = null;
|
? obj.getScaledHeight()
|
||||||
|
: (obj.height ?? 0) * (obj.scaleY ?? 1);
|
||||||
|
|
||||||
|
return top + height;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureLogicalContentHeight = (
|
||||||
|
canvas: fabric.Canvas,
|
||||||
|
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
) => {
|
||||||
|
const maxBottom = canvas
|
||||||
|
.getObjects()
|
||||||
|
.reduce((max, obj) => Math.max(max, getObjectBottom(obj)), 0);
|
||||||
|
|
||||||
|
return Math.max(minimumHeight, maxBottom + PAD);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyResponsiveViewport = (
|
||||||
|
canvas: fabric.Canvas,
|
||||||
|
wrapper: HTMLDivElement,
|
||||||
|
logicalWidth: number,
|
||||||
|
logicalHeight: number,
|
||||||
|
) => {
|
||||||
|
const physicalWidth = wrapper.clientWidth || logicalWidth;
|
||||||
|
const zoom = physicalWidth / logicalWidth;
|
||||||
|
const physicalHeight = Math.max(1, logicalHeight * zoom);
|
||||||
|
|
||||||
|
canvas.setDimensions({
|
||||||
|
width: physicalWidth,
|
||||||
|
height: physicalHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.style.height = `${physicalHeight}px`;
|
||||||
|
canvas.setViewportTransform([zoom, 0, 0, zoom, 0, 0]);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusTextbox = (
|
||||||
|
fCanvas: fabric.Canvas,
|
||||||
|
textbox: fabric.Textbox,
|
||||||
|
readOnly: boolean,
|
||||||
|
) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
|
||||||
|
fCanvas.setActiveObject(textbox);
|
||||||
|
textbox.enterEditing();
|
||||||
|
|
||||||
|
const end = textbox.text?.length ?? 0;
|
||||||
|
textbox.selectionStart = end;
|
||||||
|
textbox.selectionEnd = end;
|
||||||
|
|
||||||
|
fCanvas.requestRenderAll();
|
||||||
|
fixFabricA11y();
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMainTextbox = (canvas: fabric.Canvas): fabric.Textbox | null => {
|
||||||
|
const textbox = canvas.getObjects("Textbox")[0];
|
||||||
|
|
||||||
|
return (textbox as fabric.Textbox) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComposeCanvas = forwardRef<
|
||||||
|
CanvasTools,
|
||||||
|
{ readOnly?: boolean; initialData?: CanvasJSON | null }
|
||||||
|
>(({ readOnly = false, initialData = null }, ref) => {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||||
|
const textboxRef = useRef<fabric.Textbox | null>(null);
|
||||||
|
const deferredDataRef = useRef<CanvasJSON | null>(null);
|
||||||
|
const logicalSizeRef = useRef({
|
||||||
|
width: BASE_WIDTH,
|
||||||
|
height: DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncViewport = useCallback(() => {
|
||||||
|
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||||
|
|
||||||
|
applyResponsiveViewport(
|
||||||
|
fabricRef.current,
|
||||||
|
wrapperRef.current,
|
||||||
|
logicalSizeRef.current.width,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateLogicalHeightFromContent = useCallback(() => {
|
||||||
|
if (!fabricRef.current) return;
|
||||||
|
|
||||||
|
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||||
|
fabricRef.current,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
syncViewport();
|
||||||
|
}, [syncViewport]);
|
||||||
|
|
||||||
|
const setupTextboxInteractions = useCallback(
|
||||||
|
(fCanvas: fabric.Canvas, textbox: fabric.Textbox) => {
|
||||||
|
textbox.on("changed", () => {
|
||||||
|
updateLogicalHeightFromContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
fCanvas.on("mouse:down", (opt) => {
|
||||||
|
if (!opt.target || opt.target === textbox) {
|
||||||
|
focusTextbox(fCanvas, textbox, readOnly);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!readOnly) {
|
||||||
|
setTimeout(() => {
|
||||||
|
focusTextbox(fCanvas, textbox, readOnly);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[readOnly, updateLogicalHeightFromContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadContent = useCallback(
|
||||||
|
async (
|
||||||
|
canvas: fabric.Canvas,
|
||||||
|
data: CanvasJSON | null,
|
||||||
|
wrapper: HTMLDivElement,
|
||||||
|
): Promise<fabric.Textbox | null> => {
|
||||||
|
const logicalSize = getLogicalSize(data);
|
||||||
|
logicalSizeRef.current = logicalSize;
|
||||||
|
|
||||||
|
canvas.clear();
|
||||||
|
|
||||||
|
let textbox: fabric.Textbox | null = null;
|
||||||
|
|
||||||
|
if (data?.objects?.length) {
|
||||||
|
await canvas.loadFromJSON(data);
|
||||||
|
textbox = findMainTextbox(canvas);
|
||||||
|
} else {
|
||||||
|
textbox = createMainTextbox("Take a deep breath...", readOnly);
|
||||||
|
canvas.add(textbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
// auto window resizing based width
|
if (!textbox) return null;
|
||||||
|
|
||||||
|
textbox.selectable = !readOnly;
|
||||||
|
textbox.evented = !readOnly;
|
||||||
|
textbox.editable = !readOnly;
|
||||||
|
textbox.hasBorders = false;
|
||||||
|
textbox.lockMovementX = true;
|
||||||
|
textbox.lockMovementY = true;
|
||||||
|
textbox.lockScalingX = true;
|
||||||
|
textbox.lockScalingY = true;
|
||||||
|
textbox.lockRotation = true;
|
||||||
|
textbox.objectCaching = false;
|
||||||
|
|
||||||
|
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||||
|
canvas,
|
||||||
|
logicalSize.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
applyResponsiveViewport(
|
||||||
|
canvas,
|
||||||
|
wrapper,
|
||||||
|
logicalSizeRef.current.width,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(readOnly || data)) {
|
||||||
|
focusTextbox(canvas, textbox, readOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
return textbox;
|
||||||
|
},
|
||||||
|
[readOnly],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
let canvas: fabric.Canvas | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let lastWidth = 0;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
await document.fonts.ready;
|
||||||
|
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
||||||
|
|
||||||
|
const finalWidth = await waitForLayout(wrapperRef.current);
|
||||||
|
if (!(isMounted && canvasRef.current && wrapperRef.current)) return;
|
||||||
|
|
||||||
|
canvas = initializeCanvas(
|
||||||
|
canvasRef.current,
|
||||||
|
finalWidth,
|
||||||
|
DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
readOnly,
|
||||||
|
);
|
||||||
|
|
||||||
|
fabricRef.current = canvas;
|
||||||
|
|
||||||
|
const textbox = await loadContent(
|
||||||
|
canvas,
|
||||||
|
initialData,
|
||||||
|
wrapperRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textbox) {
|
||||||
|
textboxRef.current = textbox;
|
||||||
|
setupTextboxInteractions(canvas, textbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
fixFabricA11y();
|
||||||
|
|
||||||
lastWidth = wrapperRef.current.clientWidth;
|
lastWidth = wrapperRef.current.clientWidth;
|
||||||
resizeObserver = initResizeOberver();
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||||
|
|
||||||
|
const nextWidth = wrapperRef.current.clientWidth;
|
||||||
|
if (!nextWidth || nextWidth === lastWidth) return;
|
||||||
|
|
||||||
|
lastWidth = nextWidth;
|
||||||
|
syncViewport();
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(wrapperRef.current);
|
||||||
|
|
||||||
|
if (deferredDataRef.current) {
|
||||||
|
const data = deferredDataRef.current;
|
||||||
|
deferredDataRef.current = null;
|
||||||
|
|
||||||
|
const textbox = await loadContent(canvas, data, wrapperRef.current);
|
||||||
|
if (textbox) {
|
||||||
|
textboxRef.current = textbox;
|
||||||
|
setupTextboxInteractions(canvas, textbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
fixFabricA11y();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initCanvas().then();
|
init();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
fabricRef.current?.dispose();
|
canvas?.dispose();
|
||||||
fabricRef.current = null;
|
fabricRef.current = null;
|
||||||
textboxRef.current = null;
|
textboxRef.current = null;
|
||||||
};
|
};
|
||||||
}, [initialData, loadContent, readOnly, syncViewport]);
|
}, [
|
||||||
|
initialData,
|
||||||
|
loadContent,
|
||||||
|
readOnly,
|
||||||
|
setupTextboxInteractions,
|
||||||
|
syncViewport,
|
||||||
|
]);
|
||||||
|
|
||||||
// WHY?: fabric doesn't work like react with state and props based optimized re-renders.
|
|
||||||
// everytime we there's a change in the data, we should force the render,
|
|
||||||
// so we let the parent Editor component take control of this.
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
addImage: (url: string, file: File) => {
|
addImage: (url: string, file: File) => {
|
||||||
if (!fabricRef.current) return;
|
if (!fabricRef.current) return;
|
||||||
@@ -345,39 +395,69 @@ export function ComposeCanvas({
|
|||||||
img.set({
|
img.set({
|
||||||
originX: "left",
|
originX: "left",
|
||||||
originY: "top",
|
originY: "top",
|
||||||
|
_customRawFile: file,
|
||||||
left: PAD,
|
left: PAD,
|
||||||
top: PAD,
|
top: PAD,
|
||||||
noScaleCache: false,
|
|
||||||
objectCaching: false,
|
objectCaching: false,
|
||||||
// WHY?: after image object clean-up, its src becomes local blob://
|
|
||||||
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
|
|
||||||
_customRawFile: file,
|
|
||||||
} as Partial<FabricImageWithFile>);
|
} as Partial<FabricImageWithFile>);
|
||||||
|
|
||||||
fabricRef.current?.add(img);
|
fabricRef.current?.add(img);
|
||||||
fabricRef.current?.setActiveObject(img);
|
fabricRef.current?.setActiveObject(img);
|
||||||
|
|
||||||
syncViewport();
|
if (!fabricRef.current) return;
|
||||||
// clean up memory
|
|
||||||
|
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||||
|
fabricRef.current,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wrapperRef.current) {
|
||||||
|
applyResponsiveViewport(
|
||||||
|
fabricRef.current,
|
||||||
|
wrapperRef.current,
|
||||||
|
logicalSizeRef.current.width,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fabricRef.current?.requestRenderAll();
|
||||||
|
}
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getData: () => {
|
getData: () => {
|
||||||
if (!fabricRef.current) return { objects: [] };
|
if (!fabricRef.current) return { objects: [] };
|
||||||
syncViewport();
|
|
||||||
|
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||||
|
fabricRef.current,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
|
||||||
const json = fabricRef.current.toJSON() as CanvasJSON;
|
const json = fabricRef.current.toJSON() as CanvasJSON;
|
||||||
json.canvasWidth = logicalSizeRef.current.width;
|
json.canvasWidth = logicalSizeRef.current.width;
|
||||||
json.canvasHeight = logicalSizeRef.current.height;
|
json.canvasHeight = logicalSizeRef.current.height;
|
||||||
|
|
||||||
return json;
|
return json;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getJsonData: () => {
|
||||||
|
if (!fabricRef.current) return "";
|
||||||
|
|
||||||
|
const json = fabricRef.current.toJSON() as CanvasJSON;
|
||||||
|
json.canvasWidth = logicalSizeRef.current.width;
|
||||||
|
json.canvasHeight = logicalSizeRef.current.height;
|
||||||
|
|
||||||
|
return JSON.stringify(json);
|
||||||
|
},
|
||||||
|
|
||||||
getImages: () => {
|
getImages: () => {
|
||||||
if (!fabricRef.current) return [];
|
if (!fabricRef.current) return [];
|
||||||
|
|
||||||
const images = fabricRef.current.getObjects(
|
const images = fabricRef.current.getObjects(
|
||||||
"Image",
|
"Image",
|
||||||
) as FabricImageWithFile[];
|
) as FabricImageWithFile[];
|
||||||
|
|
||||||
return images.map((img) => ({
|
return images.map((img) => ({
|
||||||
src: img.getSrc(),
|
src: img.getSrc(),
|
||||||
file: img._customRawFile,
|
file: img._customRawFile,
|
||||||
@@ -385,21 +465,24 @@ export function ComposeCanvas({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadData: async (data: CanvasJSON) => {
|
loadData: async (data: CanvasJSON) => {
|
||||||
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
|
if (!(fabricRef.current && wrapperRef.current)) {
|
||||||
if (!fabricRef.current) {
|
|
||||||
deferredDataRef.current = data;
|
deferredDataRef.current = data;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadContent(data);
|
|
||||||
},
|
|
||||||
|
|
||||||
getStyle: () => {
|
const textbox = await loadContent(
|
||||||
const textBox = textboxRef.current;
|
fabricRef.current,
|
||||||
|
data,
|
||||||
|
wrapperRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
if (textbox) {
|
||||||
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
|
textboxRef.current = textbox;
|
||||||
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
|
setupTextboxInteractions(fabricRef.current, textbox);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
fabricRef.current.requestRenderAll();
|
||||||
|
fixFabricA11y();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -415,6 +498,6 @@ export function ComposeCanvas({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
ComposeCanvas.displayName = "ComposeCanvas";
|
ComposeCanvas.displayName = "ComposeCanvas";
|
||||||
|
|||||||
@@ -1,60 +1,38 @@
|
|||||||
import { LockIcon } from "@phosphor-icons/react";
|
import { LockIcon } from "@phosphor-icons/react";
|
||||||
import type { NavigateFunction } from "react-router-dom";
|
import type { NavigateFunction } from "react-router-dom";
|
||||||
import { PATHS, ROUTES } from "../../config/routes";
|
import { PATHS, ROUTES } from "../../config/routes";
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
|
|
||||||
interface PostSealModalProps {
|
interface PostSealModalProps {
|
||||||
sealedTargetId: string | null;
|
sealedTargetId: string | null;
|
||||||
navigate: NavigateFunction;
|
navigate: NavigateFunction;
|
||||||
type: "KEPT" | "VAULT";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostSealModal({
|
export function PostSealModal({
|
||||||
sealedTargetId,
|
sealedTargetId,
|
||||||
navigate,
|
navigate,
|
||||||
type = "KEPT",
|
|
||||||
}: PostSealModalProps) {
|
}: PostSealModalProps) {
|
||||||
|
if (!sealedTargetId) return null;
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={!!sealedTargetId} data-testid="post-seal-modal">
|
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-1000">
|
||||||
|
<div className="modal-box flex flex-col items-center text-center gap-6">
|
||||||
<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">
|
||||||
It's encrypted and always safe in your drawer.
|
It's encrypted and always safe in your drawer.
|
||||||
</p>
|
</p>
|
||||||
{type === "KEPT" ? (
|
<p className="text-base-content 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>
|
||||||
) : (
|
|
||||||
<p className="text-base-content/80 text-sm font-sans">
|
|
||||||
Be assured that the letter will find you when the time is right.
|
|
||||||
<br />
|
|
||||||
Till then,
|
|
||||||
<span className="font-bold font-display text-primary">
|
|
||||||
take a deep breath
|
|
||||||
</span>
|
|
||||||
, <span className="font-bold font-display text-accent">manifest</span>
|
|
||||||
, and
|
|
||||||
<span className="font-bold font-display text-success">
|
|
||||||
let it rest
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
|
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
|
||||||
{type === "KEPT" ? (
|
|
||||||
<>
|
|
||||||
<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,27 +40,15 @@ 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={() =>
|
||||||
if (sealedTargetId) {
|
navigate(PATHS.read(sealedTargetId), { replace: true })
|
||||||
navigate(PATHS.read(sealedTargetId));
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
View letter
|
View letter
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
|
||||||
>
|
|
||||||
Step Away...
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,49 @@
|
|||||||
import {
|
import {
|
||||||
CircleHalfTiltIcon,
|
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
PaintBucketIcon,
|
|
||||||
QuestionIcon,
|
QuestionIcon,
|
||||||
StampIcon,
|
StampIcon,
|
||||||
TextAUnderlineIcon,
|
|
||||||
TrayIcon,
|
TrayIcon,
|
||||||
VaultIcon,
|
VaultIcon,
|
||||||
XCircleIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
import type { CanvasStyle } from "./ComposeCanvas";
|
|
||||||
|
|
||||||
interface ToolBarProps {
|
interface ToolBarProps {
|
||||||
onAddImage: () => void;
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
sealBtnClicked: boolean;
|
sealBtnClicked: boolean;
|
||||||
setSealBtnClicked: (v: boolean) => void;
|
setSealBtnClicked: (v: boolean) => void;
|
||||||
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
|
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
|
||||||
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
|
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
|
||||||
onFontChange: (style: CanvasStyle) => void;
|
|
||||||
latestFontStyle: CanvasStyle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT_FAMILIES: Map<string, string> = new Map([
|
|
||||||
["Serif", "Playfair Display Variable"],
|
|
||||||
["Sans", "Jost Variable"],
|
|
||||||
["Cursive", "Playwrite HR Lijeva Variable"],
|
|
||||||
["Handwriting", "Architects Daughter"],
|
|
||||||
["Slab", "Cutive Mono"],
|
|
||||||
["Mono", "Space Mono"],
|
|
||||||
["Ink", "Kavivanar"],
|
|
||||||
["Crazy(pls no)", "Redacted Script"],
|
|
||||||
]);
|
|
||||||
const FONT_COLORS: Map<string, string> = new Map([
|
|
||||||
["Black", "#000"],
|
|
||||||
["Gold", "#866a0e"],
|
|
||||||
["Purple", "#711caf"],
|
|
||||||
["Green", "#1f5b1f"],
|
|
||||||
["Blue", "#111e67"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function ToolBar({
|
export function ToolBar({
|
||||||
onAddImage,
|
fileInputRef,
|
||||||
sealBtnClicked,
|
sealBtnClicked,
|
||||||
setSealBtnClicked,
|
setSealBtnClicked,
|
||||||
onSave,
|
onSave,
|
||||||
setConfirmModal,
|
setConfirmModal,
|
||||||
onFontChange,
|
|
||||||
latestFontStyle,
|
|
||||||
}: ToolBarProps) {
|
}: ToolBarProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="writer-toolbar"
|
id="writer-toolbar"
|
||||||
className="relative z-10 flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
|
className="flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{/* Image upload */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm group"
|
className="btn btn-ghost btn-sm group"
|
||||||
onClick={onAddImage}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<ImageIcon size={18} weight="bold" />
|
<ImageIcon size={18} weight="bold" />
|
||||||
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
|
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
|
||||||
Add Image
|
Add Image
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
|
|
||||||
|
|
||||||
{/* Font Family */}
|
|
||||||
<div className={"flex items-center gap-2 group"}>
|
|
||||||
<TextAUnderlineIcon
|
|
||||||
size={24}
|
|
||||||
weight="bold"
|
|
||||||
className={"hidden md:inline"}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="select select-sm"
|
|
||||||
onChange={(e) => {
|
|
||||||
onFontChange({ ...latestFontStyle, fontFamily: e.target.value });
|
|
||||||
}}
|
|
||||||
value={latestFontStyle.fontFamily}
|
|
||||||
>
|
|
||||||
{Array.from(FONT_FAMILIES.entries()).map(
|
|
||||||
([fontFamily, fontName]) => {
|
|
||||||
return (
|
|
||||||
<option key={fontName} value={fontName}>
|
|
||||||
{fontFamily}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
|
|
||||||
|
|
||||||
{/* Font Color */}
|
|
||||||
<div className="dropdown dropdown-bottom flex items-center gap-2 group">
|
|
||||||
<PaintBucketIcon
|
|
||||||
size={16}
|
|
||||||
weight="bold"
|
|
||||||
className={"hidden md:flex"}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm px-2 gap-2 flex items-center"
|
|
||||||
type={"button"}
|
|
||||||
>
|
|
||||||
<CircleHalfTiltIcon
|
|
||||||
size={18}
|
|
||||||
style={{ color: latestFontStyle.fontColor }}
|
|
||||||
weight="duotone"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<ul className="dropdown-content z-50 menu p-2 shadow bg-base-200/95 rounded-full md:ml-4">
|
|
||||||
{Array.from(FONT_COLORS.entries()).map(([_, colorCode]) => (
|
|
||||||
<li key={colorCode}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${latestFontStyle.fontColor === colorCode ? "active" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
onFontChange({ ...latestFontStyle, fontColor: colorCode });
|
|
||||||
(document.activeElement as HTMLButtonElement)?.blur();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircleHalfTiltIcon
|
|
||||||
size={18}
|
|
||||||
style={{ color: colorCode }}
|
|
||||||
weight="fill"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draft */}
|
|
||||||
<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-[10px] group tracking-[0.2em] 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")}
|
||||||
>
|
>
|
||||||
@@ -151,12 +53,10 @@ export function ToolBar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-4 bg-base-content/10 mx-2 hidden md:inline" />
|
<div className="w-px h-4 bg-base-content/10 mx-2" />
|
||||||
|
|
||||||
{/*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)}
|
||||||
>
|
>
|
||||||
@@ -174,11 +74,10 @@ export function ToolBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex-col items-center gap-2 absolute right-0 z-10 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
|
className={`flex-col items-center gap-2 absolute right-0 z-100000 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
|
||||||
>
|
>
|
||||||
<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 +93,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")}
|
||||||
>
|
>
|
||||||
@@ -203,31 +101,11 @@ export function ToolBar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`z-100001 absolute right-0 bg-transparent cursor-pointer ${sealBtnClicked ? "" : "hidden"}`}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSealBtnClicked(false)}
|
onClick={() => setSealBtnClicked(false)}
|
||||||
|
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
|
||||||
>
|
>
|
||||||
<XCircleIcon weight="duotone" size={20} className={"text-error"} />
|
<QuestionIcon weight="duotone" size={20} className={""} />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Help"
|
|
||||||
className={`bg-transparent cursor-pointer -mt-2 absolute z-100001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
|
|
||||||
>
|
|
||||||
<div className="tooltip tooltip-left">
|
|
||||||
<div className="tooltip-content -translate-x-38 text-left">
|
|
||||||
<span className="font-bold text-accent">Seal</span> puts the letter
|
|
||||||
in an envelope, ready to be read right away.
|
|
||||||
<div className="divider my-0"></div>
|
|
||||||
<span className="font-bold text-success">Vault</span> keeps it
|
|
||||||
locked away until the right moment, even from yourself.
|
|
||||||
</div>
|
|
||||||
<QuestionIcon
|
|
||||||
weight="duotone"
|
|
||||||
size={20}
|
|
||||||
className={"absolute -translate-x-38 -translate-y-3"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -238,7 +116,7 @@ export function LetterHead() {
|
|||||||
<div className="flex items-center justify-center mb-8 h-14">
|
<div className="flex items-center justify-center mb-8 h-14">
|
||||||
<div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full">
|
<div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full">
|
||||||
<LockIcon size={14} weight="fill" />
|
<LockIcon size={14} weight="fill" />
|
||||||
<span className="text-xxs uppercase tracking-widest font-bold">
|
<span className="text-[10px] uppercase tracking-widest font-bold">
|
||||||
Sealed & View Only
|
Sealed & View Only
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,21 +136,21 @@ export function VaultConfirmModal({
|
|||||||
setUnlockDate,
|
setUnlockDate,
|
||||||
}: VaultConfirmModalProps) {
|
}: VaultConfirmModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={true}>
|
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}>
|
||||||
|
<div className="modal-box p-12 flex flex-col items-center">
|
||||||
<VaultIcon
|
<VaultIcon
|
||||||
size={48}
|
size={48}
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
className="text-primary mx-auto mb-8 animate-pulse"
|
||||||
/>
|
/>
|
||||||
<h3 className="font-serif text-3xl">Take it away, then?</h3>
|
<h3 className="font-serif text-3xl">Vault this letter?</h3>
|
||||||
<p className="text-base-content/60 text-sm text-center mt-4">
|
<p className="text-base-content/60 text-sm text-center mt-4">
|
||||||
By vaulting this letter, you ask me to hold on to this.
|
Vaulting locks the letter permanently and will be{" "}
|
||||||
|
<span className={"font-bold text-primary"}>mailed</span> to you
|
||||||
|
automatically on the unlock date.
|
||||||
<br />
|
<br />
|
||||||
I'll remember to mail you this on the unlock date.
|
<span className={"underline"}>
|
||||||
<br />
|
You cannot edit or view the contents of the letter until then.
|
||||||
<span className={"font-bold text-primary"}>
|
|
||||||
But I won't let you read or rewrite this letter until then.
|
|
||||||
</span>
|
</span>
|
||||||
<br />
|
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
@@ -285,7 +163,6 @@ export function VaultConfirmModal({
|
|||||||
setConfirmModal(null);
|
setConfirmModal(null);
|
||||||
}}
|
}}
|
||||||
id="vault-form"
|
id="vault-form"
|
||||||
className="min-w-75"
|
|
||||||
>
|
>
|
||||||
<div className={"divider tracking-tightest font-display text-sm"}>
|
<div className={"divider tracking-tightest font-display text-sm"}>
|
||||||
Set an unlock date
|
Set an unlock date
|
||||||
@@ -296,25 +173,23 @@ export function VaultConfirmModal({
|
|||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
name="vault-date"
|
name="vault-date"
|
||||||
/>
|
/>
|
||||||
<div className="w-full flex justify-center gap-8 mt-4">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
className="btn btn-primary mt-4"
|
||||||
data-testid="vault-cancel-btn"
|
|
||||||
className="btn btn-ghost btn-sm mt-4"
|
|
||||||
onClick={() => setConfirmModal(null)}
|
|
||||||
>
|
|
||||||
I need time
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
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
|
Vault
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost mt-4"
|
||||||
|
onClick={() => setConfirmModal(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import {
|
|
||||||
HandPalmIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
WarningIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import Logo from "../Logo";
|
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
import Saajan from "../ui/Saajan";
|
|
||||||
|
|
||||||
export default function WelcomeModal({
|
|
||||||
setShowWelcome,
|
|
||||||
}: {
|
|
||||||
setShowWelcome: (show: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal isOpen={true}>
|
|
||||||
<div className="flex flex-col items-center text-center gap-2 md:gap-4">
|
|
||||||
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
|
|
||||||
<ShieldCheckIcon
|
|
||||||
size={48}
|
|
||||||
weight="duotone"
|
|
||||||
className="text-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="font-display text-2xl font-bold text-primary">
|
|
||||||
Welcome to
|
|
||||||
<Logo type="inline" />
|
|
||||||
</h3>
|
|
||||||
<p className="inline text-sm md:text-base text-base-content/80">
|
|
||||||
Before we begin, let me make a small promise.
|
|
||||||
<HandPalmIcon
|
|
||||||
size={18}
|
|
||||||
className="inline text-primary"
|
|
||||||
weight="fill"
|
|
||||||
/>
|
|
||||||
<span className="divider my-0"></span>
|
|
||||||
Everything you write here is sealed with your password,
|
|
||||||
<span className="font-display text-success">cryptographically</span>
|
|
||||||
, before it leaves your hands.
|
|
||||||
<br />
|
|
||||||
<br />A fancy way of saying, no one else can read them without your
|
|
||||||
key—not even me.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="alert alert-warning flex items-start gap-3 text-left py-3">
|
|
||||||
<WarningIcon size={24} weight="fill" className="shrink-0" />
|
|
||||||
<div className="text-xs md:text-sm font-medium text-primary-content tracking-tight">
|
|
||||||
If you ever happen to forget your password, your letters are lost
|
|
||||||
to time, forever.
|
|
||||||
<span className="mt-2 block">
|
|
||||||
I highly, <span className="font-bold italic">highly</span>
|
|
||||||
recommend storing this password in your
|
|
||||||
<a
|
|
||||||
href="https://www.privacyguides.org/en/passwords/"
|
|
||||||
target="_blank"
|
|
||||||
className="link link-neutral!"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
password manager
|
|
||||||
</a>
|
|
||||||
or somewhere safe to remember it.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-action w-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="welcome-dismiss-btn"
|
|
||||||
onClick={() => setShowWelcome(false)}
|
|
||||||
className="btn btn-primary w-full shadow-lg"
|
|
||||||
>
|
|
||||||
I'll remember
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<div className="absolute bottom-0 md:right-5/12 z-1000 font-sans w-full flex justify-center">
|
|
||||||
<Saajan
|
|
||||||
position="left"
|
|
||||||
message={"I've lost words before.\nI know what it feels like."}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,19 @@
|
|||||||
import { CampfireIcon, FlameIcon } from "@phosphor-icons/react";
|
import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
|
|
||||||
interface BurnModalProps {
|
|
||||||
burnLetter: () => void;
|
|
||||||
isBurning: boolean;
|
|
||||||
setShowBurnModal: (show: boolean) => void;
|
|
||||||
setRevealState: (state: "SEALED" | "REVEALED" | "BURNING" | "BURNED") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BurnModal({
|
export function BurnModal({
|
||||||
burnLetter,
|
burnLetter,
|
||||||
isBurning,
|
isBurning,
|
||||||
setShowBurnModal,
|
setShowBurnModal,
|
||||||
setRevealState,
|
setRevealState,
|
||||||
}: BurnModalProps) {
|
}) {
|
||||||
const [flameOn, setFlameOn] = useState(0);
|
const [flameOn, setFlameOn] = useState(0);
|
||||||
const [rotate, setRotate] = useState(0);
|
const [rotate, setRotate] = useState(0);
|
||||||
const [burnClicked, setBurnClicked] = useState(false);
|
const [burnClicked, setBurnClicked] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!burnClicked) return;
|
if (!burnClicked) return;
|
||||||
if (flameOn === 100) {
|
if (flameOn === 100) {
|
||||||
setRevealState("SEALED");
|
setRevealState("sealed");
|
||||||
burnLetter();
|
burnLetter();
|
||||||
}
|
}
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -34,15 +26,23 @@ export function BurnModal({
|
|||||||
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
|
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={true} onClose={() => setShowBurnModal(false)}>
|
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md">
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center gap-4 text-center transition-all duration-200 ease-in-out ${burnClicked ? "animate-[pulse_15s_linear_infinite]" : ""}`}
|
className={`modal-box flex flex-col items-center gap-4 py-8 text-center transition-all duration-200 ease-in-out ${burnClicked ? "animate-[pulse_15s_linear_infinite]" : ""}`}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
transform: `rotate(${rotate}deg)`,
|
transform: `rotate(${rotate}deg)`,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
|
onClick={() => setShowBurnModal(false)}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<XCircleIcon size={18} weight="bold" />
|
||||||
|
</button>
|
||||||
<CampfireIcon
|
<CampfireIcon
|
||||||
size={48}
|
size={48}
|
||||||
weight="duotone"
|
weight="duotone"
|
||||||
@@ -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">
|
||||||
@@ -94,6 +94,6 @@ export function BurnModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { WavesIcon } from "@phosphor-icons/react";
|
import { WavesIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import candle from "../../assets/envelope/candle.png";
|
|
||||||
import stamp from "../../assets/envelope/stamp.png";
|
import stamp from "../../assets/envelope/stamp.png";
|
||||||
import waxSeal from "../../assets/envelope/waxSeal.png";
|
import waxSeal from "../../assets/envelope/waxSeal.png";
|
||||||
|
|
||||||
@@ -10,8 +9,6 @@ export interface EnvelopeRevealProps {
|
|||||||
onRevealComplete: () => void;
|
onRevealComplete: () => void;
|
||||||
ignite: boolean;
|
ignite: boolean;
|
||||||
isFlip?: boolean;
|
isFlip?: boolean;
|
||||||
isInteractive?: boolean;
|
|
||||||
openFlap?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnvelopeReveal({
|
export function EnvelopeReveal({
|
||||||
@@ -20,12 +17,9 @@ export function EnvelopeReveal({
|
|||||||
onRevealComplete,
|
onRevealComplete,
|
||||||
ignite,
|
ignite,
|
||||||
isFlip,
|
isFlip,
|
||||||
isInteractive = true,
|
|
||||||
openFlap = false,
|
|
||||||
}: EnvelopeRevealProps) {
|
}: EnvelopeRevealProps) {
|
||||||
const [revealLetter, setRevealLetter] = useState(false);
|
const [revealLetter, setRevealLetter] = useState(false);
|
||||||
const [isFlipped, setIsFlipped] = useState(!!isFlip);
|
const [isFlipped, setIsFlipped] = useState(!!isFlip);
|
||||||
const [isFlapOpen, setIsFlapOpen] = useState(!!openFlap);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsFlipped(!!isFlip);
|
setIsFlipped(!!isFlip);
|
||||||
@@ -36,9 +30,7 @@ export function EnvelopeReveal({
|
|||||||
height: 0,
|
height: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const flapCheckbox = useRef<HTMLInputElement>(null);
|
||||||
setIsFlapOpen(openFlap);
|
|
||||||
}, [openFlap]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ignite) {
|
if (!ignite) {
|
||||||
@@ -74,25 +66,21 @@ export function EnvelopeReveal({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="transition checkbox absolute h-full w-full text-transparent bg-transparent z-100"
|
className="transition checkbox absolute h-full w-full text-transparent bg-transparent z-100"
|
||||||
checked={isFlapOpen}
|
ref={flapCheckbox}
|
||||||
onChange={() => setIsFlapOpen((prev) => !prev)}
|
|
||||||
disabled={!isInteractive}
|
|
||||||
/>
|
/>
|
||||||
</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"
|
||||||
}
|
}
|
||||||
src={waxSeal}
|
src={waxSeal}
|
||||||
alt="Seal"
|
alt="Seal"
|
||||||
onClick={() => setIsFlapOpen((prev) => !prev)}
|
onClick={() => flapCheckbox.current?.click()}
|
||||||
onKeyDown={() => setIsFlapOpen((prev) => !prev)}
|
onKeyDown={() => flapCheckbox.current?.click()}
|
||||||
/>
|
/>
|
||||||
<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,21 +102,14 @@ export function EnvelopeReveal({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
id="env-front"
|
id="env-front"
|
||||||
data-testid="envelope-front"
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isInteractive}
|
|
||||||
className={`text-left p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2 ${isFlipped ? "pointer-events-none" : ""}`}
|
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" : ""}`}
|
||||||
onClick={() => setIsFlipped((prev) => !prev)}
|
onClick={() => setIsFlipped((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<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}
|
||||||
@@ -148,7 +129,6 @@ export function EnvelopeReveal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{ignite && (
|
{ignite && (
|
||||||
<>
|
|
||||||
<div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
|
<div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
|
||||||
<div
|
<div
|
||||||
className="absolute z-1000 border-2 border-amber-200 -bottom-3 -right-3 w-0 h-0 transition-all duration-500 bg-base-100 rounded-tl-full rounded-bl-full origin-bottom-right"
|
className="absolute z-1000 border-2 border-amber-200 -bottom-3 -right-3 w-0 h-0 transition-all duration-500 bg-base-100 rounded-tl-full rounded-bl-full origin-bottom-right"
|
||||||
@@ -158,10 +138,6 @@ export function EnvelopeReveal({
|
|||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute z-1001 bottom-0 right-0 translate-x-15 translate-y-20">
|
|
||||||
<img src={candle} alt="candle" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,26 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ROUTES } from "../../config/routes";
|
import { ROUTES } from "../../config/routes";
|
||||||
|
|
||||||
interface PostActionOverlayProps {
|
export function PostActionOverlay({ revealState }) {
|
||||||
revealState: "SEALED" | "REVEALED" | "BURNING" | "BURNED";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PostActionOverlay({ revealState }: PostActionOverlayProps) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "BURNED" ? "opacity-100" : "opacity-0"} transition-all delay-1000 duration-1000`}
|
className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "burned" ? "opacity-100" : "opacity-0"} transition-all delay-300 duration-1000`}
|
||||||
>
|
>
|
||||||
<h1
|
<h1
|
||||||
className={`text-6xl ${revealState === "BURNED" ? "opacity-100" : "opacity-0"} lg:text-9xl italic font-extralight text-base-content animate-[pulse_3s_ease-in-out_3]`}
|
className={`text-6xl ${revealState === "burned" ? "opacity-100" : "opacity-0"} lg:text-9xl italic font-extralight text-base-content animate-[pulse_3s_ease-in-out_3]`}
|
||||||
>
|
>
|
||||||
It is done
|
It is done
|
||||||
</h1>
|
</h1>
|
||||||
<div
|
<div
|
||||||
className={`text-xl ${revealState === "BURNED" ? "opacity-100" : "opacity-0"} lg:text-4xl text-center font-extralight text-base-content font-display mt-8 delay-3000 transition-all duration-2000 tracking-wide`}
|
className={`text-xl ${revealState === "burned" ? "opacity-100" : "opacity-0"} lg:text-4xl text-center font-extralight text-base-content font-display mt-8 delay-3000 transition-all duration-2000 tracking-wide`}
|
||||||
>
|
>
|
||||||
<p className="w-full">
|
<p className="w-full">
|
||||||
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
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { EyeSlashIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
import {
|
||||||
import { Modal } from "../ui/Modal";
|
EyeSlashIcon,
|
||||||
import Saajan from "../ui/Saajan";
|
PaperPlaneTiltIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface ShareModalProps {
|
export function ShareModal({ shareLink, setShareLink }) {
|
||||||
shareLink: string | null;
|
|
||||||
setShareLink: (link: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|
||||||
const copyToClipboard = async () => {
|
const copyToClipboard = async () => {
|
||||||
if (!shareLink) return;
|
if (!shareLink) return;
|
||||||
await navigator.clipboard.writeText(shareLink);
|
await navigator.clipboard.writeText(shareLink);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
||||||
<Modal
|
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
|
||||||
isOpen={!!shareLink}
|
<button
|
||||||
onClose={() => setShareLink(null)}
|
type="button"
|
||||||
data-testid="share-letter-modal"
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
|
onClick={() => setShareLink(null)}
|
||||||
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
|
<XCircleIcon size={18} weight="bold" />
|
||||||
|
</button>
|
||||||
<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
|
||||||
@@ -28,17 +29,14 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
/>
|
/>
|
||||||
<h3 className="font-serif text-3xl">Send this letter</h3>
|
<h3 className="font-serif text-3xl">Send this letter</h3>
|
||||||
<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. Send your letter now, and
|
||||||
<br />
|
let the <span className="text-accent font-display">unsaid</span>{" "}
|
||||||
Send your letter now, and let the
|
finally find its home.
|
||||||
<span className="text-accent font-display">unsaid</span> finally
|
|
||||||
find its home.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="divider mx-auto" />
|
<div className="divider mx-auto" />
|
||||||
<blockquote className="text-sm info text-neutral-content/60 font-sans">
|
<blockquote className="text-sm info text-neutral-content/60 font-sans">
|
||||||
They'll receive it exactly as you're seeing it now.
|
The recipient will have the same viewing experience like you do
|
||||||
<br />
|
now.
|
||||||
Nothing more, nothing less.
|
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl">
|
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl">
|
||||||
@@ -51,7 +49,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,21 +56,15 @@ 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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
|
||||||
<div className="absolute bottom-0 md:right-5/11 z-1000 font-sans w-full">
|
|
||||||
<Saajan
|
|
||||||
position="top"
|
|
||||||
message={`Someone once said,\n"To send a letter is a good way to go somewhere without moving anything but your heart."\nThey were not wrong.`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function DateDisplay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`text-right flex flex-col gap-2 min-w-35 ${className}`}>
|
<div className={`text-right flex flex-col gap-2 min-w-35 ${className}`}>
|
||||||
<span className="text-xxs uppercase tracking-widester text-accent font-bold">
|
<span className="text-[10px] uppercase tracking-[0.4em] text-accent font-bold">
|
||||||
Date
|
Date
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-serif text-secondary-content italic whitespace-nowrap">
|
<span className="text-sm font-serif text-secondary-content italic whitespace-nowrap">
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ interface FormFieldProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
registration: UseFormRegisterReturn;
|
registration: UseFormRegisterReturn;
|
||||||
error?: string;
|
error?: string;
|
||||||
handleFocus?: () => void;
|
|
||||||
"data-testid"?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormField({
|
export default function FormField({
|
||||||
@@ -16,27 +14,23 @@ export default function FormField({
|
|||||||
placeholder,
|
placeholder,
|
||||||
registration,
|
registration,
|
||||||
error,
|
error,
|
||||||
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 ${
|
||||||
error ? "input-error" : ""
|
error ? "input-error" : ""
|
||||||
}`}
|
}`}
|
||||||
onFocus={handleFocus}
|
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-error">{error}</p>}
|
{error && <p className="text-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { WarningIcon } from "@phosphor-icons/react";
|
import { WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react";
|
||||||
import { Modal } from "./Modal";
|
|
||||||
|
|
||||||
interface LogModalContent {
|
interface LogModalContent {
|
||||||
status: "WARN" | "ERROR" | "RESET" | "SUCCESS";
|
status: "WARN" | "ERROR" | "RESET" | "SUCCESS";
|
||||||
@@ -16,17 +15,21 @@ export const LogModal = ({
|
|||||||
onClose,
|
onClose,
|
||||||
status,
|
status,
|
||||||
}: LogModalContent) => {
|
}: LogModalContent) => {
|
||||||
return (
|
return status === "RESET" || !isOpen ? (
|
||||||
<Modal isOpen={isOpen && status !== "RESET"} onClose={onClose}>
|
<div></div>
|
||||||
|
) : (
|
||||||
|
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
||||||
|
<div className="modal-box bg-transparent border-none shadow-none relative">
|
||||||
<div
|
<div
|
||||||
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
||||||
>
|
>
|
||||||
{status === "WARN" && (
|
{status === "WARN" && (
|
||||||
<WarningIcon className="text-warning" size={16} weight="duotone" />
|
<WarningIcon className="text-warning" size={16} weight="bold" />
|
||||||
)}
|
)}
|
||||||
<span data-testid="log-modal-message">{message}</span>
|
{status === "ERROR" && (
|
||||||
{log && (
|
<XCircleIcon className="text-error" size={16} weight="bold" />
|
||||||
<>
|
)}
|
||||||
|
{message}
|
||||||
<div className="divider text-primary-content text-xs uppercase tracking-widest">
|
<div className="divider text-primary-content text-xs uppercase tracking-widest">
|
||||||
Error Stack
|
Error Stack
|
||||||
</div>
|
</div>
|
||||||
@@ -35,9 +38,17 @@ export const LogModal = ({
|
|||||||
<code>{String(log)}</code>
|
<code>{String(log)}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<form method="dialog">
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn btn-sm btn-circle btn-ghost absolute right-6 top-6"
|
||||||
|
>
|
||||||
|
<XIcon size={6} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { XCircleIcon } from "@phosphor-icons/react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
interface ModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
"data-testid"?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Modal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
"data-testid": testId,
|
|
||||||
}: ModalProps) {
|
|
||||||
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.
|
|
||||||
const mainContainer = document.querySelector("main") || document.body;
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
data-testid={testId}
|
|
||||||
className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]"
|
|
||||||
>
|
|
||||||
<div className="modal-box border border-neutral/60 relative bg-base-100/60 flex flex-col items-center text-center gap-6">
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="modal-close-btn"
|
|
||||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<XCircleIcon size={18} weight="bold" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
mainContainer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ export const Navbar = ({ child }: { child?: React.ReactNode }) => {
|
|||||||
className="text-base-content/40 group-hover:text-primary transition-colors"
|
className="text-base-content/40 group-hover:text-primary transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-sans text-xxs tracking-widester uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
|
<span className="font-sans text-[10px] tracking-[0.3em] uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
|
||||||
Drawer
|
Drawer
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import sf_mini from "../../assets/sf_mini.png";
|
|
||||||
|
|
||||||
interface SaajanProps {
|
|
||||||
message: string;
|
|
||||||
position?: "top" | "left" | "right" | "bottom";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Saajan({ message, position = "right" }: SaajanProps) {
|
|
||||||
const [animate, setAnimate] = useState<boolean>(false);
|
|
||||||
const [tooltipPosition, setTooltipPosition] =
|
|
||||||
useState<string>("tooltip-right");
|
|
||||||
const [alignment, setAlignment] = useState<string>("justify-start");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAnimate(true);
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setAnimate(false);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTooltipPosition(`tooltip-${position}`);
|
|
||||||
if (position === "top") {
|
|
||||||
setAlignment("justify-center");
|
|
||||||
}
|
|
||||||
if (position === "right") {
|
|
||||||
setAlignment("justify-start");
|
|
||||||
}
|
|
||||||
if (position === "left") {
|
|
||||||
setAlignment("justify-end");
|
|
||||||
}
|
|
||||||
}, [position]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative w-full flex ${alignment}`}>
|
|
||||||
<div
|
|
||||||
className={`tooltip tooltip-open ${tooltipPosition} before:border before:border-dashed before:border-primary/40 before:max-w-xs before:whitespace-pre-line italic before:text-left`}
|
|
||||||
data-tip={message}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={sf_mini}
|
|
||||||
alt="saajan"
|
|
||||||
className={`sepia-20 w-35 -mb-6 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,14 +9,14 @@ export const endpoints = {
|
|||||||
LETTERS: "/api/letters/",
|
LETTERS: "/api/letters/",
|
||||||
};
|
};
|
||||||
|
|
||||||
// constructs dynamic path params for activate flow
|
// simple utility to handle path params
|
||||||
export const replacePathParams = (
|
export const replacePathParams = (
|
||||||
url: string,
|
url: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
): string => {
|
): string => {
|
||||||
let constructedUrl = url;
|
let result = url;
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
constructedUrl = constructedUrl.replace(`:${key}`, value);
|
result = result.replace(`:${key}`, value);
|
||||||
}
|
}
|
||||||
return constructedUrl;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Page Route PATTERNS
|
// Route PATTERNS
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HOME: "/",
|
HOME: "/",
|
||||||
ONBOARD: "/onboard",
|
ONBOARD: "/onboard",
|
||||||
@@ -6,13 +6,13 @@ export const ROUTES = {
|
|||||||
ACTIVATE: "/activate/:uidb64/:token",
|
ACTIVATE: "/activate/:uidb64/:token",
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
DRAWER: "/drawer",
|
DRAWER: "/drawer",
|
||||||
WRITE: "/quill/:public_id?",
|
WRITE: "/quill/:public_id?", // ← static pattern
|
||||||
READ: "/read/:public_id",
|
READ: "/read/:public_id",
|
||||||
ABOUT: "/know-piku",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic path BUILDERS
|
// Path BUILDERS
|
||||||
export const PATHS = {
|
export const PATHS = {
|
||||||
write: (public_id?: string) => `/quill/${public_id ?? ""}`,
|
write: (public_id?: string) => `/quill/${public_id ?? ""}`,
|
||||||
read: (public_id: string) => `/read/${public_id}`,
|
read: (public_id: string) => `/read/${public_id}`,
|
||||||
|
activate: (uidb64: string, token: string) => `/activate/${uidb64}/${token}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
const { masterKey } = await CryptoUtils.deriveKeyBundle(
|
||||||
password,
|
password,
|
||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate password by calling login endpoint
|
|
||||||
await api.post(endpoints.LOGIN, {
|
|
||||||
email: user.email,
|
|
||||||
password: authHash,
|
|
||||||
});
|
|
||||||
|
|
||||||
await saveMasterKey(masterKey);
|
await saveMasterKey(masterKey);
|
||||||
setMasterKey(masterKey);
|
setMasterKey(masterKey);
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 decryptLetters(
|
||||||
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" },
|
||||||
@@ -40,22 +56,19 @@ async function decryptLettersMetadata(
|
|||||||
export function useLetters() {
|
export function useLetters() {
|
||||||
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
// to fetch the letters and decryypt the metadata on load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!masterKey) {
|
if (!masterKey) {
|
||||||
setIsAuthRequired(true);
|
setIsAuthRequired(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsAuthRequired(false);
|
setIsAuthRequired(false);
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api
|
api
|
||||||
.get(endpoints.LETTERS)
|
.get(endpoints.LETTERS)
|
||||||
.then((res) => decryptLettersMetadata(res.data, masterKey))
|
.then((res) => decryptLetters(res.data, masterKey))
|
||||||
.then((decrypted) => {
|
.then((decrypted) => {
|
||||||
setLetters(
|
setLetters(
|
||||||
decrypted.sort(
|
decrypted.sort(
|
||||||
@@ -65,9 +78,7 @@ export function useLetters() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((_err) => {})
|
||||||
setError(err);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [masterKey]);
|
}, [masterKey]);
|
||||||
|
|
||||||
@@ -75,15 +86,11 @@ export function useLetters() {
|
|||||||
return {
|
return {
|
||||||
drafts: letters.filter((l) => l.status === "DRAFT"),
|
drafts: letters.filter((l) => l.status === "DRAFT"),
|
||||||
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
|
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
|
||||||
vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"),
|
vault: letters.filter((l) => l.type === "VAULT"),
|
||||||
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
||||||
};
|
};
|
||||||
}, [letters]);
|
}, [letters]);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...drawerItems,
|
...drawerItems,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -47,32 +47,15 @@
|
|||||||
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
||||||
--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;
|
--color-glass-bg: rgba(28,
|
||||||
--font-ink: "Kavivanar", sans-serif;
|
22,
|
||||||
--font-redact: "Redacted Script", cursive;
|
16,
|
||||||
--font-slab: "Cutive Mono", monospace;
|
0.45);
|
||||||
--font-hand: "Architects Daughter", cursive;
|
|
||||||
--color-glass-bg: rgba(28, 22, 16, 0.45);
|
|
||||||
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
||||||
--radius-xl: 1.5rem;
|
--radius-xl: 1.5rem;
|
||||||
--color-paper: oklch(97% 0.008 80);
|
--color-paper: oklch(97% 0.008 80);
|
||||||
--text-xxs: 10px;
|
|
||||||
--tracking-widester: 0.5em;
|
|
||||||
--background-image-vig: radial-gradient(
|
|
||||||
circle at center,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(0, 0, 0, 0.4) 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
}
|
|
||||||
|
|
||||||
.ul-wavy {
|
|
||||||
@apply decoration-primary/40 underline decoration-wavy underline-offset-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
@apply text-primary underline decoration-base-content/20 underline-offset-4 hover:decoration-primary/60 transition-colors;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
|
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.tsx";
|
||||||
import App from "./App";
|
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (root) {
|
if (root) {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default function Activate() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(uidb64 && token) || hasCalled.current) return;
|
if (!(uidb64 && token) || hasCalled.current) return;
|
||||||
|
|
||||||
|
// prevent double api calls
|
||||||
hasCalled.current = true;
|
hasCalled.current = true;
|
||||||
|
|
||||||
const activateAccount = async () => {
|
const activateAccount = async () => {
|
||||||
@@ -26,7 +28,7 @@ export default function Activate() {
|
|||||||
});
|
});
|
||||||
await publicApi.get(url);
|
await publicApi.get(url);
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
} catch {
|
} catch (_err) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -44,7 +46,7 @@ export default function Activate() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "success" && (
|
{status === "success" && (
|
||||||
<div className="flex flex-col items-center gap-6 duration-500">
|
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
|
||||||
<div className="bg-success/10 p-4 rounded-full">
|
<div className="bg-success/10 p-4 rounded-full">
|
||||||
<CheckCircleIcon
|
<CheckCircleIcon
|
||||||
size={64}
|
size={64}
|
||||||
@@ -52,22 +54,18 @@ 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 mb-8 leading-relaxed">
|
||||||
Welcome to
|
Welcome to <Logo />
|
||||||
<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"></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 +74,7 @@ export default function Activate() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
I'm ready
|
Start Writing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -87,17 +85,16 @@ export default function Activate() {
|
|||||||
<XCircleIcon size={64} weight="duotone" className="text-error" />
|
<XCircleIcon size={64} weight="duotone" className="text-error" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-display text-xl text-error">Activation Failed</h2>
|
<h2 className="font-display text-xl text-error">Activation Failed</h2>
|
||||||
<p className="opacity-70 leading-relaxed">
|
<p className="opacity-70 mb-8 leading-relaxed">
|
||||||
The link might be expired or already used. Please try registering
|
The link might be expired or already used. Please try registering
|
||||||
again.
|
again.
|
||||||
</p>
|
</p>
|
||||||
<div className="divider opacity-10 my-0"></div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost w-full"
|
className="btn btn-ghost w-full"
|
||||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||||
>
|
>
|
||||||
Register Again
|
Back to Registration
|
||||||
</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,23 @@
|
|||||||
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 { 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;
|
||||||
@@ -39,26 +27,16 @@ export default function Drawer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<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-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] 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-[0.3em] 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 +52,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-[10px] uppercase tracking-[0.3em] 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 +61,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 +79,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 +96,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 +109,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 +143,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 +151,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">
|
||||||
. . . . . .
|
. . . . . .
|
||||||
@@ -191,17 +162,9 @@ export default function Drawer() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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-[0.2em] 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">
|
|
||||||
<Saajan
|
|
||||||
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
|
||||||
position="top"
|
|
||||||
/>
|
|
||||||
</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,35 +79,42 @@ 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();
|
||||||
);
|
});
|
||||||
|
|
||||||
|
// Initial state: DRAFT (not read-only)
|
||||||
const canvas = screen.getByTestId("canvas");
|
const canvas = screen.getByTestId("canvas");
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("false");
|
expect(canvas.getAttribute("data-readonly")).toBe("false");
|
||||||
|
|
||||||
|
// Click Seal in the main toolbar (it's in the div with id="writer-toolbar")
|
||||||
const toolbar = container.querySelector("#writer-toolbar");
|
const toolbar = container.querySelector("#writer-toolbar");
|
||||||
const sealBtn = toolbar?.querySelector(".btn-primary");
|
const sealBtn = toolbar?.querySelector(".btn-primary");
|
||||||
if (!sealBtn) throw new Error("Seal button not found");
|
if (!sealBtn) throw new Error("Seal button not found");
|
||||||
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 +134,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 +142,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,9 +11,7 @@ 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 CanvasTools,
|
type CanvasTools,
|
||||||
ComposeCanvas,
|
ComposeCanvas,
|
||||||
} from "../components/editor/ComposeCanvas";
|
} from "../components/editor/ComposeCanvas";
|
||||||
@@ -25,8 +23,8 @@ import {
|
|||||||
} from "../components/editor/ToolBar";
|
} from "../components/editor/ToolBar";
|
||||||
import DateDisplay from "../components/ui/DateDisplay";
|
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 { 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,12 +32,11 @@ 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";
|
||||||
|
|
||||||
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
|
type SaveOverlay = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
const OVERLAY_FADE_MS = 250;
|
const OVERLAY_FADE_MS = 250;
|
||||||
const SAVED_VISIBLE_MS = 1400;
|
const SAVED_VISIBLE_MS = 1400;
|
||||||
const ERROR_VISIBLE_MS = 2400;
|
const ERROR_VISIBLE_MS = 2400;
|
||||||
const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000;
|
|
||||||
|
|
||||||
const toPlaceholderList = [
|
const toPlaceholderList = [
|
||||||
"Someone dear...",
|
"Someone dear...",
|
||||||
@@ -47,7 +44,6 @@ const toPlaceholderList = [
|
|||||||
"Something to bear...",
|
"Something to bear...",
|
||||||
];
|
];
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateRef = useRef<NavigateFunction>(navigate);
|
const navigateRef = useRef<NavigateFunction>(navigate);
|
||||||
@@ -73,14 +69,7 @@ export default function Editor() {
|
|||||||
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
|
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
|
||||||
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
|
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
|
||||||
|
|
||||||
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("IDLE");
|
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle");
|
||||||
const [logStatus, setLogStatus] = useState<{
|
|
||||||
status: "WARN" | "ERROR" | "RESET";
|
|
||||||
message: string;
|
|
||||||
}>({
|
|
||||||
status: "RESET",
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
|
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
|
||||||
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
|
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
|
||||||
null,
|
null,
|
||||||
@@ -89,17 +78,13 @@ export default function Editor() {
|
|||||||
const [recipient, setRecipient] = useState("");
|
const [recipient, setRecipient] = useState("");
|
||||||
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
|
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
|
||||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||||
const [canvasFontStyle, setCanvasFontStyle] = useState<CanvasStyle>({
|
|
||||||
fontColor: "",
|
|
||||||
fontFamily: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
const canvasRef = useRef<CanvasTools>(null);
|
const canvasRef = useRef<CanvasTools>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// to continuously rotate placeholder text of the recipient input
|
// Placeholder rotation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
|
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
|
||||||
@@ -108,19 +93,33 @@ export default function Editor() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// to load existing letter when public_id param and masterKey is available
|
|
||||||
// NOTE: this has to trigger just once after each save
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(public_id && masterKey)) return;
|
if (!(public_id && masterKey)) return;
|
||||||
if (justSavedRef.current) {
|
if (justSavedRef.current) {
|
||||||
justSavedRef.current = false;
|
justSavedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const decryptAndLoadLetter = async (
|
|
||||||
letterData: LetterResponseData,
|
const loadExistingLetter = async () => {
|
||||||
masterKey: CryptoKey,
|
setIsInitialLoading(true);
|
||||||
) => {
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||||
|
const letterData = res.data;
|
||||||
|
|
||||||
|
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
|
||||||
|
setLetterStatus(letterData.status);
|
||||||
|
|
||||||
|
if (letterData.status === "SEALED") {
|
||||||
|
navigateRef.current(PATHS.read(public_id), { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!letterData.encrypted_dek) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const metadata = await cryptoUtils.decryptMetadata(
|
const metadata = await cryptoUtils.decryptMetadata(
|
||||||
{
|
{
|
||||||
encrypted_content: letterData.encrypted_metadata,
|
encrypted_content: letterData.encrypted_metadata,
|
||||||
@@ -139,8 +138,7 @@ export default function Editor() {
|
|||||||
);
|
);
|
||||||
const canvasData = JSON.parse(decryptedJsonStr);
|
const canvasData = JSON.parse(decryptedJsonStr);
|
||||||
|
|
||||||
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
|
const { isDecryptionPartialFailure, error } = await decryptCanvasImages(
|
||||||
await decryptCanvasImages(
|
|
||||||
canvasData,
|
canvasData,
|
||||||
letterData.images ?? [],
|
letterData.images ?? [],
|
||||||
letterData.encrypted_dek,
|
letterData.encrypted_dek,
|
||||||
@@ -149,80 +147,59 @@ export default function Editor() {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPartialFailure) {
|
if (isDecryptionPartialFailure) {
|
||||||
setDecryptionStatus({
|
setDecryptionStatus({
|
||||||
status: "WARN",
|
status: "WARN",
|
||||||
message: "Failed to decrypt some elements. Please check the render.",
|
message:
|
||||||
log: errors.toString(),
|
"Failed to decrypt some elements. Please check the render.",
|
||||||
|
log: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
|
await canvasRef.current.loadData(canvasData);
|
||||||
}
|
}
|
||||||
};
|
} catch (_err) {
|
||||||
|
|
||||||
const loadExistingLetter = async () => {
|
|
||||||
setIsInitialLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
|
||||||
const letterData = res.data;
|
|
||||||
|
|
||||||
setLastSaved(formatRelativeDate(new Date(letterData.updated_at)));
|
|
||||||
setLetterStatus(letterData.status);
|
|
||||||
|
|
||||||
if (letterData.status === "SEALED") {
|
|
||||||
navigateRef.current(PATHS.read(public_id), { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (letterData.encrypted_dek && masterKey) {
|
|
||||||
await decryptAndLoadLetter(letterData, masterKey);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setDecryptionStatus({
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadExistingLetter().then((_) => {
|
|
||||||
if (canvasRef.current) {
|
loadExistingLetter();
|
||||||
setCanvasFontStyle(canvasRef.current.getStyle());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [public_id, masterKey]);
|
}, [public_id, masterKey]);
|
||||||
|
|
||||||
// to trigger short pulse animation for Last Saved AT element
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastSavedPulseTick === 0) return;
|
if (lastSavedPulseTick === 0) return;
|
||||||
|
|
||||||
setIsSaveDatePulsing(true);
|
setIsSaveDatePulsing(true);
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setIsSaveDatePulsing(false);
|
setIsSaveDatePulsing(false);
|
||||||
}, STOP_SAVE_DATE_PULSE_AFTER_MS);
|
}, 10000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [lastSavedPulseTick]);
|
}, [lastSavedPulseTick]);
|
||||||
|
|
||||||
// to fade in and fade out the save status overlay after each save operation
|
|
||||||
// Note: otherwise the fade efect is abrupt due to component's immediate unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveOverlay === "IDLE" || saveOverlay === "SAVING") return;
|
if (saveOverlay === "idle" || saveOverlay === "saving") return;
|
||||||
|
|
||||||
const visibleTimer = setTimeout(
|
const visibleTimer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
setShowSaveOverlay(false);
|
setShowSaveOverlay(false);
|
||||||
},
|
},
|
||||||
saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
|
saveOverlay === "saved" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
const unmountTimer = setTimeout(
|
const unmountTimer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
setSaveOverlay("IDLE");
|
setSaveOverlay("idle");
|
||||||
},
|
},
|
||||||
(saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
|
(saveOverlay === "saved" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
|
||||||
OVERLAY_FADE_MS,
|
OVERLAY_FADE_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -234,90 +211,79 @@ export default function Editor() {
|
|||||||
|
|
||||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file && file.size < MAX_FILE_SIZE) {
|
if (file) {
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
canvasRef.current?.addImage(url, file);
|
canvasRef.current?.addImage(url, file);
|
||||||
} else {
|
|
||||||
setLogStatus({
|
|
||||||
status: "WARN",
|
|
||||||
message: "Please upload images with size less than 10MB.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
if (saveOverlay === "SAVING" || !masterKey) return;
|
let targetId = public_id || letterIdRef.current;
|
||||||
|
if (!targetId) {
|
||||||
|
targetId = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
setSaveOverlay("SAVING");
|
if (saveOverlay === "saving" || !masterKey) return;
|
||||||
|
|
||||||
|
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 encImageFilesMap = await encryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
|
canvasImages,
|
||||||
|
masterKey,
|
||||||
|
cryptoUtils,
|
||||||
|
);
|
||||||
|
|
||||||
|
const encrypted_letter = await cryptoUtils.encryptLetter(
|
||||||
|
JSON.stringify(canvasData),
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
encImageFilesMap.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 });
|
||||||
@@ -327,13 +293,13 @@ export default function Editor() {
|
|||||||
setLetterStatus(status);
|
setLetterStatus(status);
|
||||||
setLastSavedPulseTick((prev) => prev + 1);
|
setLastSavedPulseTick((prev) => prev + 1);
|
||||||
|
|
||||||
if (status === "SEALED" || status === "VAULT") {
|
if (status === "SEALED") {
|
||||||
setSealedTargetId(targetId);
|
setSealedTargetId(targetId);
|
||||||
}
|
}
|
||||||
setSaveOverlay("SAVED");
|
setSaveOverlay("saved");
|
||||||
setShowSaveOverlay(true);
|
setShowSaveOverlay(true);
|
||||||
} catch {
|
} catch (_error) {
|
||||||
setSaveOverlay("ERROR");
|
setSaveOverlay("error");
|
||||||
setShowSaveOverlay(true);
|
setShowSaveOverlay(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -347,8 +313,8 @@ export default function Editor() {
|
|||||||
isSaveDatePulsing ? "animate-pulse" : ""
|
isSaveDatePulsing ? "animate-pulse" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-xxs text-neutral-content/30 flex-col justify-end leading-none text-right">
|
<div className="text-sm text-neutral-content/30 flex-col justify-end leading-none text-right">
|
||||||
<span className="uppercase tracking-widest font-bold">
|
<span className="text-[10px] uppercase tracking-widest font-bold">
|
||||||
Last Save
|
Last Save
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
@@ -382,19 +348,21 @@ export default function Editor() {
|
|||||||
weight="bold"
|
weight="bold"
|
||||||
className="animate-spin text-primary"
|
className="animate-spin text-primary"
|
||||||
/>
|
/>
|
||||||
<p
|
<p className="text-[10px] uppercase tracking-[0.4em] 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saveOverlay !== "IDLE" && (
|
{saveOverlay !== "idle" && (
|
||||||
<Modal isOpen={showSaveOverlay}>
|
<div
|
||||||
{saveOverlay === "SAVING" && (
|
className={`modal modal-open bg-base-100/20 backdrop-blur-md transition-opacity duration-300 ${
|
||||||
|
showSaveOverlay ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="modal-box p-0 bg-transparent shadow-none transition-all duration-300">
|
||||||
|
{saveOverlay === "saving" && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
|
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
|
||||||
@@ -412,10 +380,9 @@ export default function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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"
|
||||||
@@ -427,7 +394,7 @@ export default function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saveOverlay === "ERROR" && (
|
{saveOverlay === "error" && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={`alert alert-error shadow-lg transition-all duration-300 ${
|
className={`alert alert-error shadow-lg transition-all duration-300 ${
|
||||||
@@ -440,7 +407,8 @@ export default function Editor() {
|
|||||||
<span className="font-bold">Failed to save letter</span>
|
<span className="font-bold">Failed to save letter</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmModal === "VAULT" && (
|
{confirmModal === "VAULT" && (
|
||||||
@@ -451,11 +419,7 @@ export default function Editor() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sealedTargetId && (
|
{sealedTargetId && (
|
||||||
<PostSealModal
|
<PostSealModal sealedTargetId={sealedTargetId} navigate={navigate} />
|
||||||
sealedTargetId={sealedTargetId}
|
|
||||||
navigate={navigate}
|
|
||||||
type={status === "VAULT" ? "VAULT" : "KEPT"}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-w-180 mx-auto px-1 md:px-0">
|
<div className="max-w-180 mx-auto px-1 md:px-0">
|
||||||
@@ -463,13 +427,12 @@ export default function Editor() {
|
|||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="recipient"
|
htmlFor="recipient"
|
||||||
className="text-xxs uppercase tracking-widester text-secondary-content font-bold"
|
className="text-[10px] uppercase tracking-[0.4em] text-secondary-content font-bold"
|
||||||
>
|
>
|
||||||
Recipient
|
Recipient
|
||||||
</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}
|
||||||
@@ -483,13 +446,11 @@ export default function Editor() {
|
|||||||
|
|
||||||
{status === "DRAFT" ? (
|
{status === "DRAFT" ? (
|
||||||
<ToolBar
|
<ToolBar
|
||||||
onAddImage={() => fileInputRef.current?.click()}
|
fileInputRef={fileInputRef}
|
||||||
sealBtnClicked={sealBtnClicked}
|
sealBtnClicked={sealBtnClicked}
|
||||||
setSealBtnClicked={setSealBtnClicked}
|
setSealBtnClicked={setSealBtnClicked}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
setConfirmModal={setConfirmModal}
|
setConfirmModal={setConfirmModal}
|
||||||
onFontChange={setCanvasFontStyle}
|
|
||||||
latestFontStyle={canvasFontStyle}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LetterHead />
|
<LetterHead />
|
||||||
@@ -503,25 +464,9 @@ export default function Editor() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ComposeCanvas
|
<ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} />
|
||||||
ref={canvasRef}
|
|
||||||
readOnly={status !== "DRAFT"}
|
|
||||||
style={canvasFontStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<LogModal
|
|
||||||
status={logStatus.status}
|
|
||||||
message={logStatus.message}
|
|
||||||
log={""}
|
|
||||||
onClose={() =>
|
|
||||||
setLogStatus({
|
|
||||||
status: "RESET",
|
|
||||||
message: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
isOpen={logStatus.status !== "RESET"}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,400 +1,9 @@
|
|||||||
import { InfoIcon } from "@phosphor-icons/react";
|
|
||||||
import { ReactLenis } from "lenis/react";
|
|
||||||
import {
|
|
||||||
motion,
|
|
||||||
useMotionValueEvent,
|
|
||||||
useScroll,
|
|
||||||
useTransform,
|
|
||||||
} from "motion/react";
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import letterSample from "../assets/screenshots/letter.webp";
|
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
|
|
||||||
import Saajan from "../components/ui/Saajan";
|
|
||||||
import { ROUTES } from "../config/routes";
|
|
||||||
import { formatDate } from "../utils/dateFormat";
|
|
||||||
|
|
||||||
import "@fontsource/space-mono/index.css";
|
|
||||||
import "@fontsource/architects-daughter/index.css";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
|
||||||
const { scrollYProgress } = useScroll({
|
|
||||||
target: sectionContainer1,
|
|
||||||
});
|
|
||||||
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
|
||||||
const [flapOpen, setFlapOpen] = useState(false);
|
|
||||||
const [recipient, setRecipient] = useState("someone dear");
|
|
||||||
const [ignite, setIgnite] = useState(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
|
|
||||||
if (latestScrollValue > 0.54) {
|
|
||||||
setFlapOpen(false);
|
|
||||||
} else {
|
|
||||||
setFlapOpen(true);
|
|
||||||
}
|
|
||||||
if (latestScrollValue <= 0.6) {
|
|
||||||
setIsEnvelopeFlipped(true);
|
|
||||||
} else {
|
|
||||||
setIsEnvelopeFlipped(false);
|
|
||||||
}
|
|
||||||
if (latestScrollValue > 0.68) {
|
|
||||||
setRecipient("future me");
|
|
||||||
} else {
|
|
||||||
setRecipient("someone dear");
|
|
||||||
}
|
|
||||||
if (latestScrollValue > 0.77) {
|
|
||||||
setIgnite(true);
|
|
||||||
} else {
|
|
||||||
setIgnite(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
|
<div>
|
||||||
<section
|
<Logo />
|
||||||
ref={sectionContainer1}
|
|
||||||
className="relative w-full h-[850vh] bg-base-100 font-serif text-neutral-content/90"
|
|
||||||
>
|
|
||||||
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
|
||||||
{/* Intro */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
|
|
||||||
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 className="text-neutral text-4xl md:text-6xl text-center px-6">
|
|
||||||
You've been carrying something
|
|
||||||
</h1>
|
|
||||||
<motion.h2 className="text-primary text-5xl md:text-7xl mt-4 italic font-display font-light">
|
|
||||||
unsaid
|
|
||||||
</motion.h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="absolute text-center"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
|
|
||||||
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
|
|
||||||
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
|
|
||||||
and that's okay...
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
{/* pi. ku. */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute text-center px-6"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.18, 0.25, 0.3],
|
|
||||||
[0, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
|
|
||||||
}}
|
|
||||||
transition={{ delay: 4 }}
|
|
||||||
>
|
|
||||||
<Logo type="logo" scale={1.5} ul={true} />
|
|
||||||
<motion.div
|
|
||||||
className="font-serif italic font-extralight mt-6 text-4xl md:text-6xl text-neutral "
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.22, 0.25, 0.35, 0.4],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.25, 0.3, 0.35, 0.4],
|
|
||||||
[20, 0, 0, -20],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
is a{" "}
|
|
||||||
<span className="font-display text-primary font-extralight">
|
|
||||||
safe space
|
|
||||||
</span>
|
|
||||||
,<br />
|
|
||||||
<motion.span
|
|
||||||
className="opacity-0 text-2xl md:text-4xl font-hand tracking-widest italic text-neutral"
|
|
||||||
transition={{ delay: 5 }}
|
|
||||||
whileInView={{ opacity: 1 }}
|
|
||||||
viewport={{ once: false, amount: 0.3 }}
|
|
||||||
>
|
|
||||||
where you can
|
|
||||||
</motion.span>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.3, 0.35, 0.4, 0.45],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.3, 0.35, 0.4, 0.45],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
|
||||||
pen down your unsaid words into
|
|
||||||
<span className="font-display text-primary font-extralight">
|
|
||||||
letters
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</motion.h2>
|
|
||||||
{/* Seal */}
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.45, 0.5, 0.55, 0.6],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.45, 0.5, 0.55, 0.6],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
|
||||||
seal it
|
|
||||||
<span className="text-success font-mono tracking-tighter font-extrabold">
|
|
||||||
secure
|
|
||||||
</span>
|
|
||||||
and
|
|
||||||
<span className="text-info font-mono tracking-tighter italic">
|
|
||||||
private
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</motion.h2>
|
|
||||||
{/* Send / vault */}
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.6, 0.63, 0.72, 0.75],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.6, 0.63, 0.72, 0.75],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
|
||||||
send it to
|
|
||||||
<motion.span
|
|
||||||
className="font-display text-accent"
|
|
||||||
style={{
|
|
||||||
color: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.67, 1],
|
|
||||||
["var(--color-accent)", "var(--color-neutral)"],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
someone dear
|
|
||||||
</motion.span>
|
|
||||||
<motion.span
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
className="font-display text-accent"
|
|
||||||
style={{
|
|
||||||
color: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.67, 1],
|
|
||||||
["var(--color-accent)", "var(--color-neutral)"],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
or
|
|
||||||
</motion.span>
|
|
||||||
<span className="font-display text-success">
|
|
||||||
yourself in the future
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</motion.span>
|
|
||||||
</motion.h2>
|
|
||||||
{/* Burn */}
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.75, 0.8, 0.85, 0.9],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.75, 0.8, 0.85, 0.9],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
|
||||||
and even <span className="font-display text-error">burn it</span>
|
|
||||||
to release the burden.
|
|
||||||
</motion.h2>
|
|
||||||
{/* Outro */}
|
|
||||||
<motion.h2
|
|
||||||
className={
|
|
||||||
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight text-neutral-content/50"
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
|
|
||||||
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
You've been carrying it long enough.
|
|
||||||
</motion.h2>
|
|
||||||
{/* CTA */}
|
|
||||||
<motion.div
|
|
||||||
className={
|
|
||||||
"z-100 absolute -bottom-12 md:bottom-0 font-hand flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
|
|
||||||
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
|
|
||||||
display: useTransform(
|
|
||||||
scrollYProgress,
|
|
||||||
[0.96, 1],
|
|
||||||
["none", "flex"],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
|
||||||
}
|
|
||||||
type={"button"}
|
|
||||||
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
|
|
||||||
>
|
|
||||||
<InfoIcon className={"text-primary"} />
|
|
||||||
Tell me More
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale-50 hover:grayscale-0 focus:grayscale-0 active:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
|
||||||
}
|
|
||||||
type={"button"}
|
|
||||||
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
|
||||||
>
|
|
||||||
I'm ready
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative 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>
|
|
||||||
</section>
|
|
||||||
</ReactLenis>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ describe("Login Page", () => {
|
|||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should render the sign-in form correctly", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Sign in to")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("should display a technical issues message when the server is down", async () => {
|
it("should display a technical issues message when the server is down", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post(`${API_URL}${endpoints.LOGIN}`, () =>
|
http.post(`${API_URL}${endpoints.LOGIN}`, () =>
|
||||||
@@ -27,13 +37,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 +83,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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ShieldCheckIcon, WarningIcon } from "@phosphor-icons/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -7,9 +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 FormField from "../components/ui/FormField";
|
import FormField from "../components/ui/FormField";
|
||||||
import Saajan from "../components/ui/Saajan";
|
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { ROUTES } from "../config/routes";
|
import { ROUTES } from "../config/routes";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
@@ -22,6 +20,57 @@ const loginSchema = z.object({
|
|||||||
|
|
||||||
type LoginInputs = z.infer<typeof loginSchema>;
|
type LoginInputs = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
function WelcomeModal({ setShowWelcome }) {
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open backdrop-blur-sm transition-all duration-1000">
|
||||||
|
<div className="modal-box border border-primary/20 shadow-2xl p-8">
|
||||||
|
<div className="flex flex-col items-center text-center gap-4">
|
||||||
|
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
|
||||||
|
<ShieldCheckIcon
|
||||||
|
size={48}
|
||||||
|
weight="duotone"
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-2xl font-bold text-primary">
|
||||||
|
Welcome to <Logo />!
|
||||||
|
</h3>
|
||||||
|
<p className="text-base-content/80 leading-relaxed">
|
||||||
|
To ensure <span className="font-bold">complete privacy</span>, all
|
||||||
|
your letters are{" "}
|
||||||
|
<span className="font-bold underline">
|
||||||
|
sealed with your password
|
||||||
|
</span>
|
||||||
|
, which only you have access to.
|
||||||
|
<br />
|
||||||
|
<span className="font-bold">
|
||||||
|
The server never sees it, and it's a solemn promise!
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
|
||||||
|
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm font-medium text-primary-content">
|
||||||
|
If you ever happen to forget your password, your letters are lost
|
||||||
|
to time, forever.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowWelcome(false)}
|
||||||
|
className="btn btn-primary w-full shadow-lg"
|
||||||
|
>
|
||||||
|
I understand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -29,9 +78,6 @@ export default function Login() {
|
|||||||
const [apiError, setApiError] = useState<string | null>(null);
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
const { setAuthStore } = useAuth();
|
const { setAuthStore } = useAuth();
|
||||||
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
|
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
|
||||||
const [saajanMessage, setSaajanMessage] = useState<string>(
|
|
||||||
"I was wondering when you'd return.",
|
|
||||||
);
|
|
||||||
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
|
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -46,7 +92,7 @@ export default function Login() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
try {
|
try {
|
||||||
// client side key derivation for e2e encryption
|
// client side key derivation for 0 knowledge
|
||||||
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
||||||
data.password,
|
data.password,
|
||||||
data.email,
|
data.email,
|
||||||
@@ -62,9 +108,10 @@ export default function Login() {
|
|||||||
headers: { Authorization: `Bearer ${authData.access}` },
|
headers: { Authorization: `Bearer ${authData.access}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// store the auth related data
|
||||||
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.";
|
||||||
@@ -78,41 +125,34 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col gap-3">
|
||||||
{!showWelcome && <Saajan message={saajanMessage} position="top" />}
|
|
||||||
{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
|
Sign in to <Logo />
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="f.kafka@wrongtrain.com"
|
placeholder="you@email.com"
|
||||||
data-testid="email-input"
|
|
||||||
registration={register("email")}
|
registration={register("email")}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
handleFocus={() => setSaajanMessage("I remember you.")}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
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={() =>
|
|
||||||
setSaajanMessage("The one thing I cannot know for you.")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card-actions mt-4">
|
<div className="card-actions mt-4">
|
||||||
@@ -120,29 +160,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,
|
||||||
@@ -35,7 +33,6 @@ interface LetterMetadata {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WAIT_FOR_BURN_MS = 18000;
|
|
||||||
export default function Reader() {
|
export default function Reader() {
|
||||||
const { public_id } = useParams();
|
const { public_id } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -47,10 +44,13 @@ export default function Reader() {
|
|||||||
|
|
||||||
const [isDecrypting, setIsDecrypting] = useState(true);
|
const [isDecrypting, setIsDecrypting] = useState(true);
|
||||||
const [revealState, setRevealState] = useState<
|
const [revealState, setRevealState] = useState<
|
||||||
"SEALED" | "REVEALED" | "BURNED" | "BURNING"
|
"sealed" | "revealed" | "burned"
|
||||||
>("SEALED");
|
>("sealed");
|
||||||
const [logTrace, setLogTrace] = useState<{
|
const [error, setError] = useState<{
|
||||||
type: "WARN" | "ERROR";
|
message: string;
|
||||||
|
log: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [warning, setWarning] = useState<{
|
||||||
message: string;
|
message: string;
|
||||||
log: string;
|
log: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -73,8 +73,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,17 +86,14 @@ 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);
|
||||||
setIgnite(true);
|
setIgnite(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setRevealState("BURNED");
|
setRevealState("burned");
|
||||||
}, WAIT_FOR_BURN_MS);
|
}, 13000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,54 +105,30 @@ 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(
|
|
||||||
canvasData,
|
|
||||||
images,
|
|
||||||
sharingKey,
|
|
||||||
cryptoUtils,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await decryptCanvasImages(
|
|
||||||
canvasData,
|
|
||||||
images,
|
|
||||||
encrypted_dek,
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
|
||||||
masterKey!,
|
|
||||||
cryptoUtils,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setLogTrace({
|
|
||||||
message:
|
|
||||||
"Failed to decrypt elements. Images might not render in the letter as intended.",
|
|
||||||
log: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
type: "WARN",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptLetterData = async (
|
|
||||||
data: LetterResponseData,
|
|
||||||
cryptoUtils: CryptoUtils,
|
|
||||||
) => {
|
|
||||||
const isShared = !!sharingKey;
|
|
||||||
const {
|
const {
|
||||||
encrypted_content,
|
encrypted_content,
|
||||||
encrypted_metadata,
|
encrypted_metadata,
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
images,
|
images,
|
||||||
updated_at,
|
updated_at,
|
||||||
} = data;
|
status,
|
||||||
|
} = response.data;
|
||||||
|
|
||||||
|
if (status === "BURNED")
|
||||||
|
throw new Error("This letter has been burned.");
|
||||||
|
|
||||||
|
if (encrypted_dek) setEncryptedDek(encrypted_dek);
|
||||||
|
|
||||||
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
const isShared = !!sharingKey;
|
||||||
|
|
||||||
|
if (isShared && !encrypted_content) throw new Error("Content missing");
|
||||||
|
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
|
||||||
|
if (!(isShared || isDecryptionKeyAvailable))
|
||||||
|
throw new Error("Auth required: Decryption key is not available");
|
||||||
|
|
||||||
// Decrypt Metadata
|
// Decrypt Metadata
|
||||||
const decryptedMetadata = isShared
|
const decryptedMetadata = isShared
|
||||||
@@ -187,47 +159,51 @@ export default function Reader() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
|
||||||
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
|
|
||||||
setDecryptedCanvasData(canvasData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processLetterData = async (data: LetterResponseData) => {
|
|
||||||
if (data.status === "BURNED")
|
|
||||||
throw new Error("This letter has been burned.");
|
|
||||||
|
|
||||||
if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek);
|
|
||||||
|
|
||||||
const isDecryptionKeyAvailable = data.encrypted_dek && masterKey;
|
|
||||||
if (!(!!sharingKey || isDecryptionKeyAvailable)) {
|
|
||||||
throw new Error("Auth required: Decryption key is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
|
||||||
await decryptLetterData(data, cryptoUtils);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAndDecryptLetter = async () => {
|
|
||||||
try {
|
try {
|
||||||
const response: AxiosResponse<LetterResponseData> = await api.get(
|
// Decrypt Images
|
||||||
`${endpoints.LETTERS}${public_id}/`,
|
if (images?.length > 0) {
|
||||||
|
isShared
|
||||||
|
? await decryptCanvasImagesWithSharingKey(
|
||||||
|
canvasData,
|
||||||
|
images,
|
||||||
|
sharingKey,
|
||||||
|
cryptoUtils,
|
||||||
|
)
|
||||||
|
: await decryptCanvasImages(
|
||||||
|
canvasData,
|
||||||
|
images,
|
||||||
|
encrypted_dek,
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
|
||||||
|
masterKey!,
|
||||||
|
cryptoUtils,
|
||||||
);
|
);
|
||||||
await processLetterData(response.data);
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogTrace({
|
setWarning({
|
||||||
message: `Failed to load letter ☹`,
|
message:
|
||||||
|
"Failed to decrypt elements. Images might not render in the letter as intended.",
|
||||||
log: err instanceof Error ? err.message : "Unknown error",
|
log: err instanceof Error ? err.message : "Unknown error",
|
||||||
type: "ERROR",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setDecryptedCanvasData(canvasData);
|
||||||
|
} catch (err) {
|
||||||
|
setError({
|
||||||
|
message: `Failed to load letter :(`,
|
||||||
|
log: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDecrypting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadAndDecryptLetter().then(() => setIsDecrypting(false));
|
loadAndDecrypt();
|
||||||
}, [public_id, sharingKey, masterKey]);
|
}, [public_id, sharingKey, masterKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!isDecrypting &&
|
!isDecrypting &&
|
||||||
revealState === "REVEALED" &&
|
revealState === "revealed" &&
|
||||||
decryptedCanvasData &&
|
decryptedCanvasData &&
|
||||||
canvasRef.current
|
canvasRef.current
|
||||||
) {
|
) {
|
||||||
@@ -237,16 +213,13 @@ export default function Reader() {
|
|||||||
|
|
||||||
if (isDecrypting) {
|
if (isDecrypting) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-screen w-screen justify-center bg-base-100 font-sans">
|
<div className="flex items-center justify-center bg-base-100 font-serif">
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none" />
|
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.4)_100%)] pointer-events-none z-0" />
|
||||||
<div className="text-center space-y-6 z-10">
|
<div className="text-center space-y-6 z-10">
|
||||||
<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-[10px] uppercase tracking-[0.4em] 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>
|
||||||
@@ -255,32 +228,29 @@ export default function Reader() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logTrace) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<LogModal
|
<LogModal
|
||||||
isOpen={!!logTrace}
|
isOpen={!!error}
|
||||||
onClose={() => {
|
onClose={() => (window.location.href = "/")}
|
||||||
if (logTrace.type === "ERROR") window.location.href = "/";
|
message={error.message}
|
||||||
setLogTrace(null);
|
log={error.log}
|
||||||
}}
|
status="ERROR"
|
||||||
message={logTrace.message}
|
|
||||||
log={logTrace.log}
|
|
||||||
status={logTrace.type}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
<section className="min-h-fit w-full bg-base-100 px-4 py-8 md:py-16 font-serif relative overflow-hidden">
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
|
||||||
<div
|
<div
|
||||||
className={`transition-all delay-300 duration-1000 relative ${
|
className={`transition-all delay-300 duration-1000 relative ${
|
||||||
revealState === "REVEALED"
|
revealState === "revealed"
|
||||||
? "opacity-0 w-0 h-0 overflow-hidden invisible"
|
? "opacity-0 w-0 h-0 overflow-hidden invisible"
|
||||||
: "opacity-100"
|
: "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{revealState === "SEALED" && (
|
{revealState === "sealed" && (
|
||||||
<div className="h-[80vh] mx-auto flex-col items-center flex justify-center">
|
<div className="h-[80vh] mx-auto flex-col items-center flex justify-center">
|
||||||
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
|
<div className="perspective-distant scale-80 duration-1000 transition-all animate-[pulse_2s_linear_1]">
|
||||||
<EnvelopeReveal
|
<EnvelopeReveal
|
||||||
@@ -290,7 +260,7 @@ export default function Reader() {
|
|||||||
? formatDate(new Date(metadata.updated_at))
|
? formatDate(new Date(metadata.updated_at))
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onRevealComplete={() => setRevealState("REVEALED")}
|
onRevealComplete={() => setRevealState("revealed")}
|
||||||
ignite={ignite}
|
ignite={ignite}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,8 +270,16 @@ export default function Reader() {
|
|||||||
|
|
||||||
{ignite && <PostActionOverlay revealState={revealState} />}
|
{ignite && <PostActionOverlay revealState={revealState} />}
|
||||||
|
|
||||||
{revealState === "REVEALED" && (
|
<LogModal
|
||||||
<div className="max-w-180 m-8 mx-auto space-y-8 h-full relative inset-0 z-100">
|
isOpen={!!warning}
|
||||||
|
onClose={() => setWarning(null)}
|
||||||
|
message={warning?.message || ""}
|
||||||
|
log={warning?.log || ""}
|
||||||
|
status="WARN"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{revealState === "revealed" && (
|
||||||
|
<div className="max-w-4xl m-8 mx-auto space-y-8 h-full relative inset-0 z-100">
|
||||||
<div className="relative group perspective-1000">
|
<div className="relative group perspective-1000">
|
||||||
<div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" />
|
<div className="absolute inset-0 bg-primary/5 blur-3xl rounded-full scale-75 opacity-0 group-hover:opacity-100 transition-opacity duration-1000 pointer-events-none" />
|
||||||
|
|
||||||
@@ -311,7 +289,7 @@ export default function Reader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{metadata?.recipient && (
|
{metadata?.recipient && (
|
||||||
<p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
|
<p className="text-center sm:hidden text-[10px] uppercase tracking-[0.3em] text-base-content/20 mt-8">
|
||||||
For {metadata.recipient}
|
For {metadata.recipient}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -331,11 +309,10 @@ export default function Reader() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAuthor && revealState !== "BURNED" && (
|
{isAuthor && revealState !== "burned" && (
|
||||||
<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 +324,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)}
|
||||||
@@ -361,7 +337,7 @@ export default function Reader() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
|
<footer className="mt-16 text-center z-10 opacity-10 pointer-events-none">
|
||||||
<p className="text-xs font-sans uppercase tracking-widester">
|
<p className="text-xs font-sans uppercase tracking-[0.5em]">
|
||||||
Read. Remember. Release.
|
Read. Remember. Release.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { z } from "zod";
|
|||||||
import { publicApi } from "../api/apiClient";
|
import { publicApi } from "../api/apiClient";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import FormField from "../components/ui/FormField";
|
import FormField from "../components/ui/FormField";
|
||||||
import Saajan from "../components/ui/Saajan";
|
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { ROUTES } from "../config/routes";
|
import { ROUTES } from "../config/routes";
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
|
||||||
|
// validation logic
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
full_name: z.string().min(2, "Name must be at least 2 characters"),
|
full_name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
@@ -31,9 +31,6 @@ export default function Register() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [apiError, setApiError] = useState<string | null>(null);
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
const [saajanMessage, setSaajanMessage] = useState<string>(
|
|
||||||
"I didn't think I'd be here either.\nAnd yet, here we are.",
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -44,11 +41,10 @@ export default function Register() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: RegisterInputs) => {
|
const onSubmit = async (data: RegisterInputs) => {
|
||||||
setSaajanMessage("Good. I'll remember that.");
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
try {
|
try {
|
||||||
// we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db.
|
// We generate the key bundle here to get the authHash (password) for the server.
|
||||||
const { authHash } = await CryptoUtils.deriveKeyBundle(
|
const { authHash } = await CryptoUtils.deriveKeyBundle(
|
||||||
data.password,
|
data.password,
|
||||||
data.email,
|
data.email,
|
||||||
@@ -72,14 +68,11 @@ export default function Register() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
|
||||||
<Saajan message={saajanMessage} position="right" />
|
|
||||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
<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">
|
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
|
||||||
Create a<Logo type="logo" scale={0.7} />
|
Create a <Logo /> Account
|
||||||
Account
|
</h1>
|
||||||
</div>
|
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||||
@@ -90,63 +83,41 @@ 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={() =>
|
|
||||||
setSaajanMessage("Hello friend. What should I call you?")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="f.kafka@wrongtrain.com"
|
placeholder="f.kafka@email.com"
|
||||||
data-testid="email-input"
|
|
||||||
registration={register("email")}
|
registration={register("email")}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
handleFocus={() =>
|
|
||||||
setSaajanMessage(
|
|
||||||
"Where should I send your letters?\nNo empty lunchboxes, please.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
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={() =>
|
|
||||||
setSaajanMessage(
|
|
||||||
"Something only you know.\nI have one of those too.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
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={() =>
|
|
||||||
setSaajanMessage(
|
|
||||||
"Just once? Trust me, \nsome things are worth repeating twice.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
||||||
<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,
|
<span className="underline decoration-2">There is no reset.</span>{" "}
|
||||||
<span className="underline decoration-2">there is no reset</span>
|
If you lose it, your letters cannot be recovered.
|
||||||
here. If you lose it, your letters cannot be recovered.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,31 +126,16 @@ 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
|
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import Saajan from "../components/ui/Saajan";
|
|
||||||
|
|
||||||
export default function VerifyEmail() {
|
export default function VerifyEmail() {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
|
||||||
<Saajan
|
|
||||||
message={"I sent something to your inbox.\nOpen it, and we can begin."}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
|
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
|
||||||
<div className="auth-icon-container">
|
<div className="auth-icon-container">
|
||||||
<EnvelopeSimpleOpenIcon
|
<EnvelopeSimpleOpenIcon
|
||||||
@@ -19,26 +13,19 @@ export default function VerifyEmail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="font-display text-xl text-primary">
|
<h2 className="font-display text-xl text-primary">Check Your Email</h2>
|
||||||
Check Your Mailbox
|
<p className="text-sm opacity-80 leading-relaxed font-sans">
|
||||||
</h2>
|
We've sent an activation link to your inbox. <br />
|
||||||
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
|
Please click it to verify your <Logo /> account.
|
||||||
You're one train away from starting your <Logo scale={0.8} />
|
|
||||||
journey.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divider opacity-10 my-0"></div>
|
<div className="divider opacity-10"></div>
|
||||||
|
|
||||||
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed opacity-70 text-center">
|
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed text-left opacity-70">
|
||||||
<p>
|
<p>
|
||||||
Nothing yet? Sometimes letters take the wrong train. Check your spam
|
Didn't receive it? Check your spam folder or wait for a few minutes.
|
||||||
folder.
|
The link will expire in 24 hours.
|
||||||
<br />
|
|
||||||
<span className="underline font-bold">
|
|
||||||
The link expires in 24 hours.
|
|
||||||
</span>
|
|
||||||
<br /> I'm patient... but not endlessly so
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,6 +37,5 @@ export default function VerifyEmail() {
|
|||||||
You can close this window now.
|
You can close this window now.
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe("deriveKeyBundle", () => {
|
|||||||
|
|
||||||
expect(masterKey.type).toBe("secret");
|
expect(masterKey.type).toBe("secret");
|
||||||
expect(masterKey).toBeInstanceOf(CryptoKey);
|
expect(masterKey).toBeInstanceOf(CryptoKey);
|
||||||
expect(authHash).toHaveLength(64);
|
expect(authHash).toHaveLength(64); // SHA-256 hex
|
||||||
expect(typeof authHash).toBe("string");
|
expect(typeof authHash).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ describe("extractSharingKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("extracted key should decrypt the ciphertext produced by encryptLetter", async () => {
|
it("extracted key should decrypt the ciphertext produced by encryptLetter", async () => {
|
||||||
const plaintext = "hello";
|
const plaintext = "hello from the owner";
|
||||||
const encrypted = await utils.encryptLetter(plaintext, masterKey);
|
const encrypted = await utils.encryptLetter(plaintext, masterKey);
|
||||||
|
|
||||||
const extracted = await utils.extractSharingKey(
|
const extracted = await utils.extractSharingKey(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { LetterMetadata } from "../api/response";
|
/**
|
||||||
|
* 0 knowledge cryptography. No Server involved in encryption/decryption
|
||||||
|
*/
|
||||||
|
|
||||||
export interface EncryptedLetter {
|
export interface EncryptedLetter {
|
||||||
encrypted_content: string;
|
encrypted_content: string;
|
||||||
@@ -9,7 +11,6 @@ export interface EncryptedLetter {
|
|||||||
export interface EncryptedLetterMetadata {
|
export interface EncryptedLetterMetadata {
|
||||||
encrypted_content: string;
|
encrypted_content: string;
|
||||||
encrypted_dek: string;
|
encrypted_dek: string;
|
||||||
sharingKey?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncryptedImageUpload {
|
export interface EncryptedImageUpload {
|
||||||
@@ -24,88 +25,59 @@ interface SealedEnvelope {
|
|||||||
sharingKey: string;
|
sharingKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// we use a class here to keep track of instantiations (use 1 and the same DEK per letter content and metadata)
|
|
||||||
// TODO: try refactoring into a pure function for consistency
|
|
||||||
export class CryptoUtils {
|
export class CryptoUtils {
|
||||||
private dek!: CryptoKey;
|
private dek: CryptoKey = {} as CryptoKey;
|
||||||
private static readonly PBKDF2_ITERATIONS =
|
private static readonly PBKDF2_ITERATIONS = 100_000;
|
||||||
Number(import.meta.env.VITE_PBKDF2_ITERATIONS) || 600_000;
|
private static readonly AES_GCM = { name: "AES-GCM", length: 256 };
|
||||||
// NOTE: https://www.w3.org/TR/webcrypto/#aes-gcm
|
|
||||||
private static readonly AES_ALGO = { name: "AES-GCM", length: 256 };
|
|
||||||
private static readonly IV_BYTE_LENGTH = 12;
|
|
||||||
|
|
||||||
// NOTE: this MUST be called once, per letter, for all operations in a session to a fresh Data Encryption Key (DEK)
|
// Generates a fresh Data Encryption Key (DEK)
|
||||||
async initialize() {
|
async initialize() {
|
||||||
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_ALGO, true, [
|
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [
|
||||||
"encrypt",
|
"encrypt",
|
||||||
"decrypt",
|
"decrypt",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toBase64 = (buffer: Uint8Array): string => {
|
// base64 conversion for transit
|
||||||
// convert buffer to raw string
|
toBase64 = (buf: Uint8Array): string =>
|
||||||
let binaryFileString = "";
|
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), ""));
|
||||||
for (let i = 0; i < buffer.byteLength; i++) {
|
|
||||||
binaryFileString += String.fromCharCode(buffer[i]);
|
|
||||||
}
|
|
||||||
return btoa(binaryFileString);
|
|
||||||
};
|
|
||||||
|
|
||||||
private fromBase64 = (b64String: string): Uint8Array<ArrayBuffer> => {
|
fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => {
|
||||||
const decodedString = atob(b64String);
|
const str = atob(b64);
|
||||||
const arr = new Uint8Array(decodedString.length);
|
const arr = new Uint8Array(str.length);
|
||||||
for (let i = 0; i < decodedString.length; i++)
|
for (let i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i);
|
||||||
arr[i] = decodedString.charCodeAt(i);
|
|
||||||
return arr;
|
return arr;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Required structure: [12 bytes IV][Cipher text][16 bytes Auth Tag]
|
// bundle IV + data into a single base64 string
|
||||||
// NOTE: Web Crypto API auto appends the auth tag, so we focus on IV and cipher
|
packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => {
|
||||||
private packWithIv = (iv: Uint8Array, ciphertext: ArrayBuffer): string => {
|
const packed = new Uint8Array(iv.length + data.byteLength);
|
||||||
// create a buffer large enough to hold both iv and cipher text (12 + x bytes)
|
packed.set(iv);
|
||||||
const combinedPayload = new Uint8Array(
|
packed.set(new Uint8Array(data), iv.length);
|
||||||
CryptoUtils.IV_BYTE_LENGTH + ciphertext.byteLength,
|
return this.toBase64(packed);
|
||||||
);
|
|
||||||
|
|
||||||
// place the iv at the start
|
|
||||||
combinedPayload.set(iv, 0);
|
|
||||||
|
|
||||||
// place the ciphertext after the iv
|
|
||||||
combinedPayload.set(new Uint8Array(ciphertext), CryptoUtils.IV_BYTE_LENGTH);
|
|
||||||
|
|
||||||
// convert the buffer to Base64 for transit
|
|
||||||
return this.toBase64(combinedPayload);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// For decryption: extracts the IV and the data from the base64 string, easy because we know the size of iv already.
|
unpackWithIv = (
|
||||||
private unpackWithIv = (
|
b64: string,
|
||||||
encodedString: string,
|
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => {
|
||||||
): { iv: Uint8Array<ArrayBuffer>; ciphertext: Uint8Array<ArrayBuffer> } => {
|
const buf = this.fromBase64(b64);
|
||||||
// decode from base64 to array buffer
|
return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)];
|
||||||
const fullBuffer = this.fromBase64(encodedString);
|
|
||||||
|
|
||||||
// extract first 12 bytes for iv
|
|
||||||
const iv = fullBuffer.slice(0, CryptoUtils.IV_BYTE_LENGTH);
|
|
||||||
// extract rest for cipher text
|
|
||||||
const ciphertext = fullBuffer.slice(CryptoUtils.IV_BYTE_LENGTH);
|
|
||||||
|
|
||||||
return { iv: new Uint8Array(iv), ciphertext: new Uint8Array(ciphertext) };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive a key bundle (Masterkey + authHash) from email + (plain) password combo
|
* Derives a Key Bundle (MasterKey + AuthHash) from a password + email.
|
||||||
* WHY?: This is much secure than relying on server to hash and store the password. Also ensures absolute 0 knowledge
|
* Absolute zero knowledge!!
|
||||||
*/
|
*/
|
||||||
public static async deriveKeyBundle(
|
public static async deriveKeyBundle(
|
||||||
password: string,
|
password: string,
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<{ masterKey: CryptoKey; authHash: string }> {
|
): Promise<{ masterKey: CryptoKey; authHash: string }> {
|
||||||
const encoder = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const salt = encoder.encode(email.toLowerCase());
|
const salt = enc.encode(email.toLowerCase());
|
||||||
|
|
||||||
const baseKey = await crypto.subtle.importKey(
|
const baseKey = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
encoder.encode(password),
|
enc.encode(password),
|
||||||
"PBKDF2",
|
"PBKDF2",
|
||||||
false,
|
false,
|
||||||
["deriveBits", "deriveKey"],
|
["deriveBits", "deriveKey"],
|
||||||
@@ -119,61 +91,49 @@ export class CryptoUtils {
|
|||||||
hash: "SHA-256",
|
hash: "SHA-256",
|
||||||
},
|
},
|
||||||
baseKey,
|
baseKey,
|
||||||
512,
|
512, // 512 bits to split
|
||||||
);
|
);
|
||||||
|
|
||||||
// first 256 bits for masterkey, last 256 bits for authHash (password sent in REST)
|
// first 256 bits for MasterKey, last 256 bits for AuthHash
|
||||||
const masterKeyBytes = masterSeed.slice(0, 32);
|
const masterKeyBytes = masterSeed.slice(0, 32);
|
||||||
const authHashBytes = masterSeed.slice(32, 64);
|
const authHashBytes = masterSeed.slice(32, 64);
|
||||||
|
|
||||||
// Create the masterkey for client-side encryption
|
// Create the MasterKey for client-side encryption
|
||||||
const masterKey = await crypto.subtle.importKey(
|
const masterKey = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
masterKeyBytes,
|
masterKeyBytes,
|
||||||
CryptoUtils.AES_ALGO,
|
CryptoUtils.AES_GCM,
|
||||||
false,
|
false,
|
||||||
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
|
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
|
||||||
);
|
);
|
||||||
|
|
||||||
// convert bytes in to hex string
|
// Create the hex AuthHash for server-side verification
|
||||||
let authHash = "";
|
const authHash = Array.from(new Uint8Array(authHashBytes))
|
||||||
const authHashBuffer = new Uint8Array(authHashBytes);
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
for (let i = 0; i < authHashBuffer.byteLength; i++) {
|
|
||||||
// we force every bytes converted to string to be min 2 chars (otherwise 00 0a will be just a and not "000a")
|
|
||||||
authHash += authHashBuffer[i].toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { masterKey, authHash };
|
return { masterKey, authHash };
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
// Internal helper to encrypt data and wrap the key
|
||||||
* Envelope Encryption - Decryption
|
|
||||||
* WHY?: for guest access where we don't have to share the masterkey just the dek.
|
|
||||||
* This way, raw dek never leaves browser (db stores the encrypted version)
|
|
||||||
*/
|
|
||||||
|
|
||||||
// encrypt the plaintext with a DEK and then encrypt (wrap) that DEK with the user's masterkey.
|
|
||||||
private async sealEnvelope(
|
private async sealEnvelope(
|
||||||
input: Uint8Array,
|
input: Uint8Array,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<SealedEnvelope> {
|
): Promise<SealedEnvelope> {
|
||||||
if (!this.dek) {
|
|
||||||
throw new Error("DEK is not available (forgot to .initialize()?)");
|
|
||||||
}
|
|
||||||
const plainBytes = new Uint8Array(input);
|
const plainBytes = new Uint8Array(input);
|
||||||
const contentIv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const dekIv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
|
|
||||||
|
// encrypt the content with the DEK
|
||||||
|
const contentIv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const ciphertext = await crypto.subtle.encrypt(
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
{ name: CryptoUtils.AES_ALGO.name, iv: contentIv },
|
{ name: "AES-GCM", iv: contentIv },
|
||||||
this.dek,
|
this.dek,
|
||||||
plainBytes,
|
plainBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// wrap the DEK with the Master Key (for self access)
|
// wrap the DEK with the Master Key (for self/owner access)
|
||||||
|
const dekIv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, {
|
const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, {
|
||||||
name: CryptoUtils.AES_ALGO.name,
|
name: "AES-GCM",
|
||||||
iv: dekIv,
|
iv: dekIv,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,27 +147,26 @@ export class CryptoUtils {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap the DEK with the master key to get the key back. Decrypt the content with the DEK.
|
// Internal helper to unwrap the key and decrypt data
|
||||||
private async openEnvelope(
|
private async openEnvelope(
|
||||||
encryptedContent: string,
|
encryptedContent: string,
|
||||||
encrypted_dek: string,
|
encrypted_dek: string,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<Uint8Array<ArrayBuffer>> {
|
): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
const { iv: dekIv, ciphertext: wrappedDek } =
|
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
|
||||||
this.unpackWithIv(encrypted_dek);
|
|
||||||
const dek = await crypto.subtle.unwrapKey(
|
const dek = await crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
wrappedDek,
|
wrappedDek,
|
||||||
masterKey,
|
masterKey,
|
||||||
{ name: CryptoUtils.AES_ALGO.name, iv: dekIv },
|
{ name: "AES-GCM", iv: dekIv },
|
||||||
CryptoUtils.AES_ALGO,
|
CryptoUtils.AES_GCM,
|
||||||
false,
|
false,
|
||||||
["decrypt"],
|
["decrypt"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
|
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent);
|
||||||
const plainBytes = await crypto.subtle.decrypt(
|
const plainBytes = await crypto.subtle.decrypt(
|
||||||
{ name: CryptoUtils.AES_ALGO.name, iv: contentIv },
|
{ name: "AES-GCM", iv: contentIv },
|
||||||
dek,
|
dek,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
);
|
);
|
||||||
@@ -223,14 +182,14 @@ export class CryptoUtils {
|
|||||||
const dek = await crypto.subtle.importKey(
|
const dek = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
dekBytes,
|
dekBytes,
|
||||||
CryptoUtils.AES_ALGO,
|
CryptoUtils.AES_GCM,
|
||||||
false,
|
false,
|
||||||
["decrypt"],
|
["decrypt"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
|
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent);
|
||||||
const plainBytes = await crypto.subtle.decrypt(
|
const plainBytes = await crypto.subtle.decrypt(
|
||||||
{ name: CryptoUtils.AES_ALGO.name, iv: contentIv },
|
{ name: "AES-GCM", iv: contentIv },
|
||||||
dek,
|
dek,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
);
|
);
|
||||||
@@ -247,7 +206,6 @@ export class CryptoUtils {
|
|||||||
): Promise<EncryptedLetter> {
|
): Promise<EncryptedLetter> {
|
||||||
const { encryptedContent, encrypted_dek, sharingKey } =
|
const { encryptedContent, encrypted_dek, sharingKey } =
|
||||||
await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
|
await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
|
||||||
|
|
||||||
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +218,6 @@ export class CryptoUtils {
|
|||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,45 +229,41 @@ export class CryptoUtils {
|
|||||||
encrypted_content,
|
encrypted_content,
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async encryptMetadata(
|
public async encryptMetadata(
|
||||||
metadata: LetterMetadata,
|
metadata: Record<string, any>,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<EncryptedLetterMetadata> {
|
): Promise<EncryptedLetter> {
|
||||||
const { encryptedContent, encrypted_dek, sharingKey } =
|
const { encryptedContent, encrypted_dek, sharingKey } =
|
||||||
await this.sealEnvelope(
|
await this.sealEnvelope(
|
||||||
new TextEncoder().encode(JSON.stringify(metadata)),
|
new TextEncoder().encode(JSON.stringify(metadata)),
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return JSON.parse(new TextDecoder().decode(bytes));
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
);
|
);
|
||||||
|
|
||||||
return JSON.parse(new TextDecoder().decode(bytes));
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,13 +290,12 @@ export class CryptoUtils {
|
|||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||||
const plainBytes = await this.openEnvelope(
|
const bytes = await this.openEnvelope(
|
||||||
this.toBase64(encryptedBytes),
|
this.toBase64(encryptedBytes),
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
return URL.createObjectURL(new Blob([bytes]));
|
||||||
return URL.createObjectURL(new Blob([plainBytes]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async decryptImageWithSharingKey(
|
public async decryptImageWithSharingKey(
|
||||||
@@ -351,31 +303,28 @@ export class CryptoUtils {
|
|||||||
sharingKey: string,
|
sharingKey: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||||
const plainBytes = await this.openEnvelopeWithSharingKey(
|
const bytes = await this.openEnvelopeWithSharingKey(
|
||||||
this.toBase64(encryptedBytes),
|
this.toBase64(encryptedBytes),
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
|
return URL.createObjectURL(new Blob([bytes]));
|
||||||
return URL.createObjectURL(new Blob([plainBytes]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// derive raw DEK on demand (browser only, not sent to server) for guest access
|
// Re-derives the sharing key (raw DEK) on demand (browser only, not sent to server).
|
||||||
public async extractSharingKey(
|
public async extractSharingKey(
|
||||||
encrypted_dek: string,
|
encrypted_dek: string,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { iv: dekIv, ciphertext: wrappedDek } =
|
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
|
||||||
this.unpackWithIv(encrypted_dek);
|
|
||||||
const rawDek = await crypto.subtle.unwrapKey(
|
const rawDek = await crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
wrappedDek,
|
wrappedDek,
|
||||||
masterKey,
|
masterKey,
|
||||||
{ name: CryptoUtils.AES_ALGO.name, iv: dekIv },
|
{ name: "AES-GCM", iv: dekIv },
|
||||||
CryptoUtils.AES_ALGO,
|
CryptoUtils.AES_GCM,
|
||||||
true,
|
true,
|
||||||
["decrypt"],
|
["decrypt"],
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.toBase64(
|
return this.toBase64(
|
||||||
new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)),
|
new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { openDB } from "idb";
|
import { openDB } from "idb";
|
||||||
|
|
||||||
// we use indexedDB to securely store master key for easier access across tabs (better UX than having to store in session)
|
// we use this to store master key in browser - secure and good UX
|
||||||
const db = openDB("piku-keys", 1, {
|
const db = openDB("piku-keys", 1, {
|
||||||
upgrade(db) {
|
upgrade(db) {
|
||||||
db.createObjectStore("master-key");
|
db.createObjectStore("master-key");
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ vi.mock("../api/apiClient", () => ({
|
|||||||
api: {
|
api: {
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
},
|
},
|
||||||
apiServerUrl: "https://remote",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./fileUtils", () => ({
|
vi.mock("./fileUtils", () => ({
|
||||||
@@ -22,6 +21,7 @@ vi.mock("./fileUtils", () => ({
|
|||||||
describe("letterLogic image helpers", () => {
|
describe("letterLogic image helpers", () => {
|
||||||
let masterKey: CryptoKey;
|
let masterKey: CryptoKey;
|
||||||
let crypto: CryptoUtils;
|
let crypto: CryptoUtils;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const keyBundle = await CryptoUtils.deriveKeyBundle(
|
const keyBundle = await CryptoUtils.deriveKeyBundle(
|
||||||
"password123",
|
"password123",
|
||||||
@@ -58,13 +58,15 @@ describe("letterLogic image helpers", () => {
|
|||||||
|
|
||||||
const encryptImageSpy = vi.spyOn(CryptoUtils.prototype, "encryptImage");
|
const encryptImageSpy = vi.spyOn(CryptoUtils.prototype, "encryptImage");
|
||||||
|
|
||||||
const { encryptedImageFiles: uploads, encryptedCanvasData } =
|
const uploads = await encryptCanvasImages(
|
||||||
await encryptCanvasImages(canvasData, [], masterKey, crypto);
|
canvasData,
|
||||||
|
[],
|
||||||
|
masterKey,
|
||||||
|
crypto,
|
||||||
|
);
|
||||||
|
|
||||||
expect(encryptImageSpy).not.toHaveBeenCalled();
|
expect(encryptImageSpy).not.toHaveBeenCalled();
|
||||||
expect(encryptedCanvasData.objects[0].src).toBe(
|
expect(canvasData.objects[0].src).toBe("already-encrypted.png.bin");
|
||||||
"already-encrypted.png.bin",
|
|
||||||
);
|
|
||||||
expect(uploads.size).toBe(0);
|
expect(uploads.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,11 +99,15 @@ describe("letterLogic image helpers", () => {
|
|||||||
filename: "photo.png.bin",
|
filename: "photo.png.bin",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { encryptedImageFiles: uploads, encryptedCanvasData } =
|
const uploads = await encryptCanvasImages(
|
||||||
await encryptCanvasImages(canvasData, canvasImages, masterKey, crypto);
|
canvasData,
|
||||||
|
canvasImages,
|
||||||
|
masterKey,
|
||||||
|
crypto,
|
||||||
|
);
|
||||||
|
|
||||||
expect(CryptoUtils.prototype.encryptImage).toHaveBeenCalledTimes(1);
|
expect(CryptoUtils.prototype.encryptImage).toHaveBeenCalledTimes(1);
|
||||||
expect(encryptedCanvasData.objects[0].src).toBe("photo.png.bin");
|
expect(canvasData.objects[0].src).toBe("photo.png.bin");
|
||||||
expect(uploads.size).toBe(1);
|
expect(uploads.size).toBe(1);
|
||||||
expect(uploads.has("photo.png.bin")).toBe(true);
|
expect(uploads.has("photo.png.bin")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -130,7 +136,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const remoteImages = [
|
const remoteImages = [
|
||||||
{ file_name: "photo.png.bin", file: `https://remote/photo.png.bin` },
|
{ 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"]) });
|
||||||
@@ -138,7 +144,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
"blob:http://localhost/decrypted",
|
"blob:http://localhost/decrypted",
|
||||||
);
|
);
|
||||||
|
|
||||||
const { canvasDataWithDecryptedImages } = await decryptCanvasImages(
|
await decryptCanvasImages(
|
||||||
canvasData,
|
canvasData,
|
||||||
remoteImages,
|
remoteImages,
|
||||||
"wrapped-dek",
|
"wrapped-dek",
|
||||||
@@ -147,7 +153,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(api.get).toHaveBeenCalledWith(
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
`https://remote/photo.png.bin`,
|
"https://remote/photo.png.bin",
|
||||||
expect.objectContaining({ responseType: "blob" }),
|
expect.objectContaining({ responseType: "blob" }),
|
||||||
);
|
);
|
||||||
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
|
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
|
||||||
@@ -155,10 +161,8 @@ describe("letterLogic image helpers", () => {
|
|||||||
"wrapped-dek",
|
"wrapped-dek",
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
expect(canvasDataWithDecryptedImages.objects[0].src).toBe(
|
expect(canvasData.objects[0].src).toBe("blob:http://localhost/decrypted");
|
||||||
"blob:http://localhost/decrypted",
|
expect(canvasData.objects[1].text).toBe("hello");
|
||||||
);
|
|
||||||
expect(canvasDataWithDecryptedImages.objects[1].text).toBe("hello");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should include raw file when includeRawFile is true", async () => {
|
it("should include raw file when includeRawFile is true", async () => {
|
||||||
@@ -187,7 +191,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
new File(["raw"], "photo.png.bin"),
|
new File(["raw"], "photo.png.bin"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { canvasDataWithDecryptedImages } = await decryptCanvasImages(
|
await decryptCanvasImages(
|
||||||
canvasData,
|
canvasData,
|
||||||
remoteImages,
|
remoteImages,
|
||||||
"wrapped-dek",
|
"wrapped-dek",
|
||||||
@@ -200,9 +204,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
"blob:http://localhost/decrypted",
|
"blob:http://localhost/decrypted",
|
||||||
"photo.png.bin",
|
"photo.png.bin",
|
||||||
);
|
);
|
||||||
expect(
|
expect(canvasData.objects[0]._customRawFile).toBeInstanceOf(File);
|
||||||
canvasDataWithDecryptedImages.objects[0]._customRawFile,
|
|
||||||
).toBeInstanceOf(File);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,11 +223,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"]) });
|
||||||
@@ -234,7 +232,6 @@ describe("letterLogic image helpers", () => {
|
|||||||
"decryptImageWithSharingKey",
|
"decryptImageWithSharingKey",
|
||||||
).mockResolvedValue("blob:http://localhost/decrypted-shared");
|
).mockResolvedValue("blob:http://localhost/decrypted-shared");
|
||||||
|
|
||||||
const { canvasDataWithDecryptedImages } =
|
|
||||||
await decryptCanvasImagesWithSharingKey(
|
await decryptCanvasImagesWithSharingKey(
|
||||||
canvasData,
|
canvasData,
|
||||||
remoteImages,
|
remoteImages,
|
||||||
@@ -249,7 +246,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
expect(
|
expect(
|
||||||
CryptoUtils.prototype.decryptImageWithSharingKey,
|
CryptoUtils.prototype.decryptImageWithSharingKey,
|
||||||
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
|
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
|
||||||
expect(canvasDataWithDecryptedImages.objects[0].src).toBe(
|
expect(canvasData.objects[0].src).toBe(
|
||||||
"blob:http://localhost/decrypted-shared",
|
"blob:http://localhost/decrypted-shared",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { api, apiServerUrl, publicApi } from "../api/apiClient";
|
import { api } from "../api/apiClient";
|
||||||
import type { LetterImageData } from "../api/response";
|
|
||||||
import type {
|
import type {
|
||||||
CanvasJSON,
|
CanvasJSON,
|
||||||
FabricImageJSON,
|
FabricImageJSON,
|
||||||
@@ -12,35 +11,6 @@ export interface CanvasImageRef {
|
|||||||
file: File;
|
file: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecryptedFabricImageJSON extends FabricImageJSON {
|
|
||||||
_customRawFile?: File;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DecryptionResult {
|
|
||||||
canvasDataWithDecryptedImages: CanvasJSON;
|
|
||||||
isPartialFailure: boolean;
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncryptionResult {
|
|
||||||
encryptedImageFiles: Map<string, Blob>;
|
|
||||||
encryptedCanvasData: CanvasJSON;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchEncryptedBlobFromRemote(remoteUrl: string): Promise<Blob> {
|
|
||||||
// IF served statically from server, we need proper CORS setup
|
|
||||||
if (remoteUrl.includes(apiServerUrl)) {
|
|
||||||
const res = await api.get(remoteUrl, { responseType: "blob" });
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
// Note: S3 Storage fetch (external url) has to bypass our existing CORS setup
|
|
||||||
const res = await publicApi.get(remoteUrl, {
|
|
||||||
responseType: "blob",
|
|
||||||
withCredentials: false,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decryptCanvasImages(
|
export async function decryptCanvasImages(
|
||||||
canvasData: CanvasJSON,
|
canvasData: CanvasJSON,
|
||||||
remoteImages: { file_name: string; file: string }[],
|
remoteImages: { file_name: string; file: string }[],
|
||||||
@@ -48,120 +18,93 @@ export async function decryptCanvasImages(
|
|||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
cryptoUtils: CryptoUtils,
|
cryptoUtils: CryptoUtils,
|
||||||
includeRawFile = false,
|
includeRawFile = false,
|
||||||
): Promise<DecryptionResult> {
|
): Promise<{ isDecryptionPartialFailure: boolean; error: string }> {
|
||||||
if (!canvasData?.objects) {
|
if (!canvasData?.objects)
|
||||||
return {
|
return { isDecryptionPartialFailure: false, error: "" };
|
||||||
canvasDataWithDecryptedImages: canvasData,
|
let isDecryptionPartialFailure = false;
|
||||||
isPartialFailure: false,
|
let error = "";
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageMap = new Map(
|
const imageMap = new Map(
|
||||||
remoteImages.map((img) => [img.file_name, img.file]),
|
remoteImages.map((img) => [img.file_name, img.file]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const errors: string[] = [];
|
const imageDecryptionPromises = canvasData.objects.map(async (obj, index) => {
|
||||||
const processedObjects = await Promise.all(
|
if (obj.type !== "Image") return;
|
||||||
canvasData.objects.map(async (obj) => {
|
|
||||||
if (obj.type !== "Image") return obj;
|
|
||||||
|
|
||||||
const imgObj = obj as FabricImageJSON;
|
const imgObj = obj as FabricImageJSON;
|
||||||
const remoteUrl = imageMap.get(imgObj.src);
|
const remoteUrl = imageMap.get(imgObj.src);
|
||||||
if (!remoteUrl) return obj;
|
if (!remoteUrl) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
|
// HACK: For S3 Storage fetch and avoiding CORS error
|
||||||
|
const res = await api.get(remoteUrl, {
|
||||||
|
responseType: "blob",
|
||||||
|
withCredentials: false,
|
||||||
|
});
|
||||||
|
const originalSrc = imgObj.src;
|
||||||
|
|
||||||
const blobUrl = await cryptoUtils.decryptImage(
|
const blobUrl = await cryptoUtils.decryptImage(
|
||||||
blob,
|
res.data,
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
const decryptedObj: DecryptedFabricImageJSON = {
|
imgObj.src = blobUrl;
|
||||||
...imgObj,
|
|
||||||
src: blobUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (includeRawFile) {
|
if (includeRawFile) {
|
||||||
decryptedObj._customRawFile = await blobUrlToFile(
|
imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc);
|
||||||
blobUrl,
|
|
||||||
imgObj.src,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch (_error) {
|
||||||
return decryptedObj;
|
delete canvasData.objects[index];
|
||||||
} catch (err) {
|
isDecryptionPartialFailure = true;
|
||||||
errors.push(
|
error = _error instanceof Error ? _error.message : "Unknown error";
|
||||||
`Failed to decrypt ${imgObj.src}: ${err instanceof Error ? err.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
await Promise.all(imageDecryptionPromises);
|
||||||
canvasDataWithDecryptedImages: {
|
canvasData.objects = canvasData.objects.filter(Boolean);
|
||||||
...canvasData,
|
return { isDecryptionPartialFailure, error };
|
||||||
objects: processedObjects.filter((obj) => !!obj),
|
|
||||||
},
|
|
||||||
isPartialFailure: errors.length > 0,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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<{ isDecryptionPartialFailure: boolean; error: string }> {
|
||||||
if (!canvasData?.objects) {
|
if (!canvasData?.objects)
|
||||||
return {
|
return { isDecryptionPartialFailure: false, error: "" };
|
||||||
canvasDataWithDecryptedImages: canvasData,
|
let isDecryptionPartialFailure = false;
|
||||||
isPartialFailure: false,
|
let error = "";
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageMap = new Map(
|
const imageMap = new Map(
|
||||||
remoteImages.map((img) => [img.file_name, img.file]),
|
remoteImages.map((img) => [img.file_name, img.file]),
|
||||||
);
|
);
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
const processedObjects = await Promise.all(
|
const decryptionPromises = canvasData.objects.map(async (obj, index) => {
|
||||||
canvasData.objects.map(async (obj) => {
|
if (obj.type !== "Image") return;
|
||||||
if (obj.type !== "Image") return obj;
|
|
||||||
|
|
||||||
const imgObj = obj as FabricImageJSON;
|
const imgObj = obj as FabricImageJSON;
|
||||||
const remoteUrl = imageMap.get(imgObj.src);
|
const remoteUrl = imageMap.get(imgObj.src);
|
||||||
if (!remoteUrl) return obj;
|
if (!remoteUrl) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
|
const res = await api.get(remoteUrl, {
|
||||||
const blobUrl = await cryptoUtils.decryptImageWithSharingKey(
|
responseType: "blob",
|
||||||
blob,
|
withCredentials: false,
|
||||||
|
});
|
||||||
|
imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
|
||||||
|
res.data,
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
|
} catch (_error) {
|
||||||
return { ...imgObj, src: blobUrl };
|
delete canvasData.objects[index];
|
||||||
} catch (err) {
|
isDecryptionPartialFailure = true;
|
||||||
errors.push(
|
error = _error instanceof Error ? _error.message : "Unknown error";
|
||||||
`Failed to decrypt ${imgObj.src}: ${err instanceof Error ? err.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
await Promise.all(decryptionPromises);
|
||||||
canvasDataWithDecryptedImages: {
|
canvasData.objects = canvasData.objects.filter(Boolean);
|
||||||
...canvasData,
|
return { isDecryptionPartialFailure, error };
|
||||||
objects: processedObjects.filter((obj) => !!obj),
|
|
||||||
},
|
|
||||||
isPartialFailure: errors.length > 0,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptCanvasImages(
|
export async function encryptCanvasImages(
|
||||||
@@ -169,34 +112,23 @@ export async function encryptCanvasImages(
|
|||||||
canvasImages: CanvasImageRef[],
|
canvasImages: CanvasImageRef[],
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
cryptoUtils: CryptoUtils,
|
cryptoUtils: CryptoUtils,
|
||||||
): Promise<EncryptionResult> {
|
) {
|
||||||
const encryptedImageFiles = new Map<string, Blob>();
|
const encryptedFiles = new Map<string, Blob>();
|
||||||
const filenameMapping = new Map<string, string>();
|
const filenameMapping = new Map<string, string>();
|
||||||
|
|
||||||
// filter out already encrypted images
|
for (const img of canvasImages) {
|
||||||
const imagesToEncrypt = canvasImages.filter(
|
if (img.src.endsWith(".bin")) continue;
|
||||||
(img) => img.file && !img.src.endsWith(".bin"),
|
if (!img.file) continue;
|
||||||
);
|
|
||||||
|
|
||||||
// encrypt images parallelly
|
|
||||||
await Promise.all(
|
|
||||||
imagesToEncrypt.map(async (img) => {
|
|
||||||
const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
|
const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
|
||||||
img.file,
|
img.file,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
// map the og image url to the encrypted file name and filename to the encrypted source
|
|
||||||
filenameMapping.set(img.src, filename);
|
filenameMapping.set(img.src, filename);
|
||||||
encryptedImageFiles.set(filename, encryptedBlob);
|
encryptedFiles.set(filename, encryptedBlob);
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (!canvasData?.objects)
|
if (canvasData?.objects) {
|
||||||
return { encryptedImageFiles, encryptedCanvasData: canvasData };
|
canvasData.objects = canvasData.objects.map((obj) => {
|
||||||
|
|
||||||
const newCanvasData = {
|
|
||||||
...canvasData,
|
|
||||||
objects: canvasData.objects.map((obj) => {
|
|
||||||
if (obj.type === "Image") {
|
if (obj.type === "Image") {
|
||||||
const imgObj = obj as FabricImageJSON;
|
const imgObj = obj as FabricImageJSON;
|
||||||
if (filenameMapping.has(imgObj.src)) {
|
if (filenameMapping.has(imgObj.src)) {
|
||||||
@@ -207,8 +139,8 @@ export async function encryptCanvasImages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}),
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
return { encryptedImageFiles, encryptedCanvasData: newCanvasData };
|
return encryptedFiles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export default defineConfig({
|
|||||||
env: {
|
env: {
|
||||||
VITE_API_URL: "http://piku-server",
|
VITE_API_URL: "http://piku-server",
|
||||||
TZ: "Asia/Kolkata",
|
TZ: "Asia/Kolkata",
|
||||||
// using the actual 600_000 iterations causes timeout in tests
|
|
||||||
VITE_PBKDF2_ITERATIONS: "1",
|
|
||||||
},
|
},
|
||||||
include: ["**/*.test.ts", "**/*.test.tsx"],
|
include: ["**/*.test.ts", "**/*.test.tsx"],
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
|
|||||||
@@ -55,18 +55,6 @@ until $CONTAINER_BIN exec "$DB_NAME" pg_isready -U "${DB_USER:-test}" >/dev/null
|
|||||||
done
|
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.
|
|
||||||
if [ "$GITEA_ACTIONS" = "true" ]; then
|
|
||||||
sudo apt-get update && sudo apt-get install -y iproute2
|
|
||||||
# Sample: "default via <internal docker host IP> dev <network interface> proto dhcp src <IP> metric 100"
|
|
||||||
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
|
||||||
echo "Running on Gitea. Internal Docker host... $HOST_IP"
|
|
||||||
|
|
||||||
export DB_HOST=$HOST_IP
|
|
||||||
export EMAIL_HOST=$HOST_IP
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Starting Backend..."
|
echo "Starting Backend..."
|
||||||
mkdir -p ./tmp/logs
|
mkdir -p ./tmp/logs
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Setup Env from examples
|
|
||||||
if [ ! -f ".env" ]; then
|
|
||||||
cp .env.example .env
|
|
||||||
fi
|
|
||||||
if [ ! -f ".env.e2e" ]; then
|
|
||||||
cp .env.e2e.example .env.e2e
|
|
||||||
fi
|
|
||||||
|
|
||||||
NODE_BIN=$(command -v bun || command -v npm || true)
|
NODE_BIN=$(command -v bun || command -v npm || true)
|
||||||
PY_BIN=$(command -v uv || command -v pip || true)
|
PY_BIN=$(command -v uv || command -v pip || true)
|
||||||
DISTRO_BIN=$(command -v apt || command -v yum || command -v pacman || command -v zypper || true)
|
DISTRO_BIN=$(command -v apt || command -v yum || command -v pacman || command -v zypper || true)
|
||||||
@@ -43,20 +35,17 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Simplify ssl generation for local - source & credits:- https://github.com/FiloSottile/mkcert
|
# Simplify ssl generation for local - source & credits:- https://github.com/FiloSottile/mkcert
|
||||||
# Note, still try to perform the setup if pkg setups fail
|
echo "[Cert] Setting up SSL..."
|
||||||
{
|
# pre-requisites (might be available already, just in case)
|
||||||
echo "[Cert] Setting up SSL..."
|
if [ $(basename "$DISTRO_BIN") = "apt" ]; then
|
||||||
# pre-requisites (might be available already, just in case)
|
|
||||||
if [ $(basename "$DISTRO_BIN") = "apt" ]; then
|
|
||||||
sudo apt install -y libnss3-tools
|
sudo apt install -y libnss3-tools
|
||||||
elif [ $(basename "$DISTRO_BIN") = "yum" ]; then
|
elif [ $(basename "$DISTRO_BIN") = "yum" ]; then
|
||||||
sudo yum install -y nss-tools
|
sudo yum install -y nss-tools
|
||||||
elif [ $(basename "$DISTRO_BIN") = "pacman" ]; then
|
elif [ $(basename "$DISTRO_BIN") = "pacman" ]; then
|
||||||
sudo pacman -S --noconfirm nss
|
sudo pacman -S --noconfirm nss
|
||||||
elif [ $(basename "$DISTRO_BIN") = "zypper" ]; then
|
elif [ $(basename "$DISTRO_BIN") = "zypper" ]; then
|
||||||
sudo zypper install -y mozilla-nss-tools
|
sudo zypper install -y mozilla-nss-tools
|
||||||
fi
|
fi
|
||||||
} || true
|
|
||||||
|
|
||||||
# Detect os and arch to get the appropriate bin. Windows: ...NO SOUP FOR YOU!
|
# Detect os and arch to get the appropriate bin. Windows: ...NO SOUP FOR YOU!
|
||||||
OS=$(uname -s)
|
OS=$(uname -s)
|
||||||
|
|||||||