Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7dc2e8eb9 | |||
| 47e101c6fc | |||
| 9935da0496 | |||
| 05e4df2d7b | |||
| 06585163d0 | |||
| c40e3d20cb | |||
| f5757b47de | |||
| 029b02b3c8 | |||
| c5aeccf58f | |||
| 1927dedf22 | |||
| dd7f3e1fe9 | |||
| b1d2c374b6 | |||
| 2e0c4e557d | |||
| 3f761cfe7e | |||
| 6ad8837145 | |||
| ce8bb5c018 | |||
| 7a05a6040e | |||
| 587160811f | |||
| 4195fce415 | |||
| 4277298c47 | |||
| b08d505a5a |
@@ -2,11 +2,11 @@
|
|||||||
DB_NAME=piku_test_db
|
DB_NAME=piku_test_db
|
||||||
DB_USER=test
|
DB_USER=test
|
||||||
DB_PASSWORD=password123
|
DB_PASSWORD=password123
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=localhost
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# SSL
|
# SSL
|
||||||
SSL_ENABLED=true
|
SSL_ENABLED=false
|
||||||
|
|
||||||
# DJANGO
|
# DJANGO
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
@@ -17,12 +17,11 @@ BACKEND_PORT=8001
|
|||||||
# EMAIL
|
# EMAIL
|
||||||
EMAIL_HOST=127.0.0.1
|
EMAIL_HOST=127.0.0.1
|
||||||
EMAIL_PORT=1026
|
EMAIL_PORT=1026
|
||||||
|
FROM_EMAIL="Test <test@pi-ku.app>"
|
||||||
EMAIL_HOST_USER=
|
EMAIL_HOST_USER=
|
||||||
EMAIL_HOST_PASSWORD=
|
EMAIL_HOST_PASSWORD=
|
||||||
FROM_EMAIL="Test <test@pi-ku.app>"
|
|
||||||
EMAIL_API_PORT=8026
|
EMAIL_API_PORT=8026
|
||||||
|
|
||||||
# FRONTEND
|
# FRONTEND
|
||||||
FRONTEND_PORT=5199
|
FRONTEND_PORT=5199
|
||||||
FRONTEND_DOMAIN=127.0.0.1
|
FRONTEND_DOMAIN=127.0.0.1
|
||||||
VITE_API_URL=https://127.0.0.1:8001
|
|
||||||
|
|||||||
@@ -2,33 +2,23 @@
|
|||||||
DB_NAME=piku
|
DB_NAME=piku
|
||||||
DB_USER=user
|
DB_USER=user
|
||||||
DB_PASSWORD=password123
|
DB_PASSWORD=password123
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=localhost
|
||||||
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=
|
FROM_EMAIL=Pi Ku <no-reply@test.com>
|
||||||
EMAIL_HOST_PASSWORD=
|
|
||||||
FROM_EMAIL="Pi Ku <no-reply@test.com>"
|
|
||||||
|
|
||||||
# FRONTEND
|
# FRONTEND
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
FRONTEND_DOMAIN=127.0.0.1
|
FRONTEND_DOMAIN=127.0.0.1
|
||||||
VITE_API_URL=https://127.0.0.1:8000
|
|
||||||
|
|||||||
@@ -145,7 +145,3 @@ 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
|
|
||||||
|
|||||||
@@ -13,17 +13,3 @@ dist/
|
|||||||
# Certificates
|
# Certificates
|
||||||
certs/*.pem
|
certs/*.pem
|
||||||
tmp/
|
tmp/
|
||||||
.idea/.gitignore
|
|
||||||
.idea/misc.xml
|
|
||||||
.idea/modules.xml
|
|
||||||
.idea/pi ku.iml
|
|
||||||
.idea/vcs.xml
|
|
||||||
.idea/inspectionProfiles/profiles_settings.xml
|
|
||||||
.idea/runConfigurations/pi_ku.xml
|
|
||||||
backend/.idea/.gitignore
|
|
||||||
backend/.idea/backend.iml
|
|
||||||
backend/.idea/misc.xml
|
|
||||||
backend/.idea/modules.xml
|
|
||||||
backend/.idea/vcs.xml
|
|
||||||
backend/.idea/inspectionProfiles/profiles_settings.xml
|
|
||||||
backend/.idea/runConfigurations/backend.xml
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
.venv
|
|
||||||
@@ -10,4 +10,3 @@ __pycache__/
|
|||||||
|
|
||||||
docs/
|
docs/
|
||||||
encrypted-images/
|
encrypted-images/
|
||||||
logs/
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
FROM astral/uv:python3.13-bookworm-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# HACK: Force app to dump logs into the docker console immediately
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock ./
|
|
||||||
RUN uv sync --frozen --no-dev
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# NOTE: Exporting env var 'UVICORN_MAIN=true' is required for the scheduler to run on app start.
|
|
||||||
CMD ["sh", "-c", "uv run manage.py migrate && 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,96 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
LOGS_DIR = BASE_DIR / "logs"
|
|
||||||
|
|
||||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
structlog.configure(
|
|
||||||
processors=[
|
|
||||||
structlog.contextvars.merge_contextvars,
|
|
||||||
structlog.stdlib.filter_by_level,
|
|
||||||
structlog.processors.TimeStamper(fmt="iso"),
|
|
||||||
structlog.stdlib.add_logger_name,
|
|
||||||
structlog.stdlib.add_log_level,
|
|
||||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
||||||
structlog.processors.StackInfoRenderer(),
|
|
||||||
structlog.processors.format_exc_info,
|
|
||||||
structlog.processors.UnicodeDecoder(),
|
|
||||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
||||||
],
|
|
||||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
||||||
cache_logger_on_first_use=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
LOGGING = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"formatters": {
|
|
||||||
"json_formatter": {
|
|
||||||
"()": structlog.stdlib.ProcessorFormatter,
|
|
||||||
"processor": structlog.processors.JSONRenderer(),
|
|
||||||
},
|
|
||||||
"plain_console": {
|
|
||||||
"()": structlog.stdlib.ProcessorFormatter,
|
|
||||||
"processor": structlog.dev.ConsoleRenderer(colors=True),
|
|
||||||
},
|
|
||||||
"key_value": {
|
|
||||||
"()": structlog.stdlib.ProcessorFormatter,
|
|
||||||
"processor": structlog.processors.KeyValueRenderer(key_order=["timestamp", "level", "event", "logger"]),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "plain_console",
|
|
||||||
},
|
|
||||||
"json_file": {
|
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
|
||||||
"filename": LOGS_DIR / "json.log",
|
|
||||||
"formatter": "json_formatter",
|
|
||||||
},
|
|
||||||
"flat_line_file": {
|
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
|
||||||
"filename": LOGS_DIR / "flat_line.log",
|
|
||||||
"formatter": "key_value",
|
|
||||||
},
|
|
||||||
"letters_log": {
|
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
|
||||||
"filename": LOGS_DIR / "letters.log",
|
|
||||||
"formatter": "key_value",
|
|
||||||
},
|
|
||||||
"scheduler_log": {
|
|
||||||
"class": "logging.handlers.WatchedFileHandler",
|
|
||||||
"filename": LOGS_DIR / "scheduler.log",
|
|
||||||
"formatter": "key_value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"loggers": {
|
|
||||||
"django_structlog": {
|
|
||||||
"handlers": ["console", "flat_line_file", "json_file"],
|
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
"django.core.mail": {
|
|
||||||
"handlers": ["console", "flat_line_file", "json_file"],
|
|
||||||
"level": "DEBUG",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
"letters.tasks": {
|
|
||||||
"handlers": ["console", "scheduler_log"],
|
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
"letters": {
|
|
||||||
"handlers": ["console", "flat_line_file", "json_file", "letters_log"],
|
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
"": {
|
|
||||||
"handlers": ["console"],
|
|
||||||
"level": "INFO",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -16,36 +16,20 @@ 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
|
||||||
|
|
||||||
# Load dotenv files
|
# Load dotenv files
|
||||||
env = environ.Env()
|
env = environ.Env()
|
||||||
env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env"))
|
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.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("SSL_ENABLED") == "true"
|
||||||
|
FRONTEND_URL = f"https://{env('FRONTEND_DOMAIN')}" if SSL_ENABLED else f"http://{env('FRONTEND_DOMAIN')}"
|
||||||
SSL_ENABLED = env.bool("SSL_ENABLED", default=False)
|
if env("FRONTEND_PORT"):
|
||||||
URI_SCHEME = "https://" if SSL_ENABLED else "http://"
|
FRONTEND_URL += f":{env('FRONTEND_PORT')}"
|
||||||
|
|
||||||
FRONTEND_URLS = []
|
|
||||||
if env("FRONTEND_URL", default=None):
|
|
||||||
FRONTEND_URLS.append(env("FRONTEND_URL"))
|
|
||||||
if env("FRONTEND_PORT", default=None):
|
|
||||||
FRONTEND_URLS.append(f"{URI_SCHEME}{env('FRONTEND_DOMAIN')}:{env('FRONTEND_PORT')}")
|
|
||||||
else:
|
|
||||||
FRONTEND_URLS.append(f"{URI_SCHEME}{env('FRONTEND_DOMAIN')}")
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||||
@@ -54,20 +38,19 @@ else:
|
|||||||
SECRET_KEY = env("SECRET_KEY")
|
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("DEBUG")
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = [env("FRONTEND_DOMAIN")]
|
||||||
|
|
||||||
LOGGING = LOGGING
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django_apscheduler",
|
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django_extensions",
|
"django_extensions",
|
||||||
"django_structlog",
|
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"users",
|
"users",
|
||||||
@@ -84,29 +67,13 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"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"
|
||||||
|
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
|
|
||||||
@@ -121,8 +88,7 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS = FRONTEND_URLS
|
CORS_ALLOWED_ORIGINS = [FRONTEND_URL]
|
||||||
CSRF_TRUSTED_ORIGINS += FRONTEND_URLS
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
AUTH_USER_MODEL = "users.User"
|
AUTH_USER_MODEL = "users.User"
|
||||||
@@ -130,7 +96,6 @@ AUTH_USER_MODEL = "users.User"
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
@@ -147,8 +112,8 @@ NOTE: COOKIE_SAMESITE: Lax is used to allow cross-site redirection, like links
|
|||||||
"""
|
"""
|
||||||
AUTH_COOKIE = {
|
AUTH_COOKIE = {
|
||||||
"NAME": "refresh_token",
|
"NAME": "refresh_token",
|
||||||
"DOMAIN": None if DEBUG else env("FRONTEND_DOMAIN"),
|
"DOMAIN": None,
|
||||||
"SECURE": SSL_ENABLED if DEBUG else True,
|
"SECURE": SSL_ENABLED,
|
||||||
"HTTPONLY": True,
|
"HTTPONLY": True,
|
||||||
"SAMESITE": "Lax",
|
"SAMESITE": "Lax",
|
||||||
}
|
}
|
||||||
@@ -156,13 +121,11 @@ AUTH_COOKIE = {
|
|||||||
# Email config
|
# Email config
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
EMAIL_HOST = env("EMAIL_HOST")
|
EMAIL_HOST = env("EMAIL_HOST")
|
||||||
EMAIL_PORT = env.int("EMAIL_PORT")
|
EMAIL_PORT = env("EMAIL_PORT")
|
||||||
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
EMAIL_USE_TLS = not DEBUG
|
||||||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
|
|
||||||
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=False)
|
|
||||||
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", default=False)
|
|
||||||
FROM_EMAIL = env("FROM_EMAIL")
|
FROM_EMAIL = env("FROM_EMAIL")
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
@@ -181,6 +144,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||||
|
|
||||||
@@ -192,36 +156,10 @@ USE_I18N = True
|
|||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -1,23 +1,5 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class LettersConfig(AppConfig):
|
class LettersConfig(AppConfig):
|
||||||
name = "letters"
|
name = "letters"
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
"""
|
|
||||||
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++: For uvicorn, we make sure to set the env var `UVICORN_MAIN` to `true` in the docker command.
|
|
||||||
"""
|
|
||||||
if not (
|
|
||||||
os.environ.get("RUN_MAIN") == "true"
|
|
||||||
or os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
|
||||||
or os.environ.get("UVICORN_MAIN") == "true"
|
|
||||||
):
|
|
||||||
return
|
|
||||||
from .tasks import start_scheduler
|
|
||||||
|
|
||||||
start_scheduler()
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 6.0.4 on 2026-04-17 07:43
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("letters", "0007_alter_letter_public_id"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="letter",
|
|
||||||
name="notified_at",
|
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Generated by Django 6.0.4 on 2026-04-17 18:08
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("letters", "0008_letter_notified_at"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="letter",
|
|
||||||
name="notified_at",
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="letter",
|
|
||||||
name="unlock_at",
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -25,11 +24,10 @@ class Letter(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
encrypted_content = models.TextField(null=True, blank=True)
|
encrypted_content = models.TextField(null=True, blank=True)
|
||||||
encrypted_metadata = models.TextField(null=True, blank=True)
|
encrypted_metadata = models.TextField(null=True, blank=True)
|
||||||
unlock_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
unlock_at = models.DateTimeField(null=True, blank=True)
|
||||||
sealed_at = models.DateTimeField(null=True, blank=True)
|
sealed_at = models.DateTimeField(null=True, blank=True)
|
||||||
opened_at = models.DateTimeField(null=True, blank=True)
|
opened_at = models.DateTimeField(null=True, blank=True)
|
||||||
burned_at = models.DateTimeField(null=True, blank=True)
|
burned_at = models.DateTimeField(null=True, blank=True)
|
||||||
notified_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
|
||||||
encrypted_dek = models.TextField(null=True, blank=True)
|
encrypted_dek = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
@@ -40,16 +38,6 @@ class Letter(models.Model):
|
|||||||
if self.type == Letter.Type.VAULT and self.status == Letter.Status.SEALED and not self.unlock_at:
|
if self.type == Letter.Type.VAULT and self.status == Letter.Status.SEALED and not self.unlock_at:
|
||||||
raise ValidationError("A sealed VAULT letter must have an unlock_date.")
|
raise ValidationError("A sealed VAULT letter must have an unlock_date.")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Override save method to auto set BURNED and SEALED timestamps.
|
|
||||||
"""
|
|
||||||
if self.status == Letter.Status.BURNED:
|
|
||||||
self.burned_at = datetime.now(UTC)
|
|
||||||
if self.status == Letter.Status.SEALED:
|
|
||||||
self.sealed_at = datetime.now(UTC)
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.type} - {self.status}"
|
return f"{self.type} - {self.status}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from letters.models import Letter, LetterImage
|
from letters.models import Letter, LetterImage
|
||||||
@@ -36,25 +34,6 @@ class LetterSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ["created_at", "updated_at"]
|
read_only_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
def to_representation(self, instance):
|
|
||||||
fields = super().to_representation(instance)
|
|
||||||
if fields["type"] == Letter.Type.VAULT and fields["status"] == Letter.Status.SEALED:
|
|
||||||
try:
|
|
||||||
unlock_datetime = datetime.fromisoformat(fields["unlock_at"]).replace(tzinfo=UTC)
|
|
||||||
if unlock_datetime - datetime.now(tz=UTC) > timedelta(seconds=0):
|
|
||||||
fields["encrypted_content"] = None
|
|
||||||
fields["images"] = None
|
|
||||||
fields["encrypted_dek"] = None
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if fields["status"] == Letter.Status.BURNED:
|
|
||||||
fields["encrypted_content"] = None
|
|
||||||
fields["images"] = None
|
|
||||||
fields["encrypted_dek"] = None
|
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""
|
"""
|
||||||
Validates the requirmnt of DEK when encrypted content and metadata are stored.
|
Validates the requirmnt of DEK when encrypted content and metadata are stored.
|
||||||
@@ -63,6 +42,4 @@ class LetterSerializer(serializers.ModelSerializer):
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
"encrypted_dek is required when encrypted_content and encrypted_metadata are present"
|
"encrypted_dek is required when encrypted_content and encrypted_metadata are present"
|
||||||
)
|
)
|
||||||
if data.get("type") == Letter.Type.VAULT and not data.get("unlock_at"):
|
|
||||||
raise serializers.ValidationError("unlock_at is required for vault letters")
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import structlog
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from config.settings import FRONTEND_URLS
|
|
||||||
from letters.models import Letter
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_vault_letters_to_notify():
|
|
||||||
"""
|
|
||||||
Identifies the vault letters that have been recently unlocked and not notified
|
|
||||||
"""
|
|
||||||
return Letter.objects.filter(unlock_at__lt=datetime.now(UTC), notified_at=None)
|
|
||||||
|
|
||||||
|
|
||||||
def notify_unlocked_letter(letter):
|
|
||||||
"""
|
|
||||||
Notifies the author of the letter via email and if successful, updates the notified_at field for the letter.
|
|
||||||
"""
|
|
||||||
author = letter.user.get_username()
|
|
||||||
try:
|
|
||||||
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.save()
|
|
||||||
logger.info(f"Successfully notified {author} of unlocked letter")
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Failed to notify {author} of unlocked letter")
|
|
||||||
|
|
||||||
|
|
||||||
def vault_unlock_notification_polling_scheduler():
|
|
||||||
"""
|
|
||||||
Orchestrates the vault polling logic.
|
|
||||||
"""
|
|
||||||
letters_to_notify = get_vault_letters_to_notify()
|
|
||||||
for letter in letters_to_notify:
|
|
||||||
notify_unlocked_letter(letter)
|
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler():
|
|
||||||
"""
|
|
||||||
Starts the background scheduler for polling and notifying vault letters.
|
|
||||||
"""
|
|
||||||
logger.info("Starting vault polling scheduler...")
|
|
||||||
scheduler = BackgroundScheduler()
|
|
||||||
scheduler.add_job(
|
|
||||||
vault_unlock_notification_polling_scheduler,
|
|
||||||
trigger="interval",
|
|
||||||
minutes=1,
|
|
||||||
id="letter_polling",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.start()
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from unittest.mock import ANY, patch
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -212,102 +208,6 @@ class LetterAPITest(APITestCase):
|
|||||||
self.assertFalse(default_storage.exists("encrypted-images/old2.bin"))
|
self.assertFalse(default_storage.exists("encrypted-images/old2.bin"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_vault_letters_does_not_return_letter_content_before_the_unlock_date(self):
|
|
||||||
"""
|
|
||||||
Test that the vault letters does not return letter content (images and encrypted_content)
|
|
||||||
before the unlock date.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
letter = Letter.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
type="VAULT",
|
|
||||||
status="SEALED",
|
|
||||||
public_id="4281edcc-5459-4ff2-bb5e-669fb44e0757",
|
|
||||||
encrypted_content="enc_content==",
|
|
||||||
encrypted_metadata="enc_meta==",
|
|
||||||
encrypted_dek="enc_dek==",
|
|
||||||
unlock_at=datetime.now(UTC),
|
|
||||||
)
|
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
past_datetime = datetime.now(UTC) - timedelta(seconds=1)
|
|
||||||
future_datetime = datetime.now(UTC) + timedelta(seconds=1)
|
|
||||||
|
|
||||||
with freeze_time(past_datetime):
|
|
||||||
response = self.client.get(f"/api/letters/{letter.public_id}/")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(response.data["encrypted_content"], None)
|
|
||||||
self.assertEqual(response.data["encrypted_metadata"], "enc_meta==")
|
|
||||||
self.assertEqual(response.data["encrypted_dek"], None)
|
|
||||||
|
|
||||||
with freeze_time(future_datetime):
|
|
||||||
response = self.client.get(f"/api/letters/{letter.public_id}/")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(response.data["encrypted_content"], "enc_content==")
|
|
||||||
self.assertEqual(response.data["encrypted_metadata"], "enc_meta==")
|
|
||||||
self.assertEqual(response.data["encrypted_dek"], "enc_dek==")
|
|
||||||
|
|
||||||
def test_burn_letter(self):
|
|
||||||
"""
|
|
||||||
Test that a sealed letter can only be burned but not updated.
|
|
||||||
"""
|
|
||||||
letter = Letter.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
type="KEPT",
|
|
||||||
status="SEALED",
|
|
||||||
public_id="4281edcc-5459-4ff2-bb5e-669fb44e0757",
|
|
||||||
encrypted_content="enc_content==",
|
|
||||||
encrypted_metadata="enc_meta==",
|
|
||||||
encrypted_dek="enc_dek==",
|
|
||||||
)
|
|
||||||
|
|
||||||
response_update_content = self.client.patch(
|
|
||||||
self.url + letter.public_id + "/",
|
|
||||||
{
|
|
||||||
"encrypted_content": "enc_content_new==",
|
|
||||||
"encrypted_metadata": "enc_meta_new==",
|
|
||||||
"encrypted_dek": "enc_dek_new==",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response_update_content.status_code, 400)
|
|
||||||
self.assertEqual(response_update_content.data["error"], "Sealed letters can only be burned or sent.")
|
|
||||||
self.assertEqual(Letter.objects.get().encrypted_content, "enc_content==")
|
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
current_time = datetime.now(UTC)
|
|
||||||
with freeze_time(current_time):
|
|
||||||
response_burn = self.client.patch(self.url + letter.public_id + "/", {"status": "BURNED"})
|
|
||||||
|
|
||||||
self.assertEqual(response_burn.status_code, 200)
|
|
||||||
self.assertEqual(Letter.objects.count(), 1)
|
|
||||||
self.assertEqual(Letter.objects.get().status, "BURNED")
|
|
||||||
self.assertEqual(Letter.objects.get().burned_at, current_time)
|
|
||||||
|
|
||||||
def test_send_sealed_letter(self):
|
|
||||||
"""
|
|
||||||
Test that a sealed letter can be sent.
|
|
||||||
"""
|
|
||||||
letter = Letter.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
type="KEPT",
|
|
||||||
status="SEALED",
|
|
||||||
public_id="4281edcc-5459-4ff2-bb5e-669fb44e0757",
|
|
||||||
encrypted_content="enc_content==",
|
|
||||||
encrypted_metadata="enc_meta==",
|
|
||||||
encrypted_dek="enc_dek==",
|
|
||||||
)
|
|
||||||
|
|
||||||
response_sent = self.client.patch(self.url + letter.public_id + "/", {"type": "SENT"})
|
|
||||||
|
|
||||||
self.assertEqual(response_sent.status_code, 200)
|
|
||||||
self.assertEqual(Letter.objects.count(), 1)
|
|
||||||
self.assertEqual(Letter.objects.get().type, "SENT")
|
|
||||||
|
|
||||||
|
|
||||||
class LetterImageModelTest(TestCase):
|
class LetterImageModelTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -339,73 +239,3 @@ class LetterImageModelTest(TestCase):
|
|||||||
self.assertEqual(LetterImage.objects.count(), 1)
|
self.assertEqual(LetterImage.objects.count(), 1)
|
||||||
self.letter.delete()
|
self.letter.delete()
|
||||||
self.assertEqual(LetterImage.objects.count(), 0)
|
self.assertEqual(LetterImage.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class LetterTaskTest(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(email="task@pi-ku.app", password="password1234")
|
|
||||||
|
|
||||||
def test_get_vault_letters_to_be_notified(self):
|
|
||||||
"""
|
|
||||||
Test that the task can successfully retrieve the letters whose unlock date is passed and haven't been notified.
|
|
||||||
"""
|
|
||||||
from letters.tasks import get_vault_letters_to_notify
|
|
||||||
|
|
||||||
Letter.objects.create(
|
|
||||||
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) + timedelta(seconds=1)
|
|
||||||
)
|
|
||||||
Letter.objects.create(user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC))
|
|
||||||
Letter.objects.create(
|
|
||||||
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) - timedelta(seconds=1)
|
|
||||||
)
|
|
||||||
Letter.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
type="VAULT",
|
|
||||||
status="SEALED",
|
|
||||||
unlock_at=datetime.now(UTC) - timedelta(hours=1),
|
|
||||||
notified_at=datetime.now(UTC) - timedelta(minutes=59),
|
|
||||||
)
|
|
||||||
Letter.objects.create(
|
|
||||||
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC) + timedelta(seconds=1)
|
|
||||||
)
|
|
||||||
Letter.objects.create(
|
|
||||||
user=self.user,
|
|
||||||
type="KEPT",
|
|
||||||
status="SEALED",
|
|
||||||
)
|
|
||||||
|
|
||||||
unlocked_letters = get_vault_letters_to_notify()
|
|
||||||
|
|
||||||
self.assertEqual(len(unlocked_letters), 2)
|
|
||||||
|
|
||||||
def test_notify_unlocked_letter(self):
|
|
||||||
"""
|
|
||||||
Test that the task successfully notifies the user via email and updates the database field.
|
|
||||||
"""
|
|
||||||
from letters.tasks import notify_unlocked_letter
|
|
||||||
|
|
||||||
letter_to_notify1 = Letter.objects.create(
|
|
||||||
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None
|
|
||||||
)
|
|
||||||
with patch("letters.tasks.send_mail") as mock_send_mail:
|
|
||||||
notify_unlocked_letter(letter_to_notify1)
|
|
||||||
|
|
||||||
mock_send_mail.assert_called_with(
|
|
||||||
subject=ANY,
|
|
||||||
message=ANY,
|
|
||||||
from_email=settings.FROM_EMAIL,
|
|
||||||
recipient_list=[self.user.email],
|
|
||||||
fail_silently=False,
|
|
||||||
html_message=ANY,
|
|
||||||
)
|
|
||||||
self.assertIsNotNone(letter_to_notify1.notified_at)
|
|
||||||
|
|
||||||
letter_to_notify2 = Letter.objects.create(
|
|
||||||
user=self.user, type="VAULT", status="SEALED", unlock_at=datetime.now(UTC), notified_at=None
|
|
||||||
)
|
|
||||||
with patch("letters.tasks.send_mail") as mock_send_mail:
|
|
||||||
mock_send_mail.side_effect = Exception()
|
|
||||||
|
|
||||||
notify_unlocked_letter(letter_to_notify2)
|
|
||||||
|
|
||||||
self.assertIsNone(letter_to_notify2.notified_at)
|
|
||||||
|
|||||||
@@ -62,24 +62,3 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
response_serializer = self.get_serializer(letter)
|
response_serializer = self.get_serializer(letter)
|
||||||
return Response(response_serializer.data, status=201 if created else 200)
|
return Response(response_serializer.data, status=201 if created else 200)
|
||||||
|
|
||||||
def patch(self, request, public_id):
|
|
||||||
"""
|
|
||||||
Updates an existing letter.
|
|
||||||
Can update type and status only when sealed, sent and burned.
|
|
||||||
"""
|
|
||||||
letter = Letter.objects.get(public_id=public_id, user=request.user)
|
|
||||||
|
|
||||||
if letter.status == Letter.Status.SEALED:
|
|
||||||
if (
|
|
||||||
len(request.data) > 1
|
|
||||||
or (request.data.get("status") != Letter.Status.BURNED and request.data.get("status") is not None)
|
|
||||||
or (request.data.get("type") != Letter.Type.SENT and request.data.get("type") is not None)
|
|
||||||
):
|
|
||||||
return Response({"error": "Sealed letters can only be burned or sent."}, status=400)
|
|
||||||
|
|
||||||
write_serializer = self.get_serializer(letter, data=request.data, partial=True)
|
|
||||||
write_serializer.is_valid(raise_exception=True)
|
|
||||||
write_serializer.save()
|
|
||||||
response_serializer = self.get_serializer(letter)
|
|
||||||
return Response(response_serializer.data, status=200)
|
|
||||||
|
|||||||
@@ -5,25 +5,15 @@ description = "Django Rest Framework for handling requests for Pi Ku app"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"apscheduler>=3.11.2",
|
|
||||||
"boto3>=1.42.96",
|
|
||||||
"django>=6.0.4",
|
"django>=6.0.4",
|
||||||
"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",
|
|
||||||
"djangorestframework>=3.17.1",
|
"djangorestframework>=3.17.1",
|
||||||
"djangorestframework-simplejwt>=5.5.1",
|
"djangorestframework-simplejwt>=5.5.1",
|
||||||
"djangorestframework-stubs>=3.16.9",
|
|
||||||
"freezegun>=1.5.5",
|
|
||||||
"gunicorn>=25.3.0",
|
|
||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.11",
|
||||||
"pyopenssl>=26.0.0",
|
"pyopenssl>=26.0.0",
|
||||||
"rich>=15.0.0",
|
|
||||||
"ruff>=0.15.9",
|
"ruff>=0.15.9",
|
||||||
"structlog>=25.5.0",
|
|
||||||
"werkzeug>=3.1.8",
|
"werkzeug>=3.1.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
# This file was autogenerated by uv via the following command:
|
|
||||||
# uv export --format requirements-txt --output-file requirements.txt
|
|
||||||
apscheduler==3.11.2 \
|
|
||||||
--hash=sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41 \
|
|
||||||
--hash=sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d
|
|
||||||
# via
|
|
||||||
# django-apscheduler
|
|
||||||
# piku-backend
|
|
||||||
asgiref==3.11.1 \
|
|
||||||
--hash=sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce \
|
|
||||||
--hash=sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133
|
|
||||||
# via
|
|
||||||
# django
|
|
||||||
# django-cors-headers
|
|
||||||
cffi==2.0.0 ; platform_python_implementation != 'PyPy' \
|
|
||||||
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
|
|
||||||
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
|
|
||||||
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
|
|
||||||
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
|
|
||||||
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
|
|
||||||
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
|
|
||||||
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
|
|
||||||
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
|
|
||||||
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
|
|
||||||
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
|
|
||||||
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
|
|
||||||
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
|
|
||||||
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
|
|
||||||
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
|
|
||||||
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
|
|
||||||
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
|
|
||||||
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
|
|
||||||
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
|
|
||||||
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
|
|
||||||
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
|
|
||||||
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
|
|
||||||
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
|
|
||||||
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5
|
|
||||||
# via cryptography
|
|
||||||
cryptography==46.0.7 \
|
|
||||||
--hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \
|
|
||||||
--hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \
|
|
||||||
--hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \
|
|
||||||
--hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \
|
|
||||||
--hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \
|
|
||||||
--hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \
|
|
||||||
--hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \
|
|
||||||
--hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \
|
|
||||||
--hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \
|
|
||||||
--hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \
|
|
||||||
--hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \
|
|
||||||
--hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \
|
|
||||||
--hash=sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f \
|
|
||||||
--hash=sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee \
|
|
||||||
--hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \
|
|
||||||
--hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \
|
|
||||||
--hash=sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99 \
|
|
||||||
--hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \
|
|
||||||
--hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \
|
|
||||||
--hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \
|
|
||||||
--hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \
|
|
||||||
--hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \
|
|
||||||
--hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \
|
|
||||||
--hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \
|
|
||||||
--hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \
|
|
||||||
--hash=sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d \
|
|
||||||
--hash=sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1 \
|
|
||||||
--hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \
|
|
||||||
--hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \
|
|
||||||
--hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \
|
|
||||||
--hash=sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842 \
|
|
||||||
--hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \
|
|
||||||
--hash=sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2 \
|
|
||||||
--hash=sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c \
|
|
||||||
--hash=sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb \
|
|
||||||
--hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \
|
|
||||||
--hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \
|
|
||||||
--hash=sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902 \
|
|
||||||
--hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 \
|
|
||||||
--hash=sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022 \
|
|
||||||
--hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \
|
|
||||||
--hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \
|
|
||||||
--hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce
|
|
||||||
# via pyopenssl
|
|
||||||
django==6.0.4 \
|
|
||||||
--hash=sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da \
|
|
||||||
--hash=sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac
|
|
||||||
# via
|
|
||||||
# django-apscheduler
|
|
||||||
# django-cors-headers
|
|
||||||
# django-extensions
|
|
||||||
# django-stubs
|
|
||||||
# django-stubs-ext
|
|
||||||
# djangorestframework
|
|
||||||
# djangorestframework-simplejwt
|
|
||||||
# piku-backend
|
|
||||||
django-apscheduler==0.7.0 \
|
|
||||||
--hash=sha256:30d61a2ba98615922fc2c9782f84bba342ec0c5ed63384d686d71ea90a1a4318 \
|
|
||||||
--hash=sha256:869d489775420245c9455d55e35f663c856a33ebfc996d92938f786ffb8730ce
|
|
||||||
# via piku-backend
|
|
||||||
django-cors-headers==4.9.0 \
|
|
||||||
--hash=sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449 \
|
|
||||||
--hash=sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8
|
|
||||||
# via piku-backend
|
|
||||||
django-environ==0.13.0 \
|
|
||||||
--hash=sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e \
|
|
||||||
--hash=sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f
|
|
||||||
# via piku-backend
|
|
||||||
django-extensions==4.1 \
|
|
||||||
--hash=sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336 \
|
|
||||||
--hash=sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb
|
|
||||||
# via piku-backend
|
|
||||||
django-stubs==6.0.2 \
|
|
||||||
--hash=sha256:56d43b5e3741563af0063e5b6283f908c625b0439aa06314268673699d1bdccd \
|
|
||||||
--hash=sha256:c3bc84d80421758f3b2ad9e1358e001d719388a8eb106e67c873e606216108d4
|
|
||||||
# via djangorestframework-stubs
|
|
||||||
django-stubs-ext==6.0.2 \
|
|
||||||
--hash=sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c \
|
|
||||||
--hash=sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b
|
|
||||||
# via django-stubs
|
|
||||||
djangorestframework==3.17.1 \
|
|
||||||
--hash=sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5 \
|
|
||||||
--hash=sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457
|
|
||||||
# via
|
|
||||||
# djangorestframework-simplejwt
|
|
||||||
# piku-backend
|
|
||||||
djangorestframework-simplejwt==5.5.1 \
|
|
||||||
--hash=sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469 \
|
|
||||||
--hash=sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f
|
|
||||||
# via piku-backend
|
|
||||||
djangorestframework-stubs==3.16.9 \
|
|
||||||
--hash=sha256:27b3e245d5f9c22ff6988d9e54388249f98f88608cc2b365b71e9f39dd096958 \
|
|
||||||
--hash=sha256:b1abb97490c90c85eabcd09b8ecbadae1b9360f21ad3021abf830227c0129697
|
|
||||||
# via piku-backend
|
|
||||||
freezegun==1.5.5 \
|
|
||||||
--hash=sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a \
|
|
||||||
--hash=sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2
|
|
||||||
# via piku-backend
|
|
||||||
gunicorn==25.3.0 \
|
|
||||||
--hash=sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660 \
|
|
||||||
--hash=sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889
|
|
||||||
# via piku-backend
|
|
||||||
markupsafe==3.0.3 \
|
|
||||||
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
|
|
||||||
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
|
|
||||||
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
|
|
||||||
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
|
|
||||||
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
|
|
||||||
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
|
|
||||||
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
|
|
||||||
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
|
|
||||||
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
|
|
||||||
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
|
|
||||||
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
|
|
||||||
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
|
|
||||||
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
|
|
||||||
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
|
|
||||||
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
|
|
||||||
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
|
|
||||||
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
|
|
||||||
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
|
|
||||||
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
|
|
||||||
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
|
|
||||||
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
|
|
||||||
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
|
|
||||||
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
|
|
||||||
# via werkzeug
|
|
||||||
packaging==26.1 \
|
|
||||||
--hash=sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f \
|
|
||||||
--hash=sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de
|
|
||||||
# via gunicorn
|
|
||||||
psycopg2-binary==2.9.11 \
|
|
||||||
--hash=sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b \
|
|
||||||
--hash=sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316 \
|
|
||||||
--hash=sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c \
|
|
||||||
--hash=sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1 \
|
|
||||||
--hash=sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5 \
|
|
||||||
--hash=sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f \
|
|
||||||
--hash=sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c \
|
|
||||||
--hash=sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d \
|
|
||||||
--hash=sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8 \
|
|
||||||
--hash=sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f \
|
|
||||||
--hash=sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f \
|
|
||||||
--hash=sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747
|
|
||||||
# via piku-backend
|
|
||||||
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \
|
|
||||||
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
|
|
||||||
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
|
|
||||||
# via cffi
|
|
||||||
pyjwt==2.12.1 \
|
|
||||||
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
|
|
||||||
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
|
|
||||||
# via djangorestframework-simplejwt
|
|
||||||
pyopenssl==26.0.0 \
|
|
||||||
--hash=sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81 \
|
|
||||||
--hash=sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc
|
|
||||||
# via piku-backend
|
|
||||||
python-dateutil==2.9.0.post0 \
|
|
||||||
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
|
|
||||||
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
|
|
||||||
# via freezegun
|
|
||||||
ruff==0.15.9 \
|
|
||||||
--hash=sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677 \
|
|
||||||
--hash=sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53 \
|
|
||||||
--hash=sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2 \
|
|
||||||
--hash=sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6 \
|
|
||||||
--hash=sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d \
|
|
||||||
--hash=sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7 \
|
|
||||||
--hash=sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840 \
|
|
||||||
--hash=sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71 \
|
|
||||||
--hash=sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1 \
|
|
||||||
--hash=sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901 \
|
|
||||||
--hash=sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9 \
|
|
||||||
--hash=sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c \
|
|
||||||
--hash=sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59 \
|
|
||||||
--hash=sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745 \
|
|
||||||
--hash=sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed \
|
|
||||||
--hash=sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec \
|
|
||||||
--hash=sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5 \
|
|
||||||
--hash=sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8
|
|
||||||
# via piku-backend
|
|
||||||
six==1.17.0 \
|
|
||||||
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
|
|
||||||
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
|
|
||||||
# via python-dateutil
|
|
||||||
sqlparse==0.5.5 \
|
|
||||||
--hash=sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba \
|
|
||||||
--hash=sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e
|
|
||||||
# via django
|
|
||||||
types-pyyaml==6.0.12.20260408 \
|
|
||||||
--hash=sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307 \
|
|
||||||
--hash=sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384
|
|
||||||
# via
|
|
||||||
# django-stubs
|
|
||||||
# djangorestframework-stubs
|
|
||||||
typing-extensions==4.15.0 \
|
|
||||||
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
|
|
||||||
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
|
|
||||||
# via
|
|
||||||
# django-stubs
|
|
||||||
# django-stubs-ext
|
|
||||||
# djangorestframework-stubs
|
|
||||||
tzdata==2026.1 ; sys_platform == 'win32' \
|
|
||||||
--hash=sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9 \
|
|
||||||
--hash=sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98
|
|
||||||
# via
|
|
||||||
# django
|
|
||||||
# tzlocal
|
|
||||||
tzlocal==5.3.1 \
|
|
||||||
--hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \
|
|
||||||
--hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d
|
|
||||||
# via apscheduler
|
|
||||||
werkzeug==3.1.8 \
|
|
||||||
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
|
|
||||||
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
|
|
||||||
# via piku-backend
|
|
||||||
@@ -12,8 +12,7 @@ class Command(BaseCommand):
|
|||||||
If SSL is enabled, use runserver_plus command.
|
If SSL is enabled, use runserver_plus command.
|
||||||
If SSL is not enabled, use runserver command.
|
If SSL is not enabled, use runserver command.
|
||||||
"""
|
"""
|
||||||
ssl_enabled = os.getenv("SSL_ENABLED", "false").lower().strip() == "true"
|
ssl_enabled = os.getenv("SSL_ENABLED", "false").lower() == "true"
|
||||||
|
|
||||||
domain = os.getenv("BACKEND_DOMAIN", "127.0.0.1")
|
domain = os.getenv("BACKEND_DOMAIN", "127.0.0.1")
|
||||||
port = os.getenv("BACKEND_PORT", "8000")
|
port = os.getenv("BACKEND_PORT", "8000")
|
||||||
addrport = f"{domain}:{port}"
|
addrport = f"{domain}:{port}"
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{% extends 'email/base.html' %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div style="padding: 15px; font-style: italic">
|
|
||||||
<p>{{ pen_name }},</p>
|
|
||||||
<p>
|
|
||||||
Your destination is one train away.
|
|
||||||
</p>
|
|
||||||
<p>I've been keeping a place for your words.<br/>
|
|
||||||
Come when you're ready.</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footnote %}
|
|
||||||
This link expires in 24 hours.<br/>
|
|
||||||
I'm patient, but not endlessly so.
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footer %}
|
|
||||||
Didn't write to me? Then someone else did.<br/>
|
|
||||||
Ignore this. I'll forget you were ever here.
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
pi. ku.
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
{{pen_name}},
|
|
||||||
|
|
||||||
Your destination is one train away.
|
|
||||||
|
|
||||||
I've been keeping a place for your words.
|
|
||||||
Come when you're ready.
|
|
||||||
|
|
||||||
{{ cta.title }} -> {{ cta.link }}
|
|
||||||
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
This link expires in 24 hours.
|
|
||||||
I'm patient, but not endlessly so.
|
|
||||||
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
Didn't write to me? Then someone else did.
|
|
||||||
Ignore this. I'll forget you were ever here.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<title>pi. ku.</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body style="margin:0; padding:0; background-color:#1a1712;">
|
|
||||||
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
|
||||||
style="background-color:#1a1712; font-family: 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding: 48px 16px;">
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
|
||||||
style="max-width:480px; width:100%;">
|
|
||||||
|
|
||||||
{# Logo #}
|
|
||||||
<tr>
|
|
||||||
<td align="left" style="padding-bottom: 24px;">
|
|
||||||
<img src="https://cdn.jsdelivr.net/gh/ramvignesh-b/cdn@main/pi-ku_logo.png" width="80"
|
|
||||||
alt="Pi.Ku" style="display:block; border:0;">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{# Body #}
|
|
||||||
<tr>
|
|
||||||
<td style="font-family: 'Trebuchet MS', 'Lucida Sans Unicode', Arial, sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.9;
|
|
||||||
color: #cdccca;
|
|
||||||
font-style: italic;
|
|
||||||
padding-bottom: 24px;">
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{# CTA #}
|
|
||||||
{% if cta %}
|
|
||||||
<tr>
|
|
||||||
<td align="left" style="padding-bottom: 24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color: #301e19; border-radius: 3px;">
|
|
||||||
<a href='{{ cta.link }}' style="display: inline-block;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-family: 'Trebuchet MS', Arial, sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #f5e6c8;
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
font-weight: bold;">
|
|
||||||
{{ cta.title }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if footnote %}
|
|
||||||
<tr>
|
|
||||||
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
|
|
||||||
font-size: 12px;
|
|
||||||
font-style: italic;
|
|
||||||
color: #7a7974;
|
|
||||||
padding-bottom: 40px;
|
|
||||||
line-height: 1.8;">
|
|
||||||
{% block footnote %}
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Footer #}
|
|
||||||
<tr>
|
|
||||||
<td style="border-top: 1px solid #2e2c29; padding-bottom: 24px; font-size: 0; line-height: 0;">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
|
|
||||||
font-size: 12px;
|
|
||||||
font-style: italic;
|
|
||||||
color: #5a5957;
|
|
||||||
line-height: 1.8;">
|
|
||||||
{% block footer %}
|
|
||||||
{% endblock %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends 'email/base.html' %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p>
|
|
||||||
Time has a way of making things clearer.<br/>
|
|
||||||
Or heavier. Sometimes both.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You had something to say at this exact moment.<br/>
|
|
||||||
I kept it exactly as you left it. <br/>
|
|
||||||
Not a word changed. Not a word read.
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block footnote %}
|
|
||||||
<p>
|
|
||||||
You're ready now. Or maybe you're still not.<br/>
|
|
||||||
Open it anyway. You won't regret it.
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
pi. ku.
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
{{pen_name}},
|
|
||||||
|
|
||||||
Time has a way of making things clearer.
|
|
||||||
Or heavier. Sometimes both.
|
|
||||||
|
|
||||||
You had something to say at this exact moment.
|
|
||||||
I kept it exactly as you left it.
|
|
||||||
Not a word changed. Not a word read.
|
|
||||||
|
|
||||||
{{ cta.title }} -> {{ cta.link }}
|
|
||||||
|
|
||||||
-------------------------------------------
|
|
||||||
You're ready now. Or maybe you're still not.
|
|
||||||
Open it anyway. You won't regret it.
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.tokens import default_token_generator
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.encoding import force_bytes
|
from django.utils.encoding import force_bytes
|
||||||
from django.utils.http import urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_encode
|
||||||
|
|
||||||
@@ -9,26 +8,17 @@ from django.utils.http import urlsafe_base64_encode
|
|||||||
def send_activation_email(user):
|
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_URL}/activate/{uid}/{token}"
|
||||||
subject = "Activate your pi. ku. account"
|
subject = "Activate Your Piku Account"
|
||||||
context = {
|
message = f"""Hi {user.full_name},
|
||||||
"pen_name": user.full_name,
|
|
||||||
"footnote": True,
|
Welcome to Pi Ku.
|
||||||
"cta": {
|
|
||||||
"title": "Onboard",
|
Please click the link below to activate your account:
|
||||||
"link": activation_url,
|
>> {activation_url}
|
||||||
},
|
|
||||||
}
|
If you did not create this account, please ignore this email."""
|
||||||
html_content = render_to_string("email/activation.html", context)
|
send_mail(subject, message, settings.FROM_EMAIL, [user.email], fail_silently=False)
|
||||||
plain_content = render_to_string("email/activation.txt", context)
|
|
||||||
send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message=plain_content,
|
|
||||||
from_email=settings.FROM_EMAIL,
|
|
||||||
recipient_list=[user.email],
|
|
||||||
fail_silently=False,
|
|
||||||
html_message=html_content,
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,6 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "apscheduler"
|
|
||||||
version = "3.11.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "tzlocal" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asgiref"
|
name = "asgiref"
|
||||||
version = "3.11.1"
|
version = "3.11.1"
|
||||||
@@ -23,34 +11,6 @@ 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"
|
||||||
@@ -151,19 +111,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-apscheduler"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "apscheduler" },
|
|
||||||
{ name = "django" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/64/6b/873899c2da113187b74f0cccdf4c16660e07bfbbcae72621c4758e0958bf/django_apscheduler-0.7.0.tar.gz", hash = "sha256:30d61a2ba98615922fc2c9782f84bba342ec0c5ed63384d686d71ea90a1a4318", size = 473051, upload-time = "2024-09-28T04:54:09.98Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/19/c3d2dea21a6afdc93689b9f769ff3694cac810e4a09c24ab423dd1613e6c/django_apscheduler-0.7.0-py3-none-any.whl", hash = "sha256:869d489775420245c9455d55e35f663c856a33ebfc996d92938f786ffb8730ce", size = 24690, upload-time = "2024-09-28T04:54:06.884Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-cors-headers"
|
name = "django-cors-headers"
|
||||||
version = "4.9.0"
|
version = "4.9.0"
|
||||||
@@ -198,73 +145,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-ipware"
|
|
||||||
version = "7.0.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "python-ipware" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/23/64/c7e4791edf01ba483cce444770b3e6a930ba12195ba1eeb37b5bf6dce8a8/django-ipware-7.0.1.tar.gz", hash = "sha256:d9ec43d2bf7cdf216fed8d494a084deb5761a54860a53b2e74346a4f384cff47", size = 6827, upload-time = "2024-04-19T20:02:49.257Z" }
|
|
||||||
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" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[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]]
|
|
||||||
name = "django-structlog"
|
|
||||||
version = "10.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "asgiref" },
|
|
||||||
{ name = "django" },
|
|
||||||
{ name = "django-ipware" },
|
|
||||||
{ name = "structlog" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/a9/102f316fb60dbec46642168979c4f57c9d8140fe43624ddca1ca6106274a/django_structlog-10.0.0.tar.gz", hash = "sha256:4e3fa4a930697fb9b649470e389419bb73b916a1ecf4f4bf2f8727f5cbfdb002", size = 23054, upload-time = "2025-10-22T21:20:21.14Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/83/d7245a2a2bb46ae65ecff00686181d632553b131ea0c5cbfcbdb8f89c190/django_structlog-10.0.0-py3-none-any.whl", hash = "sha256:4f9db3cb7b308df6aa4afe1353d9c19d5bac757022ddbbb5c24f3d0d6a91a240", size = 18159, upload-time = "2025-10-22T21:20:19.804Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-stubs"
|
|
||||||
version = "6.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
{ name = "django-stubs-ext" },
|
|
||||||
{ name = "types-pyyaml" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/03/b2/f0214d86180f937c8e3358ff831b20f0634d95bd77436b18861c647e15bc/django_stubs-6.0.2.tar.gz", hash = "sha256:56d43b5e3741563af0063e5b6283f908c625b0439aa06314268673699d1bdccd", size = 274742, upload-time = "2026-04-01T08:27:35.092Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/e7/8f2aaa22eac7fa18db3aca0e7b651ccf5ac79a2021bf67e75a16934a7076/django_stubs-6.0.2-py3-none-any.whl", hash = "sha256:c3bc84d80421758f3b2ad9e1358e001d719388a8eb106e67c873e606216108d4", size = 538234, upload-time = "2026-04-01T08:27:33.411Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-stubs-ext"
|
|
||||||
version = "6.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/52/e0/f2e6caf627d176a51fba1ca9c34082c7ea10d3f521ff2c828532ca99fa91/django_stubs_ext-6.0.2.tar.gz", hash = "sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c", size = 6751, upload-time = "2026-04-01T08:27:01.987Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/d2/9cb93cd1ef94ddc97c26c902ff75a859f5f154051fec98cf8242649b26ce/django_stubs_ext-6.0.2-py3-none-any.whl", hash = "sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b", size = 10446, upload-time = "2026-04-01T08:27:00.847Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djangorestframework"
|
name = "djangorestframework"
|
||||||
version = "3.17.1"
|
version = "3.17.1"
|
||||||
@@ -291,65 +171,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "djangorestframework-stubs"
|
|
||||||
version = "3.16.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django-stubs" },
|
|
||||||
{ name = "types-pyyaml" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/30/84/fa0e31f763ee35152a418c2a456efdd8047a9da0f5909110147b70382191/djangorestframework_stubs-3.16.9.tar.gz", hash = "sha256:b1abb97490c90c85eabcd09b8ecbadae1b9360f21ad3021abf830227c0129697", size = 32798, upload-time = "2026-03-31T22:40:23.626Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/be/e53e3b89eaa30c21e036ae4d2ee88a92ef8cb43678400901748ddad870c5/djangorestframework_stubs-3.16.9-py3-none-any.whl", hash = "sha256:27b3e245d5f9c22ff6988d9e54388249f98f88608cc2b365b71e9f39dd096958", size = 57239, upload-time = "2026-03-31T22:40:22.314Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "freezegun"
|
|
||||||
version = "1.5.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "python-dateutil" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gunicorn"
|
|
||||||
version = "25.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "packaging" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }
|
|
||||||
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" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[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]]
|
|
||||||
name = "markdown-it-py"
|
|
||||||
version = "4.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "mdurl" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.3"
|
version = "3.0.3"
|
||||||
@@ -380,72 +201,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mdurl"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "packaging"
|
|
||||||
version = "26.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "piku-backend"
|
name = "piku-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
|
||||||
{ name = "boto3" },
|
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ 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 = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
{ name = "djangorestframework-simplejwt" },
|
{ name = "djangorestframework-simplejwt" },
|
||||||
{ name = "djangorestframework-stubs" },
|
|
||||||
{ name = "freezegun" },
|
|
||||||
{ name = "gunicorn" },
|
|
||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
{ name = "pyopenssl" },
|
{ name = "pyopenssl" },
|
||||||
{ name = "rich" },
|
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "structlog" },
|
|
||||||
{ name = "werkzeug" },
|
{ name = "werkzeug" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ 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-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 = "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" },
|
||||||
{ name = "djangorestframework-stubs", specifier = ">=3.16.9" },
|
|
||||||
{ name = "freezegun", specifier = ">=1.5.5" },
|
|
||||||
{ name = "gunicorn", specifier = ">=25.3.0" },
|
|
||||||
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||||
{ name = "pyopenssl", specifier = ">=26.0.0" },
|
{ name = "pyopenssl", specifier = ">=26.0.0" },
|
||||||
{ name = "rich", specifier = ">=15.0.0" },
|
|
||||||
{ name = "ruff", specifier = ">=0.15.9" },
|
{ name = "ruff", specifier = ">=0.15.9" },
|
||||||
{ name = "structlog", specifier = ">=25.5.0" },
|
|
||||||
{ name = "werkzeug", specifier = ">=3.1.8" },
|
{ name = "werkzeug", specifier = ">=3.1.8" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -477,15 +260,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pygments"
|
|
||||||
version = "2.20.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -507,40 +281,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-dateutil"
|
|
||||||
version = "2.9.0.post0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "six" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-ipware"
|
|
||||||
version = "3.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/60/da4426c3e9aee56f08b24091a9e85a0414260f928f97afd0013dfbd0332f/python_ipware-3.0.0.tar.gz", hash = "sha256:9117b1c4dddcb5d5ca49e6a9617de2fc66aec2ef35394563ac4eecabdf58c062", size = 16609, upload-time = "2024-04-19T20:00:58.938Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/bd/ccd7416fdb30f104ddf6cfd8ee9f699441c7d9880a26f9b3089438adee05/python_ipware-3.0.0-py3-none-any.whl", hash = "sha256:fc936e6e7ec9fcc107f9315df40658f468ac72f739482a707181742882e36b60", size = 10761, upload-time = "2024-04-19T20:00:57.171Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rich"
|
|
||||||
version = "15.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "markdown-it-py" },
|
|
||||||
{ name = "pygments" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.9"
|
version = "0.15.9"
|
||||||
@@ -566,27 +306,6 @@ 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]]
|
|
||||||
name = "six"
|
|
||||||
version = "1.17.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -596,33 +315,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "structlog"
|
|
||||||
version = "25.5.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-pyyaml"
|
|
||||||
version = "6.0.12.20260408"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.15.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tzdata"
|
name = "tzdata"
|
||||||
version = "2026.1"
|
version = "2026.1"
|
||||||
@@ -632,27 +324,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tzlocal"
|
|
||||||
version = "5.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
|
|
||||||
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" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[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"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
"noUnusedVariables": "error"
|
"noUnusedVariables": "error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"includes": ["**", "!backend"]
|
"includes": ["**/src", "!backend"]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
name: piku_e2e
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
db:
|
db:
|
||||||
# postgres database
|
# postgres database
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
container_name: piku_db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DB_NAME}
|
POSTGRES_DB: ${DB_NAME}
|
||||||
POSTGRES_USER: ${DB_USER}
|
POSTGRES_USER: ${DB_USER}
|
||||||
@@ -15,6 +16,7 @@ services:
|
|||||||
mailpit:
|
mailpit:
|
||||||
# email testing
|
# email testing
|
||||||
image: axllent/mailpit
|
image: axllent/mailpit
|
||||||
|
container_name: piku_mail
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025" # Web UI
|
- "8025:8025" # Web UI
|
||||||
- "${EMAIL_PORT}:1025" # SMTP
|
- "${EMAIL_PORT}:1025" # SMTP
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
node_modules
|
|
||||||
test-results
|
|
||||||
playwright-report
|
|
||||||
dist
|
|
||||||
coverage
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
FROM oven/bun:1 AS bun
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json bun.lock* ./
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ARG VITE_API_URL
|
|
||||||
|
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
|
||||||
|
|
||||||
RUN bun run build:prod
|
|
||||||
|
|
||||||
FROM nginxinc/nginx-unprivileged:alpine-slim
|
|
||||||
RUN touch /tmp/access.log /tmp/error.log
|
|
||||||
RUN rm /etc/nginx/conf.d/*
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
COPY --from=bun /app/dist /usr/share/nginx/html
|
|
||||||
# transfer the ownership since nginx is running rootless
|
|
||||||
USER root
|
|
||||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
|
||||||
USER nginx
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
ENTRYPOINT ["nginx", "-e", "/tmp/error.log", "-g", "daemon off;"]
|
|
||||||
@@ -8,12 +8,8 @@
|
|||||||
"@fontsource-variable/jost": "^5.2.8",
|
"@fontsource-variable/jost": "^5.2.8",
|
||||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||||
"@fontsource/architects-daughter": "^5.2.7",
|
|
||||||
"@fontsource/cutive-mono": "^5.2.8",
|
"@fontsource/cutive-mono": "^5.2.8",
|
||||||
"@fontsource/kavivanar": "^5.2.8",
|
|
||||||
"@fontsource/knewave": "^5.2.7",
|
"@fontsource/knewave": "^5.2.7",
|
||||||
"@fontsource/redacted-script": "^5.2.8",
|
|
||||||
"@fontsource/space-mono": "^5.2.9",
|
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
@@ -21,8 +17,6 @@
|
|||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.19",
|
||||||
"fabric": "^7.2.0",
|
"fabric": "^7.2.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lenis": "^1.3.23",
|
|
||||||
"motion": "^12.38.0",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
@@ -124,18 +118,10 @@
|
|||||||
|
|
||||||
"@fontsource-variable/playwrite-hr-lijeva": ["@fontsource-variable/playwrite-hr-lijeva@5.2.7", "", {}, "sha512-cQqbD8HHZDpiKdtgwUxgwAY76TC+GI9iZOxHSW0XkV/L8lA0X18z1wzR+J8yv9XZQYgLJ5WfzBGwzMSLnSLdPA=="],
|
"@fontsource-variable/playwrite-hr-lijeva": ["@fontsource-variable/playwrite-hr-lijeva@5.2.7", "", {}, "sha512-cQqbD8HHZDpiKdtgwUxgwAY76TC+GI9iZOxHSW0XkV/L8lA0X18z1wzR+J8yv9XZQYgLJ5WfzBGwzMSLnSLdPA=="],
|
||||||
|
|
||||||
"@fontsource/architects-daughter": ["@fontsource/architects-daughter@5.2.7", "", {}, "sha512-W7tHXduV9kRQZDTqcU4Rnc/GtSq9cYUHOnhvcRPjy87u5x/oRqKXPU2PghqbktTECOIh1N0qVZLt9rwqa+aWhg=="],
|
|
||||||
|
|
||||||
"@fontsource/cutive-mono": ["@fontsource/cutive-mono@5.2.8", "", {}, "sha512-Y8PKAYfbpl9Empbb1HZBoirlj4W7RtU+G4EhvX27pHzO6RE1sO0I1ElZQH5DMCTS+MSJkMmQT33sJ0+Ji9U8eQ=="],
|
"@fontsource/cutive-mono": ["@fontsource/cutive-mono@5.2.8", "", {}, "sha512-Y8PKAYfbpl9Empbb1HZBoirlj4W7RtU+G4EhvX27pHzO6RE1sO0I1ElZQH5DMCTS+MSJkMmQT33sJ0+Ji9U8eQ=="],
|
||||||
|
|
||||||
"@fontsource/kavivanar": ["@fontsource/kavivanar@5.2.8", "", {}, "sha512-wbr/9vQ2da9aabUngCpWLbbHM08XZK3nkLDuQ0eX/BhdVvoJx0MSPzaKJ0WIiKpVHy3fUL8ewOqpCyidGZlvEg=="],
|
|
||||||
|
|
||||||
"@fontsource/knewave": ["@fontsource/knewave@5.2.7", "", {}, "sha512-uzx8jgcTiQgAwKvQ/hWdX7lOQPwS+K74Eij/WCVzYvAkCX7GRTnWnbxXXx0XsKR6UIN16kH/u40LW4K8aHJb1w=="],
|
"@fontsource/knewave": ["@fontsource/knewave@5.2.7", "", {}, "sha512-uzx8jgcTiQgAwKvQ/hWdX7lOQPwS+K74Eij/WCVzYvAkCX7GRTnWnbxXXx0XsKR6UIN16kH/u40LW4K8aHJb1w=="],
|
||||||
|
|
||||||
"@fontsource/redacted-script": ["@fontsource/redacted-script@5.2.8", "", {}, "sha512-NOEGJyurXvCx5egCha9yUQB+Tt0IxXriacykYiRlohUvhdbKvisHbucAHQaK8N5/LLB6rlX62SrX8C9+t41PYQ=="],
|
|
||||||
|
|
||||||
"@fontsource/space-mono": ["@fontsource/space-mono@5.2.9", "", {}, "sha512-b61faFOHEISQ/pD25G+cfGY9o/WW6lRv6hBQQfpWvEJ4y1V+S4gmth95EVyBE2VL3qDYHeVQ8nBzrplzdXTDDg=="],
|
|
||||||
|
|
||||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||||
|
|
||||||
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
||||||
@@ -416,8 +402,6 @@
|
|||||||
|
|
||||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||||
|
|
||||||
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
|
||||||
|
|
||||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
@@ -492,8 +476,6 @@
|
|||||||
|
|
||||||
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
||||||
|
|
||||||
"lenis": ["lenis@1.3.23", "", { "peerDependencies": { "@nuxt/kit": ">=3.0.0", "react": ">=17.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "react", "vue"] }, "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg=="],
|
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
@@ -544,12 +526,6 @@
|
|||||||
|
|
||||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
|
|
||||||
|
|
||||||
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
|
||||||
|
|
||||||
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"msw": ["msw@2.13.2", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A=="],
|
"msw": ["msw@2.13.2", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A=="],
|
||||||
@@ -736,8 +712,6 @@
|
|||||||
|
|
||||||
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||||
|
|||||||
@@ -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.locator("textarea");
|
const canvasInput = page.getByLabel("Canvas text input");
|
||||||
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);
|
||||||
@@ -45,8 +45,8 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await page.keyboard.type("This is a secret draft");
|
await page.keyboard.type("This is a secret draft");
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await page.keyboard.type("It should persist.");
|
await page.keyboard.type("It should persist.");
|
||||||
logger.info(">> [Draft] Clicking Draft...");
|
logger.info(">> [Draft] Clicking Store...");
|
||||||
await page.getByRole("button", { name: /draft/i }).click();
|
await page.getByRole("button", { name: /store/i }).click();
|
||||||
|
|
||||||
// Verify Success Modal/Alert
|
// Verify Success Modal/Alert
|
||||||
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
|
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
|
||||||
@@ -60,14 +60,8 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
logger.info(">> [Draft] Reloading to verify persistence...");
|
logger.info(">> [Draft] Reloading to verify persistence...");
|
||||||
await page.goto(savedUrl);
|
await page.goto(savedUrl);
|
||||||
|
|
||||||
// Wait for initial load overlay to appear and then definitely disappear
|
// Wait for initial load overlay to disappear
|
||||||
await page
|
await expect(page.getByText(/opening your draft/i)).toBeHidden();
|
||||||
.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);
|
||||||
@@ -82,9 +76,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await expect(canvasInput).toHaveValue(/It should persist/i);
|
await expect(canvasInput).toHaveValue(/It should persist/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should seal a letter and navigate to Reader, then share on demand", async ({
|
test("should seal a letter and show sharing link", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const timestamp = Date.now() + Math.random();
|
const timestamp = Date.now() + Math.random();
|
||||||
const email = `seal-${timestamp}@example.com`;
|
const email = `seal-${timestamp}@example.com`;
|
||||||
const name = `Seal Author ${timestamp}`;
|
const name = `Seal Author ${timestamp}`;
|
||||||
@@ -92,67 +84,39 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||||
|
|
||||||
logger.info(">> [Seal] Navigating to Editor via UI...");
|
logger.info(">> [Seal] Navigating to Editor via UI...");
|
||||||
await page.locator("#write-letter-btn").click();
|
await page.getByRole("button", { name: /write something/i }).click();
|
||||||
|
|
||||||
const recipientInput = page.locator("#recipient");
|
const recipientInput = page.locator("#recipient");
|
||||||
await recipientInput.waitFor({ state: "visible", timeout: 10000 });
|
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
|
||||||
await recipientInput.fill("A Secret Guest");
|
await recipientInput.fill("A Secret Guest");
|
||||||
|
|
||||||
const canvasInput = page.locator("textarea");
|
const canvasInput = page.getByLabel("Canvas text input");
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
await canvasInput.fill("This letter will be sealed and shared.");
|
await canvasInput.fill("This letter will be sealed and shared.");
|
||||||
|
|
||||||
// Click Seal (open menu, then confirm)
|
// Click Seal
|
||||||
logger.info(">> [Seal] Clicking Seal...");
|
logger.info(">> [Seal] Clicking Seal...");
|
||||||
await page
|
await page.getByRole("button", { name: /seal/i }).click();
|
||||||
.getByRole("button", { name: /seal/i })
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /seal/i })
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Should show sealed confirmation modal
|
// Verify "Sealed & Ready" modal
|
||||||
logger.info(">> [Seal] Verifying sealed modal...");
|
logger.info(">> [Seal] Verifying sharing modal...");
|
||||||
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
await expect(page.getByText(/sealed & ready/i)).toBeVisible();
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to Reader via "View letter"
|
// Verify sharing link contains a hash (the key)
|
||||||
await page.getByRole("button", { name: /view letter/i }).click();
|
const linkInput = page.locator("input[readOnly]");
|
||||||
|
|
||||||
// Should be on Reader URL
|
|
||||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
|
|
||||||
|
|
||||||
// Open the envelope to reveal the letter
|
|
||||||
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
// Flip the envelope to show the seal
|
|
||||||
await page.locator("#env-front").click();
|
|
||||||
await page.waitForTimeout(2500); // Wait for flip transition
|
|
||||||
|
|
||||||
await page.getByAltText("Seal").click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
|
|
||||||
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Share on demand
|
|
||||||
logger.info(">> [Seal] Clicking Share button in Reader...");
|
|
||||||
await page.locator("#share-letter-btn").click();
|
|
||||||
|
|
||||||
// Verify share modal with a valid link
|
|
||||||
await expect(page.getByText(/send this letter/i)).toBeVisible();
|
|
||||||
const linkInput = page.locator("#share-link-input");
|
|
||||||
const linkValue = await linkInput.inputValue();
|
const linkValue = await linkInput.inputValue();
|
||||||
|
|
||||||
expect(linkValue).toContain("/read/");
|
expect(linkValue).toContain("/read/");
|
||||||
expect(linkValue).toContain("#");
|
expect(linkValue).toContain("#");
|
||||||
logger.info(`>> [Seal] Sharing link: ${linkValue}`);
|
|
||||||
|
|
||||||
|
logger.info(`>> [Seal] Sharing link generated: ${linkValue}`);
|
||||||
|
|
||||||
|
// Verify "Copy" button works
|
||||||
await expect(page.getByRole("button", { name: /copy/i })).toBeVisible();
|
await expect(page.getByRole("button", { name: /copy/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Close modal
|
||||||
await page.getByRole("button", { name: /close/i }).click();
|
await page.getByRole("button", { name: /close/i }).click();
|
||||||
await expect(page.getByText(/send this letter/i)).toBeHidden();
|
await expect(page.getByText(/sealed & ready/i)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
||||||
@@ -173,25 +137,20 @@ 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.locator("textarea");
|
const canvasInput = page.getByLabel("Canvas text input");
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
await canvasInput.fill(letterContent);
|
await canvasInput.fill(letterContent);
|
||||||
|
|
||||||
// Click Seal (open menu, then confirm)
|
// Click Seal
|
||||||
await page
|
await page.getByRole("button", { name: /seal/i }).click();
|
||||||
.getByRole("button", { name: /seal/i })
|
await expect(page.getByText(/sealed & ready/i)).toBeVisible();
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /seal/i })
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Sealed modal should appear — click "Keep it" to go to Drawer
|
// Close modal
|
||||||
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
await page.getByRole("button", { name: /close/i }).click();
|
||||||
timeout: 10000,
|
|
||||||
});
|
// Navigate to Drawer - use ID or precise label
|
||||||
await page.getByRole("button", { name: /keep it to myself/i }).click();
|
logger.info(">> [Drawer] Navigating to Drawer...");
|
||||||
|
await page.locator("button[aria-label='Open Drawer']").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...");
|
||||||
@@ -209,28 +168,14 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
logger.info(">> [Drawer] Verifying Reader page...");
|
logger.info(">> [Drawer] Verifying Reader page...");
|
||||||
// Give it a bit more time for decryption
|
// Give it a bit more time for decryption
|
||||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
|
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
|
||||||
// Reveal and check decrypted content in Reader
|
|
||||||
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
// Check decrypted content in Reader
|
||||||
|
await expect(page.getByText(/decrypting/i)).toBeHidden({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
// Check recipient on the front of the envelope
|
await expect(
|
||||||
await expect(page.getByText(new RegExp(recipientName, "i"))).toBeVisible();
|
page.getByText(new RegExp(`A sealed letter for ${recipientName}`, "i")),
|
||||||
|
).toBeVisible();
|
||||||
// Flip the envelope to the back
|
|
||||||
await page.getByText(new RegExp(recipientName, "i")).click();
|
|
||||||
// Wait for flip transition (2s)
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
|
|
||||||
// Reveal the letter: click seal then click letter
|
|
||||||
await page.getByAltText("Seal").click();
|
|
||||||
// Wait for flap transition
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
// Click the letter to pull it out
|
|
||||||
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
|
|
||||||
|
|
||||||
// Wait for reveal transition
|
|
||||||
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Also check if we are redirected to the Reader if we manually go to the Editor URL
|
// Also check if we are redirected to the Reader if we manually go to the Editor URL
|
||||||
const readerUrl = page.url();
|
const readerUrl = page.url();
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ const logger = pino({
|
|||||||
/**
|
/**
|
||||||
* Completes the full registration -> activation -> login cycle.
|
* Completes the full registration -> activation -> login cycle.
|
||||||
*/
|
*/
|
||||||
async function registerAndLogin(
|
export async function registerAndLogin(
|
||||||
page: Page,
|
page: Page,
|
||||||
email: string,
|
email: string,
|
||||||
fullName: string,
|
fullName: string,
|
||||||
password: string,
|
password: string,
|
||||||
) {
|
) {
|
||||||
// Register the User
|
// 1. Registration
|
||||||
logger.info(`[Auth] Registering user: ${email}`);
|
logger.info(`[Auth] Registering user: ${email}`);
|
||||||
await page.goto("/onboard");
|
await page.goto("/onboard");
|
||||||
await page.getByLabel(/pen name/i).fill(fullName);
|
await page.getByLabel(/full name/i).fill(fullName);
|
||||||
await page.getByLabel("Email", { exact: true }).fill(email);
|
await page.getByLabel("Email", { exact: true }).fill(email);
|
||||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||||
await page.getByLabel(/confirm password/i).fill(password);
|
await page.getByLabel(/confirm password/i).fill(password);
|
||||||
@@ -31,7 +31,7 @@ async function registerAndLogin(
|
|||||||
|
|
||||||
await expect(page).toHaveURL(/\/verify-email/);
|
await expect(page).toHaveURL(/\/verify-email/);
|
||||||
|
|
||||||
// Get activation URL from Mailpit and activate user
|
// 2. Activation via Mailpit
|
||||||
logger.info(`[Auth] Polling Mailpit for activation email...`);
|
logger.info(`[Auth] Polling Mailpit for activation email...`);
|
||||||
const activationLink = await MailpitHelper.getActivationLink(email);
|
const activationLink = await MailpitHelper.getActivationLink(email);
|
||||||
|
|
||||||
@@ -40,11 +40,11 @@ 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();
|
||||||
|
|
||||||
// Dismiss the Welcom Modal and Perform Login
|
// 3. Login
|
||||||
logger.info(`[Auth] Logging in...`);
|
logger.info(`[Auth] Logging in...`);
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
|
||||||
const welcomeButton = page.getByRole("button", { name: /I'll remember/i });
|
const welcomeButton = page.getByRole("button", { name: /i understand/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,4 +56,6 @@ 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 };
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const MailpitHelper = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok()) {
|
if (response.ok()) {
|
||||||
const data: { messages: MailpitMessage[] } = await response.json();
|
const data = await response.json();
|
||||||
if (data.messages?.length > 0) {
|
if (data.messages?.length > 0) {
|
||||||
const msgId = data.messages[0].ID;
|
const msgId = data.messages[0].ID;
|
||||||
const detailRes = await requestContext.get(
|
const detailRes = await requestContext.get(
|
||||||
@@ -31,8 +31,8 @@ export const MailpitHelper = {
|
|||||||
);
|
);
|
||||||
const details = await detailRes.json();
|
const details = await detailRes.json();
|
||||||
|
|
||||||
const body = details.Text || "";
|
const body = details.HTML || details.Text || "";
|
||||||
const match = body.match(/https?:\/\/\S*activate\S*/);
|
const match = body.match(/https?:\/\/\S+activate\/\S+/);
|
||||||
|
|
||||||
if (match) return match[0];
|
if (match) return match[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Pi. Ku. | A safe haven for your unsaid and unsent letters</title>
|
<title>Pi. Ku. | A safe haven for your unsent letters</title>
|
||||||
<meta name="description"
|
<meta name="description"
|
||||||
content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal your unsaid words into digital letters." />
|
content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal digital letters." />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
pid /tmp/nginx.pid;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
access_log /tmp/access.log;
|
|
||||||
error_log /tmp/error.log;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b & vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build:prod": "vite build --mode production",
|
|
||||||
"lint": "biome lint --write ./src",
|
"lint": "biome lint --write ./src",
|
||||||
"format": "biome format --write ./src",
|
"format": "biome format --write ./src",
|
||||||
"check": "biome check --write ./src",
|
"check": "biome check --write ./src",
|
||||||
@@ -16,18 +15,14 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jost": "^5.2.8",
|
"@fontsource-variable/jost": "^5.2.8",
|
||||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||||
"@fontsource/architects-daughter": "^5.2.7",
|
|
||||||
"@fontsource/cutive-mono": "^5.2.8",
|
"@fontsource/cutive-mono": "^5.2.8",
|
||||||
"@fontsource/kavivanar": "^5.2.8",
|
|
||||||
"@fontsource/knewave": "^5.2.7",
|
"@fontsource/knewave": "^5.2.7",
|
||||||
"@fontsource/redacted-script": "^5.2.8",
|
|
||||||
"@fontsource/space-mono": "^5.2.9",
|
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
@@ -35,8 +30,6 @@
|
|||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.19",
|
||||||
"fabric": "^7.2.0",
|
"fabric": "^7.2.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lenis": "^1.3.23",
|
|
||||||
"motion": "^12.38.0",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const baseUrl = getBaseUrl(
|
|||||||
env.FRONTEND_PORT,
|
env.FRONTEND_PORT,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(baseUrl);
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
expect: {
|
expect: {
|
||||||
@@ -60,7 +61,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run dev -- --mode e2e",
|
command: "bun run dev -- --mode e2e",
|
||||||
url: getBaseUrl(
|
url: getBaseUrl(
|
||||||
process.env.SSL_ENABLED === "true",
|
process.env.SSL_ENABLED === "true",
|
||||||
process.env.FRONTEND_DOMAIN,
|
process.env.FRONTEND_DOMAIN,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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,34 +1,28 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
import {
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
BrowserRouter,
|
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
ScrollRestoration,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||||
import SplashScreen from "./components/SplashScreen";
|
import SplashScreen from "./components/SplashScreen";
|
||||||
import { ROUTES } from "./config/routes";
|
import { ROUTES } from "./config/routes";
|
||||||
import { useAuth } from "./hooks/useAuth";
|
import { useAuth } from "./hooks/useAuth";
|
||||||
|
import Activate from "./pages/Activate";
|
||||||
|
import Drawer from "./pages/Drawer";
|
||||||
|
import Editor from "./pages/Editor";
|
||||||
|
// Pages
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import Reader from "./pages/Reader";
|
||||||
|
import Register from "./pages/Register";
|
||||||
|
import VerifyEmail from "./pages/VerifyEmail";
|
||||||
|
|
||||||
const Activate = lazy(() => import("./pages/Activate"));
|
let authInitialized = false;
|
||||||
const Drawer = lazy(() => import("./pages/Drawer"));
|
|
||||||
const Editor = lazy(() => import("./pages/Editor"));
|
|
||||||
const Home = lazy(() => import("./pages/Home"));
|
|
||||||
const Login = lazy(() => import("./pages/Login"));
|
|
||||||
const Reader = lazy(() => import("./pages/Reader"));
|
|
||||||
const Register = lazy(() => import("./pages/Register"));
|
|
||||||
const VerifyEmail = lazy(() => import("./pages/VerifyEmail"));
|
|
||||||
const About = lazy(() => import("./pages/About"));
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { initialize, isInitializing } = useAuth();
|
const { initialize, isInitializing } = useAuth();
|
||||||
const authInitialized = useRef<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authInitialized.current) return;
|
if (authInitialized) return;
|
||||||
authInitialized.current = true;
|
authInitialized = true;
|
||||||
initialize().then();
|
initialize();
|
||||||
}, [initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
@@ -37,8 +31,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
|
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
|
||||||
<Suspense fallback={<SplashScreen />}>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={ROUTES.HOME} element={<Home />} />
|
<Route path={ROUTES.HOME} element={<Home />} />
|
||||||
|
|
||||||
@@ -92,10 +85,8 @@ 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>
|
|
||||||
</main>
|
</main>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||||
import { server } from "../../test/mocks/server";
|
import { server } from "../../test/mocks/server";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
@@ -13,10 +21,13 @@ beforeEach(() => {
|
|||||||
user: null,
|
user: null,
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
vi.stubEnv("VITE_API_URL", VITE_API_URL);
|
vi.stubEnv("VITE_API_URL", VITE_API_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterAll(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import axios from "axios";
|
|||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
|
|
||||||
export const apiServerUrl = import.meta.env.VITE_API_URL;
|
|
||||||
|
|
||||||
// publicApi for endpoints that don't need authentication (login, refresh, register)
|
// publicApi for endpoints that don't need authentication (login, refresh, register)
|
||||||
export const publicApi = axios.create({
|
export const publicApi = axios.create({
|
||||||
baseURL: apiServerUrl,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// api for all authenticated requests
|
// api for all authenticated requests
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: apiServerUrl,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// auto-attach access token to authenticated requests
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -22,28 +22,29 @@ api.interceptors.request.use((config) => {
|
|||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
// auto handle 401 errors by attempting a silent refresh
|
|
||||||
|
// Handle 401 errors by attempting a silent refresh
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
|
||||||
// if first time 401 and we haven't tried refreshing yet, we proceed with silent refresh
|
// If 401 and we haven't tried refreshing yet
|
||||||
// else it could mean the refresh also 401'd
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Attempt silent refresh
|
||||||
const { data } = await publicApi.post(endpoints.REFRESH);
|
const { data } = await publicApi.post(endpoints.REFRESH);
|
||||||
const newAccessToken = data.access;
|
const newAccessToken = data.access;
|
||||||
|
|
||||||
// Update store with the latest accesstoken
|
// Update store
|
||||||
const { user, setAuth } = useAuthStore.getState();
|
const { user, setAuth } = useAuthStore.getState();
|
||||||
if (user) {
|
if (user) {
|
||||||
setAuth(newAccessToken, user);
|
setAuth(newAccessToken, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// retry the original request with the new token
|
// Retry the original request with the new token
|
||||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
return api(originalRequest);
|
return api(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 738 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,52 +1,26 @@
|
|||||||
import { DotIcon } from "@phosphor-icons/react";
|
import { DotIcon } from "@phosphor-icons/react";
|
||||||
import "@fontsource/knewave/400.css";
|
import "@fontsource/knewave/400.css";
|
||||||
|
|
||||||
interface LogoProps {
|
export default function Logo() {
|
||||||
scale?: number;
|
|
||||||
type?: "inline" | "mono" | "logo";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Logo({ scale = 1, type = "logo" }: LogoProps) {
|
|
||||||
if (type === "inline") {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<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 (
|
|
||||||
<div
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Pi. Ku. logo"
|
aria-label="Pi Ku"
|
||||||
className="inline-flex items-baseline justify-center leading-none select-none"
|
className="inline-flex items-baseline justify-center leading-none select-none"
|
||||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
style={{ fontFamily: "'Knewave', serif" }}
|
||||||
>
|
>
|
||||||
<span className={`text-3xl font-light text-accent`}>Pi</span>
|
<span className="text-2xl font-light text-accent">Pi</span>
|
||||||
<DotIcon
|
<DotIcon
|
||||||
weight="fill"
|
weight="fill"
|
||||||
size={12}
|
size={12}
|
||||||
className={`text-primary translate-y-1 -mx-px`}
|
className="text-accent translate-y-[0.3em] -mx-px"
|
||||||
/>
|
/>
|
||||||
<span className={`text-3xl font-light text-accent`}> Ku</span>
|
<span className="text-2xl font-light text-accent">Ku</span>
|
||||||
<DotIcon
|
<DotIcon
|
||||||
weight="fill"
|
weight="fill"
|
||||||
size={12}
|
size={12}
|
||||||
className={`text-primary translate-y-1 -mx-px`}
|
className="text-accent translate-y-[0.3em] -mx-px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe("ProtectedRoute", () => {
|
|||||||
"/protected",
|
"/protected",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
|
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
|
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ describe("PublicRoute", () => {
|
|||||||
</PublicRoute>,
|
</PublicRoute>,
|
||||||
"/public",
|
"/public",
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
|
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
|
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { useAuth } from "../hooks/useAuth";
|
|||||||
import SplashScreen from "./SplashScreen";
|
import SplashScreen from "./SplashScreen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private route guard.
|
* Post-login routes.
|
||||||
* If not authenticated, capture the current url in route
|
* Redirects to /login if not already authenticated.
|
||||||
* state so the Login component can link them back after sign-in
|
|
||||||
*/
|
*/
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isInitializing } = useAuth();
|
const { isAuthenticated, isInitializing } = useAuth();
|
||||||
@@ -15,6 +14,7 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
if (isInitializing) return <SplashScreen />;
|
if (isInitializing) return <SplashScreen />;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
// Save the intended location to redirect back after login
|
||||||
return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
|
return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public - auth route guard.
|
* Pre-login flows.
|
||||||
* If authenticated, redirect all the auth related flows to the drawer
|
* Redirects to /drawer if already authenticated.
|
||||||
*/
|
*/
|
||||||
export function PublicRoute({ children }: { children: React.ReactNode }) {
|
export function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isInitializing } = useAuth();
|
const { isAuthenticated, isInitializing } = useAuth();
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import { EnvelopeOpenIcon } from "@phosphor-icons/react";
|
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
|
|
||||||
export default function SplashScreen() {
|
export default function SplashScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed w-screen h-screen inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
<div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
||||||
<div className="flex flex-col items-center gap-6 animate-pulse">
|
<div className="flex flex-col items-center gap-6 animate-pulse">
|
||||||
<Logo />
|
<Logo />
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<EnvelopeOpenIcon
|
<span className="loading loading-ring loading-lg text-primary" />
|
||||||
weight="thin"
|
<p className="text-xs uppercase font-sans tracking-widest opacity-40">
|
||||||
className={"absolute text-primary/50"}
|
Unsealing...
|
||||||
size={40}
|
|
||||||
/>
|
|
||||||
<span className="loading loading-ring loading-xl text-primary"></span>
|
|
||||||
...
|
|
||||||
<p className="text-xs uppercase font-sans tracking-widester opacity-40">
|
|
||||||
Unsealing
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { GearFineIcon } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
interface DrawerSectionProps {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
count: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DrawerSection({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
count,
|
|
||||||
isOpen,
|
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
}: DrawerSectionProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={id}
|
|
||||||
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`transition-all duration-1500 ease-in-out bg-neutral/10 ${
|
|
||||||
isOpen
|
|
||||||
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible"
|
|
||||||
: "max-h-0 opacity-0 pointer-events-none"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div
|
|
||||||
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${
|
|
||||||
isOpen
|
|
||||||
? "text-base-content"
|
|
||||||
: "text-base-content/40 group-hover:text-base-content/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
<div className="font-sans text-[0.6rem] text-base-content/20 mt-1">
|
|
||||||
{count}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{id === "vault" ? (
|
|
||||||
<GearFineIcon
|
|
||||||
className={
|
|
||||||
"-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
|
|
||||||
}
|
|
||||||
weight={"duotone"}
|
|
||||||
size={30}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${
|
|
||||||
isOpen
|
|
||||||
? "bg-primary/80! opacity-80 scale-110"
|
|
||||||
: "group-hover:bg-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { LockIcon, LockKeyOpenIcon } from "@phosphor-icons/react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
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({
|
|
||||||
preview,
|
|
||||||
timestamp,
|
|
||||||
id,
|
|
||||||
status,
|
|
||||||
unlock_at,
|
|
||||||
isLocked = false,
|
|
||||||
}: LetterItemProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
function handleNavigate(): void {
|
|
||||||
if (isLocked) return;
|
|
||||||
if (status === "SEALED") {
|
|
||||||
navigate(PATHS.read(id));
|
|
||||||
} else {
|
|
||||||
navigate(PATHS.write(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNavigate}
|
|
||||||
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all delay-75 duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
|
|
||||||
>
|
|
||||||
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
|
|
||||||
{preview}
|
|
||||||
</div>
|
|
||||||
{unlock_at ? (
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
{isLocked ? (
|
|
||||||
<div className="font-sans text-xs badge badge-accent badge-soft rounded-2xl">
|
|
||||||
<LockIcon weight="duotone" size={16} />
|
|
||||||
Locked Until {unlock_at}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="font-sans text-xs badge badge-primary badge-soft rounded-2xl">
|
|
||||||
<LockKeyOpenIcon weight="duotone" size={16} /> Unlocked
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
|
|
||||||
{timestamp}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { LockKeyIcon } from "@phosphor-icons/react";
|
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
|
|
||||||
interface PasskeyModalProps {
|
|
||||||
onUnlock: (password: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
|
||||||
return (
|
|
||||||
<Modal isOpen={true}>
|
|
||||||
<LockKeyIcon
|
|
||||||
size={48}
|
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
|
||||||
/>
|
|
||||||
<h3 className="font-bold text-lg font-display text-primary">
|
|
||||||
Authentication Required
|
|
||||||
</h3>
|
|
||||||
<p className="py-4 font-sans">
|
|
||||||
We need your passkey to open your letters
|
|
||||||
</p>
|
|
||||||
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
|
||||||
<p className="text-xs text-neutral-content/30 font-mono italic">
|
|
||||||
Your passkey is used to decrypt your data locally.
|
|
||||||
</p>
|
|
||||||
<div className="modal-action items-center gap-4">
|
|
||||||
<form
|
|
||||||
className="form-control w-full inline-flex"
|
|
||||||
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const password = formData.get("password") as string;
|
|
||||||
if (!password) return;
|
|
||||||
await onUnlock(password);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
placeholder="password"
|
|
||||||
className="font-sans validator input input-bordered rounded-r-none"
|
|
||||||
/>
|
|
||||||
<div className="validator-message text-xs text-error"></div>
|
|
||||||
<button type="submit" className="btn btn-primary rounded-l-none">
|
|
||||||
Unlock
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
import * as fabric from "fabric";
|
|
||||||
import type * as React from "react";
|
|
||||||
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
|
||||||
|
|
||||||
const PAD = 36;
|
|
||||||
const BASE_WIDTH = 680;
|
|
||||||
const DEFAULT_LOGICAL_HEIGHT = 900;
|
|
||||||
const DEFAULT_FONT_FAMILY = "Playfair Display Variable";
|
|
||||||
const DEFAULT_FONT_COLOR = "#000";
|
|
||||||
|
|
||||||
export interface FabricObjectJSON {
|
|
||||||
type: string;
|
|
||||||
name?: string;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FabricImageJSON extends FabricObjectJSON {
|
|
||||||
type: "Image";
|
|
||||||
src: string;
|
|
||||||
_customRawFile?: File;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CanvasJSON {
|
|
||||||
objects: (FabricObjectJSON | FabricImageJSON)[];
|
|
||||||
canvasWidth?: number;
|
|
||||||
canvasHeight?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CanvasStyle {
|
|
||||||
fontFamily: string;
|
|
||||||
fontColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CanvasTools = {
|
|
||||||
addImage: (url: string, file: File) => void;
|
|
||||||
getData: () => CanvasJSON;
|
|
||||||
getImages: () => { src: string; file: File }[];
|
|
||||||
loadData: (data: CanvasJSON) => Promise<void>;
|
|
||||||
getStyle: () => CanvasStyle;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface FabricImageWithFile extends fabric.FabricImage {
|
|
||||||
_customRawFile: File;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: We use the same canvasData to render on both mobile and desktop viewports.
|
|
||||||
// Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up)
|
|
||||||
// over the last saved canvas size.
|
|
||||||
const applyResponsiveViewport = (
|
|
||||||
canvas: fabric.Canvas,
|
|
||||||
wrapper: HTMLDivElement,
|
|
||||||
logicalWidth: number,
|
|
||||||
logicalHeight: number,
|
|
||||||
) => {
|
|
||||||
const physicalWidth = wrapper.clientWidth || logicalWidth;
|
|
||||||
const zoomMultiplier = physicalWidth / logicalWidth;
|
|
||||||
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
|
|
||||||
|
|
||||||
canvas.setDimensions({
|
|
||||||
width: physicalWidth,
|
|
||||||
height: physicalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.style.height = `${physicalHeight}px`;
|
|
||||||
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
// to find the maximum height of the content to dynamically resize the canvas
|
|
||||||
// would've been wayyy easier only if canvas supported fit-content like CSS property :)
|
|
||||||
const measureLogicalContentHeight = (
|
|
||||||
canvas: fabric.Canvas,
|
|
||||||
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
) => {
|
|
||||||
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
|
|
||||||
const top = currObj.top;
|
|
||||||
const height = currObj.getScaledHeight();
|
|
||||||
return Math.max(maxHeight, top + height);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return Math.max(minimumHeight, maxBottom + PAD);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_INIT_TEXT = "Take a deep breath...";
|
|
||||||
|
|
||||||
interface ComposeCanvasProps {
|
|
||||||
readOnly?: boolean;
|
|
||||||
initialData?: CanvasJSON | null;
|
|
||||||
style?: CanvasStyle;
|
|
||||||
ref?: React.Ref<CanvasTools>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComposeCanvas({
|
|
||||||
readOnly = false,
|
|
||||||
initialData = null,
|
|
||||||
style,
|
|
||||||
ref,
|
|
||||||
}: ComposeCanvasProps) {
|
|
||||||
// wrapper is the parent div box
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
|
||||||
|
|
||||||
const textboxRef = useRef<fabric.Textbox | null>(null);
|
|
||||||
const deferredDataRef = useRef<CanvasJSON | null>(null);
|
|
||||||
const logicalSizeRef = useRef({
|
|
||||||
width: BASE_WIDTH,
|
|
||||||
height: DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
});
|
|
||||||
|
|
||||||
// re-calculates height based on content and applies the zoom transform
|
|
||||||
const syncViewport = useCallback(() => {
|
|
||||||
if (!(fabricRef.current && wrapperRef.current)) return;
|
|
||||||
|
|
||||||
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",
|
|
||||||
originX: "left",
|
|
||||||
originY: "top",
|
|
||||||
left: PAD,
|
|
||||||
top: PAD,
|
|
||||||
width: BASE_WIDTH - PAD * 2,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 500,
|
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
fill: DEFAULT_FONT_COLOR,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
// NOTE: splitByGrapheme is required for word wrap and re-low
|
|
||||||
// but fabric asks to disable this for clear font?? So we disable it for read view
|
|
||||||
splitByGrapheme: !readOnly,
|
|
||||||
lockMovementX: true,
|
|
||||||
lockMovementY: true,
|
|
||||||
lockScalingX: true,
|
|
||||||
lockScalingY: true,
|
|
||||||
lockRotation: true,
|
|
||||||
hasControls: false,
|
|
||||||
hasBorders: false,
|
|
||||||
objectCaching: false,
|
|
||||||
noScaleCache: false,
|
|
||||||
});
|
|
||||||
canvas.add(textbox);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!textbox) return;
|
|
||||||
|
|
||||||
// readonly contraints applicable for post seal view
|
|
||||||
textbox.selectable = !readOnly;
|
|
||||||
textbox.evented = !readOnly;
|
|
||||||
textbox.editable = !readOnly;
|
|
||||||
textbox.hasBorders = false;
|
|
||||||
|
|
||||||
textboxRef.current = textbox;
|
|
||||||
|
|
||||||
// observe and auto-resize the canvas height whenever typed
|
|
||||||
textbox.on("changed", syncViewport);
|
|
||||||
|
|
||||||
// trapping the focus into the textbox wherever clicked on canvas (except images)
|
|
||||||
canvas.on("mouse:down", (e) => {
|
|
||||||
if (!e.target || e.target === textbox) {
|
|
||||||
focusTextbox(textbox);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
syncViewport();
|
|
||||||
|
|
||||||
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
|
||||||
// otherwise it goes to the front
|
|
||||||
if (!readOnly) {
|
|
||||||
setTimeout(() => focusTextbox(textbox), 200);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[readOnly, syncViewport, focusTextbox],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (style && textboxRef.current) {
|
|
||||||
const textBox = textboxRef.current;
|
|
||||||
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
|
|
||||||
textBox.fill = style.fontColor || textBox.fill;
|
|
||||||
syncViewport();
|
|
||||||
}
|
|
||||||
}, [style, syncViewport]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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,
|
|
||||||
height: DEFAULT_LOGICAL_HEIGHT,
|
|
||||||
selection: !readOnly,
|
|
||||||
preserveObjectStacking: true,
|
|
||||||
allowTouchScrolling: true,
|
|
||||||
enableRetinaScaling: true,
|
|
||||||
objectCaching: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// remove default fabric background to let our CSS show through
|
|
||||||
// TODO: provision custom bg (color in scope, but how does img fit?)
|
|
||||||
const wrapperEl = canvas.getElement().parentElement;
|
|
||||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
|
||||||
|
|
||||||
fabricRef.current = canvas;
|
|
||||||
|
|
||||||
await loadContent(initialData);
|
|
||||||
|
|
||||||
// sometimes loadData() may be called before the canvas finished the init render
|
|
||||||
// so we retry that stashed render right after the init
|
|
||||||
if (deferredDataRef.current) {
|
|
||||||
await loadContent(deferredDataRef.current);
|
|
||||||
deferredDataRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// auto window resizing based width
|
|
||||||
lastWidth = wrapperRef.current.clientWidth;
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
const nextWidth = wrapperRef.current?.clientWidth;
|
|
||||||
if (!nextWidth || nextWidth === lastWidth) return;
|
|
||||||
lastWidth = nextWidth;
|
|
||||||
syncViewport();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(wrapperRef.current!);
|
|
||||||
};
|
|
||||||
|
|
||||||
initCanvas().then();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
fabricRef.current?.dispose();
|
|
||||||
fabricRef.current = null;
|
|
||||||
textboxRef.current = null;
|
|
||||||
};
|
|
||||||
}, [initialData, loadContent, readOnly, 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, () => ({
|
|
||||||
addImage: (url: string, file: File) => {
|
|
||||||
if (!fabricRef.current) return;
|
|
||||||
|
|
||||||
fabric.FabricImage.fromURL(url).then((img) => {
|
|
||||||
img.scaleToWidth(Math.min(300, img.width));
|
|
||||||
img.set({
|
|
||||||
originX: "left",
|
|
||||||
originY: "top",
|
|
||||||
left: PAD,
|
|
||||||
top: PAD,
|
|
||||||
noScaleCache: 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>);
|
|
||||||
|
|
||||||
fabricRef.current?.add(img);
|
|
||||||
fabricRef.current?.setActiveObject(img);
|
|
||||||
|
|
||||||
syncViewport();
|
|
||||||
// clean up memory
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getData: () => {
|
|
||||||
if (!fabricRef.current) return { objects: [] };
|
|
||||||
syncViewport();
|
|
||||||
|
|
||||||
const json = fabricRef.current.toJSON() as CanvasJSON;
|
|
||||||
json.canvasWidth = logicalSizeRef.current.width;
|
|
||||||
json.canvasHeight = logicalSizeRef.current.height;
|
|
||||||
return json;
|
|
||||||
},
|
|
||||||
|
|
||||||
getImages: () => {
|
|
||||||
if (!fabricRef.current) return [];
|
|
||||||
const images = fabricRef.current.getObjects(
|
|
||||||
"Image",
|
|
||||||
) as FabricImageWithFile[];
|
|
||||||
return images.map((img) => ({
|
|
||||||
src: img.getSrc(),
|
|
||||||
file: img._customRawFile,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
loadData: async (data: CanvasJSON) => {
|
|
||||||
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
|
|
||||||
if (!fabricRef.current) {
|
|
||||||
deferredDataRef.current = data;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadContent(data);
|
|
||||||
},
|
|
||||||
|
|
||||||
getStyle: () => {
|
|
||||||
const textBox = textboxRef.current;
|
|
||||||
|
|
||||||
return {
|
|
||||||
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
|
|
||||||
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={wrapperRef}
|
|
||||||
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
|
|
||||||
>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="absolute top-0 left-0"
|
|
||||||
style={{ background: "transparent" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ComposeCanvas.displayName = "ComposeCanvas";
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { LockIcon } from "@phosphor-icons/react";
|
|
||||||
import type { NavigateFunction } from "react-router-dom";
|
|
||||||
import { PATHS, ROUTES } from "../../config/routes";
|
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
|
|
||||||
interface PostSealModalProps {
|
|
||||||
sealedTargetId: string | null;
|
|
||||||
navigate: NavigateFunction;
|
|
||||||
type: "KEPT" | "VAULT";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PostSealModal({
|
|
||||||
sealedTargetId,
|
|
||||||
navigate,
|
|
||||||
type = "KEPT",
|
|
||||||
}: PostSealModalProps) {
|
|
||||||
return (
|
|
||||||
<Modal isOpen={!!sealedTargetId}>
|
|
||||||
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
|
|
||||||
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
|
|
||||||
<p className="text-base-content/60">
|
|
||||||
It's encrypted and always safe in your drawer.
|
|
||||||
</p>
|
|
||||||
{type === "KEPT" ? (
|
|
||||||
<p className="text-base-content/80 text-sm font-sans">
|
|
||||||
When you're ready,
|
|
||||||
<br />
|
|
||||||
you can{" "}
|
|
||||||
<span className="text-primary font-bold font-display">read</span> it,{" "}
|
|
||||||
<span className="text-accent font-bold font-display">send</span> it to
|
|
||||||
someone, or{" "}
|
|
||||||
<span className="text-error font-bold font-display">burn</span> it 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">
|
|
||||||
{type === "KEPT" ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
|
||||||
>
|
|
||||||
Keep it to myself
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={() => navigate(PATHS.read(sealedTargetId!))}
|
|
||||||
>
|
|
||||||
View letter
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
|
||||||
>
|
|
||||||
Step Away...
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import {
|
|
||||||
CircleHalfTiltIcon,
|
|
||||||
ImageIcon,
|
|
||||||
LockIcon,
|
|
||||||
PaintBucketIcon,
|
|
||||||
QuestionIcon,
|
|
||||||
StampIcon,
|
|
||||||
TextAUnderlineIcon,
|
|
||||||
TrayIcon,
|
|
||||||
VaultIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
import type { CanvasStyle } from "./ComposeCanvas.tsx";
|
|
||||||
|
|
||||||
interface ToolBarProps {
|
|
||||||
onAddImage: () => void;
|
|
||||||
sealBtnClicked: boolean;
|
|
||||||
setSealBtnClicked: (v: boolean) => void;
|
|
||||||
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<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({
|
|
||||||
onAddImage,
|
|
||||||
sealBtnClicked,
|
|
||||||
setSealBtnClicked,
|
|
||||||
onSave,
|
|
||||||
setConfirmModal,
|
|
||||||
onFontChange,
|
|
||||||
latestFontStyle,
|
|
||||||
}: ToolBarProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="writer-toolbar"
|
|
||||||
className="relative z-10 flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
|
|
||||||
>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{/* Image upload */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm group"
|
|
||||||
onClick={onAddImage}
|
|
||||||
>
|
|
||||||
<ImageIcon size={18} weight="bold" />
|
|
||||||
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
|
|
||||||
Add Image
|
|
||||||
</span>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Draft */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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"
|
|
||||||
onClick={() => onSave("DRAFT")}
|
|
||||||
>
|
|
||||||
<TrayIcon size={18} weight="bold" />
|
|
||||||
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
|
|
||||||
Draft
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-px h-4 bg-base-content/10 mx-2 hidden md:inline" />
|
|
||||||
|
|
||||||
{/*Seal */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
|
|
||||||
onClick={() => setSealBtnClicked(true)}
|
|
||||||
>
|
|
||||||
<StampIcon
|
|
||||||
size={16}
|
|
||||||
weight="fill"
|
|
||||||
className="mr-1 group-hover:animate-bounce"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`hidden md:inline ${sealBtnClicked ? "inline" : ""} group-hover:inline transition-all duration-1000`}
|
|
||||||
>
|
|
||||||
Seal
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`flex-col items-center gap-2 absolute right-0 z-10 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-accent btn-sm rounded-full px-6 group"
|
|
||||||
onClick={() => onSave("SEALED")}
|
|
||||||
>
|
|
||||||
<StampIcon
|
|
||||||
size={16}
|
|
||||||
weight="fill"
|
|
||||||
className="mr-1 group-hover:animate-bounce"
|
|
||||||
/>
|
|
||||||
<span className="transition-all duration-1000">Seal</span>
|
|
||||||
</button>
|
|
||||||
<div className="w-full divider text-neutral-content/60 mt-2 mb-2">
|
|
||||||
or
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-neutral btn-sm rounded-full px-6 group"
|
|
||||||
onClick={() => setConfirmModal("VAULT")}
|
|
||||||
>
|
|
||||||
<VaultIcon size={16} weight="fill" className="mr-1" />
|
|
||||||
<span className="transition-all duration-1000">Vault</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={`z-100001 absolute right-0 bg-transparent cursor-pointer ${sealBtnClicked ? "" : "hidden"}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSealBtnClicked(false)}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LetterHead() {
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
<LockIcon size={14} weight="fill" />
|
|
||||||
<span className="text-xxs uppercase tracking-widest font-bold">
|
|
||||||
Sealed & View Only
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VaultConfirmModalProps {
|
|
||||||
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
|
|
||||||
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
|
|
||||||
setUnlockDate: (d: Date | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VaultConfirmModal({
|
|
||||||
onSave,
|
|
||||||
setConfirmModal,
|
|
||||||
setUnlockDate,
|
|
||||||
}: VaultConfirmModalProps) {
|
|
||||||
return (
|
|
||||||
<Modal isOpen={true}>
|
|
||||||
<VaultIcon
|
|
||||||
size={48}
|
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
|
||||||
/>
|
|
||||||
<h3 className="font-serif text-3xl">Take it away, then?</h3>
|
|
||||||
<p className="text-base-content/60 text-sm text-center mt-4">
|
|
||||||
By vaulting this letter, you ask me to hold on to this.
|
|
||||||
<br />
|
|
||||||
I'll remember to mail you this on the unlock date.
|
|
||||||
<br />
|
|
||||||
<span className={"font-bold text-primary"}>
|
|
||||||
{" "}
|
|
||||||
But I won't let you read or rewrite this letter until then.
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const unlockDateStr = formData.get("vault-date") as string;
|
|
||||||
const newUnlockDate = new Date(unlockDateStr);
|
|
||||||
setUnlockDate(newUnlockDate);
|
|
||||||
await onSave("VAULT", newUnlockDate);
|
|
||||||
setConfirmModal(null);
|
|
||||||
}}
|
|
||||||
id="vault-form"
|
|
||||||
className="min-w-75"
|
|
||||||
>
|
|
||||||
<div className={"divider tracking-tightest font-display text-sm"}>
|
|
||||||
Set an unlock date
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="date"
|
|
||||||
className="input input-bordered w-full"
|
|
||||||
name="vault-date"
|
|
||||||
/>
|
|
||||||
<div className="w-full flex justify-center gap-8 mt-4">
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
form="vault-form"
|
|
||||||
>
|
|
||||||
Take it
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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,99 +0,0 @@
|
|||||||
import { CampfireIcon, FlameIcon } from "@phosphor-icons/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({
|
|
||||||
burnLetter,
|
|
||||||
isBurning,
|
|
||||||
setShowBurnModal,
|
|
||||||
setRevealState,
|
|
||||||
}: BurnModalProps) {
|
|
||||||
const [flameOn, setFlameOn] = useState(0);
|
|
||||||
const [rotate, setRotate] = useState(0);
|
|
||||||
const [burnClicked, setBurnClicked] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!burnClicked) return;
|
|
||||||
if (flameOn === 100) {
|
|
||||||
setRevealState("SEALED");
|
|
||||||
burnLetter();
|
|
||||||
}
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setFlameOn((prev) => prev + 1);
|
|
||||||
setRotate(Math.random() * 4 - 2);
|
|
||||||
}, 100);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [burnClicked, flameOn, setRevealState, burnLetter]);
|
|
||||||
|
|
||||||
const burnStyle = flameOn < 30 ? "" : `contrast(${flameOn / 30})`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={true} onClose={() => setShowBurnModal(false)}>
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center gap-4 text-center transition-all duration-200 ease-in-out ${burnClicked ? "animate-[pulse_15s_linear_infinite]" : ""}`}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
transform: `rotate(${rotate}deg)`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CampfireIcon
|
|
||||||
size={48}
|
|
||||||
weight="duotone"
|
|
||||||
className="text-error animate-pulse"
|
|
||||||
/>
|
|
||||||
<h3 className="font-serif text-2xl">
|
|
||||||
Are you ready to burn this letter?
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm font-sans text-base-content/80 mt-4">
|
|
||||||
Some words are meant to be unsaid, but they don't have to linger
|
|
||||||
forever.
|
|
||||||
<br />
|
|
||||||
Let the echoes of your unsaid be finally released.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 font-sans text-sm">
|
|
||||||
<span className="text-error">Press</span> and{" "}
|
|
||||||
<span className="text-error">hold</span> the{" "}
|
|
||||||
<span className="text-amber-300">flame</span> to proceed.
|
|
||||||
</div>
|
|
||||||
<div className="modal-action w-full justify-center gap-3 mt-2">
|
|
||||||
<div
|
|
||||||
className="absolute -mt-2 w-28 h-28 radial-progress pointer-events-none text-amber-200/60"
|
|
||||||
style={
|
|
||||||
{ "--value": flameOn, filter: burnStyle } as React.CSSProperties
|
|
||||||
}
|
|
||||||
role="progressbar"
|
|
||||||
></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-error btn-dashed btn-circle w-24 h-24`}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
filter: burnStyle,
|
|
||||||
cursor: burnClicked ? "grabbing" : "grab",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
onMouseDown={() => setBurnClicked(true)}
|
|
||||||
onMouseUp={() => {
|
|
||||||
setFlameOn(0);
|
|
||||||
setBurnClicked(false);
|
|
||||||
}}
|
|
||||||
disabled={isBurning}
|
|
||||||
>
|
|
||||||
{isBurning ? (
|
|
||||||
<span className="loading loading-spinner loading-xs" />
|
|
||||||
) : (
|
|
||||||
<FlameIcon size={54} weight="duotone" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { WavesIcon } from "@phosphor-icons/react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import candle from "../../assets/envelope/candle.png";
|
|
||||||
import stamp from "../../assets/envelope/stamp.png";
|
|
||||||
import waxSeal from "../../assets/envelope/waxSeal.png";
|
|
||||||
|
|
||||||
export interface EnvelopeRevealProps {
|
|
||||||
recipient?: string;
|
|
||||||
date?: string;
|
|
||||||
onRevealComplete: () => void;
|
|
||||||
ignite: boolean;
|
|
||||||
isFlip?: boolean;
|
|
||||||
isInteractive?: boolean;
|
|
||||||
openFlap?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnvelopeReveal({
|
|
||||||
recipient,
|
|
||||||
date,
|
|
||||||
onRevealComplete,
|
|
||||||
ignite,
|
|
||||||
isFlip,
|
|
||||||
isInteractive = true,
|
|
||||||
openFlap = false,
|
|
||||||
}: EnvelopeRevealProps) {
|
|
||||||
const [revealLetter, setRevealLetter] = useState(false);
|
|
||||||
const [isFlipped, setIsFlipped] = useState(!!isFlip);
|
|
||||||
const [isFlapOpen, setIsFlapOpen] = useState(!!openFlap);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsFlipped(!!isFlip);
|
|
||||||
}, [isFlip]);
|
|
||||||
|
|
||||||
const [burn, setBurn] = useState<{ width: number; height: number }>({
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsFlapOpen(openFlap);
|
|
||||||
}, [openFlap]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ignite) {
|
|
||||||
setBurn({ width: 0, height: 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const burnInterval = setInterval(() => {
|
|
||||||
setBurn((prev) => ({ width: prev.width + 4, height: prev.height + 6 }));
|
|
||||||
}, 100);
|
|
||||||
return () => clearInterval(burnInterval);
|
|
||||||
}, [ignite]);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (revealLetter) return;
|
|
||||||
setRevealLetter(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
onRevealComplete();
|
|
||||||
}, 2500);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`relative h-70 w-105 transform-3d transition-transform duration-2000 ${isFlipped ? "rotate-y-180" : ""}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={` flex backface-hidden rotate-y-180 justify-center transition-all duration-1000 ${isFlipped ? "" : "pointer-events-none"}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id="env-top"
|
|
||||||
className="z-4 delay-500 transition-all duration-2000 absolute peer h-40 w-54 mt-0 bg-base-200 mask mask-triangle-2 scale-x-234 has-checked:scale-y-[-1] has-checked:-translate-y-full has-checked:z-1 has-checked:duration-1000"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="transition checkbox absolute h-full w-full text-transparent bg-transparent z-100"
|
|
||||||
checked={isFlapOpen}
|
|
||||||
onChange={() => setIsFlapOpen((prev) => !prev)}
|
|
||||||
disabled={!isInteractive}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<img
|
|
||||||
className={
|
|
||||||
"translate-y-24 delay-2000 absolute z-6 peer-has-checked:pointer-events-none peer-has-checked:opacity-0 peer-has-checked:delay-0 transition-opacity duration-1000 cursor-pointer"
|
|
||||||
}
|
|
||||||
src={waxSeal}
|
|
||||||
alt="Seal"
|
|
||||||
onClick={() => setIsFlapOpen((prev) => !prev)}
|
|
||||||
onKeyDown={() => setIsFlapOpen((prev) => !prev)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="letter"
|
|
||||||
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-3000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
|
|
||||||
onClick={handleClick}
|
|
||||||
></button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="env-right"
|
|
||||||
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-3 -mr-48 z-3 pointer-events-none"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
id="env-left"
|
|
||||||
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-4 -ml-48 z-3 pointer-events-none"
|
|
||||||
></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="env-bottom"
|
|
||||||
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] mt-15 scale-x-240 z-3"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="env-front"
|
|
||||||
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" : ""}`}
|
|
||||||
onClick={() => setIsFlipped((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<span className={"text-neutral-content/60 font-xs font-display"}>
|
|
||||||
to
|
|
||||||
</span>
|
|
||||||
<h1 className="text-3xl font-bold text-base-content">{recipient}</h1>
|
|
||||||
<p className="text-base-content/60 font-display mt-8">{date}</p>
|
|
||||||
<img
|
|
||||||
src={stamp}
|
|
||||||
alt={"stamp"}
|
|
||||||
className={
|
|
||||||
"z-0 rotate-6 opacity-80 text-accent absolute mt-0 mr-1 top-4 right-0"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<WavesIcon
|
|
||||||
className={"absolute mt-0 mr-12 top-18 right-8 text-primary"}
|
|
||||||
size={50}
|
|
||||||
/>
|
|
||||||
<WavesIcon
|
|
||||||
className={"absolute mt-0 mr-4 top-18 right-8 text-primary"}
|
|
||||||
size={50}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{ignite && (
|
|
||||||
<>
|
|
||||||
<div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
|
|
||||||
<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"
|
|
||||||
style={{
|
|
||||||
width: 2 * burn.width,
|
|
||||||
height: 2 * burn.height,
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute z-1001 bottom-0 right-0 translate-x-15 translate-y-20">
|
|
||||||
<img src={candle} alt="candle" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { ROUTES } from "../../config/routes";
|
|
||||||
|
|
||||||
interface PostActionOverlayProps {
|
|
||||||
revealState: "SEALED" | "REVEALED" | "BURNING" | "BURNED";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PostActionOverlay({ revealState }: PostActionOverlayProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center justify-center min-h-screen bg-base-100 ${revealState === "BURNED" ? "opacity-100" : "opacity-0"} transition-all delay-1000 duration-1000`}
|
|
||||||
>
|
|
||||||
<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]`}
|
|
||||||
>
|
|
||||||
It is done
|
|
||||||
</h1>
|
|
||||||
<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`}
|
|
||||||
>
|
|
||||||
<p className="w-full">
|
|
||||||
May your <span className="italic text-primary">soul</span> find
|
|
||||||
solace,
|
|
||||||
<br />
|
|
||||||
just like your <span className="text-accent italic">unsaid</span>{" "}
|
|
||||||
words did.
|
|
||||||
</p>
|
|
||||||
<div className="divider mx-auto w-24 text-center"></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost text-sm text-neutral-content/60 font-sans"
|
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
|
||||||
>
|
|
||||||
Turn the page
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { EyeSlashIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
|
|
||||||
import { Modal } from "../ui/Modal";
|
|
||||||
import Saajan from "../ui/Saajan";
|
|
||||||
|
|
||||||
interface ShareModalProps {
|
|
||||||
shareLink: string | null;
|
|
||||||
setShareLink: (link: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
if (!shareLink) return;
|
|
||||||
await navigator.clipboard.writeText(shareLink);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal isOpen={!!shareLink} onClose={() => setShareLink(null)}>
|
|
||||||
<div className="flex flex-col items-center justify-center text-center gap-6 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<PaperPlaneTiltIcon
|
|
||||||
size={48}
|
|
||||||
weight="bold"
|
|
||||||
className="mb-4 text-primary mx-auto animate-[bounce_3s_ease-in-out_infinite]"
|
|
||||||
/>
|
|
||||||
<h3 className="font-serif text-3xl">Send this letter</h3>
|
|
||||||
<p className="text-base-content/80 text-sm font-sans mt-4">
|
|
||||||
You've carried these words long enough.
|
|
||||||
<br />
|
|
||||||
Send your letter now, and let the{" "}
|
|
||||||
<span className="text-accent font-display">unsaid</span> finally
|
|
||||||
find its home.
|
|
||||||
</p>
|
|
||||||
<div className="divider mx-auto" />
|
|
||||||
<blockquote className="text-sm info text-neutral-content/60 font-sans">
|
|
||||||
They'll receive it exactly as you're seeing it now.
|
|
||||||
<br />
|
|
||||||
Nothing more, nothing less.
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl">
|
|
||||||
<input
|
|
||||||
id="share-link-input"
|
|
||||||
readOnly
|
|
||||||
value={shareLink ?? ""}
|
|
||||||
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 uppercase tracking-widest text-base-content/30 font-sans">
|
|
||||||
<p className="textarea-xs flex items-center justify-center">
|
|
||||||
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />{" "}
|
|
||||||
Zero-Knowledge Share:
|
|
||||||
</p>
|
|
||||||
<p className="textarea-xs font-mono text-center">
|
|
||||||
The key never leaves your or the recipient's browser.
|
|
||||||
</p>
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import * as fabric from "fabric";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
const PAD = 36;
|
||||||
|
const BASE_WIDTH = 680;
|
||||||
|
const DEFAULT_LOGICAL_HEIGHT = 900;
|
||||||
|
|
||||||
|
export interface FabricObjectJSON {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FabricImageJSON extends FabricObjectJSON {
|
||||||
|
type: "Image";
|
||||||
|
src: string;
|
||||||
|
_customRawFile?: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasJSON {
|
||||||
|
objects: (FabricObjectJSON | FabricImageJSON)[];
|
||||||
|
canvasWidth?: number;
|
||||||
|
canvasHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CanvasTools = {
|
||||||
|
addImage: (url: string, file: File) => void;
|
||||||
|
getData: () => CanvasJSON;
|
||||||
|
getJsonData: () => string;
|
||||||
|
getImages: () => { src: string; file: File }[];
|
||||||
|
loadData: (data: CanvasJSON) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FabricImageWithFile extends fabric.FabricImage {
|
||||||
|
_customRawFile: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForLayout = (wrapper: HTMLDivElement): Promise<number> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const check = () => {
|
||||||
|
const width = wrapper.clientWidth || 0;
|
||||||
|
if (width > 0) resolve(width);
|
||||||
|
else requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMainTextbox = (
|
||||||
|
text: string,
|
||||||
|
isReadOnly = false,
|
||||||
|
): fabric.Textbox => {
|
||||||
|
return new fabric.Textbox(text, {
|
||||||
|
name: "main-textbox",
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
left: PAD,
|
||||||
|
top: PAD,
|
||||||
|
width: BASE_WIDTH - PAD * 2,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: "Playfair Display Variable",
|
||||||
|
fill: "#000",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
editable: !isReadOnly,
|
||||||
|
selectable: false,
|
||||||
|
evented: !isReadOnly,
|
||||||
|
hasControls: false,
|
||||||
|
hasBorders: false,
|
||||||
|
objectCaching: false,
|
||||||
|
splitByGrapheme: false,
|
||||||
|
lockMovementX: true,
|
||||||
|
lockMovementY: true,
|
||||||
|
lockScalingX: true,
|
||||||
|
lockScalingY: true,
|
||||||
|
lockRotation: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixFabricA11y = () => {
|
||||||
|
const textAreas = document.querySelectorAll(
|
||||||
|
'textarea[data-fabric="textarea"]',
|
||||||
|
);
|
||||||
|
for (const area of textAreas) {
|
||||||
|
if (!area.getAttribute("aria-label")) {
|
||||||
|
area.setAttribute("aria-label", "Canvas text input");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeCanvas = (
|
||||||
|
el: HTMLCanvasElement,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
readOnly: boolean,
|
||||||
|
) => {
|
||||||
|
const canvas = new fabric.Canvas(el, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
selection: !readOnly,
|
||||||
|
preserveObjectStacking: true,
|
||||||
|
allowTouchScrolling: true,
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
objectCaching: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapperEl = canvas.getElement().parentElement;
|
||||||
|
if (wrapperEl) wrapperEl.style.background = "transparent";
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLogicalSize = (data: CanvasJSON | null) => {
|
||||||
|
return {
|
||||||
|
width: data?.canvasWidth ?? BASE_WIDTH,
|
||||||
|
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getObjectBottom = (obj: fabric.FabricObject) => {
|
||||||
|
const top = obj.top ?? 0;
|
||||||
|
const height =
|
||||||
|
typeof obj.getScaledHeight === "function"
|
||||||
|
? obj.getScaledHeight()
|
||||||
|
: (obj.height ?? 0) * (obj.scaleY ?? 1);
|
||||||
|
|
||||||
|
return top + height;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureLogicalContentHeight = (
|
||||||
|
canvas: fabric.Canvas,
|
||||||
|
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
|
||||||
|
) => {
|
||||||
|
const maxBottom = canvas
|
||||||
|
.getObjects()
|
||||||
|
.reduce((max, obj) => Math.max(max, getObjectBottom(obj)), 0);
|
||||||
|
|
||||||
|
return Math.max(minimumHeight, maxBottom + PAD);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyResponsiveViewport = (
|
||||||
|
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;
|
||||||
|
|
||||||
|
const textbox = await loadContent(
|
||||||
|
canvas,
|
||||||
|
initialData,
|
||||||
|
wrapperRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textbox) {
|
||||||
|
textboxRef.current = textbox;
|
||||||
|
setupTextboxInteractions(canvas, textbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
fixFabricA11y();
|
||||||
|
|
||||||
|
lastWidth = wrapperRef.current.clientWidth;
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!(fabricRef.current && wrapperRef.current)) return;
|
||||||
|
|
||||||
|
const nextWidth = wrapperRef.current.clientWidth;
|
||||||
|
if (!nextWidth || nextWidth === lastWidth) return;
|
||||||
|
|
||||||
|
lastWidth = nextWidth;
|
||||||
|
syncViewport();
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(wrapperRef.current);
|
||||||
|
|
||||||
|
if (deferredDataRef.current) {
|
||||||
|
const data = deferredDataRef.current;
|
||||||
|
deferredDataRef.current = null;
|
||||||
|
|
||||||
|
const textbox = await loadContent(canvas, data, wrapperRef.current);
|
||||||
|
if (textbox) {
|
||||||
|
textboxRef.current = textbox;
|
||||||
|
setupTextboxInteractions(canvas, textbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
fixFabricA11y();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
canvas?.dispose();
|
||||||
|
fabricRef.current = null;
|
||||||
|
textboxRef.current = null;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
initialData,
|
||||||
|
loadContent,
|
||||||
|
readOnly,
|
||||||
|
setupTextboxInteractions,
|
||||||
|
syncViewport,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
addImage: (url: string, file: File) => {
|
||||||
|
if (!fabricRef.current) return;
|
||||||
|
|
||||||
|
fabric.FabricImage.fromURL(url).then((img) => {
|
||||||
|
img.scaleToWidth(Math.min(300, img.width));
|
||||||
|
img.set({
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
_customRawFile: file,
|
||||||
|
left: PAD,
|
||||||
|
top: PAD,
|
||||||
|
objectCaching: false,
|
||||||
|
} as Partial<FabricImageWithFile>);
|
||||||
|
|
||||||
|
fabricRef.current?.add(img);
|
||||||
|
fabricRef.current?.setActiveObject(img);
|
||||||
|
|
||||||
|
if (!fabricRef.current) return;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getData: () => {
|
||||||
|
if (!fabricRef.current) return { objects: [] };
|
||||||
|
|
||||||
|
logicalSizeRef.current.height = measureLogicalContentHeight(
|
||||||
|
fabricRef.current,
|
||||||
|
logicalSizeRef.current.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = fabricRef.current.toJSON() as CanvasJSON;
|
||||||
|
json.canvasWidth = logicalSizeRef.current.width;
|
||||||
|
json.canvasHeight = logicalSizeRef.current.height;
|
||||||
|
|
||||||
|
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: () => {
|
||||||
|
if (!fabricRef.current) return [];
|
||||||
|
|
||||||
|
const images = fabricRef.current.getObjects(
|
||||||
|
"Image",
|
||||||
|
) as FabricImageWithFile[];
|
||||||
|
|
||||||
|
return images.map((img) => ({
|
||||||
|
src: img.getSrc(),
|
||||||
|
file: img._customRawFile,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
loadData: async (data: CanvasJSON) => {
|
||||||
|
if (!(fabricRef.current && wrapperRef.current)) {
|
||||||
|
deferredDataRef.current = data;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textbox = await loadContent(
|
||||||
|
fabricRef.current,
|
||||||
|
data,
|
||||||
|
wrapperRef.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textbox) {
|
||||||
|
textboxRef.current = textbox;
|
||||||
|
setupTextboxInteractions(fabricRef.current, textbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
fabricRef.current.requestRenderAll();
|
||||||
|
fixFabricA11y();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute top-0 left-0"
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ComposeCanvas.displayName = "ComposeCanvas";
|
||||||
@@ -31,7 +31,7 @@ export default function DateDisplay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`text-right flex flex-col gap-2 min-w-35 ${className}`}>
|
<div className={`text-right flex flex-col gap-2 min-w-35 ${className}`}>
|
||||||
<span className="text-xxs uppercase tracking-widester text-accent font-bold">
|
<span className="text-[10px] uppercase tracking-[0.4em] text-accent font-bold">
|
||||||
Date
|
Date
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-serif text-secondary-content italic whitespace-nowrap">
|
<span className="text-sm font-serif text-secondary-content italic whitespace-nowrap">
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
interface DrawerSectionProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
count: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawerSection({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
isOpen,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: DrawerSectionProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={`join-item group flex flex-col transition-colors ${isOpen ? "bg-base-300/30" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden transition-all duration-1000 ease-in-out bg-neutral/10 ${
|
||||||
|
isOpen
|
||||||
|
? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
|
||||||
|
: "max-h-0 opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-1000 outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className={`font-sans text-xs tracking-[0.2em] uppercase transition-colors duration-300 ${
|
||||||
|
isOpen
|
||||||
|
? "text-base-content"
|
||||||
|
: "text-base-content/40 group-hover:text-base-content/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans text-[0.6rem] text-base-content/20 mt-1">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${
|
||||||
|
isOpen
|
||||||
|
? "bg-primary/80! opacity-80 scale-110"
|
||||||
|
: "group-hover:bg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ interface FormFieldProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
registration: UseFormRegisterReturn;
|
registration: UseFormRegisterReturn;
|
||||||
error?: string;
|
error?: string;
|
||||||
handleFocus?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormField({
|
export default function FormField({
|
||||||
@@ -15,7 +14,6 @@ export default function FormField({
|
|||||||
placeholder,
|
placeholder,
|
||||||
registration,
|
registration,
|
||||||
error,
|
error,
|
||||||
handleFocus,
|
|
||||||
}: FormFieldProps) {
|
}: FormFieldProps) {
|
||||||
return (
|
return (
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
@@ -33,7 +31,6 @@ 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,38 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { PATHS } from "../../config/routes";
|
||||||
|
|
||||||
|
export function LetterItem({
|
||||||
|
preview,
|
||||||
|
timestamp,
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
preview: string;
|
||||||
|
timestamp: string;
|
||||||
|
id: string;
|
||||||
|
status: "DRAFT" | "SEALED" | "BURNED";
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
function handleNavigate(): void {
|
||||||
|
if (status === "SEALED") {
|
||||||
|
navigate(PATHS.read(id));
|
||||||
|
} else {
|
||||||
|
navigate(PATHS.write(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
className="p-[16px_28px_16px_76px] border-b border-base-content/3 flex items-center gap-4 hover:bg-base-content/5 transition-colors group w-full text-left"
|
||||||
|
>
|
||||||
|
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60">
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans text-[0.6rem] text-base-content/20">
|
||||||
|
{timestamp}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { WarningIcon } from "@phosphor-icons/react";
|
import { WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react";
|
||||||
import { Modal } from "./Modal";
|
|
||||||
|
|
||||||
interface LogModalContent {
|
interface LogModalContent {
|
||||||
status: "WARN" | "ERROR" | "RESET" | "SUCCESS";
|
status: "WARN" | "ERROR" | "RESET" | "SUCCESS";
|
||||||
@@ -16,17 +15,21 @@ export const LogModal = ({
|
|||||||
onClose,
|
onClose,
|
||||||
status,
|
status,
|
||||||
}: LogModalContent) => {
|
}: LogModalContent) => {
|
||||||
return (
|
return status === "RESET" || !isOpen ? (
|
||||||
<Modal isOpen={isOpen && status !== "RESET"} onClose={onClose}>
|
<div></div>
|
||||||
|
) : (
|
||||||
|
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-100">
|
||||||
|
<div className="modal-box bg-transparent border-none shadow-none relative">
|
||||||
<div
|
<div
|
||||||
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
||||||
>
|
>
|
||||||
{status === "WARN" && (
|
{status === "WARN" && (
|
||||||
<WarningIcon className="text-warning" size={16} weight="duotone" />
|
<WarningIcon className="text-warning" size={16} weight="bold" />
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
@@ -35,9 +38,17 @@ export const LogModal = ({
|
|||||||
<code>{String(log)}</code>
|
<code>{String(log)}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<form method="dialog">
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn btn-sm btn-circle btn-ghost absolute right-6 top-6"
|
||||||
|
>
|
||||||
|
<XIcon size={6} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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-xxs tracking-widester uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
|
<span className="font-sans text-[10px] tracking-[0.3em] uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
|
||||||
Drawer
|
Drawer
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import sf_mini from "../../assets/sf_mini.png";
|
|
||||||
|
|
||||||
interface SaajanProps {
|
|
||||||
message: string;
|
|
||||||
position?: "top" | "left" | "right" | "bottom";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Saajan({ message, position = "right" }: SaajanProps) {
|
|
||||||
const [animate, setAnimate] = useState<boolean>(false);
|
|
||||||
const [tooltipPosition, setTooltipPosition] =
|
|
||||||
useState<string>("tooltip-right");
|
|
||||||
const [alignment, setAlignment] = useState<string>("justify-start");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAnimate(true);
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setAnimate(false);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTooltipPosition(`tooltip-${position}`);
|
|
||||||
if (position === "top") {
|
|
||||||
setAlignment("justify-center");
|
|
||||||
}
|
|
||||||
if (position === "right") {
|
|
||||||
setAlignment("justify-start");
|
|
||||||
}
|
|
||||||
if (position === "left") {
|
|
||||||
setAlignment("justify-end");
|
|
||||||
}
|
|
||||||
}, [position]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative w-full flex ${alignment}`}>
|
|
||||||
<div
|
|
||||||
className={`tooltip tooltip-open ${tooltipPosition} before:border before:border-dashed before:border-primary/40 before:max-w-xs before:whitespace-pre-line italic before:text-left`}
|
|
||||||
data-tip={message}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={sf_mini}
|
|
||||||
alt="saajan"
|
|
||||||
className={`sepia-20 w-35 -mb-6 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,14 +9,14 @@ export const endpoints = {
|
|||||||
LETTERS: "/api/letters/",
|
LETTERS: "/api/letters/",
|
||||||
};
|
};
|
||||||
|
|
||||||
// constructs dynamic path params for activate flow
|
// simple utility to handle path params
|
||||||
export const replacePathParams = (
|
export const replacePathParams = (
|
||||||
url: string,
|
url: string,
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
): string => {
|
): string => {
|
||||||
let constructedUrl = url;
|
let result = url;
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
constructedUrl = constructedUrl.replace(`:${key}`, value);
|
result = result.replace(`:${key}`, value);
|
||||||
}
|
}
|
||||||
return constructedUrl;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Page Route PATTERNS
|
// Route PATTERNS
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HOME: "/",
|
HOME: "/",
|
||||||
ONBOARD: "/onboard",
|
ONBOARD: "/onboard",
|
||||||
@@ -6,13 +6,13 @@ export const ROUTES = {
|
|||||||
ACTIVATE: "/activate/:uidb64/:token",
|
ACTIVATE: "/activate/:uidb64/:token",
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
DRAWER: "/drawer",
|
DRAWER: "/drawer",
|
||||||
WRITE: "/quill/:public_id?",
|
WRITE: "/quill/:public_id?", // ← static pattern
|
||||||
READ: "/read/:public_id",
|
READ: "/read/:public_id",
|
||||||
ABOUT: "/know-piku",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic path BUILDERS
|
// Path BUILDERS
|
||||||
export const PATHS = {
|
export const PATHS = {
|
||||||
write: (public_id?: string) => `/quill/${public_id ?? ""}`,
|
write: (public_id?: string) => `/quill/${public_id ?? ""}`,
|
||||||
read: (public_id: string) => `/read/${public_id}`,
|
read: (public_id: string) => `/read/${public_id}`,
|
||||||
|
activate: (uidb64: string, token: string) => `/activate/${uidb64}/${token}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { mockUser } from "../../test/fixtures/user.fixture";
|
|||||||
import { server } from "../../test/mocks/server";
|
import { server } from "../../test/mocks/server";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
import {
|
import {
|
||||||
clearMasterKey,
|
clearMasterKey,
|
||||||
loadMasterKey,
|
loadMasterKey,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from "../utils/keystore";
|
} from "../utils/keystore";
|
||||||
import { useAuth } from "./useAuth";
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
vi.mock("../utils/crypto");
|
||||||
vi.mock("../utils/keystore");
|
vi.mock("../utils/keystore");
|
||||||
|
|
||||||
const VITE_API_URL = "http://piku-server";
|
const VITE_API_URL = "http://piku-server";
|
||||||
@@ -20,6 +22,12 @@ const VITE_API_URL = "http://piku-server";
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// hack to set up mock implementations using fixtures
|
||||||
|
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
|
||||||
|
masterKey: mockMasterKey,
|
||||||
|
authHash: "mock-auth-hash",
|
||||||
|
});
|
||||||
|
|
||||||
vi.mocked(loadMasterKey).mockResolvedValue(mockMasterKey);
|
vi.mocked(loadMasterKey).mockResolvedValue(mockMasterKey);
|
||||||
vi.mocked(saveMasterKey).mockResolvedValue("masterKey");
|
vi.mocked(saveMasterKey).mockResolvedValue("masterKey");
|
||||||
vi.mocked(clearMasterKey).mockResolvedValue(undefined);
|
vi.mocked(clearMasterKey).mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ export const useAuth = () => {
|
|||||||
try {
|
try {
|
||||||
const masterKey = await loadMasterKey();
|
const masterKey = await loadMasterKey();
|
||||||
if (masterKey) setMasterKey(masterKey);
|
if (masterKey) setMasterKey(masterKey);
|
||||||
} catch {}
|
} catch {
|
||||||
|
console.error("Master key restoration failed");
|
||||||
|
}
|
||||||
|
|
||||||
// If session in memory, don't trigger refresh/me again
|
// If session in memory, don't trigger refresh/me again
|
||||||
if (accessToken && user) {
|
if (accessToken && user) {
|
||||||
@@ -80,7 +82,9 @@ export const useAuth = () => {
|
|||||||
);
|
);
|
||||||
await saveMasterKey(masterKey);
|
await saveMasterKey(masterKey);
|
||||||
setMasterKey(masterKey);
|
setMasterKey(masterKey);
|
||||||
} catch {}
|
} catch {
|
||||||
|
console.error("Master key restoration failed");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { HttpResponse, http } from "msw";
|
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import { server } from "../../test/mocks/server";
|
|
||||||
import { endpoints } from "../config/endpoints";
|
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
|
||||||
import { useLetters } from "./useLetters";
|
|
||||||
|
|
||||||
describe("useLetters hook", () => {
|
|
||||||
let masterKey: CryptoKey;
|
|
||||||
let utils: CryptoUtils;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
utils = new CryptoUtils();
|
|
||||||
await utils.initialize();
|
|
||||||
const bundle = await CryptoUtils.deriveKeyBundle("password", "salt");
|
|
||||||
masterKey = bundle.masterKey;
|
|
||||||
|
|
||||||
useKeyStore.setState({ masterKey: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should indicate authentication is required when masterKey is missing", () => {
|
|
||||||
const { result } = renderHook(() => useLetters());
|
|
||||||
|
|
||||||
expect(result.current.isAuthRequired).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fetch, decrypt, and categorize letters when masterKey is present", async () => {
|
|
||||||
useKeyStore.setState({ masterKey });
|
|
||||||
|
|
||||||
const draftPayload = { objects: [] };
|
|
||||||
const encryptedDraft = await utils.encryptMetadata(
|
|
||||||
{ recipient: "Draft Recipient" },
|
|
||||||
masterKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
const lettersResponse = [
|
|
||||||
{
|
|
||||||
public_id: "letter-1",
|
|
||||||
type: "KEPT",
|
|
||||||
status: "DRAFT",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
encrypted_metadata: encryptedDraft.encrypted_content,
|
|
||||||
encrypted_content: JSON.stringify(draftPayload),
|
|
||||||
encrypted_dek: encryptedDraft.encrypted_dek,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get(`${import.meta.env.VITE_API_URL}${endpoints.LETTERS}`, () => {
|
|
||||||
return HttpResponse.json(lettersResponse);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLetters());
|
|
||||||
|
|
||||||
// Initially loading
|
|
||||||
expect(result.current.loading).toBe(true);
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
|
|
||||||
expect(result.current.drafts).toHaveLength(1);
|
|
||||||
expect(result.current.drafts[0].metadata.recipient).toBe("Draft Recipient");
|
|
||||||
expect(result.current.kept).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should sort letters by updated_at in descending order", async () => {
|
|
||||||
useKeyStore.setState({ masterKey });
|
|
||||||
|
|
||||||
const metadata = await utils.encryptMetadata(
|
|
||||||
{ recipient: "test" },
|
|
||||||
masterKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const older = new Date(now.getTime() - 10000);
|
|
||||||
|
|
||||||
const lettersResponse = [
|
|
||||||
{
|
|
||||||
public_id: "older",
|
|
||||||
type: "KEPT",
|
|
||||||
status: "SEALED",
|
|
||||||
updated_at: older.toISOString(),
|
|
||||||
encrypted_metadata: metadata.encrypted_content,
|
|
||||||
encrypted_content: "{}",
|
|
||||||
encrypted_dek: metadata.encrypted_dek,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
public_id: "newer",
|
|
||||||
type: "KEPT",
|
|
||||||
status: "SEALED",
|
|
||||||
updated_at: now.toISOString(),
|
|
||||||
encrypted_metadata: metadata.encrypted_content,
|
|
||||||
encrypted_content: "{}",
|
|
||||||
encrypted_dek: metadata.encrypted_dek,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get(`${import.meta.env.VITE_API_URL}${endpoints.LETTERS}`, () => {
|
|
||||||
return HttpResponse.json(lettersResponse);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLetters());
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
|
|
||||||
expect(result.current.kept[0].public_id).toBe("newer");
|
|
||||||
expect(result.current.kept[1].public_id).toBe("older");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,7 @@ export interface Letter {
|
|||||||
status: "DRAFT" | "SEALED" | "BURNED";
|
status: "DRAFT" | "SEALED" | "BURNED";
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
sealed_at?: string;
|
sealed_at?: string;
|
||||||
unlock_at: string;
|
unlock_at?: string;
|
||||||
encrypted_metadata: string;
|
encrypted_metadata: string;
|
||||||
encrypted_content: string;
|
encrypted_content: string;
|
||||||
encrypted_dek: string;
|
encrypted_dek: string;
|
||||||
@@ -25,7 +25,7 @@ export interface ProcessedLetter extends Letter {
|
|||||||
metadata: LetterMetadata;
|
metadata: LetterMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptLettersMetadata(
|
async function decryptLetters(
|
||||||
letters: Letter[],
|
letters: Letter[],
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<ProcessedLetter[]> {
|
): Promise<ProcessedLetter[]> {
|
||||||
@@ -56,34 +56,21 @@ async function decryptLettersMetadata(
|
|||||||
export function useLetters() {
|
export function useLetters() {
|
||||||
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
// to fetch the letters and decryypt the metadata on load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!masterKey) {
|
if (!masterKey) {
|
||||||
setIsAuthRequired(true);
|
setIsAuthRequired(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsAuthRequired(false);
|
setIsAuthRequired(false);
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api
|
api
|
||||||
.get(endpoints.LETTERS)
|
.get(endpoints.LETTERS)
|
||||||
.then((res) => decryptLettersMetadata(res.data, masterKey))
|
.then((res) => decryptLetters(res.data, masterKey))
|
||||||
.then((decrypted) => {
|
.then(setLetters)
|
||||||
setLetters(
|
.catch((_err) => {})
|
||||||
decrypted.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.updated_at).getTime() -
|
|
||||||
new Date(a.updated_at).getTime(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [masterKey]);
|
}, [masterKey]);
|
||||||
|
|
||||||
@@ -91,18 +78,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" && l.status === "SEALED"),
|
vault: letters.filter((l) => l.type === "VAULT"),
|
||||||
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
||||||
};
|
};
|
||||||
}, [letters]);
|
}, [letters]);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...drawerItems,
|
...drawerItems,
|
||||||
loading,
|
loading,
|
||||||
|
refreshLetters: () => setLoading(true),
|
||||||
isAuthRequired,
|
isAuthRequired,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,39 +7,50 @@
|
|||||||
prefersdark: true;
|
prefersdark: true;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--color-base-100: oklch(14% 0.012 35);
|
/* ── Base surfaces ── */
|
||||||
|
--color-base-100: oklch(14% 0.012 35); /* was 0.018 hue 50 */
|
||||||
--color-base-200: oklch(18% 0.014 33);
|
--color-base-200: oklch(18% 0.014 33);
|
||||||
--color-base-300: oklch(22% 0.016 32);
|
--color-base-300: oklch(22% 0.016 32);
|
||||||
--color-base-content: oklch(82% 0.02 70);
|
--color-base-content: oklch(
|
||||||
|
82% 0.02 70
|
||||||
|
); /* aged parchment, not crisp white */
|
||||||
|
|
||||||
|
/* ── Primary: old lamp gold — warm, incandescent ── */
|
||||||
--color-primary: oklch(67% 0.11 78);
|
--color-primary: oklch(67% 0.11 78);
|
||||||
--color-primary-content: oklch(15% 0.03 70);
|
--color-primary-content: oklch(15% 0.03 70);
|
||||||
|
|
||||||
|
/* ── Secondary: dusty plum ── */
|
||||||
--color-secondary: oklch(48% 0.08 305);
|
--color-secondary: oklch(48% 0.08 305);
|
||||||
--color-secondary-content: oklch(92% 0.01 305);
|
--color-secondary-content: oklch(92% 0.01 305);
|
||||||
|
|
||||||
|
/* ── Accent: muted lavender-clay ── */
|
||||||
--color-accent: oklch(55% 0.06 325);
|
--color-accent: oklch(55% 0.06 325);
|
||||||
--color-accent-content: oklch(18% 0.03 295);
|
--color-accent-content: oklch(18% 0.03 295);
|
||||||
|
|
||||||
|
/* ── Neutral: warm stone ── */
|
||||||
--color-neutral: oklch(28% 0.02 45);
|
--color-neutral: oklch(28% 0.02 45);
|
||||||
--color-neutral-content: oklch(80% 0.015 60);
|
--color-neutral-content: oklch(80% 0.015 60);
|
||||||
|
|
||||||
|
/* ── Semantic — desaturated, no alarm ── */
|
||||||
--color-info: oklch(60% 0.07 240);
|
--color-info: oklch(60% 0.07 240);
|
||||||
--color-info-content: oklch(95% 0.01 240);
|
--color-info-content: oklch(95% 0.01 240);
|
||||||
--color-success: oklch(60% 0.08 150);
|
--color-success: oklch(60% 0.08 150);
|
||||||
--color-success-content: oklch(16% 0.03 150);
|
--color-success-content: oklch(16% 0.03 150);
|
||||||
--color-warning: oklch(68% 0.08 72);
|
--color-warning: oklch(68% 0.08 72); /* honey, not caution-sign amber */
|
||||||
--color-warning-content: oklch(18% 0.03 60);
|
--color-warning-content: oklch(18% 0.03 60);
|
||||||
--color-error: oklch(55% 0.1 22);
|
--color-error: oklch(55% 0.1 22);
|
||||||
--color-error-content: oklch(92% 0.01 22);
|
--color-error-content: oklch(92% 0.01 22);
|
||||||
|
|
||||||
|
/* ── Shape ── */
|
||||||
--radius-selector: 0.5rem;
|
--radius-selector: 0.5rem;
|
||||||
--radius-field: 0.375rem;
|
--radius-field: 0.375rem;
|
||||||
--radius-box: 0.5rem;
|
--radius-box: 0.5rem;
|
||||||
|
|
||||||
|
/* ── Effects ── */
|
||||||
--depth: 1;
|
--depth: 1;
|
||||||
--noise: 0.03;
|
--noise: 0.03;
|
||||||
|
|
||||||
|
/* ── Border ── */
|
||||||
--border: 1px;
|
--border: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,32 +58,17 @@
|
|||||||
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
--font-display: "Playwrite HR Lijeva Variable", cursive;
|
||||||
--font-sans: "Jost Variable", sans-serif;
|
--font-sans: "Jost Variable", sans-serif;
|
||||||
--font-serif: "Playfair Display Variable", serif;
|
--font-serif: "Playfair Display Variable", serif;
|
||||||
--font-mono: "Space Mono", monospace;
|
--color-glass-bg: rgba(
|
||||||
--font-tamil: "Kavivanar", sans-serif;
|
28,
|
||||||
--font-redact: "Redacted Script", cursive;
|
22,
|
||||||
--font-slab: "Cutive Mono", monospace;
|
16,
|
||||||
--font-hand: "Architects Daughter", cursive;
|
0.45
|
||||||
--color-glass-bg: rgba(28, 22, 16, 0.45);
|
); /* slightly deeper to match new base */
|
||||||
--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 m-4;
|
@apply bg-glass-bg backdrop-blur-xl border border-white/5 shadow-warm rounded-xl;
|
||||||
}
|
|
||||||
|
|
||||||
.ul-wavy {
|
|
||||||
@apply decoration-primary/40 underline decoration-wavy underline-offset-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
@apply text-primary underline decoration-base-content/20 underline-offset-4 hover:decoration-primary/60 transition-colors;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
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");
|
||||||
|
|||||||
@@ -1,896 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrowArcLeftIcon,
|
|
||||||
ArrowBendDownLeftIcon,
|
|
||||||
ArrowBendDownRightIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
CaretUpIcon,
|
|
||||||
FlowerTulipIcon,
|
|
||||||
GhostIcon,
|
|
||||||
InfoIcon,
|
|
||||||
LockLaminatedIcon,
|
|
||||||
LockOpenIcon,
|
|
||||||
PasswordIcon,
|
|
||||||
PersonArmsSpreadIcon,
|
|
||||||
PersonIcon,
|
|
||||||
ScrollIcon,
|
|
||||||
SmileyIcon,
|
|
||||||
SparkleIcon,
|
|
||||||
VaultIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import { ReactLenis } from "lenis/react";
|
|
||||||
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import stamp from "../assets/envelope/stamp.png";
|
|
||||||
import Logo from "../components/Logo.tsx";
|
|
||||||
import { Modal } from "../components/ui/Modal";
|
|
||||||
|
|
||||||
import "@fontsource/kavivanar/index.css";
|
|
||||||
import "@fontsource/space-mono/index.css";
|
|
||||||
import "@fontsource/redacted-script/index.css";
|
|
||||||
import "@fontsource/architects-daughter/index.css";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
function HorizontalScroll({ children }: { children: React.ReactNode }) {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const { scrollYProgress } = useScroll({ target: ref });
|
|
||||||
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section ref={ref} className="relative h-[200dvh]">
|
|
||||||
<div className="sticky top-0 flex h-screen w-screen items-center overflow-x-hidden">
|
|
||||||
<motion.div style={{ x }} className="flex w-[200vw]">
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function About() {
|
|
||||||
useEffect(() => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<StorySection />
|
|
||||||
|
|
||||||
<HorizontalScroll>
|
|
||||||
<ForWhoSection />
|
|
||||||
<ArchetypesSection />
|
|
||||||
</HorizontalScroll>
|
|
||||||
|
|
||||||
<PrivacySection />
|
|
||||||
|
|
||||||
<HorizontalScroll>
|
|
||||||
<SpecsSection />
|
|
||||||
<OSSSection />
|
|
||||||
</HorizontalScroll>
|
|
||||||
|
|
||||||
<AttributionSection />
|
|
||||||
</div>
|
|
||||||
</ReactLenis>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PrivacySection() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
|
|
||||||
<h1
|
|
||||||
className={
|
|
||||||
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif flex"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
The 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,6 +16,8 @@ export default function Activate() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(uidb64 && token) || hasCalled.current) return;
|
if (!(uidb64 && token) || hasCalled.current) return;
|
||||||
|
|
||||||
|
// prevent double api calls
|
||||||
hasCalled.current = true;
|
hasCalled.current = true;
|
||||||
|
|
||||||
const activateAccount = async () => {
|
const activateAccount = async () => {
|
||||||
@@ -44,7 +46,7 @@ export default function Activate() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "success" && (
|
{status === "success" && (
|
||||||
<div className="flex flex-col items-center gap-6 duration-500">
|
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
|
||||||
<div className="bg-success/10 p-4 rounded-full">
|
<div className="bg-success/10 p-4 rounded-full">
|
||||||
<CheckCircleIcon
|
<CheckCircleIcon
|
||||||
size={64}
|
size={64}
|
||||||
@@ -55,20 +57,18 @@ 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 leading-relaxed">
|
<p className="opacity-70 mb-8 leading-relaxed">
|
||||||
Welcome to <Logo scale={1} />
|
Welcome to <Logo />
|
||||||
<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 my-0"></div>
|
<div className="divider opacity-10"></div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(ROUTES.LOGIN, {
|
navigate(ROUTES.LOGIN, { state: { firstTime: true } })
|
||||||
state: { firstTime: true },
|
|
||||||
replace: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start Writing
|
Start Writing
|
||||||
@@ -82,17 +82,16 @@ export default function Activate() {
|
|||||||
<XCircleIcon size={64} weight="duotone" className="text-error" />
|
<XCircleIcon size={64} weight="duotone" className="text-error" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-display text-xl text-error">Activation Failed</h2>
|
<h2 className="font-display text-xl text-error">Activation Failed</h2>
|
||||||
<p className="opacity-70 leading-relaxed">
|
<p className="opacity-70 mb-8 leading-relaxed">
|
||||||
The link might be expired or already used. Please try registering
|
The link might be expired or already used. Please try registering
|
||||||
again.
|
again.
|
||||||
</p>
|
</p>
|
||||||
<div className="divider opacity-10 my-0"></div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost w-full"
|
className="btn btn-ghost w-full"
|
||||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||||
>
|
>
|
||||||
Register Again
|
Back to Registration
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||||
import { useLetters } from "../hooks/useLetters";
|
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
import Drawer from "./Drawer";
|
import Drawer from "./Drawer";
|
||||||
|
|
||||||
vi.mock("../hooks/useLetters");
|
|
||||||
|
|
||||||
describe("Drawer Page", () => {
|
describe("Drawer Page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setup authenticated state for the test
|
// Setup authenticated state for the test
|
||||||
@@ -16,15 +13,6 @@ describe("Drawer Page", () => {
|
|||||||
accessToken: "fake-token",
|
accessToken: "fake-token",
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(useLetters).mockReturnValue({
|
|
||||||
drafts: [],
|
|
||||||
kept: [],
|
|
||||||
sent: [],
|
|
||||||
vault: [],
|
|
||||||
loading: false,
|
|
||||||
isAuthRequired: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the cabinet sections and empty state message", () => {
|
it("renders the cabinet sections and empty state message", () => {
|
||||||
@@ -39,43 +27,4 @@ describe("Drawer Page", () => {
|
|||||||
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
|
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
|
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the loading state", () => {
|
|
||||||
vi.mocked(useLetters).mockReturnValue({
|
|
||||||
drafts: [],
|
|
||||||
kept: [],
|
|
||||||
sent: [],
|
|
||||||
vault: [],
|
|
||||||
loading: true,
|
|
||||||
isAuthRequired: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<Drawer />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the authentication required modal when api requires auth", () => {
|
|
||||||
vi.mocked(useLetters).mockReturnValue({
|
|
||||||
drafts: [],
|
|
||||||
kept: [],
|
|
||||||
sent: [],
|
|
||||||
vault: [],
|
|
||||||
loading: false,
|
|
||||||
isAuthRequired: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<Drawer />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { FeatherIcon } from "@phosphor-icons/react";
|
import { FeatherIcon, LockKeyIcon } from "@phosphor-icons/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
|
|
||||||
import { LetterItem } from "../components/drawer/LetterItem.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 { DrawerSection } from "../components/ui/DrawerSection";
|
||||||
|
import { LetterItem } from "../components/ui/LetterItem";
|
||||||
import { PATHS } from "../config/routes";
|
import { PATHS } from "../config/routes";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useLetters } from "../hooks/useLetters";
|
import { useLetters } from "../hooks/useLetters";
|
||||||
import {
|
|
||||||
formatRelativeDate,
|
|
||||||
formatRelativeDateWithoutTime,
|
|
||||||
} from "../utils/dateFormat.ts";
|
|
||||||
|
|
||||||
export default function Drawer() {
|
export default function Drawer() {
|
||||||
const { user, logout, unlock } = useAuth();
|
const { user, logout, unlock } = useAuth();
|
||||||
@@ -28,12 +22,58 @@ export default function Drawer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
|
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.5)_100%)] pointer-events-none z-0" />
|
||||||
|
|
||||||
{isAuthRequired && <PasskeyModal onUnlock={unlock} />}
|
{isAuthRequired && (
|
||||||
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
<div className="modal modal-open bg-base-100/20 backdrop-blur-md">
|
||||||
|
<div className="modal-box p-12 flex flex-col items-center">
|
||||||
|
<LockKeyIcon
|
||||||
|
size={48}
|
||||||
|
className="text-primary mx-auto mb-8 animate-pulse"
|
||||||
|
/>
|
||||||
|
<h3 className="font-bold text-lg font-display text-primary">
|
||||||
|
Authentication Required
|
||||||
|
</h3>
|
||||||
|
<p className="py-4 font-sans">
|
||||||
|
We need your passkey to open your letters
|
||||||
|
</p>
|
||||||
|
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
||||||
|
<p className="text-xs text-neutral-content/30 font-mono italic">
|
||||||
|
P.S. We don't validate your input at the moment.
|
||||||
|
</p>
|
||||||
|
<div className="modal-action items-center gap-4">
|
||||||
|
<form
|
||||||
|
className="form-control w-full inline-flex"
|
||||||
|
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const password = formData.get("password") as string;
|
||||||
|
if (!password) return;
|
||||||
|
unlock(password);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="password"
|
||||||
|
className="font-sans validator input input-bordered rounded-r-none"
|
||||||
|
/>
|
||||||
|
<div className="validator-message text-xs text-error"></div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary rounded-l-none"
|
||||||
|
>
|
||||||
|
Unlock
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-1000">
|
||||||
<Logo />
|
<Logo />
|
||||||
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
<div className="font-sans text-xs tracking-[0.3em] uppercase text-base-content/40 mt-2">
|
||||||
Personal Archive
|
Personal Archive
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
|
<div className="mt-6 font-sans text-sm text-base-content flex items-center justify-center gap-2 opacity-60 hover:opacity-100 transition-opacity">
|
||||||
@@ -49,11 +89,11 @@ export default function Drawer() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm duration-500 delay-200 min-h-64 flex flex-col">
|
<div className="join join-vertical w-full max-w-120 bg-base-200 border border-base-content/10 shadow-2xl z-10 rounded-sm overflow-hidden animate-in fade-in slide-in-from-bottom-8 duration-1000 delay-200 fill-mode-backwards min-h-64 flex flex-col">
|
||||||
{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-xxs uppercase tracking-widester font-sans text-base-content/20 animate-pulse">
|
<span className="text-[10px] uppercase tracking-[0.3em] font-sans text-base-content/20 animate-pulse">
|
||||||
Opening your cabinet...
|
Opening your cabinet...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +112,7 @@ export default function Drawer() {
|
|||||||
status={draft.status}
|
status={draft.status}
|
||||||
key={draft.public_id}
|
key={draft.public_id}
|
||||||
preview={draft.metadata?.recipient || "Untitled Draft"}
|
preview={draft.metadata?.recipient || "Untitled Draft"}
|
||||||
timestamp={formatRelativeDate(draft.updated_at)}
|
timestamp={draft.updated_at}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
@@ -90,7 +130,7 @@ export default function Drawer() {
|
|||||||
status={letter.status}
|
status={letter.status}
|
||||||
key={letter.public_id}
|
key={letter.public_id}
|
||||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||||
timestamp={formatRelativeDate(letter.updated_at)}
|
timestamp={letter.updated_at}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
@@ -107,7 +147,7 @@ export default function Drawer() {
|
|||||||
status={letter.status}
|
status={letter.status}
|
||||||
id={letter.public_id}
|
id={letter.public_id}
|
||||||
preview={letter.metadata?.recipient || "Someone dear..."}
|
preview={letter.metadata?.recipient || "Someone dear..."}
|
||||||
timestamp={formatRelativeDate(letter.updated_at)}
|
timestamp={letter.updated_at}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{sent.length === 0 && (
|
{sent.length === 0 && (
|
||||||
@@ -129,11 +169,7 @@ export default function Drawer() {
|
|||||||
status={letter.status}
|
status={letter.status}
|
||||||
id={letter.public_id}
|
id={letter.public_id}
|
||||||
preview={letter.metadata?.recipient || "Future Self"}
|
preview={letter.metadata?.recipient || "Future Self"}
|
||||||
timestamp={formatRelativeDate(letter.updated_at)}
|
timestamp={letter.updated_at}
|
||||||
unlock_at={formatRelativeDateWithoutTime(
|
|
||||||
letter.unlock_at || "",
|
|
||||||
)}
|
|
||||||
isLocked={letter.unlock_at > new Date().toISOString()}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
@@ -143,35 +179,28 @@ export default function Drawer() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="write-letter-btn"
|
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-1000"
|
||||||
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
|
onClick={() => navigate(PATHS.write(""), { replace: true })}
|
||||||
onClick={() => navigate(PATHS.write(""))}
|
|
||||||
>
|
>
|
||||||
<FeatherIcon
|
<FeatherIcon
|
||||||
size={18}
|
size={18}
|
||||||
weight="duotone"
|
weight="duotone"
|
||||||
className="text-primary/30 transition-all duration-300 group-hover:text-primary"
|
className="text-primary/30 transition-all duration-700 group-hover:text-primary"
|
||||||
/>
|
/>
|
||||||
Write something{" "}
|
Write something{" "}
|
||||||
<span className="relative inline-flex">
|
<span className="relative inline-flex">
|
||||||
<span className="transition-opacity duration-500 opacity-80 group-hover:opacity-0">
|
<span className="transition-opacity duration-1500 opacity-80 group-hover:opacity-0">
|
||||||
. . . . . .
|
. . . . . .
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute inset-0 text-primary transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
<span className="absolute inset-0 text-primary transition-opacity duration-1000 opacity-0 group-hover:opacity-100">
|
||||||
unsaid
|
unsaid
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
<footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10">
|
||||||
For your unsaid.
|
Kept. Unsent.
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { HttpResponse, http } from "msw";
|
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { mockMasterKey } from "../../test/fixtures/auth.fixture";
|
|
||||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
|
||||||
import { server } from "../../test/mocks/server";
|
|
||||||
import { endpoints } from "../config/endpoints";
|
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
|
||||||
import Editor from "./Editor";
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
|
||||||
|
|
||||||
// Mock ComposeCanvas to avoid Fabric.js issues and check readOnly prop
|
|
||||||
vi.mock("../components/editor/ComposeCanvas", () => ({
|
|
||||||
ComposeCanvas: vi.fn(({ readOnly }) => (
|
|
||||||
<div data-testid="canvas" data-readonly={readOnly}>
|
|
||||||
Canvas
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock CryptoUtils to avoid real crypto calls in UI tests
|
|
||||||
vi.mock("../utils/crypto", () => {
|
|
||||||
return {
|
|
||||||
CryptoUtils: class {
|
|
||||||
initialize = vi.fn().mockResolvedValue(undefined);
|
|
||||||
encryptLetter = vi.fn().mockResolvedValue({
|
|
||||||
encrypted_content: "enc-content",
|
|
||||||
encrypted_dek: "enc-dek",
|
|
||||||
sharingKey: "share-key",
|
|
||||||
});
|
|
||||||
encryptMetadata = vi.fn().mockResolvedValue({
|
|
||||||
encrypted_content: "enc-meta",
|
|
||||||
encrypted_dek: "enc-dek",
|
|
||||||
});
|
|
||||||
decryptMetadata = vi.fn().mockResolvedValue({ recipient: "Test User" });
|
|
||||||
decryptLetter = vi.fn().mockResolvedValue("{}");
|
|
||||||
extractSharingKey = vi.fn().mockResolvedValue("share-key");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Editor Page", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
useAuthStore.setState({
|
|
||||||
user: mockUser,
|
|
||||||
accessToken: "fake-token",
|
|
||||||
isInitializing: false,
|
|
||||||
});
|
|
||||||
useKeyStore.setState({ masterKey: mockMasterKey });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set canvas to readOnly when status is VAULT", async () => {
|
|
||||||
server.use(
|
|
||||||
http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
public_id: "test-id",
|
|
||||||
status: "DRAFT",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
encrypted_content: "{}",
|
|
||||||
encrypted_metadata: "{}",
|
|
||||||
encrypted_dek: "wrapped-dek",
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
|
||||||
return HttpResponse.json({ status: "success" });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<MemoryRouter initialEntries={["/write/test-id"]}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/write/:public_id" element={<Editor />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for initial load to complete
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const canvas = screen.getByTestId("canvas");
|
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("false");
|
|
||||||
|
|
||||||
const toolbar = container.querySelector("#writer-toolbar");
|
|
||||||
const sealBtn = toolbar?.querySelector(".btn-primary");
|
|
||||||
if (!sealBtn) throw new Error("Seal button not found");
|
|
||||||
fireEvent.click(sealBtn);
|
|
||||||
|
|
||||||
// Click Vault to show confirm modal
|
|
||||||
const vaultBtn = screen.getByRole("button", { name: /vault/i });
|
|
||||||
fireEvent.click(vaultBtn);
|
|
||||||
|
|
||||||
// Set date and submit vault form
|
|
||||||
const dateInput = container.querySelector('input[name="vault-date"]');
|
|
||||||
if (!dateInput) throw new Error("Date input not found");
|
|
||||||
fireEvent.change(dateInput, { target: { value: "2026-12-31" } });
|
|
||||||
|
|
||||||
const confirmVaultBtn = container.querySelector(
|
|
||||||
'button[form="vault-form"]',
|
|
||||||
);
|
|
||||||
if (!confirmVaultBtn) throw new Error("Confirm vault button not found");
|
|
||||||
fireEvent.click(confirmVaultBtn);
|
|
||||||
|
|
||||||
// Wait for save to complete and check readOnly
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
|
||||||
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set canvas to readOnly when status is SEALED", async () => {
|
|
||||||
server.use(
|
|
||||||
http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
public_id: "test-id",
|
|
||||||
status: "DRAFT",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
encrypted_content: "{}",
|
|
||||||
encrypted_metadata: "{}",
|
|
||||||
encrypted_dek: "wrapped-dek",
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
|
|
||||||
return HttpResponse.json({ status: "success" });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<MemoryRouter initialEntries={["/write/test-id"]}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/write/:public_id" element={<Editor />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const canvas = screen.getByTestId("canvas");
|
|
||||||
|
|
||||||
const toolbar = container.querySelector("#writer-toolbar");
|
|
||||||
const sealBtn = toolbar?.querySelector(".btn-primary");
|
|
||||||
if (!sealBtn) throw new Error("Seal button not found");
|
|
||||||
fireEvent.click(sealBtn);
|
|
||||||
|
|
||||||
// The secondary seal button appears (it has btn-accent class)
|
|
||||||
const secondarySealBtn = container.querySelector(".btn-accent");
|
|
||||||
if (!secondarySealBtn) throw new Error("Secondary seal button not found");
|
|
||||||
fireEvent.click(secondarySealBtn);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(canvas.getAttribute("data-readonly")).toBe("true");
|
|
||||||
expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||