33 Commits

Author SHA1 Message Date
ramvignesh-b 90b04f2397 feat: refactor ComposeCanvas to use reactive style props and use complete declarative approach for editor+toolbar 2026-05-01 11:32:23 +05:30
ramvignesh-b 5f56b21823 style: add additional fonts for editor and update metadada 2026-05-01 03:57:23 +05:30
ramvignesh-b e32c7a7982 style: add responsive offset for saajan in home 2026-05-01 02:02:27 +05:30
ramvignesh-b 34c6de47cc chore: update email placeholder 2026-05-01 01:41:13 +05:30
ramvignesh-b a77e88496b feat: apply UI refinements to home page and add saajan 2026-05-01 01:41:02 +05:30
ramvignesh-b a0cacfbc8c refactor: use state for flap ops instead of ref in envelope comp 2026-05-01 00:09:27 +05:30
ramvignesh-b 49cd21cffe style: add outro and letter mockup to homepage 2026-05-01 00:09:00 +05:30
ramvignesh-b 9910e44ee2 refactor: extract custom utility classes for text and bg css properties 2026-05-01 00:04:17 +05:30
ramvignesh-b 49177a5b12 refactor: optimize ComposeCanvas textbox pick and canvas focus logic 2026-04-30 06:00:48 +05:30
ramvignesh-b 3f81b7be3a test: update textarea selectors in e2e tests 2026-04-30 06:00:12 +05:30
ramvignesh-b b6f45aa93c fix: reduce pbkdf iteration in tests to prevent timeout nad keep them fast 2026-04-30 05:25:55 +05:30
ramvignesh-b 2bb77d1bed feat: add custom font styling to canvas text 2026-04-30 05:23:36 +05:30
ramvignesh-b 70a056a1d6 refactor: extract welcome modal for consistency 2026-04-29 23:12:39 +05:30
ramvignesh-b d9e1febfee refactor: update route related simplifications 2026-04-29 23:12:05 +05:30
ramvignesh-b df96cead93 refactor: simplify letterlogic by removing object mutation 2026-04-29 23:09:32 +05:30
ramvignesh-b b9716d368d refactor: simplify crypto utils 2026-04-29 22:19:00 +05:30
ramvignesh-b d9827c9e82 style: refactor email template 2026-04-29 15:14:39 +05:30
ramvignesh-b a6bde0258d refactor: enable proper progation for loggers 2026-04-29 15:14:19 +05:30
ramvignesh-b a987241120 refactor: update crypto to generate iv on demand 2026-04-29 03:11:04 +05:30
ramvignesh-b ebf7186b06 chore: add favicon assets and manifest 2026-04-29 03:07:31 +05:30
ramvignesh-b 150832419a feat: enable scheduler execution in Docker by checking UVICORN_MAIN environment variable 2026-04-29 02:20:18 +05:30
ramvignesh-b 4893c91c20 fix: update reader splash screen to be full width and height 2026-04-29 01:36:00 +05:30
ramvignesh-b dc0d688885 chore: import and apply custom logging configuration in settings 2026-04-29 01:14:11 +05:30
ramvignesh-b 46c7d9ffeb refactor: centralize log directory path handling and remove redundant log directory creation in Dockerfile 2026-04-29 00:34:16 +05:30
ramvignesh-b 16a04ae4b8 style: add dashed primary border to Saajan component tooltip 2026-04-28 23:23:04 +05:30
ramvignesh-b 574baa6860 feat: enhance homescreen with scroll motion 2026-04-28 23:22:57 +05:30
ramvignesh-b 35e8d6761e feat: add candle to ignite envelope 2026-04-28 23:09:25 +05:30
ramvignesh-b faee0b45d6 refactor: implement reusable Modal component 2026-04-28 22:52:06 +05:30
ramvignesh-b 8b28949d73 feat: re-use dek and copntent ivs for a letteer's metadata and content 2026-04-28 20:51:34 +05:30
RamVignesh B 6cf24731ce Feature/saajan persona (#3)
* feat: add template based email content (html + plaintext fallback)

* feat: init saajan component

* feat: add aesthetic noise background and implement Saajan component in register and login

* feat: add post seal modal for vault

* refactor: add proper props interfaces

* refactor: expose props on ui components

* feat: add ssajan in lots of flows

* fix: remove render test with no value and add aria helper for btn identification

* refactor: update email notification to account for proper arguments

* refactor:  refactor E2E auth helper and mail parsing logic

---------

Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
2026-04-28 20:51:23 +05:30
ramvignesh-b 8a9ded42b5 feat: add secure proxy configuration for http to https 2026-04-28 18:02:27 +05:30
ramvignesh-b f522a369ab chore: streamline frontend Docker build arguments 2026-04-28 17:17:24 +05:30
RamVignesh B 48b6a06571 Feature/s3 integration (#2)
* feat: add s3 storage for media

* refactor: update letter decryption test to look for key request properties

* fix: update db url to be ipv4 for ssl context match

* ci: output backend logs to the console

* ci: unset email host creds for local testing

---------

Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
2026-04-26 21:40:00 +05:30
80 changed files with 2367 additions and 1170 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
DB_NAME=piku_test_db DB_NAME=piku_test_db
DB_USER=test DB_USER=test
DB_PASSWORD=password123 DB_PASSWORD=password123
DB_HOST=localhost DB_HOST=127.0.0.1
DB_PORT=5433 DB_PORT=5433
# SSL # SSL
@@ -17,8 +17,8 @@ BACKEND_PORT=8001
# EMAIL # EMAIL
EMAIL_HOST=127.0.0.1 EMAIL_HOST=127.0.0.1
EMAIL_PORT=1026 EMAIL_PORT=1026
EMAIL_HOST_USER=test EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=password123 EMAIL_HOST_PASSWORD=
FROM_EMAIL="Test <test@pi-ku.app>" FROM_EMAIL="Test <test@pi-ku.app>"
EMAIL_API_PORT=8026 EMAIL_API_PORT=8026
+10 -3
View File
@@ -2,23 +2,30 @@
DB_NAME=piku DB_NAME=piku
DB_USER=user DB_USER=user
DB_PASSWORD=password123 DB_PASSWORD=password123
DB_HOST=localhost DB_HOST=127.0.0.1
DB_PORT=5432 DB_PORT=5432
# SSL # SSL
SSL_ENABLED=true SSL_ENABLED=true
S3_ENABLED=false
# DJANGO # DJANGO
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=8000 BACKEND_PORT=8000
# S3
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_REGION_NAME=
R2_ENDPOINT_URL=
R2_PUBLIC_URL=
# EMAIL # EMAIL
EMAIL_HOST=127.0.0.1 EMAIL_HOST=127.0.0.1
EMAIL_PORT=1025 EMAIL_PORT=1025
EMAIL_HOST_USER=test EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=password123 EMAIL_HOST_PASSWORD=
FROM_EMAIL="Pi Ku <no-reply@test.com>" FROM_EMAIL="Pi Ku <no-reply@test.com>"
# FRONTEND # FRONTEND
+4
View File
@@ -145,3 +145,7 @@ jobs:
name: playwright-report name: playwright-report
path: frontend/playwright-report/ path: frontend/playwright-report/
retention-days: 10 retention-days: 10
- name: Print Backend Logs on Failure
if: failure()
run: cat tmp/logs/backend.log || true
+1
View File
@@ -10,3 +10,4 @@ __pycache__/
docs/ docs/
encrypted-images/ encrypted-images/
logs/
+2 -4
View File
@@ -10,9 +10,7 @@ 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
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"] # 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 && UVICORN_MAIN=true uv run gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - --capture-output --log-level debug config.wsgi:application"]
+17 -10
View File
@@ -1,5 +1,12 @@
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,
@@ -41,22 +48,22 @@ LOGGING = {
}, },
"json_file": { "json_file": {
"class": "logging.handlers.WatchedFileHandler", "class": "logging.handlers.WatchedFileHandler",
"filename": "logs/json.log", "filename": LOGS_DIR / "json.log",
"formatter": "json_formatter", "formatter": "json_formatter",
}, },
"flat_line_file": { "flat_line_file": {
"class": "logging.handlers.WatchedFileHandler", "class": "logging.handlers.WatchedFileHandler",
"filename": "logs/flat_line.log", "filename": LOGS_DIR / "flat_line.log",
"formatter": "key_value", "formatter": "key_value",
}, },
"letters_log": { "letters_log": {
"class": "logging.handlers.WatchedFileHandler", "class": "logging.handlers.WatchedFileHandler",
"filename": "logs/letters.log", "filename": LOGS_DIR / "letters.log",
"formatter": "key_value", "formatter": "key_value",
}, },
"scheduler_log": { "scheduler_log": {
"class": "logging.handlers.WatchedFileHandler", "class": "logging.handlers.WatchedFileHandler",
"filename": "logs/scheduler.log", "filename": LOGS_DIR / "scheduler.log",
"formatter": "key_value", "formatter": "key_value",
}, },
}, },
@@ -71,18 +78,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", "flat_line_file", "json_file"], "handlers": ["console"],
"level": "INFO", "level": "INFO",
}, },
}, },
+54 -2
View File
@@ -16,6 +16,8 @@ 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
@@ -25,8 +27,14 @@ 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)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) # Security Settings
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN")) 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("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=[])
SSL_ENABLED = env.bool("SSL_ENABLED", default=False) SSL_ENABLED = env.bool("SSL_ENABLED", default=False)
URI_SCHEME = "https://" if SSL_ENABLED else "http://" URI_SCHEME = "https://" if SSL_ENABLED else "http://"
@@ -48,6 +56,7 @@ 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
@@ -78,6 +87,21 @@ 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"
@@ -98,6 +122,7 @@ DATABASES = {
} }
CORS_ALLOWED_ORIGINS = FRONTEND_URLS CORS_ALLOWED_ORIGINS = FRONTEND_URLS
CSRF_TRUSTED_ORIGINS += FRONTEND_URLS
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
AUTH_USER_MODEL = "users.User" AUTH_USER_MODEL = "users.User"
@@ -172,4 +197,31 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
if env.bool("S3_ENABLED", default=False):
MEDIA_URL = f"{env('R2_PUBLIC_URL')}/media/"
# HACK: S3 auto pre-pends the url scheme forcefully and this prevents double https
R2_HOST = env("R2_PUBLIC_URL").replace("https://", "")
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"access_key": env("R2_ACCESS_KEY_ID"),
"secret_key": env("R2_SECRET_ACCESS_KEY"),
"bucket_name": env("R2_STORAGE_BUCKET_NAME"),
"region_name": env("R2_REGION_NAME"),
"endpoint_url": env("R2_ENDPOINT_URL"),
"location": "media",
"signature_version": "s3v4",
"file_overwrite": False,
"custom_domain": R2_HOST,
"querystring_auth": False,
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
DEFAULT_FILE_STORAGE = "storages.backends.s3.S3Storage"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
+6 -2
View File
@@ -10,9 +10,13 @@ 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 (
if not (os.environ.get("RUN_MAIN") == "true" or os.environ.get("WERKZEUG_RUN_MAIN") == "true"): os.environ.get("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
+20 -1
View File
@@ -3,8 +3,10 @@ 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__)
@@ -23,9 +25,26 @@ def notify_unlocked_letter(letter):
""" """
author = letter.user.get_username() author = letter.user.get_username()
try: try:
send_mail(subject="", message="", from_email=settings.FROM_EMAIL, recipient_list=[author], fail_silently=False) letter_link = f"{FRONTEND_URLS[0]}/read/{letter.public_id}"
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")
+1
View File
@@ -396,6 +396,7 @@ 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)
+2
View File
@@ -6,11 +6,13 @@ readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"apscheduler>=3.11.2", "apscheduler>=3.11.2",
"boto3>=1.42.96",
"django>=6.0.4", "django>=6.0.4",
"django-apscheduler>=0.7.0", "django-apscheduler>=0.7.0",
"django-cors-headers>=4.9.0", "django-cors-headers>=4.9.0",
"django-environ>=0.13.0", "django-environ>=0.13.0",
"django-extensions>=4.1", "django-extensions>=4.1",
"django-storages>=1.14.6",
"django-structlog>=10.0.0", "django-structlog>=10.0.0",
"djangorestframework>=3.17.1", "djangorestframework>=3.17.1",
"djangorestframework-simplejwt>=5.5.1", "djangorestframework-simplejwt>=5.5.1",
+22
View File
@@ -0,0 +1,22 @@
{% 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 %}
+21
View File
@@ -0,0 +1,21 @@
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.
+103
View File
@@ -0,0 +1,103 @@
<!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;">
&nbsp;</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>
+20
View File
@@ -0,0 +1,20 @@
{% 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 %}
+17
View File
@@ -0,0 +1,17 @@
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.
+20 -10
View File
@@ -1,6 +1,7 @@
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
@@ -9,16 +10,25 @@ 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 Piku Account" subject = "Activate your pi. ku. account"
message = f"""Hi {user.full_name}, context = {
"pen_name": user.full_name,
Welcome to Pi Ku. "footnote": True,
"cta": {
Please click the link below to activate your account: "title": "Onboard",
>> {activation_url} "link": activation_url,
},
If you did not create this account, please ignore this email.""" }
send_mail(subject, message, settings.FROM_EMAIL, [user.email], fail_silently=False) html_content = render_to_string("email/activation.html", context)
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
+74
View File
@@ -23,6 +23,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
] ]
[[package]]
name = "boto3"
version = "1.42.96"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/2d/69fb3acd50bab83fb295c167d33c4b653faeb5fb0f42bfca4d9b69d6fb68/boto3-1.42.96.tar.gz", hash = "sha256:b38a9e4a3fbbee9017252576f1379780d0a5814768676c08df2f539d31fcdd68", size = 113203, upload-time = "2026-04-24T19:47:18.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/9d/b3f617d011c42eb804d993103b8fa9acdce153e181a3042f58bfe33d7cb4/boto3-1.42.96-py3-none-any.whl", hash = "sha256:2f4566da2c209a98bdbfc874d813ef231c84ad24e4f815e9bc91de5f63351a24", size = 140557, upload-time = "2026-04-24T19:47:15.824Z" },
]
[[package]]
name = "botocore"
version = "1.42.96"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/77/2c333622a1d47cf5bf73cdcab0cb6c92addafbef2ec05f81b9f75687d9e5/botocore-1.42.96.tar.gz", hash = "sha256:75b3b841ffacaa944f645196655a21ca777591dd8911e732bfb6614545af0250", size = 15263344, upload-time = "2026-04-24T19:47:05.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/56/152c3a859ca1b9d77ed16deac3cf81682013677c68cf5715698781fc81bd/botocore-1.42.96-py3-none-any.whl", hash = "sha256:db2c3e2006628be6fde81a24124a6563c363d6982fb92728837cf174bad9d98a", size = 14945920, upload-time = "2026-04-24T19:47:00.323Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -182,6 +210,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/33/bf539925b102d68200da5b1d3eacb8aa5d5d9a065972e8b8724d0d53bb0d/django_ipware-7.0.1-py2.py3-none-any.whl", hash = "sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709", size = 6425, upload-time = "2024-04-19T20:02:47.469Z" }, { url = "https://files.pythonhosted.org/packages/11/33/bf539925b102d68200da5b1d3eacb8aa5d5d9a065972e8b8724d0d53bb0d/django_ipware-7.0.1-py2.py3-none-any.whl", hash = "sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709", size = 6425, upload-time = "2024-04-19T20:02:47.469Z" },
] ]
[[package]]
name = "django-storages"
version = "1.14.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
]
[[package]] [[package]]
name = "django-structlog" name = "django-structlog"
version = "10.0.0" version = "10.0.0"
@@ -289,6 +329,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" version = "4.0.0"
@@ -355,11 +404,13 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "boto3" },
{ name = "django" }, { name = "django" },
{ name = "django-apscheduler" }, { name = "django-apscheduler" },
{ name = "django-cors-headers" }, { name = "django-cors-headers" },
{ name = "django-environ" }, { name = "django-environ" },
{ name = "django-extensions" }, { name = "django-extensions" },
{ name = "django-storages" },
{ name = "django-structlog" }, { name = "django-structlog" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" }, { name = "djangorestframework-simplejwt" },
@@ -377,11 +428,13 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "apscheduler", specifier = ">=3.11.2" }, { name = "apscheduler", specifier = ">=3.11.2" },
{ name = "boto3", specifier = ">=1.42.96" },
{ name = "django", specifier = ">=6.0.4" }, { name = "django", specifier = ">=6.0.4" },
{ name = "django-apscheduler", specifier = ">=0.7.0" }, { name = "django-apscheduler", specifier = ">=0.7.0" },
{ name = "django-cors-headers", specifier = ">=4.9.0" }, { name = "django-cors-headers", specifier = ">=4.9.0" },
{ name = "django-environ", specifier = ">=0.13.0" }, { name = "django-environ", specifier = ">=0.13.0" },
{ name = "django-extensions", specifier = ">=4.1" }, { name = "django-extensions", specifier = ">=4.1" },
{ name = "django-storages", specifier = ">=1.14.6" },
{ name = "django-structlog", specifier = ">=10.0.0" }, { name = "django-structlog", specifier = ">=10.0.0" },
{ name = "djangorestframework", specifier = ">=3.17.1" }, { name = "djangorestframework", specifier = ">=3.17.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
@@ -513,6 +566,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
] ]
[[package]]
name = "s3transfer"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@@ -579,6 +644,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
] ]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.8" version = "3.1.8"
-7
View File
@@ -4,15 +4,8 @@ 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
+21
View File
@@ -8,8 +8,12 @@
"@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",
@@ -17,6 +21,7 @@
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"fabric": "^7.2.0", "fabric": "^7.2.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"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",
@@ -118,10 +123,18 @@
"@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=="],
@@ -402,6 +415,8 @@
"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=="],
@@ -526,6 +541,12 @@
"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=="],
+12 -6
View File
@@ -34,7 +34,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
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.getByLabel("Canvas text input"); const canvasInput = page.locator("textarea");
await canvasInput.waitFor({ state: "attached" }); 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);
@@ -60,8 +60,14 @@ 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 disappear // Wait for initial load overlay to appear and then definitely disappear
await expect(page.getByText(/opening your draft/i)).toBeHidden(); await page
.getByText(/opening your draft/i)
.waitFor({ state: "visible", timeout: 2000 })
.catch(() => {});
await expect(page.getByText(/opening your draft/i)).toBeHidden({
timeout: 10000,
});
// Check recipient // Check recipient
await expect(page.locator("#recipient")).toHaveValue(recipientName); await expect(page.locator("#recipient")).toHaveValue(recipientName);
@@ -92,7 +98,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
await recipientInput.waitFor({ state: "visible", timeout: 10000 }); await recipientInput.waitFor({ state: "visible", timeout: 10000 });
await recipientInput.fill("A Secret Guest"); await recipientInput.fill("A Secret Guest");
const canvasInput = page.getByLabel("Canvas text input"); const canvasInput = page.locator("textarea");
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.");
@@ -167,7 +173,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
await recipientInput.waitFor({ state: "visible" }); await recipientInput.waitFor({ state: "visible" });
await recipientInput.fill(recipientName); await recipientInput.fill(recipientName);
const canvasInput = page.getByLabel("Canvas text input"); const canvasInput = page.locator("textarea");
await canvasInput.focus(); await canvasInput.focus();
await canvasInput.fill(letterContent); await canvasInput.fill(letterContent);
@@ -185,7 +191,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({ await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
await page.getByRole("button", { name: /keep it/i }).click(); await page.getByRole("button", { name: /keep it to myself/i }).click();
// Open "Kept" section - search for the section with id='kept' and click its toggle button // Open "Kept" section - search for the section with id='kept' and click its toggle button
logger.info(">> [Drawer] Opening Kept section..."); logger.info(">> [Drawer] Opening Kept section...");
+5 -7
View File
@@ -14,13 +14,13 @@ const logger = pino({
/** /**
* Completes the full registration -> activation -> login cycle. * Completes the full registration -> activation -> login cycle.
*/ */
export async function registerAndLogin( async function registerAndLogin(
page: Page, page: Page,
email: string, email: string,
fullName: string, fullName: string,
password: string, password: string,
) { ) {
// 1. Registration // Register the User
logger.info(`[Auth] Registering user: ${email}`); logger.info(`[Auth] Registering user: ${email}`);
await page.goto("/onboard"); await page.goto("/onboard");
await page.getByLabel(/pen name/i).fill(fullName); await page.getByLabel(/pen name/i).fill(fullName);
@@ -31,7 +31,7 @@ export async function registerAndLogin(
await expect(page).toHaveURL(/\/verify-email/); await expect(page).toHaveURL(/\/verify-email/);
// 2. Activation via Mailpit // Get activation URL from Mailpit and activate user
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);
@@ -40,11 +40,11 @@ export async function registerAndLogin(
await expect(page.getByText(/account activated/i)).toBeVisible(); await expect(page.getByText(/account activated/i)).toBeVisible();
await page.getByRole("button", { name: /start writing/i }).click(); await page.getByRole("button", { name: /start writing/i }).click();
// 3. Login // Dismiss the Welcom Modal and Perform Login
logger.info(`[Auth] Logging in...`); logger.info(`[Auth] Logging in...`);
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
const welcomeButton = page.getByRole("button", { name: /i understand/i }); const welcomeButton = page.getByRole("button", { name: /I'll remember/i });
await welcomeButton.waitFor({ state: "visible", timeout: 10000 }); await welcomeButton.waitFor({ state: "visible", timeout: 10000 });
await welcomeButton.click(); await welcomeButton.click();
await expect(welcomeButton).toBeHidden(); await expect(welcomeButton).toBeHidden();
@@ -56,6 +56,4 @@ export async function registerAndLogin(
await expect(page).toHaveURL(/\/drawer/); await expect(page).toHaveURL(/\/drawer/);
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 };
+2 -2
View File
@@ -31,8 +31,8 @@ export const MailpitHelper = {
); );
const details = await detailRes.json(); const details = await detailRes.json();
const body = details.HTML || details.Text || ""; const body = 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];
} }
+6 -2
View File
@@ -4,10 +4,14 @@
<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 unsent letters</title> <title>Pi. Ku. | A safe haven for your unsaid and unsent letters</title>
<meta name="description" <meta name="description"
content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal digital letters." /> content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal your unsaid words into digital letters." />
</head> </head>
<body> <body>
+5
View File
@@ -22,8 +22,12 @@
"@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",
@@ -31,6 +35,7 @@
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"fabric": "^7.2.0", "fabric": "^7.2.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Pi. Ku.",
"short_name": "Pi. Ku.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#d4a24f",
"background_color": "#3b1d13",
"display": "standalone"
}
+6 -7
View File
@@ -1,12 +1,10 @@
import { lazy, Suspense, useEffect } from "react"; import { lazy, Suspense, useEffect, useRef } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards"; import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
import SplashScreen from "./components/SplashScreen"; import SplashScreen from "./components/SplashScreen";
import { ROUTES } from "./config/routes"; import { ROUTES } from "./config/routes";
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"));
@@ -18,11 +16,12 @@ const VerifyEmail = lazy(() => import("./pages/VerifyEmail"));
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) return; if (authInitialized.current) return;
authInitialized = true; authInitialized.current = true;
initialize(); initialize().then();
}, [initialize]); }, [initialize]);
if (isInitializing) { if (isInitializing) {
@@ -31,7 +30,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full"> <main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<Suspense fallback={<SplashScreen />}> <Suspense fallback={<SplashScreen />}>
<Routes> <Routes>
<Route path={ROUTES.HOME} element={<Home />} /> <Route path={ROUTES.HOME} element={<Home />} />
+9 -10
View File
@@ -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: import.meta.env.VITE_API_URL, baseURL: apiServerUrl,
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: import.meta.env.VITE_API_URL, baseURL: apiServerUrl,
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,29 +22,28 @@ 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 401 and we haven't tried refreshing yet // if first time 401 and we haven't tried refreshing yet, we proceed with silent refresh
// 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 // Update store with the latest accesstoken
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) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+5 -5
View File
@@ -1,7 +1,7 @@
import { DotIcon } from "@phosphor-icons/react"; import { DotIcon } from "@phosphor-icons/react";
import "@fontsource/knewave/400.css"; import "@fontsource/knewave/400.css";
export default function Logo({ scale = 2 }) { export default function Logo({ scale = 1 }) {
return ( return (
<div <div
role="img" role="img"
@@ -9,16 +9,16 @@ export default function Logo({ scale = 2 }) {
className="inline-flex items-baseline justify-center leading-none select-none" className="inline-flex items-baseline justify-center leading-none select-none"
style={{ fontFamily: "'Knewave', serif", scale }} style={{ fontFamily: "'Knewave', serif", scale }}
> >
<span className={`text-xl font-light text-accent`}>&nbsp;Pi</span> <span className={`text-3xl font-light text-accent`}>Pi</span>
<DotIcon <DotIcon
weight="fill" weight="fill"
size={6} size={12}
className={`text-primary translate-y-1 -mx-px`} className={`text-primary translate-y-1 -mx-px`}
/> />
<span className={`text-xl font-light text-accent`}>&nbsp;Ku</span> <span className={`text-3xl font-light text-accent`}>&nbsp;Ku</span>
<DotIcon <DotIcon
weight="fill" weight="fill"
size={6} size={12}
className={`text-primary translate-y-1 -mx-px`} className={`text-primary translate-y-1 -mx-px`}
/> />
</div> </div>
+5 -5
View File
@@ -4,8 +4,9 @@ import { useAuth } from "../hooks/useAuth";
import SplashScreen from "./SplashScreen"; import SplashScreen from "./SplashScreen";
/** /**
* Post-login routes. * Private route guard.
* Redirects to /login if not already authenticated. * If not authenticated, capture the current url in route
* 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();
@@ -14,7 +15,6 @@ 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,8 +22,8 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
} }
/** /**
* Pre-login flows. * Public - auth route guard.
* Redirects to /drawer if already authenticated. * If authenticated, redirect all the auth related flows to the drawer
*/ */
export function PublicRoute({ children }: { children: React.ReactNode }) { export function PublicRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isInitializing } = useAuth(); const { isAuthenticated, isInitializing } = useAuth();
+1 -1
View File
@@ -15,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-[1em] opacity-40"> <p className="text-xs uppercase font-sans tracking-widester opacity-40">
Unsealing Unsealing
</p> </p>
</div> </div>
@@ -39,7 +39,7 @@ export function DrawerSection({
> >
<div className="flex-1"> <div className="flex-1">
<div <div
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"
+10 -8
View File
@@ -2,6 +2,15 @@ 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,
@@ -9,14 +18,7 @@ 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;
+40 -41
View File
@@ -1,4 +1,5 @@
import { LockKeyIcon } from "@phosphor-icons/react"; import { LockKeyIcon } from "@phosphor-icons/react";
import { Modal } from "../ui/Modal";
interface PasskeyModalProps { interface PasskeyModalProps {
onUnlock: (password: string) => Promise<void>; onUnlock: (password: string) => Promise<void>;
@@ -6,47 +7,45 @@ interface PasskeyModalProps {
export function PasskeyModal({ onUnlock }: PasskeyModalProps) { export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
return ( return (
<div className="modal modal-open bg-base-100/20 backdrop-blur-md z-100"> <Modal isOpen={true}>
<div className="modal-box p-12 flex flex-col items-center"> <LockKeyIcon
<LockKeyIcon 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-bold text-lg font-display text-primary">
<h3 className="font-bold text-lg font-display text-primary"> Authentication Required
Authentication Required </h3>
</h3> <p className="py-4 font-sans">
<p className="py-4 font-sans"> We need your passkey to open your letters
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"> Your passkey is used to decrypt your data locally.
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 className="form-control w-full inline-flex"
className="form-control w-full inline-flex" onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => { e.preventDefault();
e.preventDefault(); 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 onUnlock(password);
await onUnlock(password); }}
}} >
> <input
<input name="password"
name="password" required
required type="password"
type="password" placeholder="password"
placeholder="password" 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 type="submit" className="btn btn-primary rounded-l-none">
<button type="submit" className="btn btn-primary rounded-l-none"> Unlock
Unlock </button>
</button> </form>
</form>
</div>
</div> </div>
</div> </Modal>
); );
} }
+208 -318
View File
@@ -1,15 +1,12 @@
import * as fabric from "fabric"; import * as fabric from "fabric";
import { import type * as React from "react";
forwardRef, import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from "react";
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;
@@ -18,6 +15,7 @@ export interface FabricObjectJSON {
left: number; left: number;
width: number; width: number;
height: number; height: number;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -33,121 +31,26 @@ 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;
} }
const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => { // NOTE: We use the same canvasData to render on both mobile and desktop viewports.
return new Promise((resolve) => { // Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up)
const check = () => { // over the last saved canvas size.
const width = wrapper.clientWidth || 0;
if (width > 0) resolve(width);
else requestAnimationFrame(check);
};
check();
});
};
const createMainTextbox = (
text: string,
isReadOnly = false,
): fabric.Textbox => {
return new fabric.Textbox(text, {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: BASE_WIDTH - PAD * 2,
fontSize: 18,
fontWeight: 500,
fontFamily: "Playfair Display Variable",
fill: "#000",
lineHeight: 1.5,
editable: !isReadOnly,
selectable: false,
evented: !isReadOnly,
hasControls: false,
hasBorders: false,
objectCaching: false,
splitByGrapheme: false,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
});
};
const fixFabricA11y = () => {
const textAreas = document.querySelectorAll(
'textarea[data-fabric="textarea"]',
);
for (const area of textAreas) {
if (!area.getAttribute("aria-label")) {
area.setAttribute("aria-label", "Canvas text input");
}
}
};
const initializeCanvas = (
el: HTMLCanvasElement,
width: number,
height: number,
readOnly: boolean,
) => {
const canvas = new fabric.Canvas(el, {
width,
height,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
enableRetinaScaling: true,
objectCaching: false,
});
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
return canvas;
};
const getLogicalSize = (data: CanvasJSON | null) => {
return {
width: data?.canvasWidth ?? BASE_WIDTH,
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
};
};
const getObjectBottom = (obj: fabric.FabricObject) => {
const top = obj.top ?? 0;
const height =
typeof obj.getScaledHeight === "function"
? 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 = ( const applyResponsiveViewport = (
canvas: fabric.Canvas, canvas: fabric.Canvas,
wrapper: HTMLDivElement, wrapper: HTMLDivElement,
@@ -155,8 +58,8 @@ const applyResponsiveViewport = (
logicalHeight: number, logicalHeight: number,
) => { ) => {
const physicalWidth = wrapper.clientWidth || logicalWidth; const physicalWidth = wrapper.clientWidth || logicalWidth;
const zoom = physicalWidth / logicalWidth; const zoomMultiplier = physicalWidth / logicalWidth;
const physicalHeight = Math.max(1, logicalHeight * zoom); const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
canvas.setDimensions({ canvas.setDimensions({
width: physicalWidth, width: physicalWidth,
@@ -164,41 +67,45 @@ const applyResponsiveViewport = (
}); });
wrapper.style.height = `${physicalHeight}px`; wrapper.style.height = `${physicalHeight}px`;
canvas.setViewportTransform([zoom, 0, 0, zoom, 0, 0]); canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
canvas.requestRenderAll(); canvas.requestRenderAll();
}; };
const focusTextbox = ( // to find the maximum height of the content to dynamically resize the canvas
fCanvas: fabric.Canvas, // would've been wayyy easier only if canvas supported fit-content like CSS property :)
textbox: fabric.Textbox, const measureLogicalContentHeight = (
readOnly: boolean, canvas: fabric.Canvas,
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
) => { ) => {
if (readOnly) return; const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
const top = currObj.top;
const height = currObj.getScaledHeight();
return Math.max(maxHeight, top + height);
}, 0);
fCanvas.setActiveObject(textbox); return Math.max(minimumHeight, maxBottom + PAD);
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 DEFAULT_INIT_TEXT = "Take a deep breath...";
const textbox = canvas.getObjects("Textbox")[0];
return (textbox as fabric.Textbox) ?? null; interface ComposeCanvasProps {
}; readOnly?: boolean;
initialData?: CanvasJSON | null;
style?: CanvasStyle;
ref?: React.Ref<CanvasTools>;
}
export const ComposeCanvas = forwardRef< export function ComposeCanvas({
CanvasTools, readOnly = false,
{ readOnly?: boolean; initialData?: CanvasJSON | null } initialData = null,
>(({ readOnly = false, initialData = null }, ref) => { style,
ref,
}: ComposeCanvasProps) {
// wrapper is the parent div box
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null); const fabricRef = useRef<fabric.Canvas | null>(null);
const textboxRef = useRef<fabric.Textbox | null>(null); const textboxRef = useRef<fabric.Textbox | null>(null);
const deferredDataRef = useRef<CanvasJSON | null>(null); const deferredDataRef = useRef<CanvasJSON | null>(null);
const logicalSizeRef = useRef({ const logicalSizeRef = useRef({
@@ -206,186 +113,202 @@ export const ComposeCanvas = forwardRef<
height: DEFAULT_LOGICAL_HEIGHT, height: DEFAULT_LOGICAL_HEIGHT,
}); });
// re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => { const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return; if (!(fabricRef.current && wrapperRef.current)) return;
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
fabricRef.current,
minHeight,
);
applyResponsiveViewport( applyResponsiveViewport(
fabricRef.current, fabricRef.current,
wrapperRef.current, wrapperRef.current,
logicalSizeRef.current.width, logicalSizeRef.current.width,
logicalSizeRef.current.height, logicalSizeRef.current.height,
); );
}, []);
const updateLogicalHeightFromContent = useCallback(() => { fabricRef.current.requestRenderAll();
if (!fabricRef.current) return; }, [initialData]);
logicalSizeRef.current.height = measureLogicalContentHeight( // auto focus the cursor into the main textbox no matter the latest element added
fabricRef.current, const focusTextbox = useCallback(
logicalSizeRef.current.height, (textbox: fabric.Textbox) => {
); if (readOnly || !fabricRef.current) return;
syncViewport(); fabricRef.current.setActiveObject(textbox);
}, [syncViewport]); textbox.enterEditing();
const setupTextboxInteractions = useCallback( // move the cursor to the end of the text
(fCanvas: fabric.Canvas, textbox: fabric.Textbox) => { const textLength = textbox.text?.length ?? 0;
textbox.on("changed", () => { textbox.selectionStart = textLength;
updateLogicalHeightFromContent(); textbox.selectionEnd = textLength;
});
fCanvas.on("mouse:down", (opt) => { fabricRef.current.requestRenderAll();
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);
}
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], [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,
};
if (data?.objects?.length) {
await canvas.loadFromJSON(data);
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
} else {
// Create a fresh letter if no data exists
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: BASE_WIDTH - PAD * 2,
fontSize: 18,
fontWeight: 500,
fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5,
// NOTE: splitByGrapheme is required for word wrap and re-low
// but fabric asks to disable this for clear font?? So we disable it for read view
splitByGrapheme: !readOnly,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
noScaleCache: false,
});
canvas.add(textbox);
}
if (!textbox) return;
// readonly contraints applicable for post seal view
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);
}
});
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],
);
useEffect(() => {
if (style && textboxRef.current) {
const textBox = textboxRef.current;
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
textBox.fill = style.fontColor || textBox.fill;
syncViewport();
}
}, [style, syncViewport]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
let canvas: fabric.Canvas | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0; let lastWidth = 0;
const init = async () => { const initCanvas = async () => {
// HACK: actual font may change the text-width - small ux improvement
await document.fonts.ready; await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return; if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
const finalWidth = await waitForLayout(wrapperRef.current); let width = wrapperRef.current.clientWidth;
if (!(isMounted && canvasRef.current && wrapperRef.current)) return; if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
canvas = initializeCanvas( // init the fabric instance
canvasRef.current, const canvas = new fabric.Canvas(canvasRef.current, {
finalWidth, width,
DEFAULT_LOGICAL_HEIGHT, height: DEFAULT_LOGICAL_HEIGHT,
readOnly, selection: !readOnly,
); preserveObjectStacking: true,
allowTouchScrolling: true,
enableRetinaScaling: true,
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;
if (wrapperEl) wrapperEl.style.background = "transparent";
fabricRef.current = canvas; fabricRef.current = canvas;
const textbox = await loadContent( await loadContent(initialData);
canvas,
initialData,
wrapperRef.current,
);
if (textbox) { // sometimes loadData() may be called before the canvas finished the init render
textboxRef.current = textbox; // so we retry that stashed render right after the init
setupTextboxInteractions(canvas, textbox); if (deferredDataRef.current) {
await loadContent(deferredDataRef.current);
deferredDataRef.current = null;
} }
canvas.requestRenderAll(); // auto window resizing based width
fixFabricA11y();
lastWidth = wrapperRef.current.clientWidth; lastWidth = wrapperRef.current.clientWidth;
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
if (!(fabricRef.current && wrapperRef.current)) return; const nextWidth = wrapperRef.current?.clientWidth;
const nextWidth = wrapperRef.current.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return; if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth; lastWidth = nextWidth;
syncViewport(); syncViewport();
}); });
resizeObserver.observe(wrapperRef.current!);
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();
}
}; };
init(); initCanvas().then();
return () => { return () => {
isMounted = false; isMounted = false;
resizeObserver?.disconnect(); resizeObserver?.disconnect();
canvas?.dispose(); fabricRef.current?.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;
@@ -395,69 +318,39 @@ export const ComposeCanvas = forwardRef<
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);
if (!fabricRef.current) return; syncViewport();
// 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,
@@ -465,24 +358,21 @@ export const ComposeCanvas = forwardRef<
}, },
loadData: async (data: CanvasJSON) => { loadData: async (data: CanvasJSON) => {
if (!(fabricRef.current && wrapperRef.current)) { // if canvas isn't ready yet, stash the data and let the useEffect pick it up
if (!fabricRef.current) {
deferredDataRef.current = data; deferredDataRef.current = data;
return; return;
} }
await loadContent(data);
},
const textbox = await loadContent( getStyle: () => {
fabricRef.current, const textBox = textboxRef.current;
data,
wrapperRef.current,
);
if (textbox) { return {
textboxRef.current = textbox; fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
setupTextboxInteractions(fabricRef.current, textbox); fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
} };
fabricRef.current.requestRenderAll();
fixFabricA11y();
}, },
})); }));
@@ -498,6 +388,6 @@ export const ComposeCanvas = forwardRef<
/> />
</div> </div>
); );
}); }
ComposeCanvas.displayName = "ComposeCanvas"; ComposeCanvas.displayName = "ComposeCanvas";
@@ -1,26 +1,28 @@
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 (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-1000"> <Modal isOpen={!!sealedTargetId}>
<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{" "}
@@ -30,25 +32,50 @@ export function PostSealModal({
<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>
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4"> ) : (
<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">
{type === "KEPT" ? (
<>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)}
>
Keep it to myself
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => navigate(PATHS.read(sealedTargetId!))}
>
View letter
</button>
</>
) : (
<button <button
type="button" type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)} onClick={() => navigate(ROUTES.DRAWER)}
> >
Keep it to myself Step Away...
</button> </button>
<button )}
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(PATHS.read(sealedTargetId), { replace: true })
}
>
View letter
</button>
</div>
</div> </div>
</div> </Modal>
); );
} }
+179 -59
View File
@@ -1,49 +1,146 @@
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.tsx";
interface ToolBarProps { interface ToolBarProps {
fileInputRef: React.RefObject<HTMLInputElement | null>; onAddImage: () => void;
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"],
["Tamil", "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({
fileInputRef, onAddImage,
sealBtnClicked, sealBtnClicked,
setSealBtnClicked, setSealBtnClicked,
onSave, onSave,
setConfirmModal, setConfirmModal,
onFontChange,
latestFontStyle,
}: ToolBarProps) { }: ToolBarProps) {
return ( return (
<div <div
id="writer-toolbar" id="writer-toolbar"
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" 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"
> >
<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={() => fileInputRef.current?.click()} onClick={onAddImage}
> >
<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"
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")}
> >
@@ -53,8 +150,9 @@ export function ToolBar({
</span> </span>
</button> </button>
<div className="w-px h-4 bg-base-content/10 mx-2" /> <div className="w-px h-4 bg-base-content/10 mx-2 hidden md:inline" />
{/*Seal */}
<button <button
type="button" type="button"
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"}`}
@@ -74,7 +172,7 @@ export function ToolBar({
</div> </div>
<div <div
className={`flex-col items-center gap-2 absolute right-0 z-100000 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`} className={`flex-col items-center gap-2 absolute right-0 z-10 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
> >
<button <button
type="button" type="button"
@@ -101,11 +199,31 @@ 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"}`}
> >
<QuestionIcon weight="duotone" size={20} className={""} /> <XCircleIcon weight="duotone" size={20} className={"text-error"} />
</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>
); );
@@ -116,7 +234,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-[10px] uppercase tracking-widest font-bold"> <span className="text-xxs uppercase tracking-widest font-bold">
Sealed & View Only Sealed & View Only
</span> </span>
</div> </div>
@@ -136,60 +254,62 @@ export function VaultConfirmModal({
setUnlockDate, setUnlockDate,
}: VaultConfirmModalProps) { }: VaultConfirmModalProps) {
return ( return (
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}> <Modal isOpen={true}>
<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>
<p className="text-base-content/60 text-sm text-center mt-4">
By vaulting this letter, you ask me to hold on to this.
<br />
I'll remember to mail you this on the unlock date.
<br />
<span className={"font-bold text-primary"}>
{" "}
But I won't let you read or rewrite this letter until then.
</span>
<br />
</p>
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const unlockDateStr = formData.get("vault-date") as string;
const newUnlockDate = new Date(unlockDateStr);
setUnlockDate(newUnlockDate);
await onSave("VAULT", newUnlockDate);
setConfirmModal(null);
}}
id="vault-form"
className="min-w-75"
>
<div className={"divider tracking-tightest font-display text-sm"}>
Set an unlock date
</div>
<input
required
type="date"
className="input input-bordered w-full"
name="vault-date"
/> />
<h3 className="font-serif text-3xl">Vault this letter?</h3> <div className="w-full flex justify-center gap-8 mt-4">
<p className="text-base-content/60 text-sm text-center mt-4">
Vaulting locks the letter permanently and will be{" "}
<span className={"font-bold text-primary"}>mailed</span> to you
automatically on the unlock date.
<br />
<span className={"underline"}>
You cannot edit or view the contents of the letter until then.
</span>
</p>
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const unlockDateStr = formData.get("vault-date") as string;
const newUnlockDate = new Date(unlockDateStr);
setUnlockDate(newUnlockDate);
await onSave("VAULT", newUnlockDate);
setConfirmModal(null);
}}
id="vault-form"
>
<div className={"divider tracking-tightest font-display text-sm"}>
Set an unlock date
</div>
<input
required
type="date"
className="input input-bordered w-full"
name="vault-date"
/>
<button <button
className="btn btn-primary mt-4" type="button"
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"
form="vault-form" form="vault-form"
> >
Vault Take it
</button> </button>
</div>
<button </form>
type="button" </Modal>
className="btn btn-ghost mt-4"
onClick={() => setConfirmModal(null)}
>
Cancel
</button>
</form>
</div>
</div>
); );
} }
@@ -0,0 +1,85 @@
import {
HandPalmIcon,
ShieldCheckIcon,
WarningIcon,
} from "@phosphor-icons/react";
import Logo from "../Logo.tsx";
import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan.tsx";
export default function WelcomeModal({
setShowWelcome,
}: {
setShowWelcome: (show: boolean) => void;
}) {
return (
<>
<Modal isOpen={true}>
<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 &nbsp;
<Logo /> &nbsp;!
</h3>
<p className="text-base-content/80 leading-relaxed">
Before we begin, let me make a small promise.
<HandPalmIcon
size={18}
className="inline text-primary"
weight="fill"
/>
<div className="divider my-0"></div>
<br />
Everything you write here is sealed with your password,{" "}
<span className="font-display text-success">cryptographically</span>
, before it leaves your hands.
<br />A fancy way of saying, I couldn't if I tried.
</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.
<br />
<span className="font-bold mt-2">
I highly, highly recommend storing this password in your{" "}
<a
href="https://www.privacyguides.org/en/passwords/"
target="_blank"
className="link link-primary-content"
rel="noopener"
>
password manager
</a>{" "}
or somewhere safe to remember it.
</span>
</p>
</div>
<div className="modal-action w-full">
<button
type="button"
onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg"
>
I'll remember
</button>
</div>
</div>
</Modal>
<div className="absolute bottom-0 right-0 z-1000 font-sans w-full">
<Saajan
position="top"
message={"I've lost words before.\nI know what it feels like."}
/>
</div>
</>
);
}
+14 -14
View File
@@ -1,19 +1,27 @@
import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react"; import { CampfireIcon, FlameIcon } 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(() => {
@@ -26,23 +34,15 @@ export function BurnModal({
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`; const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
return ( return (
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md"> <Modal isOpen={true} onClose={() => setShowBurnModal(false)}>
<div <div
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]" : ""}`} className={`flex flex-col items-center gap-4 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"
@@ -94,6 +94,6 @@ export function BurnModal({
</button> </button>
</div> </div>
</div> </div>
</div> </Modal>
); );
} }
@@ -1,5 +1,6 @@
import { WavesIcon } from "@phosphor-icons/react"; import { WavesIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, 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";
@@ -9,6 +10,8 @@ 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({
@@ -17,9 +20,12 @@ 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);
@@ -30,7 +36,9 @@ export function EnvelopeReveal({
height: 0, height: 0,
}); });
const flapCheckbox = useRef<HTMLInputElement>(null); useEffect(() => {
setIsFlapOpen(openFlap);
}, [openFlap]);
useEffect(() => { useEffect(() => {
if (!ignite) { if (!ignite) {
@@ -66,7 +74,9 @@ 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"
ref={flapCheckbox} checked={isFlapOpen}
onChange={() => setIsFlapOpen((prev) => !prev)}
disabled={!isInteractive}
/> />
</div> </div>
<img <img
@@ -75,8 +85,8 @@ export function EnvelopeReveal({
} }
src={waxSeal} src={waxSeal}
alt="Seal" alt="Seal"
onClick={() => flapCheckbox.current?.click()} onClick={() => setIsFlapOpen((prev) => !prev)}
onKeyDown={() => flapCheckbox.current?.click()} onKeyDown={() => setIsFlapOpen((prev) => !prev)}
/> />
<button <button
type="button" type="button"
@@ -103,6 +113,7 @@ export function EnvelopeReveal({
<button <button
id="env-front" id="env-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)}
> >
@@ -129,15 +140,20 @@ 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 <div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
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" <div
style={{ 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"
width: 2 * burn.width, style={{
height: 2 * burn.height, width: 2 * burn.width,
}} height: 2 * burn.height,
></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,19 +1,23 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ROUTES } from "../../config/routes"; import { ROUTES } from "../../config/routes";
export function PostActionOverlay({ revealState }) { interface PostActionOverlayProps {
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-300 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-1000 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
+26 -22
View File
@@ -1,25 +1,20 @@
import { import { EyeSlashIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
EyeSlashIcon, import { Modal } from "../ui/Modal";
PaperPlaneTiltIcon, import Saajan from "../ui/Saajan";
XCircleIcon,
} from "@phosphor-icons/react";
export function ShareModal({ shareLink, setShareLink }) { interface ShareModalProps {
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"> <>
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative"> <Modal isOpen={!!shareLink} onClose={() => setShareLink(null)}>
<button
type="button"
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
@@ -29,14 +24,17 @@ export function ShareModal({ shareLink, setShareLink }) {
/> />
<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. Send your letter now, and You've carried these words long enough.
let the <span className="text-accent font-display">unsaid</span>{" "} <br />
finally find its home. Send your letter now, and let the{" "}
<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">
The recipient will have the same viewing experience like you do They'll receive it exactly as you're seeing it now.
now. <br />
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">
@@ -64,7 +62,13 @@ export function ShareModal({ shareLink, setShareLink }) {
</p> </p>
</div> </div>
</div> </div>
</Modal>
<div className="absolute bottom-0 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> </>
); );
} }
+1 -1
View File
@@ -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-[10px] uppercase tracking-[0.4em] text-accent font-bold"> <span className="text-xxs uppercase tracking-widester 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">
+3
View File
@@ -6,6 +6,7 @@ interface FormFieldProps {
placeholder?: string; placeholder?: string;
registration: UseFormRegisterReturn; registration: UseFormRegisterReturn;
error?: string; error?: string;
handleFocus?: () => void;
} }
export default function FormField({ export default function FormField({
@@ -14,6 +15,7 @@ export default function FormField({
placeholder, placeholder,
registration, registration,
error, error,
handleFocus,
}: FormFieldProps) { }: FormFieldProps) {
return ( return (
<div className="form-control"> <div className="form-control">
@@ -31,6 +33,7 @@ export default function FormField({
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>
+24 -35
View File
@@ -1,4 +1,5 @@
import { WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react"; import { WarningIcon } from "@phosphor-icons/react";
import { Modal } from "./Modal";
interface LogModalContent { interface LogModalContent {
status: "WARN" | "ERROR" | "RESET" | "SUCCESS"; status: "WARN" | "ERROR" | "RESET" | "SUCCESS";
@@ -15,40 +16,28 @@ export const LogModal = ({
onClose, onClose,
status, status,
}: LogModalContent) => { }: LogModalContent) => {
return status === "RESET" || !isOpen ? ( return (
<div></div> <Modal isOpen={isOpen && status !== "RESET"} onClose={onClose}>
) : ( <div
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100"> className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
<div className="modal-box bg-transparent border-none shadow-none relative"> >
<div {status === "WARN" && (
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`} <WarningIcon className="text-warning" size={16} weight="duotone" />
> )}
{status === "WARN" && ( {message}
<WarningIcon className="text-warning" size={16} weight="bold" /> {log && (
)} <>
{status === "ERROR" && ( <div className="divider text-primary-content text-xs uppercase tracking-widest">
<XCircleIcon className="text-error" size={16} weight="bold" /> Error Stack
)} </div>
{message} <div className="mockup-code bg-base-100 text-error w-full">
<div className="divider text-primary-content text-xs uppercase tracking-widest"> <pre>
Error Stack <code>{String(log)}</code>
</div> </pre>
<div className="mockup-code bg-base-100 text-error w-full"> </div>
<pre> </>
<code>{String(log)}</code> )}
</pre>
</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>
); );
}; };
+30
View File
@@ -0,0 +1,30 @@
import { XCircleIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
interface ModalProps {
isOpen: boolean;
onClose?: () => void;
children: ReactNode;
}
export function Modal({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
{onClose && (
<button
type="button"
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>
);
}
+1 -1
View File
@@ -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-[10px] tracking-[0.3em] uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors"> <span className="font-sans text-xxs tracking-widester uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
Drawer Drawer
</span> </span>
</button> </button>
+53
View File
@@ -0,0 +1,53 @@
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-3 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
/>
</div>
</div>
);
}
+4 -4
View File
@@ -9,14 +9,14 @@ export const endpoints = {
LETTERS: "/api/letters/", LETTERS: "/api/letters/",
}; };
// simple utility to handle path params // constructs dynamic path params for activate flow
export const replacePathParams = ( export const replacePathParams = (
url: string, url: string,
params: Record<string, string>, params: Record<string, string>,
): string => { ): string => {
let result = url; let constructedUrl = url;
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
result = result.replace(`:${key}`, value); constructedUrl = constructedUrl.replace(`:${key}`, value);
} }
return result; return constructedUrl;
}; };
+3 -4
View File
@@ -1,4 +1,4 @@
// Route PATTERNS // Page Route PATTERNS
export const ROUTES = { export const ROUTES = {
HOME: "/", HOME: "/",
ONBOARD: "/onboard", ONBOARD: "/onboard",
@@ -6,13 +6,12 @@ export const ROUTES = {
ACTIVATE: "/activate/:uidb64/:token", ACTIVATE: "/activate/:uidb64/:token",
LOGIN: "/login", LOGIN: "/login",
DRAWER: "/drawer", DRAWER: "/drawer",
WRITE: "/quill/:public_id?", // ← static pattern WRITE: "/quill/:public_id?",
READ: "/read/:public_id", READ: "/read/:public_id",
}; };
// Path BUILDERS // Dynamic 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}`,
}; };
+13 -4
View File
@@ -25,7 +25,7 @@ export interface ProcessedLetter extends Letter {
metadata: LetterMetadata; metadata: LetterMetadata;
} }
async function decryptLetters( async function decryptLettersMetadata(
letters: Letter[], letters: Letter[],
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<ProcessedLetter[]> { ): Promise<ProcessedLetter[]> {
@@ -56,19 +56,22 @@ async function decryptLetters(
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) => decryptLetters(res.data, masterKey)) .then((res) => decryptLettersMetadata(res.data, masterKey))
.then((decrypted) => { .then((decrypted) => {
setLetters( setLetters(
decrypted.sort( decrypted.sort(
@@ -78,7 +81,9 @@ export function useLetters() {
), ),
); );
}) })
.catch((_err) => {}) .catch((err) => {
setError(err);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [masterKey]); }, [masterKey]);
@@ -86,11 +91,15 @@ 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"), vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"),
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,
+50 -41
View File
@@ -2,60 +2,69 @@
@plugin "daisyui"; @plugin "daisyui";
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "piku"; name: "piku";
default: true; default: true;
prefersdark: true; prefersdark: true;
color-scheme: dark; color-scheme: dark;
--color-base-100: oklch(14% 0.012 35); --color-base-100: oklch(14% 0.012 35);
--color-base-200: oklch(18% 0.014 33); --color-base-200: oklch(18% 0.014 33);
--color-base-300: oklch(22% 0.016 32); --color-base-300: oklch(22% 0.016 32);
--color-base-content: oklch(82% 0.02 70); --color-base-content: oklch(82% 0.02 70);
--color-primary: oklch(67% 0.11 78); --color-primary: oklch(67% 0.11 78);
--color-primary-content: oklch(15% 0.03 70); --color-primary-content: oklch(15% 0.03 70);
--color-secondary: oklch(48% 0.08 305); --color-secondary: oklch(48% 0.08 305);
--color-secondary-content: oklch(92% 0.01 305); --color-secondary-content: oklch(92% 0.01 305);
--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(28% 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.07 240); --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(60% 0.08 150); --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);
--color-error: oklch(55% 0.1 22); --color-error: oklch(55% 0.1 22);
--color-error-content: oklch(92% 0.01 22); --color-error-content: oklch(92% 0.01 22);
--radius-selector: 0.5rem; --radius-selector: 0.5rem;
--radius-field: 0.375rem; --radius-field: 0.375rem;
--radius-box: 0.5rem; --radius-box: 0.5rem;
--depth: 1; --depth: 1;
--noise: 0.03; --noise: 0.03;
--border: 1px; --border: 1px;
} }
@theme { @theme {
--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;
--color-glass-bg: rgba(28, --font-mono: "Space Mono", monospace;
22, --font-tamil: "Kavivanar", sans-serif;
16, --font-redact: "Redacted Script", cursive;
0.45); --font-slab: "Cutive Mono", monospace;
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6); --font-hand: "Architects Daughter", cursive;
--radius-xl: 1.5rem; --color-glass-bg: rgba(28, 22, 16, 0.45);
--color-paper: oklch(97% 0.008 80); --shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
--radius-xl: 1.5rem;
--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 backdrop-blur-xl border border-white/5 shadow-warm rounded-xl; @apply bg-glass-bg backdrop-blur-xl border border-white/5 shadow-warm rounded-xl;
} }
+3
View File
@@ -1,9 +1,12 @@
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.tsx";
const root = document.getElementById("root"); const root = document.getElementById("root");
+7 -9
View File
@@ -16,8 +16,6 @@ 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 () => {
@@ -46,7 +44,7 @@ export default function Activate() {
)} )}
{status === "success" && ( {status === "success" && (
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500"> <div className="flex flex-col items-center gap-6 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}
@@ -57,13 +55,12 @@ export default function Activate() {
<h2 className="font-display text-xl text-success"> <h2 className="font-display text-xl text-success">
Account Activated! Account Activated!
</h2> </h2>
<p className="opacity-70 mb-8 leading-relaxed"> <p className="opacity-70 leading-relaxed">
Welcome to <Logo /> Welcome to <Logo scale={1} />
<br /> <br />
Your identity is now verified and ready for timeless letters. Your identity is now verified and ready for timeless letters.
</p> </p>
<div className="divider opacity-10"></div> <div className="divider opacity-10 my-0"></div>
<button <button
type="button" type="button"
className="btn btn-primary w-full shadow-lg" className="btn btn-primary w-full shadow-lg"
@@ -85,16 +82,17 @@ 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 mb-8 leading-relaxed"> <p className="opacity-70 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)}
> >
Back to Registration Register Again
</button> </button>
</div> </div>
)} )}
+11 -4
View File
@@ -5,6 +5,7 @@ import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
import { LetterItem } from "../components/drawer/LetterItem.tsx"; import { LetterItem } from "../components/drawer/LetterItem.tsx";
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx"; import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan.tsx";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters"; import { useLetters } from "../hooks/useLetters";
@@ -27,12 +28,12 @@ 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-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig pointer-events-none z-0" />
{isAuthRequired && <PasskeyModal onUnlock={unlock} />} {isAuthRequired && <PasskeyModal onUnlock={unlock} />}
<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-[0.3em] uppercase text-base-content/40 mt-2"> <div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
Personal Archive Personal Archive
</div> </div>
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity"> <div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
@@ -52,7 +53,7 @@ export default function Drawer() {
{loading ? ( {loading ? (
<div className="flex-1 flex flex-col items-center justify-center p-12 gap-4"> <div className="flex-1 flex flex-col items-center justify-center p-12 gap-4">
<span className="loading loading-ring loading-lg text-primary opacity-20"></span> <span className="loading loading-ring loading-lg text-primary opacity-20"></span>
<span className="text-[10px] uppercase tracking-[0.3em] font-sans text-base-content/20 animate-pulse"> <span className="text-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse">
Opening your cabinet... Opening your cabinet...
</span> </span>
</div> </div>
@@ -162,9 +163,15 @@ export default function Drawer() {
</span> </span>
</button> </button>
<footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10"> <footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
For your unsaid. For your unsaid.
</footer> </footer>
<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>
); );
} }
-2
View File
@@ -83,11 +83,9 @@ describe("Editor Page", () => {
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument(); 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");
+146 -98
View File
@@ -12,6 +12,7 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import { import {
type CanvasStyle,
type CanvasTools, type CanvasTools,
ComposeCanvas, ComposeCanvas,
} from "../components/editor/ComposeCanvas"; } from "../components/editor/ComposeCanvas";
@@ -23,6 +24,7 @@ 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";
@@ -32,11 +34,18 @@ 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"; import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/cutive-mono/index.css";
import "@fontsource/architects-daughter/index.css";
import "@fontsource/redacted-script/index.css";
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
const OVERLAY_FADE_MS = 250; 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...",
@@ -44,6 +53,7 @@ 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);
@@ -69,7 +79,14 @@ 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,
@@ -78,13 +95,17 @@ 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);
// Placeholder rotation // to continuously rotate placeholder text of the recipient input
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length); setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
@@ -93,13 +114,14 @@ 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 loadExistingLetter = async () => { const loadExistingLetter = async () => {
setIsInitialLoading(true); setIsInitialLoading(true);
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
@@ -138,26 +160,27 @@ export default function Editor() {
); );
const canvasData = JSON.parse(decryptedJsonStr); const canvasData = JSON.parse(decryptedJsonStr);
const { isDecryptionPartialFailure, error } = await decryptCanvasImages( const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
canvasData, await decryptCanvasImages(
letterData.images ?? [], canvasData,
letterData.encrypted_dek, letterData.images ?? [],
masterKey, letterData.encrypted_dek,
cryptoUtils, masterKey,
true, cryptoUtils,
); true,
);
if (isDecryptionPartialFailure) { if (isPartialFailure) {
setDecryptionStatus({ setDecryptionStatus({
status: "WARN", status: "WARN",
message: message:
"Failed to decrypt some elements. Please check the render.", "Failed to decrypt some elements. Please check the render.",
log: error, log: errors.toString(),
}); });
} }
if (canvasRef.current) { if (canvasRef.current) {
await canvasRef.current.loadData(canvasData); await canvasRef.current.loadData(canvasDataWithDecryptedImages);
} }
} catch (_err) { } catch (_err) {
setDecryptionStatus({ setDecryptionStatus({
@@ -169,37 +192,40 @@ export default function Editor() {
setIsInitialLoading(false); setIsInitialLoading(false);
} }
}; };
loadExistingLetter().then((_) => {
loadExistingLetter(); if (canvasRef.current) {
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);
}, 10000); }, STOP_SAVE_DATE_PULSE_AFTER_MS);
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,
); );
@@ -211,9 +237,14 @@ 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) { if (file && file.size < MAX_FILE_SIZE) {
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.",
});
} }
}; };
@@ -228,9 +259,9 @@ export default function Editor() {
targetId = crypto.randomUUID(); targetId = crypto.randomUUID();
} }
if (saveOverlay === "saving" || !masterKey) return; if (saveOverlay === "SAVING" || !masterKey) return;
setSaveOverlay("saving"); setSaveOverlay("SAVING");
setShowSaveOverlay(true); setShowSaveOverlay(true);
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
@@ -240,15 +271,16 @@ export default function Editor() {
const canvasData = canvasRef.current?.getData() || { objects: [] }; const canvasData = canvasRef.current?.getData() || { objects: [] };
const canvasImages = canvasRef.current?.getImages() || []; const canvasImages = canvasRef.current?.getImages() || [];
const encImageFilesMap = await encryptCanvasImages( const { encryptedImageFiles, encryptedCanvasData } =
canvasData, await encryptCanvasImages(
canvasImages, canvasData,
masterKey, canvasImages,
cryptoUtils, masterKey,
); cryptoUtils,
);
const encrypted_letter = await cryptoUtils.encryptLetter( const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(canvasData), JSON.stringify(encryptedCanvasData),
masterKey, masterKey,
); );
@@ -277,7 +309,7 @@ export default function Editor() {
encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_content,
); );
encImageFilesMap.forEach((blob, filename) => { encryptedImageFiles.forEach((blob, filename) => {
formData.append("image_files", blob, filename); formData.append("image_files", blob, filename);
}); });
@@ -293,13 +325,13 @@ export default function Editor() {
setLetterStatus(status); setLetterStatus(status);
setLastSavedPulseTick((prev) => prev + 1); setLastSavedPulseTick((prev) => prev + 1);
if (status === "SEALED") { if (status === "SEALED" || status === "VAULT") {
setSealedTargetId(targetId); setSealedTargetId(targetId);
} }
setSaveOverlay("saved"); setSaveOverlay("SAVED");
setShowSaveOverlay(true); setShowSaveOverlay(true);
} catch (_error) { } catch (_error) {
setSaveOverlay("error"); setSaveOverlay("ERROR");
setShowSaveOverlay(true); setShowSaveOverlay(true);
} }
}; };
@@ -313,8 +345,8 @@ export default function Editor() {
isSaveDatePulsing ? "animate-pulse" : "" isSaveDatePulsing ? "animate-pulse" : ""
}`} }`}
> >
<div className="text-sm text-neutral-content/30 flex-col justify-end leading-none text-right"> <div className="text-xxs text-neutral-content/30 flex-col justify-end leading-none text-right">
<span className="text-[10px] uppercase tracking-widest font-bold"> <span className="uppercase tracking-widest font-bold">
Last Save Last Save
</span> </span>
<br /> <br />
@@ -348,67 +380,61 @@ export default function Editor() {
weight="bold" weight="bold"
className="animate-spin text-primary" className="animate-spin text-primary"
/> />
<p className="text-[10px] uppercase tracking-[0.4em] font-bold text-base-content/40"> <p 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" && (
<div <Modal isOpen={showSaveOverlay}>
className={`modal modal-open bg-base-100/20 backdrop-blur-md transition-opacity duration-300 ${ {saveOverlay === "SAVING" && (
showSaveOverlay ? "opacity-100" : "opacity-0" <div
}`} role="alert"
> className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
<div className="modal-box p-0 bg-transparent shadow-none transition-all duration-300"> showSaveOverlay
{saveOverlay === "saving" && ( ? "opacity-100 scale-100 translate-y-0"
<div : "opacity-0 scale-95 translate-y-1"
role="alert" }`}
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${ >
showSaveOverlay <SpinnerGapIcon
? "opacity-100 scale-100 translate-y-0" size={18}
: "opacity-0 scale-95 translate-y-1" weight="bold"
}`} className="animate-spin"
> />
<SpinnerGapIcon <span className="font-bold">Securing your letter...</span>
size={18} </div>
weight="bold" )}
className="animate-spin"
/>
<span className="font-bold">Securing your letter...</span>
</div>
)}
{saveOverlay === "saved" && ( {saveOverlay === "SAVED" && (
<div <div
role="alert" role="alert"
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"
: "opacity-0 scale-95 translate-y-1" : "opacity-0 scale-95 translate-y-1"
}`} }`}
> >
<DownloadSimpleIcon size={18} weight="bold" /> <DownloadSimpleIcon size={18} weight="bold" />
<span className="font-bold">Your letter is saved!</span> <span className="font-bold">Your letter is saved!</span>
</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 ${
showSaveOverlay showSaveOverlay
? "opacity-100 scale-100 translate-y-0" ? "opacity-100 scale-100 translate-y-0"
: "opacity-0 scale-95 translate-y-1" : "opacity-0 scale-95 translate-y-1"
}`} }`}
> >
<XIcon size={18} weight="bold" /> <XIcon size={18} weight="bold" />
<span className="font-bold">Failed to save letter</span> <span className="font-bold">Failed to save letter</span>
</div> </div>
)} )}
</div> </Modal>
</div>
)} )}
{confirmModal === "VAULT" && ( {confirmModal === "VAULT" && (
@@ -419,7 +445,11 @@ export default function Editor() {
/> />
)} )}
{sealedTargetId && ( {sealedTargetId && (
<PostSealModal sealedTargetId={sealedTargetId} navigate={navigate} /> <PostSealModal
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">
@@ -427,7 +457,7 @@ 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-[10px] uppercase tracking-[0.4em] text-secondary-content font-bold" className="text-xxs uppercase tracking-widester text-secondary-content font-bold"
> >
Recipient Recipient
</label> </label>
@@ -446,11 +476,13 @@ export default function Editor() {
{status === "DRAFT" ? ( {status === "DRAFT" ? (
<ToolBar <ToolBar
fileInputRef={fileInputRef} onAddImage={() => fileInputRef.current?.click()}
sealBtnClicked={sealBtnClicked} sealBtnClicked={sealBtnClicked}
setSealBtnClicked={setSealBtnClicked} setSealBtnClicked={setSealBtnClicked}
onSave={handleSave} onSave={handleSave}
setConfirmModal={setConfirmModal} setConfirmModal={setConfirmModal}
onFontChange={setCanvasFontStyle}
latestFontStyle={canvasFontStyle}
/> />
) : ( ) : (
<LetterHead /> <LetterHead />
@@ -464,9 +496,25 @@ export default function Editor() {
className="hidden" className="hidden"
/> />
<ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} /> <ComposeCanvas
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"}
/>
</> </>
); );
} }
+389 -3
View File
@@ -1,9 +1,395 @@
import { InfoIcon } from "@phosphor-icons/react";
import {
motion,
useMotionValueEvent,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
import Saajan from "../components/ui/Saajan.tsx";
import { ROUTES } from "../config/routes.ts";
import { formatDate } from "../utils/dateFormat.ts";
export default function Home() { export default function Home() {
const sectionContainer1 = useRef<HTMLDivElement>(null);
const { scrollYProgress: section1ScrollProgress } = useScroll({
target: sectionContainer1,
});
const smoothProgress1 = useSpring(section1ScrollProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
const [flapOpen, setFlapOpen] = useState(false);
const [recipient, setRecipient] = useState("someone dear");
const [ignite, setIgnite] = useState(false);
const navigate = useNavigate();
useMotionValueEvent(section1ScrollProgress, "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 (
<div> <section
<Logo /> ref={sectionContainer1}
</div> className="relative w-full h-[850vh] bg-base-100 font-serif"
>
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
{/* Intro */}
<motion.div
className="absolute flex flex-col items-center justify-center pointer-events-none"
style={{
opacity: useTransform(smoothProgress1, [0, 0.12, 1], [1, 0, 0]),
scale: useTransform(smoothProgress1, [0, 0.12], [1, 10]),
}}
>
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
You've been carrying something
</h1>
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
unsaid
</h2>
</motion.div>
<motion.div
className="absolute text-center"
style={{
opacity: useTransform(smoothProgress1, [0, 0.15, 0.2], [0, 1, 0]),
y: useTransform(smoothProgress1, [0, 0.15, 0.2], [40, 0, -40]),
scale: useTransform(smoothProgress1, [0, 0.15, 0.2], [0.8, 1, 3]),
}}
>
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
and that's okay...
</div>
</motion.div>
{/* pi. ku. */}
<motion.div
className="absolute text-center px-6"
style={{
opacity: useTransform(
smoothProgress1,
[0.18, 0.25, 0.3],
[0, 1, 0],
),
y: useTransform(smoothProgress1, [0.18, 0.25, 0.3], [20, 0, -20]),
}}
transition={{ delay: 4 }}
>
<Logo scale={2} />
<motion.div
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
style={{
opacity: useTransform(
smoothProgress1,
[0.22, 0.25, 0.35, 0.4],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
),
}}
>
is a{" "}
<span className="font-display text-primary font-extralight">
safe space
</span>
,<br />
<motion.span
className="opacity-0 text-3xl md:text-5xl"
transition={{ delay: 3 }}
whileInView={{ opacity: 1 }}
viewport={{ once: false, amount: 0.3 }}
>
where you can
</motion.span>
</motion.div>
</motion.div>
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.3, 0.35, 0.4, 0.45],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.3, 0.35, 0.4, 0.45],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
pen down your unsaid words into{" "}
<span className="font-display text-primary font-extralight">
letters
</span>
.
</motion.h2>
{/* Seal */}
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.45, 0.5, 0.55, 0.6],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
seal it{" "}
<span className="text-secondary font-display italic font-extralight">
secure
</span>{" "}
and{" "}
<span className="text-secondary font-display font-extralight italic">
private
</span>
.
</motion.h2>
{/* Send / vault */}
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.6, 0.63, 0.72, 0.75],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
send it to{" "}
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
smoothProgress1,
[0.67, 1],
["var(--color-accent)", "var(--color-neutral)"],
),
}}
>
someone dear
</motion.span>
<motion.span
style={{
opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
}}
>
<motion.span
className="font-display text-accent"
style={{
color: useTransform(
smoothProgress1,
[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(
smoothProgress1,
[0.75, 0.8, 0.85, 0.9],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.75, 0.8, 0.85, 0.9],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
and even <span className="font-display text-error">burn it</span> to
release the burden.
</motion.h2>
{/* Outro */}
<motion.h2
className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
}
style={{
opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
y: useTransform(smoothProgress1, [0.9, 1], [80, 0]),
}}
>
You've been carrying it long enough.
</motion.h2>
{/* CTA */}
<motion.div
className={
"z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
}
style={{
opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
display: useTransform(
smoothProgress1,
[0.96, 1],
["none", "flex"],
),
}}
>
<button
className={
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
>
<InfoIcon className={"text-primary"} />
Tell me More
</button>
<button
className={
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
>
I'm ready
</button>
</motion.div>
</div>
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.div
className={"z-21 absolute"}
style={{
opacity: useTransform(
smoothProgress1,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(smoothProgress1, [0.3, 0.45, 0.5], [300, 0, 200]),
scale: useTransform(
smoothProgress1,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
}}
>
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src="/screenshots/letter.webp" />
</div>
</div>
</motion.div>
{/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
smoothProgress1,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(smoothProgress1, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
isInteractive={false}
ignite={ignite}
recipient={recipient}
date={formatDate(new Date().toISOString())}
onRevealComplete={() => {}}
isFlip={isEnvelopeFlipped}
openFlap={flapOpen}
/>
</motion.div>
{/* Saajan */}
<motion.div
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
smoothProgress1,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(smoothProgress1, [0.98, 1], [50, -10]),
}}
>
<Saajan
message={
"I think we forget things\nif there is nobody to tell them."
}
position={"top"}
/>
</motion.div>
{/* Orb */}
<motion.div
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
transition={{
backgroundColor: { ease: "easeIn", duration: 2 },
}}
style={{
backgroundColor: useTransform(
smoothProgress1,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-success)",
"var(--color-error)",
],
),
scale: useTransform(smoothProgress1, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div>
</section>
); );
} }
-10
View File
@@ -14,16 +14,6 @@ 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}`, () =>
+15 -57
View File
@@ -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,7 +7,9 @@ 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.tsx";
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";
@@ -20,57 +22,6 @@ 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();
@@ -78,6 +29,9 @@ 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 {
@@ -92,7 +46,7 @@ export default function Login() {
setIsLoading(true); setIsLoading(true);
setApiError(null); setApiError(null);
try { try {
// client side key derivation for 0 knowledge // client side key derivation for e2e encryption
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle( const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
data.password, data.password,
data.email, data.email,
@@ -108,7 +62,6 @@ 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 }); navigate(nextRoute, { replace: true });
@@ -125,12 +78,13 @@ export default function Login() {
}; };
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col items-center">
{!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="card-title 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">
Sign in to <Logo /> Enter <Logo /> Archive
</h1> </h1>
{apiError && ( {apiError && (
@@ -142,9 +96,10 @@ export default function Login() {
<FormField <FormField
label="Email" label="Email"
type="email" type="email"
placeholder="you@email.com" placeholder="f.kafka@wrongtrain.com"
registration={register("email")} registration={register("email")}
error={errors.email?.message} error={errors.email?.message}
handleFocus={() => setSaajanMessage("I remember you.")}
/> />
<FormField <FormField
@@ -153,6 +108,9 @@ export default function Login() {
placeholder="••••••••" placeholder="••••••••"
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">
+35 -42
View File
@@ -33,6 +33,7 @@ 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();
@@ -44,13 +45,10 @@ 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" "SEALED" | "REVEALED" | "BURNED" | "BURNING"
>("sealed"); >("SEALED");
const [error, setError] = useState<{ const [logTrace, setLogTrace] = useState<{
message: string; type: "WARN" | "ERROR";
log: string;
} | null>(null);
const [warning, setWarning] = useState<{
message: string; message: string;
log: string; log: string;
} | null>(null); } | null>(null);
@@ -92,8 +90,8 @@ export default function Reader() {
setShowBurnModal(false); setShowBurnModal(false);
setIgnite(true); setIgnite(true);
setTimeout(() => { setTimeout(() => {
setRevealState("burned"); setRevealState("BURNED");
}, 13000); }, WAIT_FOR_BURN_MS);
} }
}; };
@@ -180,30 +178,30 @@ export default function Reader() {
); );
} }
} catch (err) { } catch (err) {
setWarning({ setLogTrace({
message: message:
"Failed to decrypt elements. Images might not render in the letter as intended.", "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: "WARN",
}); });
} }
setDecryptedCanvasData(canvasData); setDecryptedCanvasData(canvasData);
} catch (err) { } catch (err) {
setError({ setLogTrace({
message: `Failed to load letter :(`, message: `Failed to load letter `,
log: err instanceof Error ? err.message : "Unknown error", log: err instanceof Error ? err.message : "Unknown error",
type: "ERROR",
}); });
} finally {
setIsDecrypting(false);
} }
}; };
loadAndDecrypt(); loadAndDecrypt().then(() => setIsDecrypting(false));
}, [public_id, sharingKey, masterKey]); }, [public_id, sharingKey, masterKey]);
useEffect(() => { useEffect(() => {
if ( if (
!isDecrypting && !isDecrypting &&
revealState === "revealed" && revealState === "REVEALED" &&
decryptedCanvasData && decryptedCanvasData &&
canvasRef.current canvasRef.current
) { ) {
@@ -213,13 +211,13 @@ export default function Reader() {
if (isDecrypting) { if (isDecrypting) {
return ( return (
<div className="flex items-center justify-center bg-base-100 font-serif"> <div className="flex items-center h-screen w-screen justify-center bg-base-100 font-sans">
<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="fixed inset-0 bg-vig pointer-events-none" />
<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 className="text-[10px] uppercase tracking-[0.4em] text-base-content/20 animate-pulse"> <p className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse">
Breaking the seal... Breaking the seal...
</p> </p>
</div> </div>
@@ -228,29 +226,32 @@ export default function Reader() {
); );
} }
if (error) { if (logTrace) {
return ( return (
<LogModal <LogModal
isOpen={!!error} isOpen={!!logTrace}
onClose={() => (window.location.href = "/")} onClose={() => {
message={error.message} if (logTrace.type === "ERROR") window.location.href = "/";
log={error.log} setLogTrace(null);
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-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig 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
@@ -260,7 +261,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>
@@ -270,16 +271,8 @@ export default function Reader() {
{ignite && <PostActionOverlay revealState={revealState} />} {ignite && <PostActionOverlay revealState={revealState} />}
<LogModal {revealState === "REVEALED" && (
isOpen={!!warning} <div className="max-w-180 m-8 mx-auto space-y-8 h-full relative inset-0 z-100">
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" />
@@ -289,7 +282,7 @@ export default function Reader() {
</div> </div>
{metadata?.recipient && ( {metadata?.recipient && (
<p className="text-center sm:hidden text-[10px] uppercase tracking-[0.3em] text-base-content/20 mt-8"> <p className="text-center sm:hidden text-xxs uppercase tracking-widester text-base-content/20 mt-8">
For {metadata.recipient} For {metadata.recipient}
</p> </p>
)} )}
@@ -309,7 +302,7 @@ 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"
@@ -337,7 +330,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-[0.5em]"> <p className="text-xs font-sans uppercase tracking-widester">
Read. Remember. Release. Read. Remember. Release.
</p> </p>
</footer> </footer>
+88 -63
View File
@@ -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,6 +31,9 @@ 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,
@@ -41,10 +44,11 @@ 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) for the server. // we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db.
const { authHash } = await CryptoUtils.deriveKeyBundle( const { authHash } = await CryptoUtils.deriveKeyBundle(
data.password, data.password,
data.email, data.email,
@@ -68,74 +72,95 @@ export default function Register() {
}; };
return ( return (
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom"> <div className="flex flex-col">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4"> <Saajan message={saajanMessage} position="right" />
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight"> <div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
Create a <Logo /> Account <form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
</h1> <div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
Create a <Logo /> Account
{apiError && (
<div className="alert alert-error text-xs py-2 rounded-md">
<span>{apiError}</span>
</div> </div>
)}
<FormField {apiError && (
label="Pen Name" <div className="alert alert-error text-xs py-2 rounded-md">
placeholder="Word Smith" <span>{apiError}</span>
registration={register("full_name")} </div>
error={errors.full_name?.message} )}
/>
<FormField <FormField
label="Email" label="Pen Name"
type="email" placeholder="Word Smith"
placeholder="f.kafka@email.com" registration={register("full_name")}
registration={register("email")} error={errors.full_name?.message}
error={errors.email?.message} handleFocus={() =>
/> setSaajanMessage("Hello friend. What should I call you?")
}
/>
<FormField <FormField
label="Password" label="Email"
type="password" type="email"
placeholder="••••••••" placeholder="f.kafka@wrongtrain.com"
registration={register("password")} registration={register("email")}
error={errors.password?.message} error={errors.email?.message}
/> handleFocus={() =>
setSaajanMessage(
"Where should I send your letters?\nNo empty lunchboxes, please.",
)
}
/>
<FormField <FormField
label="Confirm Password" label="Password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
registration={register("confirm_password")} registration={register("password")}
error={errors.confirm_password?.message} error={errors.password?.message}
/> handleFocus={() =>
setSaajanMessage(
"Something only you know.\nI have one of those too.",
)
}
/>
{/* Warning */} <FormField
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20"> label="Confirm Password"
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" /> type="password"
<p className="text-sm font-semibold"> placeholder="••••••••"
Choose a password you won't forget. <br /> registration={register("confirm_password")}
<span className="underline decoration-2">There is no reset.</span>{" "} error={errors.confirm_password?.message}
If you lose it, your letters cannot be recovered. handleFocus={() =>
</p> setSaajanMessage(
</div> "Just once? Trust me, \nsome things are worth repeating twice.",
)
}
/>
<div className="card-actions mt-4"> <div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
<button <InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
type="submit" <p className="text-sm font-semibold">
disabled={isLoading} Choose a password you won't forget. <br />
aria-label="Register" Just like life,{" "}
className="btn btn-primary w-full shadow-lg" <span className="underline decoration-2">there is no reset</span>{" "}
> here. If you lose it, your letters cannot be recovered.
{isLoading ? ( </p>
<span className="loading loading-spinner loading-sm" /> </div>
) : (
"Register" <div className="card-actions mt-4">
)} <button
</button> type="submit"
</div> disabled={isLoading}
</form> aria-label="Register"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Register"
)}
</button>
</div>
</form>
</div>
</div> </div>
); );
} }
+46 -32
View File
@@ -1,41 +1,55 @@
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="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom"> <div className="relative">
<div className="auth-icon-container"> <Saajan
<EnvelopeSimpleOpenIcon message={"I sent something to your inbox.\nOpen it, and we can begin."}
size={32} />
weight="duotone"
className="text-primary" <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">
<EnvelopeSimpleOpenIcon
size={32}
weight="duotone"
className="text-primary"
/>
</div>
<div className="space-y-2">
<h2 className="font-display text-xl text-primary">
Check Your Mailbox
</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
You're one train away from starting your <Logo scale={0.8} />{" "}
journey.
</p>
</div>
<div className="divider opacity-10 my-0"></div>
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed opacity-70 text-center">
<p>
Nothing yet? Sometimes letters take the wrong train. Check your spam
folder.
<br />
<span className="underline font-bold">
The link expires in 24 hours.
</span>
<br /> I'm patient... but not endlessly so
</p>
</div>
<button
type="button"
className="text-xs italic opacity-40 cursor-pointer underline"
onClick={() => window.close()}
>
You can close this window now.
</button>
</div> </div>
<div className="space-y-2">
<h2 className="font-display text-xl text-primary">Check Your Email</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans">
We've sent an activation link to your inbox. <br />
Please click it to verify your <Logo /> account.
</p>
</div>
<div className="divider opacity-10"></div>
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed text-left opacity-70">
<p>
Didn't receive it? Check your spam folder or wait for a few minutes.
The link will expire in 24 hours.
</p>
</div>
<button
type="button"
className="text-xs italic opacity-40 cursor-pointer underline"
onClick={() => window.close()}
>
You can close this window now.
</button>
</div> </div>
); );
} }
+2 -2
View File
@@ -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); // SHA-256 hex expect(authHash).toHaveLength(64);
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 from the owner"; const plaintext = "hello";
const encrypted = await utils.encryptLetter(plaintext, masterKey); const encrypted = await utils.encryptLetter(plaintext, masterKey);
const extracted = await utils.extractSharingKey( const extracted = await utils.extractSharingKey(
+114 -65
View File
@@ -1,7 +1,3 @@
/**
* 0 knowledge cryptography. No Server involved in encryption/decryption
*/
export interface EncryptedLetter { export interface EncryptedLetter {
encrypted_content: string; encrypted_content: string;
encrypted_dek: string; encrypted_dek: string;
@@ -11,6 +7,7 @@ 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 {
@@ -25,59 +22,88 @@ 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 = {} as CryptoKey; private dek!: CryptoKey;
private static readonly PBKDF2_ITERATIONS = 100_000; private static readonly PBKDF2_ITERATIONS =
private static readonly AES_GCM = { name: "AES-GCM", length: 256 }; Number(import.meta.env.VITE_PBKDF2_ITERATIONS) || 600_000;
// 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;
// Generates a fresh Data Encryption Key (DEK) // NOTE: this MUST be called once, per letter, for all operations in a session to a fresh Data Encryption Key (DEK)
async initialize() { async initialize() {
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [ this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_ALGO, true, [
"encrypt", "encrypt",
"decrypt", "decrypt",
]); ]);
} }
// base64 conversion for transit private toBase64 = (buffer: Uint8Array): string => {
toBase64 = (buf: Uint8Array): string => // convert buffer to raw string
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); let binaryFileString = "";
for (let i = 0; i < buffer.byteLength; i++) {
binaryFileString += String.fromCharCode(buffer[i]);
}
return btoa(binaryFileString);
};
fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => { private fromBase64 = (b64String: string): Uint8Array<ArrayBuffer> => {
const str = atob(b64); const decodedString = atob(b64String);
const arr = new Uint8Array(str.length); const arr = new Uint8Array(decodedString.length);
for (let i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i); for (let i = 0; i < decodedString.length; i++)
arr[i] = decodedString.charCodeAt(i);
return arr; return arr;
}; };
// bundle IV + data into a single base64 string // Required structure: [12 bytes IV][Cipher text][16 bytes Auth Tag]
packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => { // NOTE: Web Crypto API auto appends the auth tag, so we focus on IV and cipher
const packed = new Uint8Array(iv.length + data.byteLength); private packWithIv = (iv: Uint8Array, ciphertext: ArrayBuffer): string => {
packed.set(iv); // create a buffer large enough to hold both iv and cipher text (12 + x bytes)
packed.set(new Uint8Array(data), iv.length); const combinedPayload = new Uint8Array(
return this.toBase64(packed); CryptoUtils.IV_BYTE_LENGTH + ciphertext.byteLength,
);
// 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);
}; };
unpackWithIv = ( // For decryption: extracts the IV and the data from the base64 string, easy because we know the size of iv already.
b64: string, private unpackWithIv = (
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => { encodedString: string,
const buf = this.fromBase64(b64); ): { iv: Uint8Array<ArrayBuffer>; ciphertext: Uint8Array<ArrayBuffer> } => {
return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)]; // decode from base64 to array buffer
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) };
}; };
/** /**
* Derives a Key Bundle (MasterKey + AuthHash) from a password + email. * Derive a key bundle (Masterkey + authHash) from email + (plain) password combo
* Absolute zero knowledge!! * WHY?: This is much secure than relying on server to hash and store the password. Also ensures absolute 0 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 enc = new TextEncoder(); const encoder = new TextEncoder();
const salt = enc.encode(email.toLowerCase()); const salt = encoder.encode(email.toLowerCase());
const baseKey = await crypto.subtle.importKey( const baseKey = await crypto.subtle.importKey(
"raw", "raw",
enc.encode(password), encoder.encode(password),
"PBKDF2", "PBKDF2",
false, false,
["deriveBits", "deriveKey"], ["deriveBits", "deriveKey"],
@@ -91,49 +117,61 @@ export class CryptoUtils {
hash: "SHA-256", hash: "SHA-256",
}, },
baseKey, baseKey,
512, // 512 bits to split 512,
); );
// first 256 bits for MasterKey, last 256 bits for AuthHash // first 256 bits for masterkey, last 256 bits for authHash (password sent in REST)
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_GCM, CryptoUtils.AES_ALGO,
false, false,
["encrypt", "decrypt", "wrapKey", "unwrapKey"], ["encrypt", "decrypt", "wrapKey", "unwrapKey"],
); );
// Create the hex AuthHash for server-side verification // convert bytes in to hex string
const authHash = Array.from(new Uint8Array(authHashBytes)) let authHash = "";
.map((b) => b.toString(16).padStart(2, "0")) const authHashBuffer = new Uint8Array(authHashBytes);
.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);
// encrypt the content with the DEK
const contentIv = crypto.getRandomValues(new Uint8Array(12)); const contentIv = crypto.getRandomValues(new Uint8Array(12));
const dekIv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: contentIv }, { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
this.dek, this.dek,
plainBytes, plainBytes,
); );
// wrap the DEK with the Master Key (for self/owner access) // wrap the DEK with the Master Key (for self 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: "AES-GCM", name: CryptoUtils.AES_ALGO.name,
iv: dekIv, iv: dekIv,
}); });
@@ -147,26 +185,27 @@ export class CryptoUtils {
}; };
} }
// Internal helper to unwrap the key and decrypt data // Unwrap the DEK with the master key to get the key back. Decrypt the content with the DEK.
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 [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); const { iv: dekIv, ciphertext: wrappedDek } =
this.unpackWithIv(encrypted_dek);
const dek = await crypto.subtle.unwrapKey( const dek = await crypto.subtle.unwrapKey(
"raw", "raw",
wrappedDek, wrappedDek,
masterKey, masterKey,
{ name: "AES-GCM", iv: dekIv }, { name: CryptoUtils.AES_ALGO.name, iv: dekIv },
CryptoUtils.AES_GCM, CryptoUtils.AES_ALGO,
false, false,
["decrypt"], ["decrypt"],
); );
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
const plainBytes = await crypto.subtle.decrypt( const plainBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: contentIv }, { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
dek, dek,
ciphertext, ciphertext,
); );
@@ -182,14 +221,14 @@ export class CryptoUtils {
const dek = await crypto.subtle.importKey( const dek = await crypto.subtle.importKey(
"raw", "raw",
dekBytes, dekBytes,
CryptoUtils.AES_GCM, CryptoUtils.AES_ALGO,
false, false,
["decrypt"], ["decrypt"],
); );
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
const plainBytes = await crypto.subtle.decrypt( const plainBytes = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: contentIv }, { name: CryptoUtils.AES_ALGO.name, iv: contentIv },
dek, dek,
ciphertext, ciphertext,
); );
@@ -206,6 +245,7 @@ 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 };
} }
@@ -218,6 +258,7 @@ export class CryptoUtils {
encrypted_dek, encrypted_dek,
masterKey, masterKey,
); );
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
@@ -229,18 +270,20 @@ 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: Record<string, any>, metadata: Record<string, any>,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<EncryptedLetter> { ): Promise<EncryptedLetterMetadata> {
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 };
} }
@@ -253,6 +296,7 @@ export class CryptoUtils {
encrypted_metadata.encrypted_dek, encrypted_metadata.encrypted_dek,
masterKey, masterKey,
); );
return JSON.parse(new TextDecoder().decode(bytes)); return JSON.parse(new TextDecoder().decode(bytes));
} }
@@ -264,6 +308,7 @@ export class CryptoUtils {
encrypted_content, encrypted_content,
sharingKey, sharingKey,
); );
return JSON.parse(new TextDecoder().decode(bytes)); return JSON.parse(new TextDecoder().decode(bytes));
} }
@@ -290,12 +335,13 @@ 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 bytes = await this.openEnvelope( const plainBytes = 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(
@@ -303,28 +349,31 @@ 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 bytes = await this.openEnvelopeWithSharingKey( const plainBytes = await this.openEnvelopeWithSharingKey(
this.toBase64(encryptedBytes), this.toBase64(encryptedBytes),
sharingKey, sharingKey,
); );
return URL.createObjectURL(new Blob([bytes]));
return URL.createObjectURL(new Blob([plainBytes]));
} }
// Re-derives the sharing key (raw DEK) on demand (browser only, not sent to server). // derive raw DEK on demand (browser only, not sent to server) for guest access
public async extractSharingKey( public async extractSharingKey(
encrypted_dek: string, encrypted_dek: string,
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<string> { ): Promise<string> {
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); const { iv: dekIv, ciphertext: wrappedDek } =
this.unpackWithIv(encrypted_dek);
const rawDek = await crypto.subtle.unwrapKey( const rawDek = await crypto.subtle.unwrapKey(
"raw", "raw",
wrappedDek, wrappedDek,
masterKey, masterKey,
{ name: "AES-GCM", iv: dekIv }, { name: CryptoUtils.AES_ALGO.name, iv: dekIv },
CryptoUtils.AES_GCM, CryptoUtils.AES_ALGO,
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 -1
View File
@@ -1,6 +1,6 @@
import { openDB } from "idb"; import { openDB } from "idb";
// we use this to store master key in browser - secure and good UX // we use indexedDB to securely store master key for easier access across tabs (better UX than having to store in session)
const db = openDB("piku-keys", 1, { const db = openDB("piku-keys", 1, {
upgrade(db) { upgrade(db) {
db.createObjectStore("master-key"); db.createObjectStore("master-key");
+35 -34
View File
@@ -12,6 +12,7 @@ vi.mock("../api/apiClient", () => ({
api: { api: {
get: vi.fn(), get: vi.fn(),
}, },
apiServerUrl: "https://remote",
})); }));
vi.mock("./fileUtils", () => ({ vi.mock("./fileUtils", () => ({
@@ -21,7 +22,6 @@ 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,15 +58,13 @@ describe("letterLogic image helpers", () => {
const encryptImageSpy = vi.spyOn(CryptoUtils.prototype, "encryptImage"); const encryptImageSpy = vi.spyOn(CryptoUtils.prototype, "encryptImage");
const uploads = await encryptCanvasImages( const { encryptedImageFiles: uploads, encryptedCanvasData } =
canvasData, await encryptCanvasImages(canvasData, [], masterKey, crypto);
[],
masterKey,
crypto,
);
expect(encryptImageSpy).not.toHaveBeenCalled(); expect(encryptImageSpy).not.toHaveBeenCalled();
expect(canvasData.objects[0].src).toBe("already-encrypted.png.bin"); expect(encryptedCanvasData.objects[0].src).toBe(
"already-encrypted.png.bin",
);
expect(uploads.size).toBe(0); expect(uploads.size).toBe(0);
}); });
@@ -99,15 +97,11 @@ describe("letterLogic image helpers", () => {
filename: "photo.png.bin", filename: "photo.png.bin",
}); });
const uploads = await encryptCanvasImages( const { encryptedImageFiles: uploads, encryptedCanvasData } =
canvasData, await encryptCanvasImages(canvasData, canvasImages, masterKey, crypto);
canvasImages,
masterKey,
crypto,
);
expect(CryptoUtils.prototype.encryptImage).toHaveBeenCalledTimes(1); expect(CryptoUtils.prototype.encryptImage).toHaveBeenCalledTimes(1);
expect(canvasData.objects[0].src).toBe("photo.png.bin"); expect(encryptedCanvasData.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);
}); });
@@ -136,7 +130,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"]) });
@@ -144,7 +138,7 @@ describe("letterLogic image helpers", () => {
"blob:http://localhost/decrypted", "blob:http://localhost/decrypted",
); );
await decryptCanvasImages( const { canvasDataWithDecryptedImages } = await decryptCanvasImages(
canvasData, canvasData,
remoteImages, remoteImages,
"wrapped-dek", "wrapped-dek",
@@ -152,16 +146,19 @@ describe("letterLogic image helpers", () => {
crypto, crypto,
); );
expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", { expect(api.get).toHaveBeenCalledWith(
responseType: "blob", `https://remote/photo.png.bin`,
}); expect.objectContaining({ responseType: "blob" }),
);
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith( expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
expect.any(Blob), expect.any(Blob),
"wrapped-dek", "wrapped-dek",
masterKey, masterKey,
); );
expect(canvasData.objects[0].src).toBe("blob:http://localhost/decrypted"); expect(canvasDataWithDecryptedImages.objects[0].src).toBe(
expect(canvasData.objects[1].text).toBe("hello"); "blob:http://localhost/decrypted",
);
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 () => {
@@ -190,7 +187,7 @@ describe("letterLogic image helpers", () => {
new File(["raw"], "photo.png.bin"), new File(["raw"], "photo.png.bin"),
); );
await decryptCanvasImages( const { canvasDataWithDecryptedImages } = await decryptCanvasImages(
canvasData, canvasData,
remoteImages, remoteImages,
"wrapped-dek", "wrapped-dek",
@@ -203,7 +200,9 @@ describe("letterLogic image helpers", () => {
"blob:http://localhost/decrypted", "blob:http://localhost/decrypted",
"photo.png.bin", "photo.png.bin",
); );
expect(canvasData.objects[0]._customRawFile).toBeInstanceOf(File); expect(
canvasDataWithDecryptedImages.objects[0]._customRawFile,
).toBeInstanceOf(File);
}); });
}); });
@@ -231,20 +230,22 @@ describe("letterLogic image helpers", () => {
"decryptImageWithSharingKey", "decryptImageWithSharingKey",
).mockResolvedValue("blob:http://localhost/decrypted-shared"); ).mockResolvedValue("blob:http://localhost/decrypted-shared");
await decryptCanvasImagesWithSharingKey( const { canvasDataWithDecryptedImages } =
canvasData, await decryptCanvasImagesWithSharingKey(
remoteImages, canvasData,
"raw-sharing-key", remoteImages,
crypto, "raw-sharing-key",
); crypto,
);
expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", { expect(api.get).toHaveBeenCalledWith(
responseType: "blob", "https://remote/photo.png.bin",
}); expect.objectContaining({ responseType: "blob" }),
);
expect( expect(
CryptoUtils.prototype.decryptImageWithSharingKey, CryptoUtils.prototype.decryptImageWithSharingKey,
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key"); ).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
expect(canvasData.objects[0].src).toBe( expect(canvasDataWithDecryptedImages.objects[0].src).toBe(
"blob:http://localhost/decrypted-shared", "blob:http://localhost/decrypted-shared",
); );
}); });
+146 -66
View File
@@ -1,4 +1,4 @@
import { api } from "../api/apiClient"; import { api, apiServerUrl, publicApi } from "../api/apiClient";
import type { import type {
CanvasJSON, CanvasJSON,
FabricImageJSON, FabricImageJSON,
@@ -11,6 +11,35 @@ 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 }[],
@@ -18,47 +47,66 @@ export async function decryptCanvasImages(
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
includeRawFile = false, includeRawFile = false,
): Promise<{ isDecryptionPartialFailure: boolean; error: string }> { ): Promise<DecryptionResult> {
if (!canvasData?.objects) if (!canvasData?.objects) {
return { isDecryptionPartialFailure: false, error: "" }; return {
let isDecryptionPartialFailure = false; canvasDataWithDecryptedImages: canvasData,
let error = ""; isPartialFailure: false,
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 decryptionPromises = canvasData.objects.map(async (obj, index) => { const errors: string[] = [];
if (obj.type !== "Image") return; const processedObjects = await Promise.all(
const imgObj = obj as FabricImageJSON; canvasData.objects.map(async (obj) => {
const remoteUrl = imageMap.get(imgObj.src); if (obj.type !== "Image") return obj;
if (!remoteUrl) return;
try { const imgObj = obj as FabricImageJSON;
const res = await api.get(remoteUrl, { responseType: "blob" }); const remoteUrl = imageMap.get(imgObj.src);
const originalSrc = imgObj.src; if (!remoteUrl) return obj;
const blobUrl = await cryptoUtils.decryptImage( try {
res.data, const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
encrypted_dek, const blobUrl = await cryptoUtils.decryptImage(
masterKey, blob,
); encrypted_dek,
masterKey,
);
imgObj.src = blobUrl; const decryptedObj: DecryptedFabricImageJSON = {
...imgObj,
src: blobUrl,
};
if (includeRawFile) { if (includeRawFile) {
imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc); decryptedObj._customRawFile = await blobUrlToFile(
blobUrl,
imgObj.src,
);
}
return decryptedObj;
} catch (err) {
errors.push(
`Failed to decrypt ${imgObj.src}: ${err instanceof Error ? err.message : "Unknown error"}`,
);
return null;
} }
} catch (_error) { }),
delete canvasData.objects[index]; );
isDecryptionPartialFailure = true;
error = _error instanceof Error ? _error.message : "Unknown error";
}
});
await Promise.all(decryptionPromises); return {
canvasData.objects = canvasData.objects.filter(Boolean); canvasDataWithDecryptedImages: {
return { isDecryptionPartialFailure, error }; ...canvasData,
objects: processedObjects.filter((obj) => !!obj),
},
isPartialFailure: errors.length > 0,
errors,
};
} }
export async function decryptCanvasImagesWithSharingKey( export async function decryptCanvasImagesWithSharingKey(
@@ -66,32 +114,53 @@ export async function decryptCanvasImagesWithSharingKey(
remoteImages: { file_name: string; file: string }[], remoteImages: { file_name: string; file: string }[],
sharingKey: string, sharingKey: string,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
) { ): Promise<DecryptionResult> {
if (!canvasData?.objects) return; if (!canvasData?.objects) {
return {
canvasDataWithDecryptedImages: canvasData,
isPartialFailure: false,
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 decryptionPromises = canvasData.objects.map(async (obj) => { 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; if (!remoteUrl) return obj;
try { try {
const res = await api.get(remoteUrl, { responseType: "blob" }); const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
imgObj.src = await cryptoUtils.decryptImageWithSharingKey( const blobUrl = await cryptoUtils.decryptImageWithSharingKey(
res.data, blob,
sharingKey, sharingKey,
); );
} catch (_error) {
// Keep original or handle failure
}
});
await Promise.all(decryptionPromises); return { ...imgObj, src: blobUrl };
} catch (err) {
errors.push(
`Failed to decrypt ${imgObj.src}: ${err instanceof Error ? err.message : "Unknown error"}`,
);
return null;
}
}),
);
return {
canvasDataWithDecryptedImages: {
...canvasData,
objects: processedObjects.filter((obj) => !!obj),
},
isPartialFailure: errors.length > 0,
errors,
};
} }
export async function encryptCanvasImages( export async function encryptCanvasImages(
@@ -99,23 +168,34 @@ export async function encryptCanvasImages(
canvasImages: CanvasImageRef[], canvasImages: CanvasImageRef[],
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
) { ): Promise<EncryptionResult> {
const encryptedFiles = new Map<string, Blob>(); const encryptedImageFiles = new Map<string, Blob>();
const filenameMapping = new Map<string, string>(); const filenameMapping = new Map<string, string>();
for (const img of canvasImages) { // filter out already encrypted images
if (img.src.endsWith(".bin")) continue; const imagesToEncrypt = canvasImages.filter(
if (!img.file) continue; (img) => img.file && !img.src.endsWith(".bin"),
const { filename, encryptedBlob } = await cryptoUtils.encryptImage( );
img.file,
masterKey,
);
filenameMapping.set(img.src, filename);
encryptedFiles.set(filename, encryptedBlob);
}
if (canvasData?.objects) { // encrypt images parallelly
canvasData.objects = canvasData.objects.map((obj) => { await Promise.all(
imagesToEncrypt.map(async (img) => {
const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
img.file,
masterKey,
);
// map the og image url to the encrypted file name and filename to the encrypted source
filenameMapping.set(img.src, filename);
encryptedImageFiles.set(filename, encryptedBlob);
}),
);
if (!canvasData?.objects)
return { encryptedImageFiles, encryptedCanvasData: canvasData };
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)) {
@@ -126,8 +206,8 @@ export async function encryptCanvasImages(
} }
} }
return obj; return obj;
}); }),
} };
return encryptedFiles; return { encryptedImageFiles, encryptedCanvasData: newCanvasData };
} }
+2
View File
@@ -9,6 +9,8 @@ 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",