mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2a1abe7eb | |||
| df73fb6b6a | |||
| 412abd912c | |||
| 8a9ded42b5 | |||
| f522a369ab | |||
| 72346d8721 | |||
| ac2c7b0eac | |||
| 935a43c311 | |||
| 4f178a3b03 | |||
| 867b01bd1e | |||
| c9ee9f7825 | |||
| 02070cee4a | |||
| 409fc76619 | |||
| 48b6a06571 |
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
DB_NAME=piku_test_db
|
DB_NAME=piku_test_db
|
||||||
DB_USER=test
|
DB_USER=test
|
||||||
DB_PASSWORD=password123
|
DB_PASSWORD=password123
|
||||||
DB_HOST=localhost
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
@@ -17,8 +17,8 @@ BACKEND_PORT=8001
|
|||||||
# EMAIL
|
# EMAIL
|
||||||
EMAIL_HOST=127.0.0.1
|
EMAIL_HOST=127.0.0.1
|
||||||
EMAIL_PORT=1026
|
EMAIL_PORT=1026
|
||||||
EMAIL_HOST_USER=test
|
EMAIL_HOST_USER=
|
||||||
EMAIL_HOST_PASSWORD=password123
|
EMAIL_HOST_PASSWORD=
|
||||||
FROM_EMAIL="Test <test@pi-ku.app>"
|
FROM_EMAIL="Test <test@pi-ku.app>"
|
||||||
EMAIL_API_PORT=8026
|
EMAIL_API_PORT=8026
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -2,23 +2,30 @@
|
|||||||
DB_NAME=piku
|
DB_NAME=piku
|
||||||
DB_USER=user
|
DB_USER=user
|
||||||
DB_PASSWORD=password123
|
DB_PASSWORD=password123
|
||||||
DB_HOST=localhost
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
SSL_ENABLED=true
|
SSL_ENABLED=true
|
||||||
|
S3_ENABLED=false
|
||||||
|
|
||||||
# DJANGO
|
# DJANGO
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
SECRET_KEY=django-secret-key
|
SECRET_KEY=django-secret-key
|
||||||
BACKEND_DOMAIN=127.0.0.1
|
BACKEND_DOMAIN=127.0.0.1
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
# S3
|
||||||
|
R2_ACCESS_KEY_ID=
|
||||||
|
R2_SECRET_ACCESS_KEY=
|
||||||
|
R2_REGION_NAME=
|
||||||
|
R2_ENDPOINT_URL=
|
||||||
|
R2_PUBLIC_URL=
|
||||||
|
|
||||||
# EMAIL
|
# EMAIL
|
||||||
EMAIL_HOST=127.0.0.1
|
EMAIL_HOST=127.0.0.1
|
||||||
EMAIL_PORT=1025
|
EMAIL_PORT=1025
|
||||||
EMAIL_HOST_USER=test
|
EMAIL_HOST_USER=
|
||||||
EMAIL_HOST_PASSWORD=password123
|
EMAIL_HOST_PASSWORD=
|
||||||
FROM_EMAIL="Pi Ku <no-reply@test.com>"
|
FROM_EMAIL="Pi Ku <no-reply@test.com>"
|
||||||
|
|
||||||
# FRONTEND
|
# FRONTEND
|
||||||
|
|||||||
@@ -145,3 +145,7 @@ jobs:
|
|||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: frontend/playwright-report/
|
path: frontend/playwright-report/
|
||||||
retention-days: 10
|
retention-days: 10
|
||||||
|
|
||||||
|
- name: Print Backend Logs on Failure
|
||||||
|
if: failure()
|
||||||
|
run: cat tmp/logs/backend.log || true
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env")
|
|||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
environ.Env.read_env(env_file, overwrite=False)
|
environ.Env.read_env(env_file, overwrite=False)
|
||||||
|
|
||||||
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
|
# Security Settings
|
||||||
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN"))
|
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
|
||||||
|
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
|
||||||
|
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
|
||||||
|
# NOTE: Set to forward https when using reverse proxy
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||||
|
|
||||||
SSL_ENABLED = env.bool("SSL_ENABLED", default=False)
|
SSL_ENABLED = env.bool("SSL_ENABLED", default=False)
|
||||||
URI_SCHEME = "https://" if SSL_ENABLED else "http://"
|
URI_SCHEME = "https://" if SSL_ENABLED else "http://"
|
||||||
@@ -78,6 +84,21 @@ MIDDLEWARE = [
|
|||||||
"django_structlog.middlewares.RequestMiddleware",
|
"django_structlog.middlewares.RequestMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
||||||
@@ -98,6 +119,7 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS = FRONTEND_URLS
|
CORS_ALLOWED_ORIGINS = FRONTEND_URLS
|
||||||
|
CSRF_TRUSTED_ORIGINS += FRONTEND_URLS
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
AUTH_USER_MODEL = "users.User"
|
AUTH_USER_MODEL = "users.User"
|
||||||
@@ -172,4 +194,31 @@ USE_TZ = True
|
|||||||
|
|
||||||
STATIC_URL = "static/"
|
STATIC_URL = "static/"
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
|
|
||||||
|
if env.bool("S3_ENABLED", default=False):
|
||||||
|
MEDIA_URL = f"{env('R2_PUBLIC_URL')}/media/"
|
||||||
|
# HACK: S3 auto pre-pends the url scheme forcefully and this prevents double https
|
||||||
|
R2_HOST = env("R2_PUBLIC_URL").replace("https://", "")
|
||||||
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "storages.backends.s3.S3Storage",
|
||||||
|
"OPTIONS": {
|
||||||
|
"access_key": env("R2_ACCESS_KEY_ID"),
|
||||||
|
"secret_key": env("R2_SECRET_ACCESS_KEY"),
|
||||||
|
"bucket_name": env("R2_STORAGE_BUCKET_NAME"),
|
||||||
|
"region_name": env("R2_REGION_NAME"),
|
||||||
|
"endpoint_url": env("R2_ENDPOINT_URL"),
|
||||||
|
"location": "media",
|
||||||
|
"signature_version": "s3v4",
|
||||||
|
"file_overwrite": False,
|
||||||
|
"custom_domain": R2_HOST,
|
||||||
|
"querystring_auth": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
DEFAULT_FILE_STORAGE = "storages.backends.s3.S3Storage"
|
||||||
|
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"apscheduler>=3.11.2",
|
"apscheduler>=3.11.2",
|
||||||
|
"boto3>=1.42.96",
|
||||||
"django>=6.0.4",
|
"django>=6.0.4",
|
||||||
"django-apscheduler>=0.7.0",
|
"django-apscheduler>=0.7.0",
|
||||||
"django-cors-headers>=4.9.0",
|
"django-cors-headers>=4.9.0",
|
||||||
"django-environ>=0.13.0",
|
"django-environ>=0.13.0",
|
||||||
"django-extensions>=4.1",
|
"django-extensions>=4.1",
|
||||||
|
"django-storages>=1.14.6",
|
||||||
"django-structlog>=10.0.0",
|
"django-structlog>=10.0.0",
|
||||||
"djangorestframework>=3.17.1",
|
"djangorestframework>=3.17.1",
|
||||||
"djangorestframework-simplejwt>=5.5.1",
|
"djangorestframework-simplejwt>=5.5.1",
|
||||||
|
|||||||
@@ -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,100 @@
|
|||||||
|
<!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;">
|
||||||
|
<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: 36px;">
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/ramvignesh-b/cdn@main/pi-ku_logo.png" width="100"
|
||||||
|
height="50" alt="Pi.Ku"
|
||||||
|
style="display:block;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{# Body #}
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: Lora, Georgia, serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.9;
|
||||||
|
color: #cdccca;
|
||||||
|
font-style: italic;
|
||||||
|
padding-bottom: 32px;">
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{# CTA #}
|
||||||
|
{% if cta %}
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding-bottom: 32px;">
|
||||||
|
<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 28px;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f5e6c8;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.04em;">
|
||||||
|
{{ cta.title }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if footnote %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: Lora, Georgia, serif;
|
||||||
|
font-size: 13px;
|
||||||
|
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;"> </td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: Lora, serif;
|
||||||
|
font-size: 13px;
|
||||||
|
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.
|
||||||
+20
-10
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+74
@@ -23,6 +23,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "boto3"
|
||||||
|
version = "1.42.96"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "s3transfer" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/2d/69fb3acd50bab83fb295c167d33c4b653faeb5fb0f42bfca4d9b69d6fb68/boto3-1.42.96.tar.gz", hash = "sha256:b38a9e4a3fbbee9017252576f1379780d0a5814768676c08df2f539d31fcdd68", size = 113203, upload-time = "2026-04-24T19:47:18.677Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/9d/b3f617d011c42eb804d993103b8fa9acdce153e181a3042f58bfe33d7cb4/boto3-1.42.96-py3-none-any.whl", hash = "sha256:2f4566da2c209a98bdbfc874d813ef231c84ad24e4f815e9bc91de5f63351a24", size = 140557, upload-time = "2026-04-24T19:47:15.824Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "botocore"
|
||||||
|
version = "1.42.96"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/77/2c333622a1d47cf5bf73cdcab0cb6c92addafbef2ec05f81b9f75687d9e5/botocore-1.42.96.tar.gz", hash = "sha256:75b3b841ffacaa944f645196655a21ca777591dd8911e732bfb6614545af0250", size = 15263344, upload-time = "2026-04-24T19:47:05.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/56/152c3a859ca1b9d77ed16deac3cf81682013677c68cf5715698781fc81bd/botocore-1.42.96-py3-none-any.whl", hash = "sha256:db2c3e2006628be6fde81a24124a6563c363d6982fb92728837cf174bad9d98a", size = 14945920, upload-time = "2026-04-24T19:47:00.323Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cffi"
|
name = "cffi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -182,6 +210,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/11/33/bf539925b102d68200da5b1d3eacb8aa5d5d9a065972e8b8724d0d53bb0d/django_ipware-7.0.1-py2.py3-none-any.whl", hash = "sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709", size = 6425, upload-time = "2024-04-19T20:02:47.469Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/33/bf539925b102d68200da5b1d3eacb8aa5d5d9a065972e8b8724d0d53bb0d/django_ipware-7.0.1-py2.py3-none-any.whl", hash = "sha256:db16bbee920f661ae7f678e4270460c85850f03c6761a4eaeb489bdc91f64709", size = 6425, upload-time = "2024-04-19T20:02:47.469Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-storages"
|
||||||
|
version = "1.14.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-structlog"
|
name = "django-structlog"
|
||||||
version = "10.0.0"
|
version = "10.0.0"
|
||||||
@@ -289,6 +329,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jmespath"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@@ -355,11 +404,13 @@ version = "0.1.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
{ name = "boto3" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-apscheduler" },
|
{ name = "django-apscheduler" },
|
||||||
{ name = "django-cors-headers" },
|
{ name = "django-cors-headers" },
|
||||||
{ name = "django-environ" },
|
{ name = "django-environ" },
|
||||||
{ name = "django-extensions" },
|
{ name = "django-extensions" },
|
||||||
|
{ name = "django-storages" },
|
||||||
{ name = "django-structlog" },
|
{ name = "django-structlog" },
|
||||||
{ name = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
{ name = "djangorestframework-simplejwt" },
|
{ name = "djangorestframework-simplejwt" },
|
||||||
@@ -377,11 +428,13 @@ dependencies = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "apscheduler", specifier = ">=3.11.2" },
|
{ name = "apscheduler", specifier = ">=3.11.2" },
|
||||||
|
{ name = "boto3", specifier = ">=1.42.96" },
|
||||||
{ name = "django", specifier = ">=6.0.4" },
|
{ name = "django", specifier = ">=6.0.4" },
|
||||||
{ name = "django-apscheduler", specifier = ">=0.7.0" },
|
{ name = "django-apscheduler", specifier = ">=0.7.0" },
|
||||||
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||||
{ name = "django-environ", specifier = ">=0.13.0" },
|
{ name = "django-environ", specifier = ">=0.13.0" },
|
||||||
{ name = "django-extensions", specifier = ">=4.1" },
|
{ name = "django-extensions", specifier = ">=4.1" },
|
||||||
|
{ name = "django-storages", specifier = ">=1.14.6" },
|
||||||
{ name = "django-structlog", specifier = ">=10.0.0" },
|
{ name = "django-structlog", specifier = ">=10.0.0" },
|
||||||
{ name = "djangorestframework", specifier = ">=3.17.1" },
|
{ name = "djangorestframework", specifier = ">=3.17.1" },
|
||||||
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
|
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
|
||||||
@@ -513,6 +566,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "s3transfer"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/19/90d7d4ed51932c022d53f1d02d564b62d10e272692a1f9b76425c1ad2a02/s3transfer-0.16.1-py3-none-any.whl", hash = "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", size = 86825, upload-time = "2026-04-22T20:36:04.992Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "six"
|
name = "six"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
@@ -579,6 +644,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.8"
|
version = "3.1.8"
|
||||||
|
|||||||
+1
-8
@@ -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
|
||||||
@@ -29,4 +22,4 @@ RUN chown -R nginx:nginx /usr/share/nginx/html
|
|||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["nginx", "-e", "/tmp/error.log", "-g", "daemon off;"]
|
ENTRYPOINT ["nginx", "-e", "/tmp/error.log", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -185,7 +185,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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 738 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -1,7 +1,7 @@
|
|||||||
import { DotIcon } from "@phosphor-icons/react";
|
import { DotIcon } from "@phosphor-icons/react";
|
||||||
import "@fontsource/knewave/400.css";
|
import "@fontsource/knewave/400.css";
|
||||||
|
|
||||||
export default function Logo({ scale = 2 }) {
|
export default function Logo({ scale = 1 }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
@@ -9,16 +9,16 @@ export default function Logo({ scale = 2 }) {
|
|||||||
className="inline-flex items-baseline justify-center leading-none select-none"
|
className="inline-flex items-baseline justify-center leading-none select-none"
|
||||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
style={{ fontFamily: "'Knewave', serif", scale }}
|
||||||
>
|
>
|
||||||
<span className={`text-xl font-light text-accent`}> 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { PATHS, ROUTES } from "../../config/routes";
|
|||||||
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;
|
if (!sealedTargetId) return null;
|
||||||
return (
|
return (
|
||||||
@@ -20,33 +22,61 @@ export function PostSealModal({
|
|||||||
<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" ? (
|
||||||
When you're ready,
|
<p className="text-base-content/80 text-sm font-sans">
|
||||||
<br />
|
When you're ready,
|
||||||
you can{" "}
|
<br />
|
||||||
<span className="text-primary font-bold font-display">read</span> it,{" "}
|
you can{" "}
|
||||||
<span className="text-accent font-bold font-display">send</span> it to
|
<span className="text-primary font-bold font-display">read</span>{" "}
|
||||||
someone, or{" "}
|
it, <span className="text-accent font-bold font-display">send</span>{" "}
|
||||||
<span className="text-error font-bold font-display">burn</span> it to
|
it to someone, or{" "}
|
||||||
release
|
<span className="text-error font-bold font-display">burn</span> it
|
||||||
</p>
|
to release
|
||||||
|
</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">
|
||||||
<button
|
{type === "KEPT" ? (
|
||||||
type="button"
|
<>
|
||||||
className="btn btn-ghost btn-sm"
|
<button
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
type="button"
|
||||||
>
|
className="btn btn-ghost btn-sm"
|
||||||
Keep it to myself
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
</button>
|
>
|
||||||
<button
|
Keep it to myself
|
||||||
type="button"
|
</button>
|
||||||
className="btn btn-primary btn-sm"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
navigate(PATHS.read(sealedTargetId), { replace: true })
|
className="btn btn-primary btn-sm"
|
||||||
}
|
onClick={() => navigate(PATHS.read(sealedTargetId))}
|
||||||
>
|
>
|
||||||
View letter
|
View letter
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
|
>
|
||||||
|
Step Away...
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,10 +102,24 @@ export function ToolBar({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Help"
|
||||||
onClick={() => setSealBtnClicked(false)}
|
onClick={() => setSealBtnClicked(false)}
|
||||||
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
|
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
|
||||||
>
|
>
|
||||||
<QuestionIcon weight="duotone" size={20} className={""} />
|
<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>
|
||||||
);
|
);
|
||||||
@@ -136,21 +150,23 @@ export function VaultConfirmModal({
|
|||||||
setUnlockDate,
|
setUnlockDate,
|
||||||
}: VaultConfirmModalProps) {
|
}: VaultConfirmModalProps) {
|
||||||
return (
|
return (
|
||||||
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}>
|
<div className={"modal modal-open bg-base-100/10 backdrop-blur-md"}>
|
||||||
<div className="modal-box p-12 flex flex-col items-center">
|
<div className="modal-box p-12 flex flex-col items-center bg-base-100/90">
|
||||||
<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) => {
|
||||||
@@ -158,11 +174,13 @@ export function VaultConfirmModal({
|
|||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const unlockDateStr = formData.get("vault-date") as string;
|
const unlockDateStr = formData.get("vault-date") as string;
|
||||||
const newUnlockDate = new Date(unlockDateStr);
|
const newUnlockDate = new Date(unlockDateStr);
|
||||||
|
console.log(newUnlockDate);
|
||||||
setUnlockDate(newUnlockDate);
|
setUnlockDate(newUnlockDate);
|
||||||
await onSave("VAULT", newUnlockDate);
|
await onSave("VAULT", newUnlockDate);
|
||||||
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,21 +191,22 @@ export function VaultConfirmModal({
|
|||||||
className="input input-bordered w-full"
|
className="input input-bordered w-full"
|
||||||
name="vault-date"
|
name="vault-date"
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="w-full flex justify-center gap-8 mt-4">
|
||||||
className="btn btn-primary mt-4"
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
form="vault-form"
|
className="btn btn-ghost btn-sm mt-4"
|
||||||
>
|
onClick={() => setConfirmModal(null)}
|
||||||
Vault
|
>
|
||||||
</button>
|
I need time
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
className="btn btn-primary btn-sm mt-4"
|
||||||
className="btn btn-ghost mt-4"
|
type="submit"
|
||||||
onClick={() => setConfirmModal(null)}
|
form="vault-form"
|
||||||
>
|
>
|
||||||
Cancel
|
Take it
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react";
|
import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface EnvelopeRevealProps {
|
|||||||
onRevealComplete: () => void;
|
onRevealComplete: () => void;
|
||||||
ignite: boolean;
|
ignite: boolean;
|
||||||
isFlip?: boolean;
|
isFlip?: boolean;
|
||||||
|
isInteractive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnvelopeReveal({
|
export function EnvelopeReveal({
|
||||||
@@ -17,6 +18,7 @@ export function EnvelopeReveal({
|
|||||||
onRevealComplete,
|
onRevealComplete,
|
||||||
ignite,
|
ignite,
|
||||||
isFlip,
|
isFlip,
|
||||||
|
isInteractive = true,
|
||||||
}: EnvelopeRevealProps) {
|
}: EnvelopeRevealProps) {
|
||||||
const [revealLetter, setRevealLetter] = useState(false);
|
const [revealLetter, setRevealLetter] = useState(false);
|
||||||
const [isFlipped, setIsFlipped] = useState(!!isFlip);
|
const [isFlipped, setIsFlipped] = useState(!!isFlip);
|
||||||
@@ -67,6 +69,7 @@ export function EnvelopeReveal({
|
|||||||
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}
|
ref={flapCheckbox}
|
||||||
|
disabled={!isInteractive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
@@ -103,6 +106,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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "@phosphor-icons/react";
|
} 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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ 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]);
|
||||||
|
|||||||
@@ -47,10 +47,7 @@
|
|||||||
--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,
|
--color-glass-bg: rgba(28, 22, 16, 0.45);
|
||||||
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);
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -165,6 +166,12 @@ export default function Drawer() {
|
|||||||
<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-[0.2em] 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");
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ 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");
|
||||||
@@ -419,7 +419,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">
|
||||||
|
|||||||
@@ -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,9 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ShieldCheckIcon, WarningIcon } from "@phosphor-icons/react";
|
import {
|
||||||
|
HandPalmIcon,
|
||||||
|
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";
|
||||||
@@ -8,6 +12,7 @@ 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 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,10 +25,19 @@ const loginSchema = z.object({
|
|||||||
|
|
||||||
type LoginInputs = z.infer<typeof loginSchema>;
|
type LoginInputs = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
function WelcomeModal({ setShowWelcome }) {
|
function WelcomeModal({
|
||||||
|
setShowWelcome,
|
||||||
|
}: {
|
||||||
|
setShowWelcome: (show: boolean) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="modal modal-open backdrop-blur-sm transition-all duration-1000">
|
<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="absolute bottom-1">
|
||||||
|
<Saajan
|
||||||
|
message={"I've lost words before.\nI know what it feels like."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-box border bg-base-100/20 border-primary/20 shadow-2xl p-8">
|
||||||
<div className="flex flex-col items-center text-center gap-4">
|
<div className="flex flex-col items-center text-center gap-4">
|
||||||
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
|
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
|
||||||
<ShieldCheckIcon
|
<ShieldCheckIcon
|
||||||
@@ -33,19 +47,22 @@ function WelcomeModal({ setShowWelcome }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-display text-2xl font-bold text-primary">
|
<h3 className="font-display text-2xl font-bold text-primary">
|
||||||
Welcome to <Logo />!
|
Welcome to
|
||||||
|
<Logo /> !
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base-content/80 leading-relaxed">
|
<p className="text-base-content/80 leading-relaxed">
|
||||||
To ensure <span className="font-bold">complete privacy</span>, all
|
Before we begin, let me make a small promise.
|
||||||
your letters are{" "}
|
<HandPalmIcon
|
||||||
<span className="font-bold underline">
|
size={18}
|
||||||
sealed with your password
|
className="inline text-primary"
|
||||||
</span>
|
weight="fill"
|
||||||
, which only you have access to.
|
/>
|
||||||
|
<div className="divider my-0"></div>
|
||||||
<br />
|
<br />
|
||||||
<span className="font-bold">
|
Everything you write here is sealed with your password,{" "}
|
||||||
The server never sees it, and it's a solemn promise!
|
<span className="font-display text-success">cryptographically</span>
|
||||||
</span>
|
, before it leaves your hands.
|
||||||
|
<br />A fancy way of saying, I couldn't if I tried.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
|
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
|
||||||
@@ -53,6 +70,19 @@ function WelcomeModal({ setShowWelcome }) {
|
|||||||
<p className="text-sm font-medium text-primary-content">
|
<p className="text-sm font-medium text-primary-content">
|
||||||
If you ever happen to forget your password, your letters are lost
|
If you ever happen to forget your password, your letters are lost
|
||||||
to time, forever.
|
to time, forever.
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,7 +92,7 @@ function WelcomeModal({ setShowWelcome }) {
|
|||||||
onClick={() => setShowWelcome(false)}
|
onClick={() => setShowWelcome(false)}
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
>
|
>
|
||||||
I understand
|
I'll remember
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +108,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 {
|
||||||
@@ -125,12 +158,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 +176,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 +188,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">
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ 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 [error, setError] = useState<{
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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";
|
||||||
@@ -31,6 +32,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,6 +45,7 @@ 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 {
|
||||||
@@ -68,74 +73,96 @@ export default function Register() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
<div className="flex flex-col">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
<Saajan message={saajanMessage} position="right" />
|
||||||
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
|
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
|
||||||
Create a <Logo /> Account
|
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
|
||||||
</h1>
|
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
||||||
|
Create a <Logo /> Account
|
||||||
{apiError && (
|
|
||||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
|
||||||
<span>{apiError}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
{apiError && (
|
||||||
label="Pen Name"
|
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||||
placeholder="Word Smith"
|
<span>{apiError}</span>
|
||||||
registration={register("full_name")}
|
</div>
|
||||||
error={errors.full_name?.message}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label="Pen Name"
|
||||||
type="email"
|
placeholder="Word Smith"
|
||||||
placeholder="f.kafka@email.com"
|
registration={register("full_name")}
|
||||||
registration={register("email")}
|
error={errors.full_name?.message}
|
||||||
error={errors.email?.message}
|
handleFocus={() =>
|
||||||
/>
|
setSaajanMessage("Hello friend. What should I call you?")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Password"
|
label="Email"
|
||||||
type="password"
|
type="email"
|
||||||
placeholder="••••••••"
|
placeholder="f.kafka@email.com"
|
||||||
registration={register("password")}
|
registration={register("email")}
|
||||||
error={errors.password?.message}
|
error={errors.email?.message}
|
||||||
/>
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Where should I send your letters?\nNo empty lunchboxes, please.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Confirm Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
registration={register("confirm_password")}
|
registration={register("password")}
|
||||||
error={errors.confirm_password?.message}
|
error={errors.password?.message}
|
||||||
/>
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Something only you know.\nI have one of those too.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Warning */}
|
<FormField
|
||||||
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
label="Confirm Password"
|
||||||
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
type="password"
|
||||||
<p className="text-sm font-semibold">
|
placeholder="••••••••"
|
||||||
Choose a password you won't forget. <br />
|
registration={register("confirm_password")}
|
||||||
<span className="underline decoration-2">There is no reset.</span>{" "}
|
error={errors.confirm_password?.message}
|
||||||
If you lose it, your letters cannot be recovered.
|
handleFocus={() =>
|
||||||
</p>
|
setSaajanMessage(
|
||||||
</div>
|
"Just once? Trust me, \nsome things are worth repeating twice.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="card-actions mt-4">
|
{/* Warning */}
|
||||||
<button
|
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
||||||
type="submit"
|
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
||||||
disabled={isLoading}
|
<p className="text-sm font-semibold">
|
||||||
aria-label="Register"
|
Choose a password you won't forget. <br />
|
||||||
className="btn btn-primary w-full shadow-lg"
|
Just like life,{" "}
|
||||||
>
|
<span className="underline decoration-2">there is no reset</span>{" "}
|
||||||
{isLoading ? (
|
here. If you lose it, your letters cannot be recovered.
|
||||||
<span className="loading loading-spinner loading-sm" />
|
</p>
|
||||||
) : (
|
</div>
|
||||||
"Register"
|
|
||||||
)}
|
<div className="card-actions mt-4">
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="submit"
|
||||||
</form>
|
disabled={isLoading}
|
||||||
|
aria-label="Register"
|
||||||
|
className="btn btn-primary w-full shadow-lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
) : (
|
||||||
|
"Register"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,55 @@
|
|||||||
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
|
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
|
import Saajan from "../components/ui/Saajan";
|
||||||
|
|
||||||
export default function VerifyEmail() {
|
export default function VerifyEmail() {
|
||||||
return (
|
return (
|
||||||
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
|
<div className="relative">
|
||||||
<div className="auth-icon-container">
|
<Saajan
|
||||||
<EnvelopeSimpleOpenIcon
|
message={"I sent something to your inbox.\nOpen it, and we can begin."}
|
||||||
size={32}
|
/>
|
||||||
weight="duotone"
|
|
||||||
className="text-primary"
|
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
|
||||||
/>
|
<div className="auth-icon-container">
|
||||||
|
<EnvelopeSimpleOpenIcon
|
||||||
|
size={32}
|
||||||
|
weight="duotone"
|
||||||
|
className="text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="font-display text-xl text-primary">
|
||||||
|
Check Your Mailbox
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
|
||||||
|
You're one train away from starting your <Logo scale={0.8} />{" "}
|
||||||
|
journey.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider opacity-10 my-0"></div>
|
||||||
|
|
||||||
|
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed opacity-70 text-center">
|
||||||
|
<p>
|
||||||
|
Nothing yet? Sometimes letters take the wrong train. Check your spam
|
||||||
|
folder.
|
||||||
|
<br />
|
||||||
|
<span className="underline font-bold">
|
||||||
|
The link expires in 24 hours.
|
||||||
|
</span>
|
||||||
|
<br /> I'm patient... but not endlessly so
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs italic opacity-40 cursor-pointer underline"
|
||||||
|
onClick={() => window.close()}
|
||||||
|
>
|
||||||
|
You can close this window now.
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="font-display text-xl text-primary">Check Your Email</h2>
|
|
||||||
<p className="text-sm opacity-80 leading-relaxed font-sans">
|
|
||||||
We've sent an activation link to your inbox. <br />
|
|
||||||
Please click it to verify your <Logo /> account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divider opacity-10"></div>
|
|
||||||
|
|
||||||
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed text-left opacity-70">
|
|
||||||
<p>
|
|
||||||
Didn't receive it? Check your spam folder or wait for a few minutes.
|
|
||||||
The link will expire in 24 hours.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs italic opacity-40 cursor-pointer underline"
|
|
||||||
onClick={() => window.close()}
|
|
||||||
>
|
|
||||||
You can close this window now.
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,9 +152,10 @@ describe("letterLogic image helpers", () => {
|
|||||||
crypto,
|
crypto,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", {
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
responseType: "blob",
|
"https://remote/photo.png.bin",
|
||||||
});
|
expect.objectContaining({ responseType: "blob" }),
|
||||||
|
);
|
||||||
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
|
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
|
||||||
expect.any(Blob),
|
expect.any(Blob),
|
||||||
"wrapped-dek",
|
"wrapped-dek",
|
||||||
@@ -238,9 +239,10 @@ describe("letterLogic image helpers", () => {
|
|||||||
crypto,
|
crypto,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", {
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
responseType: "blob",
|
"https://remote/photo.png.bin",
|
||||||
});
|
expect.objectContaining({ responseType: "blob" }),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
CryptoUtils.prototype.decryptImageWithSharingKey,
|
CryptoUtils.prototype.decryptImageWithSharingKey,
|
||||||
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
|
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
|
||||||
|
|||||||
@@ -28,14 +28,18 @@ export async function decryptCanvasImages(
|
|||||||
remoteImages.map((img) => [img.file_name, img.file]),
|
remoteImages.map((img) => [img.file_name, img.file]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const decryptionPromises = canvasData.objects.map(async (obj, index) => {
|
const imageDecryptionPromises = canvasData.objects.map(async (obj, index) => {
|
||||||
if (obj.type !== "Image") return;
|
if (obj.type !== "Image") return;
|
||||||
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;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get(remoteUrl, { responseType: "blob" });
|
// HACK: For S3 Storage fetch and avoiding CORS error
|
||||||
|
const res = await api.get(remoteUrl, {
|
||||||
|
responseType: "blob",
|
||||||
|
withCredentials: false,
|
||||||
|
});
|
||||||
const originalSrc = imgObj.src;
|
const originalSrc = imgObj.src;
|
||||||
|
|
||||||
const blobUrl = await cryptoUtils.decryptImage(
|
const blobUrl = await cryptoUtils.decryptImage(
|
||||||
@@ -56,7 +60,7 @@ export async function decryptCanvasImages(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(decryptionPromises);
|
await Promise.all(imageDecryptionPromises);
|
||||||
canvasData.objects = canvasData.objects.filter(Boolean);
|
canvasData.objects = canvasData.objects.filter(Boolean);
|
||||||
return { isDecryptionPartialFailure, error };
|
return { isDecryptionPartialFailure, error };
|
||||||
}
|
}
|
||||||
@@ -66,14 +70,16 @@ 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 }> {
|
||||||
if (!canvasData?.objects) return;
|
if (!canvasData?.objects)
|
||||||
|
return { isDecryptionPartialFailure: false, error: "" };
|
||||||
|
let isDecryptionPartialFailure = false;
|
||||||
|
let error = "";
|
||||||
const imageMap = new Map(
|
const imageMap = new Map(
|
||||||
remoteImages.map((img) => [img.file_name, img.file]),
|
remoteImages.map((img) => [img.file_name, img.file]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const decryptionPromises = canvasData.objects.map(async (obj) => {
|
const decryptionPromises = canvasData.objects.map(async (obj, index) => {
|
||||||
if (obj.type !== "Image") return;
|
if (obj.type !== "Image") return;
|
||||||
|
|
||||||
const imgObj = obj as FabricImageJSON;
|
const imgObj = obj as FabricImageJSON;
|
||||||
@@ -81,17 +87,24 @@ export async function decryptCanvasImagesWithSharingKey(
|
|||||||
if (!remoteUrl) return;
|
if (!remoteUrl) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get(remoteUrl, { responseType: "blob" });
|
const res = await api.get(remoteUrl, {
|
||||||
|
responseType: "blob",
|
||||||
|
withCredentials: false,
|
||||||
|
});
|
||||||
imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
|
imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
|
||||||
res.data,
|
res.data,
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Keep original or handle failure
|
delete canvasData.objects[index];
|
||||||
|
isDecryptionPartialFailure = true;
|
||||||
|
error = _error instanceof Error ? _error.message : "Unknown error";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(decryptionPromises);
|
await Promise.all(decryptionPromises);
|
||||||
|
canvasData.objects = canvasData.objects.filter(Boolean);
|
||||||
|
return { isDecryptionPartialFailure, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptCanvasImages(
|
export async function encryptCanvasImages(
|
||||||
|
|||||||
Reference in New Issue
Block a user