40 Commits

Author SHA1 Message Date
ramvignesh-b bea9b13249 style: adjust bottom margin of Saajan component image to -6
CI / Generate Certificates (push) Successful in 2m35s
CI / Frontend CI (push) Successful in 2m27s
CI / Backend CI (push) Failing after 1m43s
CI / E2E Tests (push) Failing after 4m22s
2026-05-04 06:46:02 +05:30
ramvignesh-b 84445f16b3 fix: implement scroll-to-top behavior for about navigation 2026-05-04 06:45:56 +05:30
ramvignesh-b fce0b5b539 feat: add navigation to About page on Tell me More button click 2026-05-04 06:33:19 +05:30
ramvignesh-b fb3cb2eb69 feat: add typography styles and complete about page 2026-05-04 06:23:10 +05:30
ramvignesh-b 2659f73577 feat: add inline verion of logo 2026-05-03 14:36:08 +05:30
ramvignesh-b bf6aa34536 feat: add learn more about component 2026-05-02 04:08:00 +05:30
ramvignesh-b dddda69c2f style: add additional fonts for editor and update metadada 2026-05-01 15:06:02 +05:30
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
83 changed files with 3417 additions and 1172 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
+26
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,8 @@
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"fabric": "^7.2.0", "fabric": "^7.2.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"lenis": "^1.3.23",
"motion": "^12.38.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.72.1",
@@ -118,10 +124,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 +416,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=="],
@@ -476,6 +492,8 @@
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="], "jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
"lenis": ["lenis@1.3.23", "", { "peerDependencies": { "@nuxt/kit": ">=3.0.0", "react": ">=17.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "react", "vue"] }, "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -526,6 +544,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=="],
@@ -712,6 +736,8 @@
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
+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>
+6
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,8 @@
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"fabric": "^7.2.0", "fabric": "^7.2.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"lenis": "^1.3.23",
"motion": "^12.38.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.72.1",
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: 118 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 327 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"
}
+15 -8
View File
@@ -1,12 +1,16 @@
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,
ScrollRestoration,
} 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"));
@@ -15,14 +19,16 @@ const Login = lazy(() => import("./pages/Login"));
const Reader = lazy(() => import("./pages/Reader")); const Reader = lazy(() => import("./pages/Reader"));
const Register = lazy(() => import("./pages/Register")); const Register = lazy(() => import("./pages/Register"));
const VerifyEmail = lazy(() => import("./pages/VerifyEmail")); const VerifyEmail = lazy(() => import("./pages/VerifyEmail"));
const About = lazy(() => import("./pages/About"));
export default function App() { export default function App() {
const { initialize, isInitializing } = useAuth(); const { initialize, isInitializing } = useAuth();
const authInitialized = useRef<boolean>(false);
useEffect(() => { useEffect(() => {
if (authInitialized) return; if (authInitialized.current) return;
authInitialized = true; authInitialized.current = true;
initialize(); initialize().then();
}, [initialize]); }, [initialize]);
if (isInitializing) { if (isInitializing) {
@@ -31,7 +37,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 />} />
@@ -86,6 +92,7 @@ export default function App() {
} }
/> />
<Route path={ROUTES.READ} element={<Reader />} /> <Route path={ROUTES.READ} element={<Reader />} />
<Route path={ROUTES.ABOUT} element={<About />} />
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} /> <Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
+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

+32 -6
View File
@@ -1,24 +1,50 @@
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 }) { interface LogoProps {
scale?: number;
type?: "inline" | "mono" | "logo";
}
export default function Logo({ scale = 1, type = "logo" }: LogoProps) {
if (type === "inline") {
return (
<span
className={
"text-accent font-serif italic drop-shadow-xs drop-shadow-base-200/60 "
}
>
Pi<span className="text-primary">.</span>&nbsp;Ku
<span className="text-primary">.</span>&nbsp;
</span>
);
}
if (type === "mono") {
return (
<span className="font-mono italic font-bold border-b-3 border-dashed border-stone-800/50">
pi. ku.
</span>
);
}
return ( return (
<div <div
role="img" role="img"
aria-label="Pi Ku" aria-label="Pi. Ku. logo"
className="inline-flex items-baseline justify-center leading-none select-none" className="inline-flex items-baseline justify-center leading-none select-none"
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-6 ${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;
}; };
+4 -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,13 @@ 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",
ABOUT: "/know-piku",
}; };
// 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,
+58 -41
View File
@@ -2,60 +2,77 @@
@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 m-4;
}
.ul-wavy {
@apply decoration-primary/40 underline decoration-wavy underline-offset-4;
}
a {
@apply text-primary underline decoration-base-content/20 underline-offset-4 hover:decoration-primary/60 transition-colors;
} }
+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");
+896
View File
@@ -0,0 +1,896 @@
import {
ArrowArcLeftIcon,
ArrowBendDownLeftIcon,
ArrowBendDownRightIcon,
ArrowRightIcon,
CaretUpIcon,
FlowerTulipIcon,
GhostIcon,
InfoIcon,
LockLaminatedIcon,
LockOpenIcon,
PasswordIcon,
PersonArmsSpreadIcon,
PersonIcon,
ScrollIcon,
SmileyIcon,
SparkleIcon,
VaultIcon,
} from "@phosphor-icons/react";
import { ReactLenis } from "lenis/react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import stamp from "../assets/envelope/stamp.png";
import Logo from "../components/Logo.tsx";
import { Modal } from "../components/ui/Modal";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/redacted-script/index.css";
import "@fontsource/architects-daughter/index.css";
import { useNavigate } from "react-router-dom";
function HorizontalScroll({ children }: { children: React.ReactNode }) {
const ref = useRef(null);
const { scrollYProgress } = useScroll({ target: ref });
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
return (
<section ref={ref} className="relative h-[200dvh]">
<div className="sticky top-0 flex h-screen w-screen items-center overflow-x-hidden">
<motion.div style={{ x }} className="flex w-[200vw]">
{children}
</motion.div>
</div>
</section>
);
}
export default function About() {
useEffect(() => {
window.scrollTo(0, 0);
}, []);
return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
<div className="flex flex-col">
<StorySection />
<HorizontalScroll>
<ForWhoSection />
<ArchetypesSection />
</HorizontalScroll>
<PrivacySection />
<HorizontalScroll>
<SpecsSection />
<OSSSection />
</HorizontalScroll>
<AttributionSection />
</div>
</ReactLenis>
);
}
function PrivacySection() {
return (
<div className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif flex"
}
>
The &nbsp; Promise
<span className="absolute -translate-y-6 md:-translate-y-12 font-display italic text-4xl md:text-6xl text-success translate-x-6 md:translate-x-12 -rotate-6">
privacy
</span>
<CaretUpIcon
className="absolute translate-y-6 md:translate-y-12 translate-x-20 md:translate-x-36 text-neutral -rotate-6"
weight="bold"
/>
</h1>
<div className="flex flex-col items-center shrink-0 gap-8 max-w-11/12 w-200">
<p className="text-xxs md:text-sm tracking-widester text-neutral-content/80 font-semibold uppercase mt-6">
<span className="text-accent">Your letters.</span>{" "}
<span className="text-error">Nobody else's.</span>
</p>
<p className="text-sm md:text-lg">
When you write something here, it gets encrypted in your browser
before anything leaves your device. What reaches the server isn't your
letter. It's something unreadable &mdash; and the server has no way to
change that, because the key never left you.
</p>
<figure className="diff aspect-3/4 touch-pan-y select-none">
<div className="diff-item-1 z-1" role="img">
<div className="bg-primary text-primary-content grid place-content-center text-sm md:gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-3xl md:text-6xl uppercase font-bold tracking-widest mt-2 md:mt-8">
you see
</h1>
<PasswordIcon
className="text-neutral mx-auto -mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Password
</h2>
<p className="text-center md:text-2xl font-bold font-mono">
<br />
B@z1ng4A
</p>
</div>
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
<LockOpenIcon size={48} />
</div>
<div className="flex flex-col items-center md:gap-2">
<ScrollIcon
className="text-neutral mx-auto md:-mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Letter
</h2>
<div className="p-6 bg-paper w-82 md:w-150 h-200 flex flex-col gap-4 text-xs md:text-lg overflow-hidden max-h-68 md:max-h-full">
<p className="wrap-anywhere">Hello friend,</p>
<p>I've never told this to anyone...</p>
<p className="font-redact">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut
semper, justo eget vehicula vestibulum, enim enim suscipit
lectus, et sagittis nibh risus vel metus. Quisque eu ornare
ante, et gravida mauris. Vivamus massa justo, sagittis non
viverra sed, sodales non nisi. Nunc semper, massa a aliquet
dictum, enim nisi malesuada orci, et elementum lectus turpis
et velit. Nam vel felis vitae tortor dignissim malesuada.
Nam suscipit, justo eu elementum pulvinar, magna sem tempor
ex, vitae iaculis tellus odio non nisl. Duis dolor orci,
viverra ut finibus sed, aliquet vitae tortor. Proin sodales
ipsum ac ipsum hendrerit tempus. Nunc nec nibh nibh. Aenean
consequat auctor posuere. Integer sed magna volutpat,
efficitur nisl ut, dignissim neque. Vestibulum convallis nec
dui a euismod. Duis dignissim magna in mattis pulvinar. Sed
blandit nibh quis arcu ornare, sit amet fermentum nisi
rhoncus.
</p>
</div>
</div>
</div>
</div>
<div className="diff-item-2" role="img">
<div className="bg-neutral-content bg-[url('https://www.transparenttextures.com/patterns/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8">
server see
</h1>
<PasswordIcon
className="text-neutral mx-auto -mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Password
</h2>
<p className="text-center md:text-2xl font-bold font-mono max-w-150 break-all">
9e54d05f88bdd67a675b03bf1cd0a1647e2109b5aa18185ff6a9ba4c6959a19d
</p>
</div>
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
<LockLaminatedIcon size={48} />
</div>
<div className="flex flex-col items-center md:gap-2">
<ScrollIcon
className="text-neutral mx-auto md:-mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Letter
</h2>
<div className="p-6 bg-paper w-82 md:w-150 h-200 text-xxs md:text-sm font-mono md:leading-loose overflow-hidden max-h-68 md:max-h-full">
<p className="break-all">
SZ0Mq9M9sCZsdDB8HGjk7JfWG56Kaot8Lgma74MCusDUYibUGoR7VviWgvc341pvFV9/IAyot9KtlDvwIX1ZmUw9Oh340JMaajRQ7iNgVjHgAwmJAr2cLbReNqlF6xzaf3mIYkiK9BXNQekk2h/9XufklsqoIXpaK1re7xWQ8mdddzy6z4EQFVH/Ev3np5ERW/ss7Z1kqYWUnANK7olWNL/7GgZmhU+L29rgbR52kcH9fng7gnEI3KEuISYExYCg81G1VaJYspkW3A4qwcet+jXdgmbKvkux5qNw6gyNi9d/YqKV7OUNrmoH190rHdJ5A7HOIv3/SvPhb3Zm4sNF5PcMxmhM0+T9m5PejV1GhV9bMBHbbgacay7hZJU3O0+q+7fBAE/+pqfvZdv78lLDFSdtHAXUpYOvHPrI5BNNwuS3T+FK1zjurLnUPThlOSYRICoZSUcxVswXz897PoRmFNNvbal0dpKUmCFrBwV5c/W3d1+iZor5msbm/JxpbNtys59e0StSTwHKsxvxm/rTuUAxWSOmzt13MDBxxd2zyVnX8rtQ7mEjMJ8IHHpvhKjONoa2S11VBJY68Ee1vNrw7htu+wajvmXhHAyfh1lYql8pu8VvPUG7leEQ9I0pMY35Y/C1cYCBLkDT5zf8NeZFtbp0BNgHd+QDVSFH+GSnvTskU2BCio3YE+zE6cDhvLUOMy3e5RAtPqsi5VzpEUcdCwph+Z+1pFlTxiEZ62i4wNpqw2lhS3b/E9ifJgnncSgRHLtfw/VxHZCRc4tBQ24xSZ507lSlQch+5lQeO7rx2htgd2D7aGNx/UN/xmeuEd4a28AxNOVS3uYh3wTDh8CSXyBRCRPxrANOV1ZBojdfK+v5fOJNPgDn3r5/pG80L3FTkecRB0zFuKNG8jIzi5ADx9k4SlhRNo17gPl2if8gRA6tzTae4kbzieG+woxhUWj/qvXg0MQmg59VTK2HHS34exdKDP9a561svlw+lJ2AtM1EL9srJk8i3kiyEPUeIlaLl3AfgbbSuC2RhlzFFAYuQ06rbsSvEoe4rrYeMXxL9jwVsXX0xrp8H25mOJu3ahn5pFYzADMSGf4L11H1vDArpefj/lW+8zcmogxxBktYYNF/qU4v+9367hp4MEn/84tQPpmb47TL+XpVnl9tQ3r9OfOaW3zX7NkWZbqoX7OgdgHOtTLP/euQujSs2MAzMO4BmbuCS7pR/GTZwDqF1sXiWAkunjo2qpKHieqlvSVmtwEhh6wsNwYTKEkddmTqvKSx0fHRvs3D9lMGJfg7wLSz/3Otx3G65tk9l/3B3r87qQTvbqXmcfnFdEIaR8mO/yMyCKnxtJkJb3lEzNUOrvnSxwL7Gyn54TLTWA==
</p>
</div>
</div>
</div>
</div>
<div className="diff-resizer"></div>
</figure>
</div>
</div>
);
}
function SpecsSection() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<section className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
<h1 className="relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-display z-10">
S'more Specs
</h1>
<div className="flex flex-col items-start shrink-0 gap-6 max-w-11/12 w-200 mt-4 md:mt-12">
<h2 className="text-xl md:text-3xl text-center mx-auto">
<Logo type={"inline"} /> uses{" "}
<span className="text-accent font-mono">Zero Knowledge</span>{" "}
<span className="group ul-wavy font-mono text-primary">
E
<span className="hidden group-hover:inline group-focus-within:inline">
nd&nbsp;
</span>
2
<span className="hidden group-hover:inline group-focus-within:inline">
&nbsp;
</span>
E
<span className="hidden group-hover:inline group-focus-within:inline">
nd
</span>
<span className="hidden group-hover:inline group-focus-within:inline">
&nbsp;<span>E</span>
<span className="hidden group-hover:inline group-focus-within:inline">
ncryption
</span>
</span>
</span>{" "}
with{" "}
<span className="font-mono text-primary">Envelope Encryption</span>
</h2>
<p className="text-sm md:text-xl leading-relaxed">
This means, both the encryption and decryption runs on your device, in
your browser.
<br />
Every letter has a{" "}
<span className="font-mono text-primary">unique key</span> which is
derived from your original password.
<br />
Both the letter and the key are encrypted securely and sent to the
server.
<br />
Now, the server holds{" "}
<span className="text-primary font-bold">the envelope</span>,{" "}
<span className="text-primary font-bold">the seal</span> and{" "}
<span className="text-primary font-bold">another locked box</span>{" "}
with a key inside that unseals your letter. But you,{" "}
<span className="italic text-primary">only you</span>, hold the only
thing that opens the box &mdash;{" "}
<span className="font-mono text-accent">your password</span>.
</p>
<p className="text-sm md:text-xl text-right w-full flex items-center justify-end gap-4 leading-relaxed">
Nothing on the server is readable without your actual password.
<br />
Even if someone were to breach in, all they'd find is encrypted noise.
<VaultIcon size={48} weight="duotone" />
</p>
<button
type={"button"}
className="btn btn-outline border-base-300 w-full justify-between font-medium opacity-80"
onClick={() => setIsModalOpen(true)}
>
<span className="text-sm md:text-lg font-mono ul-wavy font-bold">
Nerd Stuff
</span>
<ArrowRightIcon size={20} />
</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<div className="w-full bg-paper rounded-md p-6">
<img src="/screenshots/e2e.svg" alt="pi ku e2e diagram" />
</div>
</Modal>
<p className="text-sm md:text-lg">
This level of privacy comes with a catch.{" "}
<span className="text-error font-bold">No password reset.</span>
</p>
<p className="text-sm md:text-lg alert alert-warning font-semibold">
<InfoIcon weight="duotone" /> Your original password is never stored
on the server. Which means if it's lost, the letters stay sealed
forever.
</p>
</div>
</section>
);
}
function OSSSection() {
return (
<section className="flex flex-col h-screen w-screen items-center justify-center py-18 gap-4">
<h1
className={
"relative tracking-tighter text-4xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif text-center"
}
>
<span className="hidden absolute -translate-y-24 translate-x-45 font-display text-3xl md:text-6xl opacity-70 rotate-8">
only for
<br />
<span className="text-primary">your letters</span> <SmileyIcon />
<ArrowArcLeftIcon className="inline rotate-45 -translate-y-8" />
</span>
<Logo type={"inline"} /> is{" "}
<span className="line-through decoration-6 decoration-error">
&nbsp;private
</span>{" "}
<span className="text-success">open source !</span>
</h1>
<div className="flex flex-col items-center shrink-0 max-w-11/12 w-200 gap-4 p-4 md:p-6">
<p className="text-sm md:text-xl">
<Logo type={"mono"} /> is fully open source. Every claim about privacy
and encryption is publicly available in the code so you don't have to
take anyone's word for it.
</p>
<p className="text-sm md:text-lg">
You can also{" "}
<span className="uppercase font-bold text-primary">Self-host</span>{" "}
<Logo type={"inline"} /> in just 4 steps.
</p>
<div className="mockup-code w-full text-xs">
<pre data-prefix="$">
<code>git clone https://git.ramvignesh.dev/me/pi-ku.git</code>
</pre>
<pre data-prefix="$">
<code>cd pi-ku</code>
</pre>
<pre data-prefix="$">
<code>./scripts/setup.sh</code>
</pre>
<pre data-prefix="$">
<code>./scripts/start.sh</code>
</pre>
</div>
<div className="flex flex-wrap gap-4 w-full items-center justify-center">
<a
href="https://git.ramvignesh.dev/me/pi-ku"
target="_blank"
rel="noopener noreferrer"
className="text-primary"
>
View on GitHub
</a>
<p className="text-xs md:text-base opacity-70">
Found something to report or request?{" "}
<a
href="https://git.ramvignesh.dev/me/pi-ku/issues"
target="_blank"
rel="noopener noreferrer"
>
Please say so.
</a>
</p>
</div>
<div className="divider opacity-30 my-0"></div>
<p className="text-xxs md:text-sm tracking-widester font-semibold uppercase text-accent">
Built on the shoulders of open source.
</p>
<p className="text-sm md:text-lg">
<Logo type={"mono"} /> wouldn't exist without the work of people who
chose to build in the open.
</p>
<p className="text-sm md:text-lg">
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API"
target="_blank"
rel="noopener noreferrer"
>
Web Crypto API
</a>{" "}
&mdash; the backbone of everything promised. Browser-native
cryptography that runs entirely on your device. Without it, none of
the privacy here would be possible &mdash; or credible.
</p>
<p className="text-sm md:text-lg">
<a
href="https://daisyui.com"
target="_blank"
rel="noopener noreferrer"
>
DaisyUI
</a>{" "}
·{" "}
<a
href="http://fabricjs.com"
target="_blank"
rel="noopener noreferrer"
>
Fabric.js
</a>{" "}
·{" "}
<a
href="https://phosphoricons.com"
target="_blank"
rel="noopener noreferrer"
>
Phosphor Icons
</a>{" "}
&mdash; the beautiful work by others that let me focus on the core
experience.
</p>
<p className="text-sm md:text-lg mt-4">
Open source is what made this possible. It felt right to give it back
the same way.
</p>
</div>
</section>
);
}
function StorySection() {
return (
<div className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif"
}
>
The Story
</h1>
<div className="flex flex-col items-center shrink-0">
<div className="translate-x-2">
<Logo />
</div>
<div className="flex ml-10 font-tamil text-2xl md:text-3xl group">
<div className={"flex flex-col flex-wrap ul-wavy"}>
ி
<span
className={
"font-sans transition-all duration-1000 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 text-xxs tracking-widester uppercase text-neutral-content/60 mt-2"
}
>
after
</span>
</div>
<ArrowBendDownLeftIcon className={"text-primary"} />
<ArrowBendDownRightIcon className="ml-8 text-primary" />
<div className={"flex flex-col flex-wrap group ul-wavy"}>
ி
<span
className={
"font-sans transition-all duration-1000 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 text-xxs tracking-[.2em] uppercase text-neutral-content/60 mt-2"
}
>
note. remark.
</span>
</div>
</div>
{/* Dict Card */}
<div className="hover-3d -my-8 md:m-4 scale-75 md:scale-100 md:my-12 cursor-pointer">
<div className="card w-96 bg-base-200 bg-[radial-gradient(circle_at_bottom_left,#ffffff04_35%,transparent_36%),radial-gradient(circle_at_top_right,#ffffff04_35%,transparent_36%)] bg-size-[1.95em_1.95em]">
<div className="card-body">
<div className="mb-3 flex justify-between">
<div className="text-lg">pin·ku·rip·pu</div>
</div>
<div className="mb-4 text-lg opacity-40">
/noun/ <span className={"tracking-widest text-sm"}>tamil</span>
</div>
<ol className="flex flex-col gap-4 list-decimal list-inside p-0 m-0">
<li>
postscript; a note written after the letter is signed.
<br />
<blockquote className="text-primary/50 italic mt-2 ml-2 border-l-primary/20 leading-none border-l">
"the most honest thing was always in the{" "}
<span className="font-tamil">பி. கு.</span>"
</blockquote>
</li>
<li>the thing you almost didn't say.</li>
</ol>
</div>
</div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div
className={
"max-w-200 md:text-xl p-6 flex flex-col gap-4 md:gap-8 text-base-content/70 leading-relaxed"
}
>
<p className={""}>
<Logo type={"inline"} /> is an abbreviated transliteration of the
Tamil word for{" "}
<span
className={
"group italic text-primary font-serif inline underline decoration-dotted underline-offset-2 decoration-primary/40"
}
>
P
<span
className={
"text-neutral hidden group-hover:inline group-focus-within:inline "
}
>
ost
</span>
. S
<span
className={
"text-neutral hidden group-hover:inline group-focus-within:inline"
}
>
cript
</span>
.
</span>{" "}
&mdash; the thing you add after you've already signed your name,
what you write when you thought you were finished, but weren't.
</p>
<p>
<span className={"font-medium text-primary"}>
Most of what we actually mean to say never gets said.
</span>
<br />
It sits in drafts , in half-written notes, in the pause before we
change the subject. <br />
Those words{" "}
<span
className={
"blur-sm hover:blur-none active:blur-none focus:blur-none focus:outline-none transition-all duration-500"
}
>
don't just disappear. They
</span>{" "}
stay <span className={"text-primary font-hand"}>unsaid</span>{" "}
&mdash; a quiet weight difficult to bear.
</p>
<p className={"italic text-primary"}>And that's okay...</p>
<p>
<Logo type={"inline"} />
<span className={"text-primary"}>
was built for putting that weight down.
</span>
<br />A space for the letters you meant to send, the afterthoughts
that deserved more than silence.
</p>
</div>
</div>
</div>
);
}
function ForWhoSection() {
return (
<div className="flex flex-col h-screen w-screen justify-center items-center py-18 bg-primary/80">
<div className="max-w-4xl z-10">
<h2 className="text-7xl md:text-9xl font-serif italic font-black tracking-tighter text-stone-900 leading-tightest mb-12">
Who is <br /> this for?
</h2>
<div className="space-y-6 max-w-200 p-4 text-base-200 text-xl md:text-2xl leading-relaxed">
<p>
<Logo type={"mono"} /> wasn't built for one kind of person, but a
particular kind of feeling &mdash;
<span className="italic font-serif text-stone-900">
{" "}
the one that lingers very quietly
</span>{" "}
&mdash; fragile, yet never breaks.
</p>
<div className="pt-8 flex items-center gap-4">
<span className="text-xs md:text-sm uppercase tracking-widest font-mono opacity-60">
See if any of these feel too familiar to you
</span>
<div className="w-24 animate-pulse">
<ArrowRightIcon size={24} />
</div>
</div>
</div>
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-64 h-64 rounded-full bg-white/5 blur-3xl"></div>
</div>
);
}
function ArchetypesSection() {
return (
<div className="flex flex-col h-screen w-screen items-center justify-center py-18 bg-primary/80">
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-base-300/80 font-extrabold italic font-serif"
}
>
The Archetypes
</h1>
<div className="flex flex-col items-center shrink-0 w-200 max-w-11/12 gap-2 md:gap-8 my-4">
<div className="relative w-full">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
open
>
<summary className="collapse-title md:text-xl leading-tight font-hand flex items-center gap-4">
<GhostIcon weight="duotone" className="text-accent" size={32} />{" "}
To someone you can't reach anymore.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
A person who left. A relationship that ended without a real
ending. Someone who's still in your life but will never know
what you felt. Some conversations just close before they're
finished.
<br />
</p>
<p className="font-serif font-medium opacity-70">
Write the letter anyway. Keep it close.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500 rotate">
01
</span>
</div>
<div className="relative w-full">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
>
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-center gap-4">
<FlowerTulipIcon
weight="duotone"
className="text-accent"
size={32}
/>{" "}
To someone who's still here.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
Not every letter is about distance. Sometimes you just need to
say something properly &mdash; without a text thread, without
the noise of a conversation already in motion. A letter slows it
down.
</p>
<p className="font-serif font-medium opacity-70">
Give people their due flowers while they can still smell them.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500">
02
</span>
</div>
<div className="relative w-full group">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
>
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-baseline gap-4">
<div className="flex items-center">
<PersonIcon
weight="duotone"
className="text-accent"
size={14}
/>{" "}
<PersonArmsSpreadIcon
weight="duotone"
className="text-accent"
size={24}
/>
</div>
To yourself, further along.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
Not a journal. Not a note-to-self. A proper letter &mdash; to
whoever you'll be in a year, or five, or ten.
<br />
Ask yourself of the healed wounds, forgotten fears, or the
things you finally learned to live with.
</p>
<p className="font-serif font-medium opacity-70">
Set a date and let a letter surprise you when you've long
forgotten writing it.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500">
03
</span>
</div>
<div className="relative w-full">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
>
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-center gap-4">
<SparkleIcon weight="duotone" className="text-accent" size={32} />{" "}
For liberation.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
Some unsaid words just need to leave your headspace. There's no
recipient, no subject line, no send button. Just the act of
putting it somewhere outside of yourself. <br />
That's sometimes enough.
</p>
<p className="font-serif font-medium opacity-70">
Say it once. All of it. Then let it fade.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500">
04
</span>
</div>
<div className="flex items-center justify-center gap-2 group mt-12">
<img
src={stamp}
alt="stamp"
className="rotate-6 group-hover:rotate-0 group-focus-within:rotate-0 transition-all duration-1000"
/>
<p className="md:text-xl mt-4">
If any of these felt familiar,
<br />
no matter how little,
<br />
this is for you.
</p>
</div>
</div>
</div>
);
}
function AttributionSection() {
const [hover, setHover] = useState<{
visible: boolean;
x: number;
y: number;
}>({ visible: false, x: 0, y: 0 });
const navigate = useNavigate();
return (
<div className="flex flex-col min-h-screen w-screen items-center py-18">
{/* Saajan hover image */}
<AnimatePresence>
{hover.visible && (
<motion.img
src="/saajan.png"
alt="Saajan Fernandes from The Lunchbox, cutout"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="pointer-events-none fixed z-50 w-56 md:w-72 rounded-lg shadow-warm object-cover"
style={{
left: hover.x + 16,
top: hover.y - 32,
}}
/>
)}
</AnimatePresence>
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif"
}
>
Honest Speak
</h1>
<div className="flex flex-col items-center shrink-0">
<div
className={
"max-w-200 m-2 md:m-8 text-sm md:text-lg px-4 md:px-8 py-6 md:py-12 flex flex-col gap-4 md:gap-8 text-base-100 leading-relaxed bg-paper font-mono tracking-tight"
}
>
Hi.
<p>Thank you so much for making it this far. Really.</p>
<p>
<Logo type={"inline"} /> took a while to exist.
<br />
This started as a{" "}
<a
href="https://cs50.harvard.edu/web/"
target="_blank"
rel="noopener noreferrer"
>
CS50W
</a>{" "}
capstone, one I kept postponing until I ran out of reasons not to.
When I eventually sat down to build, I knew it had to be more than a
deadline; it had to be something that outlasted the grade. I wanted
to create a space for the feelings we usually keep to ourselves and
every hour spent on it was worth it. I've shared the edges of{" "}
<Logo type={"inline"} /> here, but the heart of it is best found by
exploring it yourself.
</p>
<p>
I kept coming back to{" "}
<span
role="tooltip"
className="cursor-default ul-wavy text-accent"
onMouseEnter={(e) =>
setHover({
visible: true,
x: e.clientX,
y: e.clientY,
})
}
onMouseMove={(e) =>
setHover((h) => ({
...h,
x: e.clientX,
y: e.clientY,
}))
}
onMouseLeave={() => setHover((h) => ({ ...h, visible: false }))}
>
Saajan
</span>{" "}
from{" "}
<a
href="https://www.imdb.com/title/tt2350496/"
target="_blank"
rel="noopener noreferrer"
>
The Lunchbox
</a>{" "}
&mdash;{" "}
<span className="italic">
one of the most subtle yet brilliant portrayals by Irrfan Khan
</span>{" "}
&mdash; the quiet emotional weight he carries throughout the film,
going through the motions of a lonely life, until those letters
arrive and something inside him finally loosens. Of course, the
ending felt like a deep sigh of "it is what it is". But something
about the act of writing and letting the unsaid out eased it, even
briefly. I think about that a lot.
</p>
<p>
There's a lot that goes{" "}
<span className={"text-primary font-hand text-lg md:text-xl"}>
unsaid
</span>{" "}
now. Not that people feel less or for the lack of time, but because
the ways we reach each other have quietly changed. We're always
reachable <span className="italic">digitally,</span> yet somehow the
things that matter most end up staying inside.
<br />
Maybe writing will help with that. Maybe something about putting
words somewhere deliberate makes them feel less like something
you're carrying.
</p>
<p>Or maybe it won't, but it's worth a try.</p>
<p>
<Logo type={"inline"} /> is for that try. I hope it helps.
</p>
<p
className={
"text-right font-hand text-base-content text-lg md:text-xl"
}
>
&mdash; Ram
</p>
</div>
<blockquote className="text-primary/50 italic mt-8 md:mt-12 mx-auto border-l-primary/20 leading-relaxed border-l pl-4 max-w-11/12 text-lg">
"I think we forget things if there is nobody to tell them."
<span className="block mt-2 text-sm not-italic text-base-content/30 w-full text-right">
~ Saajan Fernandes, <span className="italic">The Lunchbox</span>
</span>
</blockquote>
</div>
<div className="mt-40 mb-44 w-full justify-center flex">
<button
type={"button"}
onClick={() => navigate("/onboard")}
className="btn btn-primary btn-wide rounded-full px-14 font-mono"
>
Begin
</button>
</div>
</div>
);
}
+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"}
/>
</> </>
); );
} }
+390 -3
View File
@@ -1,9 +1,396 @@
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"}
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
>
<InfoIcon className={"text-primary"} />
Tell me More
</button>
<button
className={
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
>
I'm ready
</button>
</motion.div>
</div>
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.div
className={"z-21 absolute"}
style={{
opacity: useTransform(
smoothProgress1,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(smoothProgress1, [0.3, 0.45, 0.5], [300, 0, 200]),
scale: useTransform(
smoothProgress1,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
}}
>
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src="/screenshots/letter.webp" />
</div>
</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",