Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb3cb2eb69 | |||
| 2659f73577 | |||
| bf6aa34536 | |||
| dddda69c2f | |||
| 90b04f2397 | |||
| 5f56b21823 | |||
| e32c7a7982 | |||
| 34c6de47cc | |||
| a77e88496b | |||
| a0cacfbc8c | |||
| 49cd21cffe | |||
| 9910e44ee2 | |||
| 49177a5b12 | |||
| 3f81b7be3a | |||
| b6f45aa93c | |||
| 2bb77d1bed | |||
| 70a056a1d6 | |||
| d9e1febfee | |||
| df96cead93 | |||
| b9716d368d | |||
| d9827c9e82 | |||
| a6bde0258d | |||
| a987241120 | |||
| ebf7186b06 | |||
| 150832419a | |||
| 4893c91c20 | |||
| dc0d688885 | |||
| 46c7d9ffeb | |||
| 16a04ae4b8 | |||
| 574baa6860 | |||
| 35e8d6761e | |||
| faee0b45d6 | |||
| 8b28949d73 | |||
| 6cf24731ce | |||
| 8a9ded42b5 | |||
| f522a369ab | |||
| 48b6a06571 |
@@ -10,3 +10,4 @@ __pycache__/
|
|||||||
|
|
||||||
docs/
|
docs/
|
||||||
encrypted-images/
|
encrypted-images/
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,9 +27,12 @@ env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env")
|
|||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
environ.Env.read_env(env_file, overwrite=False)
|
environ.Env.read_env(env_file, overwrite=False)
|
||||||
|
|
||||||
|
# Security Settings
|
||||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
|
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
|
||||||
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
|
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
|
||||||
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
|
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
|
||||||
|
# NOTE: Set to forward https when using reverse proxy
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||||
|
|
||||||
@@ -51,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
|
||||||
|
|
||||||
@@ -81,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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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.
|
||||||
@@ -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;">
|
||||||
|
</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>
|
||||||
@@ -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 %}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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...");
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { lazy, Suspense, useEffect } from "react";
|
import { lazy, Suspense, useEffect, useRef } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||||
import SplashScreen from "./components/SplashScreen";
|
import SplashScreen from "./components/SplashScreen";
|
||||||
import { ROUTES } from "./config/routes";
|
import { ROUTES } from "./config/routes";
|
||||||
import { useAuth } from "./hooks/useAuth";
|
import { useAuth } from "./hooks/useAuth";
|
||||||
|
|
||||||
let authInitialized = false;
|
|
||||||
|
|
||||||
const Activate = lazy(() => import("./pages/Activate"));
|
const Activate = lazy(() => import("./pages/Activate"));
|
||||||
const Drawer = lazy(() => import("./pages/Drawer"));
|
const Drawer = lazy(() => import("./pages/Drawer"));
|
||||||
const Editor = lazy(() => import("./pages/Editor"));
|
const Editor = lazy(() => import("./pages/Editor"));
|
||||||
@@ -15,14 +13,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 +31,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 +86,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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 738 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -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> Ku
|
||||||
|
<span className="text-primary">.</span>
|
||||||
|
</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`}> 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`}> Ku</span>
|
<span className={`text-3xl font-light text-accent`}> 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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,8 +7,7 @@ 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"
|
||||||
@@ -46,7 +46,6 @@ export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,34 +31,148 @@ 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;
|
const applyResponsiveViewport = (
|
||||||
if (width > 0) resolve(width);
|
canvas: fabric.Canvas,
|
||||||
else requestAnimationFrame(check);
|
wrapper: HTMLDivElement,
|
||||||
};
|
logicalWidth: number,
|
||||||
check();
|
logicalHeight: number,
|
||||||
|
) => {
|
||||||
|
const physicalWidth = wrapper.clientWidth || logicalWidth;
|
||||||
|
const zoomMultiplier = physicalWidth / logicalWidth;
|
||||||
|
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
|
||||||
|
|
||||||
|
canvas.setDimensions({
|
||||||
|
width: physicalWidth,
|
||||||
|
height: physicalHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
wrapper.style.height = `${physicalHeight}px`;
|
||||||
|
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
|
||||||
|
canvas.requestRenderAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMainTextbox = (
|
// to find the maximum height of the content to dynamically resize the canvas
|
||||||
text: string,
|
// would've been wayyy easier only if canvas supported fit-content like CSS property :)
|
||||||
isReadOnly = false,
|
const measureLogicalContentHeight = (
|
||||||
): fabric.Textbox => {
|
canvas: fabric.Canvas,
|
||||||
return new fabric.Textbox(text, {
|
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
) => {
|
||||||
|
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
|
||||||
|
const top = currObj.top;
|
||||||
|
const height = currObj.getScaledHeight();
|
||||||
|
return Math.max(maxHeight, top + height);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return Math.max(minimumHeight, maxBottom + PAD);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_INIT_TEXT = "Take a deep breath...";
|
||||||
|
|
||||||
|
interface ComposeCanvasProps {
|
||||||
|
readOnly?: boolean;
|
||||||
|
initialData?: CanvasJSON | null;
|
||||||
|
style?: CanvasStyle;
|
||||||
|
ref?: React.Ref<CanvasTools>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposeCanvas({
|
||||||
|
readOnly = false,
|
||||||
|
initialData = null,
|
||||||
|
style,
|
||||||
|
ref,
|
||||||
|
}: ComposeCanvasProps) {
|
||||||
|
// wrapper is the parent div box
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||||
|
|
||||||
|
const textboxRef = useRef<fabric.Textbox | null>(null);
|
||||||
|
const deferredDataRef = useRef<CanvasJSON | null>(null);
|
||||||
|
const logicalSizeRef = useRef({
|
||||||
|
width: BASE_WIDTH,
|
||||||
|
height: DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// re-calculates height based on content and applies the zoom transform
|
||||||
|
const syncViewport = useCallback(() => {
|
||||||
|
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||||
|
|
||||||
|
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
|
||||||
|
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||||
|
fabricRef.current,
|
||||||
|
minHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
applyResponsiveViewport(
|
||||||
|
fabricRef.current,
|
||||||
|
wrapperRef.current,
|
||||||
|
logicalSizeRef.current.width,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
fabricRef.current.requestRenderAll();
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// auto focus the cursor into the main textbox no matter the latest element added
|
||||||
|
const focusTextbox = useCallback(
|
||||||
|
(textbox: fabric.Textbox) => {
|
||||||
|
if (readOnly || !fabricRef.current) return;
|
||||||
|
|
||||||
|
fabricRef.current.setActiveObject(textbox);
|
||||||
|
textbox.enterEditing();
|
||||||
|
|
||||||
|
// move the cursor to the end of the text
|
||||||
|
const textLength = textbox.text?.length ?? 0;
|
||||||
|
textbox.selectionStart = textLength;
|
||||||
|
textbox.selectionEnd = textLength;
|
||||||
|
|
||||||
|
fabricRef.current.requestRenderAll();
|
||||||
|
},
|
||||||
|
[readOnly],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadContent = useCallback(
|
||||||
|
async (data: CanvasJSON | null) => {
|
||||||
|
const canvas = fabricRef.current;
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!(canvas && wrapper)) return;
|
||||||
|
|
||||||
|
// clean the canvas everytime and set fresh
|
||||||
|
canvas.clear();
|
||||||
|
let textbox: fabric.Textbox | null = null;
|
||||||
|
|
||||||
|
// restore logical size from prev saved data if available (in case of existing letter)
|
||||||
|
logicalSizeRef.current = {
|
||||||
|
width: data?.canvasWidth ?? BASE_WIDTH,
|
||||||
|
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
name: "main-textbox",
|
||||||
originX: "left",
|
originX: "left",
|
||||||
originY: "top",
|
originY: "top",
|
||||||
@@ -69,44 +181,86 @@ const createMainTextbox = (
|
|||||||
width: BASE_WIDTH - PAD * 2,
|
width: BASE_WIDTH - PAD * 2,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontFamily: "Playfair Display Variable",
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
fill: "#000",
|
fill: DEFAULT_FONT_COLOR,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
editable: !isReadOnly,
|
// NOTE: splitByGrapheme is required for word wrap and re-low
|
||||||
selectable: false,
|
// but fabric asks to disable this for clear font?? So we disable it for read view
|
||||||
evented: !isReadOnly,
|
splitByGrapheme: !readOnly,
|
||||||
hasControls: false,
|
|
||||||
hasBorders: false,
|
|
||||||
objectCaching: false,
|
|
||||||
splitByGrapheme: false,
|
|
||||||
lockMovementX: true,
|
lockMovementX: true,
|
||||||
lockMovementY: true,
|
lockMovementY: true,
|
||||||
lockScalingX: true,
|
lockScalingX: true,
|
||||||
lockScalingY: true,
|
lockScalingY: true,
|
||||||
lockRotation: true,
|
lockRotation: true,
|
||||||
|
hasControls: false,
|
||||||
|
hasBorders: false,
|
||||||
|
objectCaching: false,
|
||||||
|
noScaleCache: false,
|
||||||
});
|
});
|
||||||
};
|
canvas.add(textbox);
|
||||||
|
}
|
||||||
|
|
||||||
const fixFabricA11y = () => {
|
if (!textbox) return;
|
||||||
const textAreas = document.querySelectorAll(
|
|
||||||
'textarea[data-fabric="textarea"]',
|
// 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],
|
||||||
);
|
);
|
||||||
for (const area of textAreas) {
|
|
||||||
if (!area.getAttribute("aria-label")) {
|
|
||||||
area.setAttribute("aria-label", "Canvas text input");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const initializeCanvas = (
|
useEffect(() => {
|
||||||
el: HTMLCanvasElement,
|
if (style && textboxRef.current) {
|
||||||
width: number,
|
const textBox = textboxRef.current;
|
||||||
height: number,
|
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
|
||||||
readOnly: boolean,
|
textBox.fill = style.fontColor || textBox.fill;
|
||||||
) => {
|
syncViewport();
|
||||||
const canvas = new fabric.Canvas(el, {
|
}
|
||||||
|
}, [style, syncViewport]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let lastWidth = 0;
|
||||||
|
|
||||||
|
const initCanvas = async () => {
|
||||||
|
// HACK: actual font may change the text-width - small ux improvement
|
||||||
|
await document.fonts.ready;
|
||||||
|
|
||||||
|
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
||||||
|
|
||||||
|
let width = wrapperRef.current.clientWidth;
|
||||||
|
if (width === 0) {
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// init the fabric instance
|
||||||
|
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||||
width,
|
width,
|
||||||
height,
|
height: DEFAULT_LOGICAL_HEIGHT,
|
||||||
selection: !readOnly,
|
selection: !readOnly,
|
||||||
preserveObjectStacking: true,
|
preserveObjectStacking: true,
|
||||||
allowTouchScrolling: true,
|
allowTouchScrolling: true,
|
||||||
@@ -114,278 +268,47 @@ const initializeCanvas = (
|
|||||||
objectCaching: false,
|
objectCaching: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// remove default fabric background to let our CSS show through
|
||||||
|
// TODO: provision custom bg (color in scope, but how does img fit?)
|
||||||
const wrapperEl = canvas.getElement().parentElement;
|
const wrapperEl = canvas.getElement().parentElement;
|
||||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
if (wrapperEl) wrapperEl.style.background = "transparent";
|
||||||
|
|
||||||
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 = (
|
|
||||||
canvas: fabric.Canvas,
|
|
||||||
wrapper: HTMLDivElement,
|
|
||||||
logicalWidth: number,
|
|
||||||
logicalHeight: number,
|
|
||||||
) => {
|
|
||||||
const physicalWidth = wrapper.clientWidth || logicalWidth;
|
|
||||||
const zoom = physicalWidth / logicalWidth;
|
|
||||||
const physicalHeight = Math.max(1, logicalHeight * zoom);
|
|
||||||
|
|
||||||
canvas.setDimensions({
|
|
||||||
width: physicalWidth,
|
|
||||||
height: physicalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.style.height = `${physicalHeight}px`;
|
|
||||||
canvas.setViewportTransform([zoom, 0, 0, zoom, 0, 0]);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
const focusTextbox = (
|
|
||||||
fCanvas: fabric.Canvas,
|
|
||||||
textbox: fabric.Textbox,
|
|
||||||
readOnly: boolean,
|
|
||||||
) => {
|
|
||||||
if (readOnly) return;
|
|
||||||
|
|
||||||
fCanvas.setActiveObject(textbox);
|
|
||||||
textbox.enterEditing();
|
|
||||||
|
|
||||||
const end = textbox.text?.length ?? 0;
|
|
||||||
textbox.selectionStart = end;
|
|
||||||
textbox.selectionEnd = end;
|
|
||||||
|
|
||||||
fCanvas.requestRenderAll();
|
|
||||||
fixFabricA11y();
|
|
||||||
};
|
|
||||||
|
|
||||||
const findMainTextbox = (canvas: fabric.Canvas): fabric.Textbox | null => {
|
|
||||||
const textbox = canvas.getObjects("Textbox")[0];
|
|
||||||
|
|
||||||
return (textbox as fabric.Textbox) ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComposeCanvas = forwardRef<
|
|
||||||
CanvasTools,
|
|
||||||
{ readOnly?: boolean; initialData?: CanvasJSON | null }
|
|
||||||
>(({ readOnly = false, initialData = null }, ref) => {
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
|
||||||
const textboxRef = useRef<fabric.Textbox | null>(null);
|
|
||||||
const deferredDataRef = useRef<CanvasJSON | null>(null);
|
|
||||||
const logicalSizeRef = useRef({
|
|
||||||
width: BASE_WIDTH,
|
|
||||||
height: DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
});
|
|
||||||
|
|
||||||
const syncViewport = useCallback(() => {
|
|
||||||
if (!(fabricRef.current && wrapperRef.current)) return;
|
|
||||||
|
|
||||||
applyResponsiveViewport(
|
|
||||||
fabricRef.current,
|
|
||||||
wrapperRef.current,
|
|
||||||
logicalSizeRef.current.width,
|
|
||||||
logicalSizeRef.current.height,
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateLogicalHeightFromContent = useCallback(() => {
|
|
||||||
if (!fabricRef.current) return;
|
|
||||||
|
|
||||||
logicalSizeRef.current.height = measureLogicalContentHeight(
|
|
||||||
fabricRef.current,
|
|
||||||
logicalSizeRef.current.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
syncViewport();
|
|
||||||
}, [syncViewport]);
|
|
||||||
|
|
||||||
const setupTextboxInteractions = useCallback(
|
|
||||||
(fCanvas: fabric.Canvas, textbox: fabric.Textbox) => {
|
|
||||||
textbox.on("changed", () => {
|
|
||||||
updateLogicalHeightFromContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
fCanvas.on("mouse:down", (opt) => {
|
|
||||||
if (!opt.target || opt.target === textbox) {
|
|
||||||
focusTextbox(fCanvas, textbox, readOnly);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!readOnly) {
|
|
||||||
setTimeout(() => {
|
|
||||||
focusTextbox(fCanvas, textbox, readOnly);
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[readOnly, updateLogicalHeightFromContent],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadContent = useCallback(
|
|
||||||
async (
|
|
||||||
canvas: fabric.Canvas,
|
|
||||||
data: CanvasJSON | null,
|
|
||||||
wrapper: HTMLDivElement,
|
|
||||||
): Promise<fabric.Textbox | null> => {
|
|
||||||
const logicalSize = getLogicalSize(data);
|
|
||||||
logicalSizeRef.current = logicalSize;
|
|
||||||
|
|
||||||
canvas.clear();
|
|
||||||
|
|
||||||
let textbox: fabric.Textbox | null = null;
|
|
||||||
|
|
||||||
if (data?.objects?.length) {
|
|
||||||
await canvas.loadFromJSON(data);
|
|
||||||
textbox = findMainTextbox(canvas);
|
|
||||||
} else {
|
|
||||||
textbox = createMainTextbox("Take a deep breath...", readOnly);
|
|
||||||
canvas.add(textbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!textbox) return null;
|
|
||||||
|
|
||||||
textbox.selectable = !readOnly;
|
|
||||||
textbox.evented = !readOnly;
|
|
||||||
textbox.editable = !readOnly;
|
|
||||||
textbox.hasBorders = false;
|
|
||||||
textbox.lockMovementX = true;
|
|
||||||
textbox.lockMovementY = true;
|
|
||||||
textbox.lockScalingX = true;
|
|
||||||
textbox.lockScalingY = true;
|
|
||||||
textbox.lockRotation = true;
|
|
||||||
textbox.objectCaching = false;
|
|
||||||
|
|
||||||
logicalSizeRef.current.height = measureLogicalContentHeight(
|
|
||||||
canvas,
|
|
||||||
logicalSize.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
applyResponsiveViewport(
|
|
||||||
canvas,
|
|
||||||
wrapper,
|
|
||||||
logicalSizeRef.current.width,
|
|
||||||
logicalSizeRef.current.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(readOnly || data)) {
|
|
||||||
focusTextbox(canvas, textbox, readOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
return textbox;
|
|
||||||
},
|
|
||||||
[readOnly],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
let canvas: fabric.Canvas | null = null;
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
let lastWidth = 0;
|
|
||||||
|
|
||||||
const init = async () => {
|
|
||||||
await document.fonts.ready;
|
|
||||||
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
|
|
||||||
|
|
||||||
const finalWidth = await waitForLayout(wrapperRef.current);
|
|
||||||
if (!(isMounted && canvasRef.current && wrapperRef.current)) return;
|
|
||||||
|
|
||||||
canvas = initializeCanvas(
|
|
||||||
canvasRef.current,
|
|
||||||
finalWidth,
|
|
||||||
DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
readOnly,
|
|
||||||
);
|
|
||||||
|
|
||||||
fabricRef.current = canvas;
|
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>
|
||||||
<p className="text-base-content font-sans">
|
{type === "KEPT" ? (
|
||||||
|
<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,7 +32,25 @@ 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>
|
||||||
|
) : (
|
||||||
|
<p className="text-base-content/80 text-sm font-sans">
|
||||||
|
Be assured that the letter will find you when the time is right.
|
||||||
|
<br />
|
||||||
|
Till then,{" "}
|
||||||
|
<span className="font-bold font-display text-primary">
|
||||||
|
take a deep breath
|
||||||
|
</span>
|
||||||
|
, <span className="font-bold font-display text-accent">manifest</span>
|
||||||
|
, and{" "}
|
||||||
|
<span className="font-bold font-display text-success">
|
||||||
|
let it rest
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
|
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
|
||||||
|
{type === "KEPT" ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
@@ -41,14 +61,21 @@ export function PostSealModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() =>
|
onClick={() => navigate(PATHS.read(sealedTargetId!))}
|
||||||
navigate(PATHS.read(sealedTargetId), { replace: true })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
View letter
|
View letter
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
|
>
|
||||||
|
Step Away...
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +254,22 @@ 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">Vault this letter?</h3>
|
<h3 className="font-serif text-3xl">Take it away, then?</h3>
|
||||||
<p className="text-base-content/60 text-sm text-center mt-4">
|
<p className="text-base-content/60 text-sm text-center mt-4">
|
||||||
Vaulting locks the letter permanently and will be{" "}
|
By vaulting this letter, you ask me to hold on to this.
|
||||||
<span className={"font-bold text-primary"}>mailed</span> to you
|
|
||||||
automatically on the unlock date.
|
|
||||||
<br />
|
<br />
|
||||||
<span className={"underline"}>
|
I'll remember to mail you this on the unlock date.
|
||||||
You cannot edit or view the contents of the letter until then.
|
<br />
|
||||||
|
<span className={"font-bold text-primary"}>
|
||||||
|
{" "}
|
||||||
|
But I won't let you read or rewrite this letter until then.
|
||||||
</span>
|
</span>
|
||||||
|
<br />
|
||||||
</p>
|
</p>
|
||||||
<form
|
<form
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
@@ -163,6 +282,7 @@ export function VaultConfirmModal({
|
|||||||
setConfirmModal(null);
|
setConfirmModal(null);
|
||||||
}}
|
}}
|
||||||
id="vault-form"
|
id="vault-form"
|
||||||
|
className="min-w-75"
|
||||||
>
|
>
|
||||||
<div className={"divider tracking-tightest font-display text-sm"}>
|
<div className={"divider tracking-tightest font-display text-sm"}>
|
||||||
Set an unlock date
|
Set an unlock date
|
||||||
@@ -173,23 +293,23 @@ export function VaultConfirmModal({
|
|||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
name="vault-date"
|
name="vault-date"
|
||||||
/>
|
/>
|
||||||
|
<div className="w-full flex justify-center gap-8 mt-4">
|
||||||
<button
|
<button
|
||||||
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
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost mt-4"
|
|
||||||
onClick={() => setConfirmModal(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</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
|
||||||
|
<Logo /> !
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +140,7 @@ export function EnvelopeReveal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{ignite && (
|
{ignite && (
|
||||||
|
<>
|
||||||
<div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
|
<div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
|
||||||
<div
|
<div
|
||||||
className="absolute z-1000 border-2 border-amber-200 -bottom-3 -right-3 w-0 h-0 transition-all duration-500 bg-base-100 rounded-tl-full rounded-bl-full origin-bottom-right"
|
className="absolute z-1000 border-2 border-amber-200 -bottom-3 -right-3 w-0 h-0 transition-all duration-500 bg-base-100 rounded-tl-full rounded-bl-full origin-bottom-right"
|
||||||
@@ -138,6 +150,10 @@ export function EnvelopeReveal({
|
|||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute z-1001 bottom-0 right-0 translate-x-15 translate-y-20">
|
||||||
|
<img src={candle} alt="candle" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,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
|
||||||
|
|||||||
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,21 +16,17 @@ export const LogModal = ({
|
|||||||
onClose,
|
onClose,
|
||||||
status,
|
status,
|
||||||
}: LogModalContent) => {
|
}: LogModalContent) => {
|
||||||
return status === "RESET" || !isOpen ? (
|
return (
|
||||||
<div></div>
|
<Modal isOpen={isOpen && status !== "RESET"} onClose={onClose}>
|
||||||
) : (
|
|
||||||
<div className="modal modal-open modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
|
||||||
<div className="modal-box bg-transparent border-none shadow-none relative">
|
|
||||||
<div
|
<div
|
||||||
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
||||||
>
|
>
|
||||||
{status === "WARN" && (
|
{status === "WARN" && (
|
||||||
<WarningIcon className="text-warning" size={16} weight="bold" />
|
<WarningIcon className="text-warning" size={16} weight="duotone" />
|
||||||
)}
|
|
||||||
{status === "ERROR" && (
|
|
||||||
<XCircleIcon className="text-error" size={16} weight="bold" />
|
|
||||||
)}
|
)}
|
||||||
{message}
|
{message}
|
||||||
|
{log && (
|
||||||
|
<>
|
||||||
<div className="divider text-primary-content text-xs uppercase tracking-widest">
|
<div className="divider text-primary-content text-xs uppercase tracking-widest">
|
||||||
Error Stack
|
Error Stack
|
||||||
</div>
|
</div>
|
||||||
@@ -38,17 +35,9 @@ export const LogModal = ({
|
|||||||
<code>{String(log)}</code>
|
<code>{String(log)}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog">
|
</>
|
||||||
<button
|
)}
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="btn btn-sm btn-circle btn-ghost absolute right-6 top-6"
|
|
||||||
>
|
|
||||||
<XIcon size={6} weight="bold" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import sf_mini from "../../assets/sf_mini.png";
|
||||||
|
|
||||||
|
interface SaajanProps {
|
||||||
|
message: string;
|
||||||
|
position?: "top" | "left" | "right" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Saajan({ message, position = "right" }: SaajanProps) {
|
||||||
|
const [animate, setAnimate] = useState<boolean>(false);
|
||||||
|
const [tooltipPosition, setTooltipPosition] =
|
||||||
|
useState<string>("tooltip-right");
|
||||||
|
const [alignment, setAlignment] = useState<string>("justify-start");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAnimate(true);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setAnimate(false);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTooltipPosition(`tooltip-${position}`);
|
||||||
|
if (position === "top") {
|
||||||
|
setAlignment("justify-center");
|
||||||
|
}
|
||||||
|
if (position === "right") {
|
||||||
|
setAlignment("justify-start");
|
||||||
|
}
|
||||||
|
if (position === "left") {
|
||||||
|
setAlignment("justify-end");
|
||||||
|
}
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative w-full flex ${alignment}`}>
|
||||||
|
<div
|
||||||
|
className={`tooltip tooltip-open ${tooltipPosition} before:border before:border-dashed before:border-primary/40 before:max-w-xs before:whitespace-pre-line italic before:text-left`}
|
||||||
|
data-tip={message}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={sf_mini}
|
||||||
|
alt="saajan"
|
||||||
|
className={`sepia-20 w-35 -mb-3 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -47,15 +47,32 @@
|
|||||||
--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;
|
||||||
|
--font-hand: "Architects Daughter", cursive;
|
||||||
|
--color-glass-bg: rgba(28, 22, 16, 0.45);
|
||||||
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
|
||||||
--radius-xl: 1.5rem;
|
--radius-xl: 1.5rem;
|
||||||
--color-paper: oklch(97% 0.008 80);
|
--color-paper: oklch(97% 0.008 80);
|
||||||
|
--text-xxs: 10px;
|
||||||
|
--tracking-widester: 0.5em;
|
||||||
|
--background-image-vig: radial-gradient(
|
||||||
|
circle at center,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(0, 0, 0, 0.4) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@apply bg-glass-bg 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1,893 @@
|
|||||||
|
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 { 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() {
|
||||||
|
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 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 — 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
|
||||||
|
</span>
|
||||||
|
2
|
||||||
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
|
|
||||||
|
</span>
|
||||||
|
E
|
||||||
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
|
nd
|
||||||
|
</span>
|
||||||
|
<span className="hidden group-hover:inline group-focus-within:inline">
|
||||||
|
<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 —{" "}
|
||||||
|
<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">
|
||||||
|
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>{" "}
|
||||||
|
— the backbone of everything promised. Browser-native
|
||||||
|
cryptography that runs entirely on your device. Without it, none of
|
||||||
|
the privacy here would be possible — 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>{" "}
|
||||||
|
— 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>{" "}
|
||||||
|
— 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>{" "}
|
||||||
|
— 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 —
|
||||||
|
<span className="italic font-serif text-stone-900">
|
||||||
|
{" "}
|
||||||
|
the one that lingers very quietly
|
||||||
|
</span>{" "}
|
||||||
|
— 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 — 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 — 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>{" "}
|
||||||
|
—{" "}
|
||||||
|
<span className="italic">
|
||||||
|
one of the most subtle yet brilliant portrayals by Irrfan Khan
|
||||||
|
</span>{" "}
|
||||||
|
— 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"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
— 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,7 +160,8 @@ export default function Editor() {
|
|||||||
);
|
);
|
||||||
const canvasData = JSON.parse(decryptedJsonStr);
|
const canvasData = JSON.parse(decryptedJsonStr);
|
||||||
|
|
||||||
const { isDecryptionPartialFailure, error } = await decryptCanvasImages(
|
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
|
||||||
|
await decryptCanvasImages(
|
||||||
canvasData,
|
canvasData,
|
||||||
letterData.images ?? [],
|
letterData.images ?? [],
|
||||||
letterData.encrypted_dek,
|
letterData.encrypted_dek,
|
||||||
@@ -147,17 +170,17 @@ export default function Editor() {
|
|||||||
true,
|
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,7 +271,8 @@ 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 } =
|
||||||
|
await encryptCanvasImages(
|
||||||
canvasData,
|
canvasData,
|
||||||
canvasImages,
|
canvasImages,
|
||||||
masterKey,
|
masterKey,
|
||||||
@@ -248,7 +280,7 @@ export default function Editor() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
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,21 +380,16 @@ 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 className="modal-box p-0 bg-transparent shadow-none transition-all duration-300">
|
|
||||||
{saveOverlay === "saving" && (
|
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
|
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
|
||||||
@@ -380,7 +407,7 @@ export default function Editor() {
|
|||||||
</div>
|
</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 ${
|
||||||
@@ -394,7 +421,7 @@ export default function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saveOverlay === "error" && (
|
{saveOverlay === "ERROR" && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={`alert alert-error shadow-lg transition-all duration-300 ${
|
className={`alert alert-error shadow-lg transition-all duration-300 ${
|
||||||
@@ -407,8 +434,7 @@ export default function Editor() {
|
|||||||
<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"}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,395 @@
|
|||||||
|
import { InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
motion,
|
||||||
|
useMotionValueEvent,
|
||||||
|
useScroll,
|
||||||
|
useSpring,
|
||||||
|
useTransform,
|
||||||
|
} from "motion/react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
|
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
|
||||||
|
import Saajan from "../components/ui/Saajan.tsx";
|
||||||
|
import { ROUTES } from "../config/routes.ts";
|
||||||
|
import { formatDate } from "../utils/dateFormat.ts";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
||||||
|
const { scrollYProgress: section1ScrollProgress } = useScroll({
|
||||||
|
target: sectionContainer1,
|
||||||
|
});
|
||||||
|
const smoothProgress1 = useSpring(section1ScrollProgress, {
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 30,
|
||||||
|
restDelta: 0.001,
|
||||||
|
});
|
||||||
|
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
||||||
|
const [flapOpen, setFlapOpen] = useState(false);
|
||||||
|
const [recipient, setRecipient] = useState("someone dear");
|
||||||
|
const [ignite, setIgnite] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useMotionValueEvent(section1ScrollProgress, "change", (latestScrollValue) => {
|
||||||
|
if (latestScrollValue > 0.54) {
|
||||||
|
setFlapOpen(false);
|
||||||
|
} else {
|
||||||
|
setFlapOpen(true);
|
||||||
|
}
|
||||||
|
if (latestScrollValue <= 0.6) {
|
||||||
|
setIsEnvelopeFlipped(true);
|
||||||
|
} else {
|
||||||
|
setIsEnvelopeFlipped(false);
|
||||||
|
}
|
||||||
|
if (latestScrollValue > 0.68) {
|
||||||
|
setRecipient("future me");
|
||||||
|
} else {
|
||||||
|
setRecipient("someone dear");
|
||||||
|
}
|
||||||
|
if (latestScrollValue > 0.77) {
|
||||||
|
setIgnite(true);
|
||||||
|
} else {
|
||||||
|
setIgnite(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<section
|
||||||
<Logo />
|
ref={sectionContainer1}
|
||||||
|
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>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
{/* pi. ku. */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute text-center px-6"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.18, 0.25, 0.3],
|
||||||
|
[0, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.18, 0.25, 0.3], [20, 0, -20]),
|
||||||
|
}}
|
||||||
|
transition={{ delay: 4 }}
|
||||||
|
>
|
||||||
|
<Logo scale={2} />
|
||||||
|
<motion.div
|
||||||
|
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.22, 0.25, 0.35, 0.4],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.25, 0.3, 0.35, 0.4],
|
||||||
|
[20, 0, 0, -20],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
is a{" "}
|
||||||
|
<span className="font-display text-primary font-extralight">
|
||||||
|
safe space
|
||||||
|
</span>
|
||||||
|
,<br />
|
||||||
|
<motion.span
|
||||||
|
className="opacity-0 text-3xl md:text-5xl"
|
||||||
|
transition={{ delay: 3 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: false, amount: 0.3 }}
|
||||||
|
>
|
||||||
|
where you can
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.35, 0.4, 0.45],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.35, 0.4, 0.45],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
pen down your unsaid words into{" "}
|
||||||
|
<span className="font-display text-primary font-extralight">
|
||||||
|
letters
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</motion.h2>
|
||||||
|
{/* Seal */}
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.45, 0.5, 0.55, 0.6],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.45, 0.5, 0.55, 0.6],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
seal it{" "}
|
||||||
|
<span className="text-secondary font-display italic font-extralight">
|
||||||
|
secure
|
||||||
|
</span>{" "}
|
||||||
|
and{" "}
|
||||||
|
<span className="text-secondary font-display font-extralight italic">
|
||||||
|
private
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</motion.h2>
|
||||||
|
{/* Send / vault */}
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.6, 0.63, 0.72, 0.75],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.6, 0.63, 0.72, 0.75],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
send it to{" "}
|
||||||
|
<motion.span
|
||||||
|
className="font-display text-accent"
|
||||||
|
style={{
|
||||||
|
color: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.67, 1],
|
||||||
|
["var(--color-accent)", "var(--color-neutral)"],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
someone dear
|
||||||
|
</motion.span>
|
||||||
|
<motion.span
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="font-display text-accent"
|
||||||
|
style={{
|
||||||
|
color: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.67, 1],
|
||||||
|
["var(--color-accent)", "var(--color-neutral)"],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
or{" "}
|
||||||
|
</motion.span>
|
||||||
|
<span className="font-display text-success">
|
||||||
|
yourself in the future
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</motion.span>
|
||||||
|
</motion.h2>
|
||||||
|
{/* Burn */}
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.75, 0.8, 0.85, 0.9],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.75, 0.8, 0.85, 0.9],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
and even <span className="font-display text-error">burn it</span> to
|
||||||
|
release the burden.
|
||||||
|
</motion.h2>
|
||||||
|
{/* Outro */}
|
||||||
|
<motion.h2
|
||||||
|
className={
|
||||||
|
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
|
||||||
|
y: useTransform(smoothProgress1, [0.9, 1], [80, 0]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
You've been carrying it long enough.
|
||||||
|
</motion.h2>
|
||||||
|
{/* CTA */}
|
||||||
|
<motion.div
|
||||||
|
className={
|
||||||
|
"z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
|
||||||
|
y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
|
||||||
|
display: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.96, 1],
|
||||||
|
["none", "flex"],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||||
|
}
|
||||||
|
type={"button"}
|
||||||
|
>
|
||||||
|
<InfoIcon className={"text-primary"} />
|
||||||
|
Tell me More
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||||
|
}
|
||||||
|
type={"button"}
|
||||||
|
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
||||||
|
>
|
||||||
|
I'm ready
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<motion.div
|
||||||
|
className={"z-21 absolute"}
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.4, 0.5, 0.52],
|
||||||
|
[0, 1, 0.1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.3, 0.45, 0.5], [300, 0, 200]),
|
||||||
|
scale: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.3, 0.4, 0.5],
|
||||||
|
[1, 1, 0.6],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mockup-phone w-[75vw] border-primary">
|
||||||
|
<div className="mockup-phone-camera"></div>
|
||||||
|
<div className="mockup-phone-display">
|
||||||
|
<img alt="letter" src="/screenshots/letter.webp" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
{/* Envelope */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute scale-50 md:scale-80 z-10"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
|
||||||
|
[0, 0.6, 1, 1, 0.3, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.45, 0.5, 1], [600, 200, 0]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EnvelopeReveal
|
||||||
|
isInteractive={false}
|
||||||
|
ignite={ignite}
|
||||||
|
recipient={recipient}
|
||||||
|
date={formatDate(new Date().toISOString())}
|
||||||
|
onRevealComplete={() => {}}
|
||||||
|
isFlip={isEnvelopeFlipped}
|
||||||
|
openFlap={flapOpen}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
{/* Saajan */}
|
||||||
|
<motion.div
|
||||||
|
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.98, 0.995, 1],
|
||||||
|
[0, 0.5, 1],
|
||||||
|
),
|
||||||
|
y: useTransform(smoothProgress1, [0.98, 1], [50, -10]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Saajan
|
||||||
|
message={
|
||||||
|
"I think we forget things\nif there is nobody to tell them."
|
||||||
|
}
|
||||||
|
position={"top"}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
{/* Orb */}
|
||||||
|
<motion.div
|
||||||
|
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
|
||||||
|
transition={{
|
||||||
|
backgroundColor: { ease: "easeIn", duration: 2 },
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: useTransform(
|
||||||
|
smoothProgress1,
|
||||||
|
[0.45, 0.5, 0.7, 0.75, 1],
|
||||||
|
[
|
||||||
|
"var(--color-primary)",
|
||||||
|
"var(--color-secondary)",
|
||||||
|
"var(--color-accent)",
|
||||||
|
"var(--color-success)",
|
||||||
|
"var(--color-error)",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
scale: useTransform(smoothProgress1, [0, 1], [0.6, 2.5]),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`, () =>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +72,13 @@ export default function Register() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Saajan message={saajanMessage} position="right" />
|
||||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||||
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
|
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
||||||
Create a <Logo /> Account
|
Create a <Logo /> Account
|
||||||
</h1>
|
</div>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||||
@@ -85,14 +91,22 @@ export default function Register() {
|
|||||||
placeholder="Word Smith"
|
placeholder="Word Smith"
|
||||||
registration={register("full_name")}
|
registration={register("full_name")}
|
||||||
error={errors.full_name?.message}
|
error={errors.full_name?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage("Hello friend. What should I call you?")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="f.kafka@email.com"
|
placeholder="f.kafka@wrongtrain.com"
|
||||||
registration={register("email")}
|
registration={register("email")}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Where should I send your letters?\nNo empty lunchboxes, please.",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -101,6 +115,11 @@ export default function Register() {
|
|||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
registration={register("password")}
|
registration={register("password")}
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Something only you know.\nI have one of those too.",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -109,15 +128,20 @@ export default function Register() {
|
|||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
registration={register("confirm_password")}
|
registration={register("confirm_password")}
|
||||||
error={errors.confirm_password?.message}
|
error={errors.confirm_password?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Just once? Trust me, \nsome things are worth repeating twice.",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Warning */}
|
|
||||||
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
||||||
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
||||||
<p className="text-sm font-semibold">
|
<p className="text-sm font-semibold">
|
||||||
Choose a password you won't forget. <br />
|
Choose a password you won't forget. <br />
|
||||||
<span className="underline decoration-2">There is no reset.</span>{" "}
|
Just like life,{" "}
|
||||||
If you lose it, your letters cannot be recovered.
|
<span className="underline decoration-2">there is no reset</span>{" "}
|
||||||
|
here. If you lose it, your letters cannot be recovered.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,5 +161,6 @@ export default function Register() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
|
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
|
import Saajan from "../components/ui/Saajan";
|
||||||
|
|
||||||
export default function VerifyEmail() {
|
export default function VerifyEmail() {
|
||||||
return (
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Saajan
|
||||||
|
message={"I sent something to your inbox.\nOpen it, and we can begin."}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
|
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
|
||||||
<div className="auth-icon-container">
|
<div className="auth-icon-container">
|
||||||
<EnvelopeSimpleOpenIcon
|
<EnvelopeSimpleOpenIcon
|
||||||
@@ -13,19 +19,26 @@ export default function VerifyEmail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="font-display text-xl text-primary">Check Your Email</h2>
|
<h2 className="font-display text-xl text-primary">
|
||||||
<p className="text-sm opacity-80 leading-relaxed font-sans">
|
Check Your Mailbox
|
||||||
We've sent an activation link to your inbox. <br />
|
</h2>
|
||||||
Please click it to verify your <Logo /> account.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divider opacity-10"></div>
|
<div className="divider opacity-10 my-0"></div>
|
||||||
|
|
||||||
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed text-left opacity-70">
|
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed opacity-70 text-center">
|
||||||
<p>
|
<p>
|
||||||
Didn't receive it? Check your spam folder or wait for a few minutes.
|
Nothing yet? Sometimes letters take the wrong train. Check your spam
|
||||||
The link will expire in 24 hours.
|
folder.
|
||||||
|
<br />
|
||||||
|
<span className="underline font-bold">
|
||||||
|
The link expires in 24 hours.
|
||||||
|
</span>
|
||||||
|
<br /> I'm patient... but not endlessly so
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,5 +50,6 @@ export default function VerifyEmail() {
|
|||||||
You can close this window now.
|
You can close this window now.
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe("deriveKeyBundle", () => {
|
|||||||
|
|
||||||
expect(masterKey.type).toBe("secret");
|
expect(masterKey.type).toBe("secret");
|
||||||
expect(masterKey).toBeInstanceOf(CryptoKey);
|
expect(masterKey).toBeInstanceOf(CryptoKey);
|
||||||
expect(authHash).toHaveLength(64); // 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(
|
||||||
|
|||||||
@@ -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,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");
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -153,7 +147,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(api.get).toHaveBeenCalledWith(
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
"https://remote/photo.png.bin",
|
`https://remote/photo.png.bin`,
|
||||||
expect.objectContaining({ responseType: "blob" }),
|
expect.objectContaining({ responseType: "blob" }),
|
||||||
);
|
);
|
||||||
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
|
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
|
||||||
@@ -161,8 +155,10 @@ describe("letterLogic image helpers", () => {
|
|||||||
"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 () => {
|
||||||
@@ -191,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",
|
||||||
@@ -204,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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,6 +230,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
"decryptImageWithSharingKey",
|
"decryptImageWithSharingKey",
|
||||||
).mockResolvedValue("blob:http://localhost/decrypted-shared");
|
).mockResolvedValue("blob:http://localhost/decrypted-shared");
|
||||||
|
|
||||||
|
const { canvasDataWithDecryptedImages } =
|
||||||
await decryptCanvasImagesWithSharingKey(
|
await decryptCanvasImagesWithSharingKey(
|
||||||
canvasData,
|
canvasData,
|
||||||
remoteImages,
|
remoteImages,
|
||||||
@@ -246,7 +245,7 @@ describe("letterLogic image helpers", () => {
|
|||||||
expect(
|
expect(
|
||||||
CryptoUtils.prototype.decryptImageWithSharingKey,
|
CryptoUtils.prototype.decryptImageWithSharingKey,
|
||||||
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
|
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
|
||||||
expect(canvasData.objects[0].src).toBe(
|
expect(canvasDataWithDecryptedImages.objects[0].src).toBe(
|
||||||
"blob:http://localhost/decrypted-shared",
|
"blob:http://localhost/decrypted-shared",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,51 +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 imageDecryptionPromises = canvasData.objects.map(async (obj, index) => {
|
const errors: string[] = [];
|
||||||
if (obj.type !== "Image") return;
|
const processedObjects = await Promise.all(
|
||||||
|
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 {
|
||||||
// HACK: For S3 Storage fetch and avoiding CORS error
|
const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
|
||||||
const res = await api.get(remoteUrl, {
|
|
||||||
responseType: "blob",
|
|
||||||
withCredentials: false,
|
|
||||||
});
|
|
||||||
const originalSrc = imgObj.src;
|
|
||||||
|
|
||||||
const blobUrl = await cryptoUtils.decryptImage(
|
const blobUrl = await cryptoUtils.decryptImage(
|
||||||
res.data,
|
blob,
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
|
||||||
delete canvasData.objects[index];
|
|
||||||
isDecryptionPartialFailure = true;
|
|
||||||
error = _error instanceof Error ? _error.message : "Unknown error";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(imageDecryptionPromises);
|
return decryptedObj;
|
||||||
canvasData.objects = canvasData.objects.filter(Boolean);
|
} catch (err) {
|
||||||
return { isDecryptionPartialFailure, error };
|
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 decryptCanvasImagesWithSharingKey(
|
export async function decryptCanvasImagesWithSharingKey(
|
||||||
@@ -70,41 +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<{ 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 errors: string[] = [];
|
||||||
|
|
||||||
const decryptionPromises = canvasData.objects.map(async (obj, index) => {
|
const processedObjects = await Promise.all(
|
||||||
if (obj.type !== "Image") return;
|
canvasData.objects.map(async (obj) => {
|
||||||
|
if (obj.type !== "Image") return obj;
|
||||||
|
|
||||||
const imgObj = obj as FabricImageJSON;
|
const imgObj = obj as FabricImageJSON;
|
||||||
const remoteUrl = imageMap.get(imgObj.src);
|
const remoteUrl = imageMap.get(imgObj.src);
|
||||||
if (!remoteUrl) return;
|
if (!remoteUrl) return obj;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get(remoteUrl, {
|
const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
|
||||||
responseType: "blob",
|
const blobUrl = await cryptoUtils.decryptImageWithSharingKey(
|
||||||
withCredentials: false,
|
blob,
|
||||||
});
|
|
||||||
imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
|
|
||||||
res.data,
|
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
} catch (_error) {
|
|
||||||
delete canvasData.objects[index];
|
|
||||||
isDecryptionPartialFailure = true;
|
|
||||||
error = _error instanceof Error ? _error.message : "Unknown error";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(decryptionPromises);
|
return { ...imgObj, src: blobUrl };
|
||||||
canvasData.objects = canvasData.objects.filter(Boolean);
|
} catch (err) {
|
||||||
return { isDecryptionPartialFailure, error };
|
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(
|
||||||
@@ -112,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"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// encrypt images parallelly
|
||||||
|
await Promise.all(
|
||||||
|
imagesToEncrypt.map(async (img) => {
|
||||||
const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
|
const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
|
||||||
img.file,
|
img.file,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
// map the og image url to the encrypted file name and filename to the encrypted source
|
||||||
filenameMapping.set(img.src, filename);
|
filenameMapping.set(img.src, filename);
|
||||||
encryptedFiles.set(filename, encryptedBlob);
|
encryptedImageFiles.set(filename, encryptedBlob);
|
||||||
}
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (canvasData?.objects) {
|
if (!canvasData?.objects)
|
||||||
canvasData.objects = canvasData.objects.map((obj) => {
|
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)) {
|
||||||
@@ -139,8 +206,8 @@ export async function encryptCanvasImages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
});
|
}),
|
||||||
}
|
};
|
||||||
|
|
||||||
return encryptedFiles;
|
return { encryptedImageFiles, encryptedCanvasData: newCanvasData };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||