Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fff90902b5 | |||
| a3a56d4316 | |||
| 2ba5d6964f | |||
| ffe588c3ec | |||
| a7cced71ee | |||
| c6545a11b1 | |||
| 2419b73b15 | |||
| a599dbeb30 | |||
| 143b992391 | |||
| d625cbb1fb | |||
| 7e53229308 | |||
| ca352fa88b | |||
| 7eb19788e7 | |||
| 3a56d9fd77 | |||
| fe94047f18 | |||
| f5e1813ec3 | |||
| 3ec8bb2226 | |||
| ac2f541ebe | |||
| 8d0ab979f5 | |||
| 8449377b6d | |||
| 3b5f140d21 | |||
| 740753cb33 | |||
| c7764952d8 | |||
| 098835757b | |||
| c6dc82591d | |||
| 38a75440a5 | |||
| bea9b13249 | |||
| 84445f16b3 | |||
| fce0b5b539 | |||
| fb3cb2eb69 | |||
| 2659f73577 | |||
| bf6aa34536 | |||
| dddda69c2f | |||
| 90b04f2397 | |||
| 5f56b21823 | |||
| e32c7a7982 | |||
| 34c6de47cc | |||
| a77e88496b | |||
| a0cacfbc8c | |||
| 49cd21cffe | |||
| 9910e44ee2 | |||
| 49177a5b12 | |||
| 3f81b7be3a | |||
| b6f45aa93c | |||
| 2bb77d1bed | |||
| 70a056a1d6 | |||
| d9e1febfee | |||
| df96cead93 | |||
| b9716d368d | |||
| d9827c9e82 | |||
| a6bde0258d | |||
| a987241120 | |||
| ebf7186b06 | |||
| 150832419a | |||
| 4893c91c20 | |||
| dc0d688885 | |||
| 46c7d9ffeb | |||
| 16a04ae4b8 | |||
| 574baa6860 | |||
| 35e8d6761e | |||
| faee0b45d6 | |||
| 8b28949d73 | |||
| 6cf24731ce | |||
| 8a9ded42b5 | |||
| f522a369ab | |||
| 48b6a06571 | |||
| 1f47b6f4dd | |||
| 25d5bf142a | |||
| fbd4bd4aec | |||
| ce370a9fc6 | |||
| 2896c60c5f | |||
| 97e4d0be98 | |||
| 3e4a4512a3 | |||
| b83a8d12f2 | |||
| 01bac9840f | |||
| 73e1e64a33 | |||
| fe25231da7 | |||
| 94e024bd5f | |||
| dadb688c50 | |||
| f352c298e7 | |||
| fca23c4fc8 | |||
| 7279798bd4 | |||
| fa1b3f1bcf | |||
| 18e9af651d | |||
| 42493a950c | |||
| db31be4ec8 | |||
| c562c99d3a | |||
| 2f3d5161ed | |||
| ae52a79bd0 | |||
| 00c16627cc | |||
| a84d837942 | |||
| 33995ffee1 | |||
| 7eed38f27e | |||
| b49dc69a25 | |||
| 3111e14732 | |||
| d5444bd47f | |||
| 17564282e8 | |||
| a2aadb5d2b | |||
| 6e0f300518 | |||
| 218ed42f00 | |||
| 7f61ce169e | |||
| d92590f764 | |||
| 7dece74698 | |||
| 6872853125 | |||
| 6552783d64 | |||
| e2ad5cef75 | |||
| f509d74f62 | |||
| 7ff4c1de29 | |||
| a986878a3b | |||
| 59ea95a912 | |||
| 5355194026 | |||
| c3a1e1e252 | |||
| 3cd516039a | |||
| 6aff578ca5 | |||
| cb9d5e35fd | |||
| 625475b740 | |||
| 516991c33a | |||
| 4a971a93b0 | |||
| 27b725e8ec | |||
| 694715a90c | |||
| 11b9e8b04c | |||
| b9d1880951 | |||
| 50bae8d2ce | |||
| 4c204b0a80 | |||
| b5d55bd258 | |||
| bd0f2e0171 | |||
| 7bab2937bc | |||
| ec769818f5 | |||
| 1c1b5ea14e | |||
| e68dcb068b | |||
| ad5bc57eee | |||
| d17d5c01e8 | |||
| 2db7e1f9f5 | |||
| 428db97ba2 | |||
| 86f41a9d90 | |||
| de46b2c631 | |||
| 78e2625883 | |||
| 11780cdbd4 | |||
| 1e7a1c15c9 | |||
| 3d764703dd | |||
| f124efd8c1 | |||
| c9bb4799ce | |||
| 3c9c72d25f |
@@ -2,26 +2,27 @@
|
||||
DB_NAME=piku_test_db
|
||||
DB_USER=test
|
||||
DB_PASSWORD=password123
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5433
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5443
|
||||
|
||||
# SSL
|
||||
SSL_ENABLED=false
|
||||
SSL_ENABLED=true
|
||||
|
||||
# DJANGO
|
||||
DEBUG=True
|
||||
SECRET_KEY=django-insecure-initial-key
|
||||
BACKEND_DOMAIN=127.0.0.1
|
||||
BACKEND_PORT=8001
|
||||
BACKEND_PORT=8101
|
||||
|
||||
# EMAIL
|
||||
EMAIL_HOST=127.0.0.1
|
||||
EMAIL_PORT=1026
|
||||
FROM_EMAIL="Test <test@pi-ku.app>"
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
FROM_EMAIL="Test <test@pi-ku.app>"
|
||||
EMAIL_API_PORT=8026
|
||||
|
||||
# FRONTEND
|
||||
FRONTEND_PORT=5199
|
||||
FRONTEND_DOMAIN=127.0.0.1
|
||||
VITE_API_URL=https://127.0.0.1:8101
|
||||
|
||||
@@ -2,23 +2,33 @@
|
||||
DB_NAME=piku
|
||||
DB_USER=user
|
||||
DB_PASSWORD=password123
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5442
|
||||
|
||||
# SSL
|
||||
SSL_ENABLED=true
|
||||
S3_ENABLED=false
|
||||
|
||||
# DJANGO
|
||||
DEBUG=True
|
||||
SECRET_KEY=django-secret-key
|
||||
BACKEND_DOMAIN=127.0.0.1
|
||||
BACKEND_PORT=8000
|
||||
BACKEND_PORT=8100
|
||||
# S3
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_REGION_NAME=
|
||||
R2_ENDPOINT_URL=
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# EMAIL
|
||||
EMAIL_HOST=127.0.0.1
|
||||
EMAIL_PORT=1025
|
||||
FROM_EMAIL=Pi Ku <no-reply@test.com>
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
FROM_EMAIL="Pi Ku <no-reply@test.com>"
|
||||
|
||||
# FRONTEND
|
||||
FRONTEND_PORT=5173
|
||||
FRONTEND_DOMAIN=127.0.0.1
|
||||
VITE_API_URL=https://127.0.0.1:8100
|
||||
|
||||
@@ -19,11 +19,12 @@ jobs:
|
||||
mkcert -install
|
||||
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
||||
|
||||
- name: Cache certificates
|
||||
uses: actions/cache/save@v4
|
||||
- name: Upload certificates
|
||||
uses: christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
retention-days: 1
|
||||
|
||||
frontend:
|
||||
name: Frontend CI
|
||||
@@ -37,10 +38,10 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Restore certificates
|
||||
uses: actions/cache/restore@v4
|
||||
uses: christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -61,15 +62,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup-environment
|
||||
services:
|
||||
postgres:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: piku
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_DB: piku__test
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: password123
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
- 5442:5432
|
||||
options: --tmpfs /var/lib/postgresql/data --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./backend
|
||||
@@ -82,18 +83,28 @@ jobs:
|
||||
cache-dependency-glob: "backend/uv.lock"
|
||||
|
||||
- name: Restore certificates
|
||||
uses: actions/cache/restore@v4
|
||||
uses: christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
|
||||
- name: Setup Environment
|
||||
- name: Setup & Test
|
||||
run: |
|
||||
cp ../.env.example ../.env
|
||||
uv sync
|
||||
|
||||
- name: Lint & Test
|
||||
run: |
|
||||
export DB_NAME="piku__test"
|
||||
export DB_USER="test"
|
||||
export DB_PASSWORD="password123"
|
||||
|
||||
if [ "$GITEA_ACTIONS" = "true" ]; then
|
||||
export DB_HOST="db"
|
||||
export DB_PORT="5432"
|
||||
else
|
||||
export DB_HOST="127.0.0.1"
|
||||
export DB_PORT="5442"
|
||||
fi
|
||||
|
||||
uv run ruff check
|
||||
uv run python manage.py test
|
||||
|
||||
@@ -101,23 +112,27 @@ jobs:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup-environment
|
||||
# Skipping on Gitea pushes until cache server is configured
|
||||
if: github.server_url == 'https://github.com' || github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Certificates
|
||||
uses: actions/cache/restore@v4
|
||||
uses: christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
path: certs
|
||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
||||
name: ssl-certs
|
||||
path: certs/
|
||||
|
||||
- name: Setup Tools
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
|
||||
- name: Cache Playwright
|
||||
id: playwright-cache
|
||||
# Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing
|
||||
# TODO: setup cache server in Gitea
|
||||
if: github.server_url == 'https://github.com'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
@@ -140,8 +155,12 @@ jobs:
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 10
|
||||
|
||||
- name: Print Backend Logs on Failure
|
||||
if: failure()
|
||||
run: cat tmp/logs/backend.log || true
|
||||
|
||||
@@ -13,3 +13,17 @@ dist/
|
||||
# Certificates
|
||||
certs/*.pem
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.venv
|
||||
@@ -10,3 +10,4 @@ __pycache__/
|
||||
|
||||
docs/
|
||||
encrypted-images/
|
||||
logs/
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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"]
|
||||
@@ -0,0 +1,96 @@
|
||||
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,20 +16,36 @@ from pathlib import Path
|
||||
|
||||
import environ
|
||||
|
||||
from .logging import LOGGING
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Load dotenv files
|
||||
env = environ.Env()
|
||||
env_file = os.path.join(BASE_DIR.parent, ".env")
|
||||
env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env"))
|
||||
if os.path.exists(env_file):
|
||||
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")
|
||||
|
||||
SSL_ENABLED = env("SSL_ENABLED") == "true"
|
||||
FRONTEND_URL = f"https://{env('FRONTEND_DOMAIN')}" if SSL_ENABLED else f"http://{env('FRONTEND_DOMAIN')}"
|
||||
if env("FRONTEND_PORT"):
|
||||
FRONTEND_URL += f":{env('FRONTEND_PORT')}"
|
||||
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||
|
||||
SSL_ENABLED = env.bool("SSL_ENABLED", default=False)
|
||||
URI_SCHEME = "https://" if SSL_ENABLED else "http://"
|
||||
|
||||
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
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
@@ -38,19 +54,20 @@ if env("FRONTEND_PORT"):
|
||||
SECRET_KEY = env("SECRET_KEY")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = env("DEBUG")
|
||||
|
||||
ALLOWED_HOSTS = [env("FRONTEND_DOMAIN")]
|
||||
DEBUG = env.bool("DEBUG", default=False)
|
||||
|
||||
LOGGING = LOGGING
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django_apscheduler",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.staticfiles",
|
||||
"django_extensions",
|
||||
"django_structlog",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"users",
|
||||
@@ -67,13 +84,29 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"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"
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
@@ -88,7 +121,8 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [FRONTEND_URL]
|
||||
CORS_ALLOWED_ORIGINS = FRONTEND_URLS
|
||||
CSRF_TRUSTED_ORIGINS += FRONTEND_URLS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
@@ -96,6 +130,7 @@ AUTH_USER_MODEL = "users.User"
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
@@ -112,8 +147,8 @@ NOTE: COOKIE_SAMESITE: Lax is used to allow cross-site redirection, like links
|
||||
"""
|
||||
AUTH_COOKIE = {
|
||||
"NAME": "refresh_token",
|
||||
"DOMAIN": None,
|
||||
"SECURE": SSL_ENABLED,
|
||||
"DOMAIN": None if DEBUG else env("FRONTEND_DOMAIN"),
|
||||
"SECURE": SSL_ENABLED if DEBUG else True,
|
||||
"HTTPONLY": True,
|
||||
"SAMESITE": "Lax",
|
||||
}
|
||||
@@ -121,11 +156,13 @@ AUTH_COOKIE = {
|
||||
# Email config
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
EMAIL_HOST = env("EMAIL_HOST")
|
||||
EMAIL_PORT = env("EMAIL_PORT")
|
||||
EMAIL_USE_TLS = not DEBUG
|
||||
EMAIL_PORT = env.int("EMAIL_PORT")
|
||||
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
||||
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")
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
@@ -144,7 +181,6 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
@@ -156,10 +192,36 @@ USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import os
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LettersConfig(AppConfig):
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -24,10 +25,11 @@ class Letter(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
encrypted_content = models.TextField(null=True, blank=True)
|
||||
encrypted_metadata = models.TextField(null=True, blank=True)
|
||||
unlock_at = models.DateTimeField(null=True, blank=True)
|
||||
unlock_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
sealed_at = models.DateTimeField(null=True, blank=True)
|
||||
opened_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)
|
||||
|
||||
def clean(self):
|
||||
@@ -38,6 +40,16 @@ class Letter(models.Model):
|
||||
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.")
|
||||
|
||||
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):
|
||||
return f"{self.type} - {self.status}"
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from letters.models import Letter, LetterImage
|
||||
@@ -34,6 +36,25 @@ class LetterSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
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):
|
||||
"""
|
||||
Validates the requirmnt of DEK when encrypted content and metadata are stored.
|
||||
@@ -42,4 +63,6 @@ class LetterSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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,3 +1,7 @@
|
||||
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.core.files.base import ContentFile
|
||||
from django.test import TestCase
|
||||
@@ -208,6 +212,102 @@ class LetterAPITest(APITestCase):
|
||||
self.assertFalse(default_storage.exists("encrypted-images/old2.bin"))
|
||||
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):
|
||||
def setUp(self):
|
||||
@@ -239,3 +339,73 @@ class LetterImageModelTest(TestCase):
|
||||
self.assertEqual(LetterImage.objects.count(), 1)
|
||||
self.letter.delete()
|
||||
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,3 +62,24 @@ class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
response_serializer = self.get_serializer(letter)
|
||||
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,15 +5,25 @@ description = "Django Rest Framework for handling requests for Pi Ku app"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"apscheduler>=3.11.2",
|
||||
"boto3>=1.42.96",
|
||||
"django>=6.0.4",
|
||||
"django-apscheduler>=0.7.0",
|
||||
"django-cors-headers>=4.9.0",
|
||||
"django-environ>=0.13.0",
|
||||
"django-extensions>=4.1",
|
||||
"django-storages>=1.14.6",
|
||||
"django-structlog>=10.0.0",
|
||||
"djangorestframework>=3.17.1",
|
||||
"djangorestframework-simplejwt>=5.5.1",
|
||||
"djangorestframework-stubs>=3.16.9",
|
||||
"freezegun>=1.5.5",
|
||||
"gunicorn>=25.3.0",
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"pyopenssl>=26.0.0",
|
||||
"rich>=15.0.0",
|
||||
"ruff>=0.15.9",
|
||||
"structlog>=25.5.0",
|
||||
"werkzeug>=3.1.8",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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,7 +12,8 @@ class Command(BaseCommand):
|
||||
If SSL is enabled, use runserver_plus command.
|
||||
If SSL is not enabled, use runserver command.
|
||||
"""
|
||||
ssl_enabled = os.getenv("SSL_ENABLED", "false").lower() == "true"
|
||||
ssl_enabled = os.getenv("SSL_ENABLED", "false").lower().strip() == "true"
|
||||
|
||||
domain = os.getenv("BACKEND_DOMAIN", "127.0.0.1")
|
||||
port = os.getenv("BACKEND_PORT", "8000")
|
||||
addrport = f"{domain}:{port}"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends 'email/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div style="padding: 15px; font-style: italic">
|
||||
<p>{{ pen_name }},</p>
|
||||
<p>
|
||||
Your destination is one train away.
|
||||
</p>
|
||||
<p>I've been keeping a place for your words.<br/>
|
||||
Come when you're ready.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block footnote %}
|
||||
This link expires in 24 hours.<br/>
|
||||
I'm patient, but not endlessly so.
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
Didn't write to me? Then someone else did.<br/>
|
||||
Ignore this. I'll forget you were ever here.
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
pi. ku.
|
||||
-------------------------------------------
|
||||
|
||||
{{pen_name}},
|
||||
|
||||
Your destination is one train away.
|
||||
|
||||
I've been keeping a place for your words.
|
||||
Come when you're ready.
|
||||
|
||||
{{ cta.title }} -> {{ cta.link }}
|
||||
|
||||
-------------------------------------------
|
||||
|
||||
This link expires in 24 hours.
|
||||
I'm patient, but not endlessly so.
|
||||
|
||||
-------------------------------------------
|
||||
|
||||
Didn't write to me? Then someone else did.
|
||||
Ignore this. I'll forget you were ever here.
|
||||
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>pi. ku.</title>
|
||||
</head>
|
||||
|
||||
<body style="margin:0; padding:0; background-color:#1a1712;">
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="background-color:#1a1712; font-family: 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 48px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
|
||||
style="max-width:480px; width:100%;">
|
||||
|
||||
{# Logo #}
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom: 24px;">
|
||||
<img src="https://cdn.jsdelivr.net/gh/ramvignesh-b/cdn@main/pi-ku_logo.png" width="80"
|
||||
alt="Pi.Ku" style="display:block; border:0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{# Body #}
|
||||
<tr>
|
||||
<td style="font-family: 'Trebuchet MS', 'Lucida Sans Unicode', Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
color: #cdccca;
|
||||
font-style: italic;
|
||||
padding-bottom: 24px;">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{# CTA #}
|
||||
{% if cta %}
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom: 24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="background-color: #301e19; border-radius: 3px;">
|
||||
<a href='{{ cta.link }}' style="display: inline-block;
|
||||
padding: 12px 24px;
|
||||
font-family: 'Trebuchet MS', Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #f5e6c8;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: bold;">
|
||||
{{ cta.title }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if footnote %}
|
||||
<tr>
|
||||
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: #7a7974;
|
||||
padding-bottom: 40px;
|
||||
line-height: 1.8;">
|
||||
{% block footnote %}
|
||||
{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{# Footer #}
|
||||
<tr>
|
||||
<td style="border-top: 1px solid #2e2c29; padding-bottom: 24px; font-size: 0; line-height: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: #5a5957;
|
||||
line-height: 1.8;">
|
||||
{% block footer %}
|
||||
{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends 'email/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
Time has a way of making things clearer.<br/>
|
||||
Or heavier. Sometimes both.
|
||||
</p>
|
||||
<p>
|
||||
You had something to say at this exact moment.<br/>
|
||||
I kept it exactly as you left it. <br/>
|
||||
Not a word changed. Not a word read.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block footnote %}
|
||||
<p>
|
||||
You're ready now. Or maybe you're still not.<br/>
|
||||
Open it anyway. You won't regret it.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
pi. ku.
|
||||
-------------------------------------------
|
||||
|
||||
{{pen_name}},
|
||||
|
||||
Time has a way of making things clearer.
|
||||
Or heavier. Sometimes both.
|
||||
|
||||
You had something to say at this exact moment.
|
||||
I kept it exactly as you left it.
|
||||
Not a word changed. Not a word read.
|
||||
|
||||
{{ cta.title }} -> {{ cta.link }}
|
||||
|
||||
-------------------------------------------
|
||||
You're ready now. Or maybe you're still not.
|
||||
Open it anyway. You won't regret it.
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
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.http import urlsafe_base64_encode
|
||||
|
||||
@@ -8,17 +9,26 @@ from django.utils.http import urlsafe_base64_encode
|
||||
def send_activation_email(user):
|
||||
token = default_token_generator.make_token(user)
|
||||
uid = urlsafe_base64_encode(force_bytes(user.public_id))
|
||||
activation_url = f"{settings.FRONTEND_URL}/activate/{uid}/{token}"
|
||||
subject = "Activate Your Piku Account"
|
||||
message = f"""Hi {user.full_name},
|
||||
|
||||
Welcome to Pi Ku.
|
||||
|
||||
Please click the link below to activate your account:
|
||||
>> {activation_url}
|
||||
|
||||
If you did not create this account, please ignore this email."""
|
||||
send_mail(subject, message, settings.FROM_EMAIL, [user.email], fail_silently=False)
|
||||
activation_url = f"{settings.FRONTEND_URLS[0]}/activate/{uid}/{token}"
|
||||
subject = "Activate your pi. ku. account"
|
||||
context = {
|
||||
"pen_name": user.full_name,
|
||||
"footnote": True,
|
||||
"cta": {
|
||||
"title": "Onboard",
|
||||
"link": activation_url,
|
||||
},
|
||||
}
|
||||
html_content = render_to_string("email/activation.html", context)
|
||||
plain_content = render_to_string("email/activation.txt", context)
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_content,
|
||||
from_email=settings.FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
html_message=html_content,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@ version = 1
|
||||
revision = 3
|
||||
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]]
|
||||
name = "asgiref"
|
||||
version = "3.11.1"
|
||||
@@ -11,6 +23,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
@@ -111,6 +151,19 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "django-cors-headers"
|
||||
version = "4.9.0"
|
||||
@@ -145,6 +198,73 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "djangorestframework"
|
||||
version = "3.17.1"
|
||||
@@ -171,6 +291,65 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
@@ -201,34 +380,72 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "piku-backend"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
{ name = "boto3" },
|
||||
{ name = "django" },
|
||||
{ name = "django-apscheduler" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "django-environ" },
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-storages" },
|
||||
{ name = "django-structlog" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "djangorestframework-simplejwt" },
|
||||
{ name = "djangorestframework-stubs" },
|
||||
{ name = "freezegun" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pyopenssl" },
|
||||
{ name = "rich" },
|
||||
{ name = "ruff" },
|
||||
{ name = "structlog" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "apscheduler", specifier = ">=3.11.2" },
|
||||
{ name = "boto3", specifier = ">=1.42.96" },
|
||||
{ name = "django", specifier = ">=6.0.4" },
|
||||
{ name = "django-apscheduler", specifier = ">=0.7.0" },
|
||||
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||
{ name = "django-environ", specifier = ">=0.13.0" },
|
||||
{ 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-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 = "pyopenssl", specifier = ">=26.0.0" },
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.9" },
|
||||
{ name = "structlog", specifier = ">=25.5.0" },
|
||||
{ name = "werkzeug", specifier = ">=3.1.8" },
|
||||
]
|
||||
|
||||
@@ -260,6 +477,15 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
@@ -281,6 +507,40 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "ruff"
|
||||
version = "0.15.9"
|
||||
@@ -306,6 +566,27 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
@@ -315,6 +596,33 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tzdata"
|
||||
version = "2026.1"
|
||||
@@ -324,6 +632,27 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.8"
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"noUnusedVariables": "error"
|
||||
}
|
||||
},
|
||||
"includes": ["**/src", "!backend"]
|
||||
"includes": ["**", "!backend"]
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
name: piku_e2e
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
@@ -2,7 +2,6 @@ services:
|
||||
db:
|
||||
# postgres database
|
||||
image: postgres:16-alpine
|
||||
container_name: piku_db
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
@@ -16,7 +15,6 @@ services:
|
||||
mailpit:
|
||||
# email testing
|
||||
image: axllent/mailpit
|
||||
container_name: piku_mail
|
||||
ports:
|
||||
- "8025:8025" # Web UI
|
||||
- "${EMAIL_PORT}:1025" # SMTP
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
node_modules
|
||||
test-results
|
||||
playwright-report
|
||||
dist
|
||||
coverage
|
||||
@@ -0,0 +1,25 @@
|
||||
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,8 +8,12 @@
|
||||
"@fontsource-variable/jost": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||
"@fontsource/architects-daughter": "^5.2.7",
|
||||
"@fontsource/cutive-mono": "^5.2.8",
|
||||
"@fontsource/kavivanar": "^5.2.8",
|
||||
"@fontsource/knewave": "^5.2.7",
|
||||
"@fontsource/redacted-script": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
@@ -17,6 +21,8 @@
|
||||
"daisyui": "^5.5.19",
|
||||
"fabric": "^7.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"lenis": "^1.3.23",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
@@ -118,10 +124,18 @@
|
||||
|
||||
"@fontsource-variable/playwrite-hr-lijeva": ["@fontsource-variable/playwrite-hr-lijeva@5.2.7", "", {}, "sha512-cQqbD8HHZDpiKdtgwUxgwAY76TC+GI9iZOxHSW0XkV/L8lA0X18z1wzR+J8yv9XZQYgLJ5WfzBGwzMSLnSLdPA=="],
|
||||
|
||||
"@fontsource/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/kavivanar": ["@fontsource/kavivanar@5.2.8", "", {}, "sha512-wbr/9vQ2da9aabUngCpWLbbHM08XZK3nkLDuQ0eX/BhdVvoJx0MSPzaKJ0WIiKpVHy3fUL8ewOqpCyidGZlvEg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
||||
@@ -402,6 +416,8 @@
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -476,6 +492,8 @@
|
||||
|
||||
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
||||
|
||||
"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-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -526,6 +544,12 @@
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"msw": ["msw@2.13.2", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A=="],
|
||||
@@ -712,6 +736,8 @@
|
||||
|
||||
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import pino from "pino";
|
||||
import { AuthHelper } from "./utils/auth";
|
||||
import { revealEnvelope } from "./utils/envelope";
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
@@ -22,20 +23,19 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||
|
||||
logger.info(">> [Draft] Navigating to Editor via UI...");
|
||||
await page.getByRole("button", { name: /write something/i }).click();
|
||||
await page.getByTestId("write-letter-btn").click();
|
||||
|
||||
logger.info(`>> [Draft] Current URL after click: ${page.url()}`);
|
||||
|
||||
// Wait for the recipient input to be present in the DOM
|
||||
const recipientInput = page.locator("#recipient");
|
||||
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
|
||||
// Editor page
|
||||
await expect(page.getByTestId("recipient-input")).toBeVisible();
|
||||
const recipientInput = page.getByTestId("recipient-input");
|
||||
|
||||
const recipientName = "Dear Friend";
|
||||
await recipientInput.fill(recipientName);
|
||||
|
||||
// Initial load: verify textarea value (populated by Fabric when focused)
|
||||
const canvasInput = page.getByLabel("Canvas text input");
|
||||
await canvasInput.waitFor({ state: "attached" });
|
||||
const canvasInput = page.locator("textarea");
|
||||
await canvasInput.focus();
|
||||
await expect(canvasInput).toHaveValue(/Take a deep breath/i);
|
||||
|
||||
@@ -45,11 +45,11 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await page.keyboard.type("This is a secret draft");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("It should persist.");
|
||||
logger.info(">> [Draft] Clicking Store...");
|
||||
await page.getByRole("button", { name: /store/i }).click();
|
||||
logger.info(">> [Draft] Clicking Draft...");
|
||||
await page.getByTestId("draft-btn").click();
|
||||
|
||||
// Verify Success Modal/Alert
|
||||
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
|
||||
await expect(page.getByTestId("save-success-toast")).toBeVisible();
|
||||
|
||||
// Verify URL updated with a UUID
|
||||
await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/);
|
||||
@@ -60,23 +60,23 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
logger.info(">> [Draft] Reloading to verify persistence...");
|
||||
await page.goto(savedUrl);
|
||||
|
||||
// Wait for initial load overlay to disappear
|
||||
await expect(page.getByText(/opening your draft/i)).toBeHidden();
|
||||
// Wait for initial load overlay to appear and then definitely disappear
|
||||
await expect(page.getByTestId("opening-draft-overlay")).toBeHidden();
|
||||
|
||||
// Check recipient
|
||||
await expect(page.locator("#recipient")).toHaveValue(recipientName);
|
||||
await expect(page.getByTestId("recipient-input")).toHaveValue(recipientName);
|
||||
|
||||
// Check canvas content
|
||||
// We wait for the content to appear in the textarea.
|
||||
// toHaveValue will poll until it matches or timeouts.
|
||||
await canvasInput.focus();
|
||||
await expect(canvasInput).toHaveValue(/This is a secret draft/i, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(canvasInput).toHaveValue(/This is a secret draft/i);
|
||||
await expect(canvasInput).toHaveValue(/It should persist/i);
|
||||
});
|
||||
|
||||
test("should seal a letter and show sharing link", async ({ page }) => {
|
||||
test("should seal a letter and navigate to Reader, then share on demand", async ({
|
||||
page,
|
||||
}) => {
|
||||
const timestamp = Date.now() + Math.random();
|
||||
const email = `seal-${timestamp}@example.com`;
|
||||
const name = `Seal Author ${timestamp}`;
|
||||
@@ -84,39 +84,52 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||
|
||||
logger.info(">> [Seal] Navigating to Editor via UI...");
|
||||
await page.getByRole("button", { name: /write something/i }).click();
|
||||
await page.getByTestId("write-letter-btn").click();
|
||||
|
||||
const recipientInput = page.locator("#recipient");
|
||||
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
|
||||
const recipientInput = page.getByTestId("recipient-input");
|
||||
await recipientInput.fill("A Secret Guest");
|
||||
|
||||
const canvasInput = page.getByLabel("Canvas text input");
|
||||
const canvasInput = page.locator("textarea");
|
||||
await canvasInput.focus();
|
||||
await canvasInput.fill("This letter will be sealed and shared.");
|
||||
|
||||
// Click Seal
|
||||
// Click Seal (open menu, then confirm)
|
||||
logger.info(">> [Seal] Clicking Seal...");
|
||||
await page.getByRole("button", { name: /seal/i }).click();
|
||||
await page.getByTestId("seal-trigger-btn").click();
|
||||
await page.getByTestId("seal-confirm-btn").click();
|
||||
|
||||
// Verify "Sealed & Ready" modal
|
||||
logger.info(">> [Seal] Verifying sharing modal...");
|
||||
await expect(page.getByText(/sealed & ready/i)).toBeVisible();
|
||||
// Should show sealed confirmation modal
|
||||
logger.info(">> [Seal] Verifying sealed modal...");
|
||||
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
|
||||
|
||||
// Verify sharing link contains a hash (the key)
|
||||
const linkInput = page.locator("input[readOnly]");
|
||||
// Navigate to Reader via "View letter"
|
||||
await page.getByTestId("view-letter-btn").click();
|
||||
|
||||
// Should be on Reader URL
|
||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/);
|
||||
|
||||
// Open the envelope to reveal the letter
|
||||
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
|
||||
// Flip the envelope to show the seal and reveal the letter
|
||||
await revealEnvelope(page);
|
||||
await expect(page.getByTestId("envelope-letter")).toBeHidden();
|
||||
|
||||
// Share on demand
|
||||
logger.info(">> [Seal] Clicking Share button in Reader...");
|
||||
await page.getByTestId("share-letter-btn").click();
|
||||
|
||||
// Verify share modal with a valid link
|
||||
await expect(page.getByTestId("share-letter-modal")).toBeVisible();
|
||||
const linkInput = page.locator("#share-link-input");
|
||||
const linkValue = await linkInput.inputValue();
|
||||
|
||||
expect(linkValue).toContain("/read/");
|
||||
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();
|
||||
|
||||
// Close modal
|
||||
await page.getByRole("button", { name: /close/i }).click();
|
||||
await expect(page.getByText(/sealed & ready/i)).toBeHidden();
|
||||
await expect(page.getByTestId("copy-link-btn")).toBeVisible();
|
||||
// Assuming Close button in ShareModal might need a testid too, but for now let's use text if unique or add testid
|
||||
await page.getByTestId("modal-close-btn").click();
|
||||
await expect(page.getByTestId("share-letter-modal")).toBeHidden();
|
||||
});
|
||||
|
||||
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
||||
@@ -131,51 +144,44 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||
|
||||
logger.info(">> [Drawer] Creating and sealing a letter...");
|
||||
await page.getByRole("button", { name: /write something/i }).click();
|
||||
await page.getByTestId("write-letter-btn").click();
|
||||
|
||||
const recipientInput = page.locator("#recipient");
|
||||
await recipientInput.waitFor({ state: "visible" });
|
||||
const recipientInput = page.getByTestId("recipient-input");
|
||||
await recipientInput.fill(recipientName);
|
||||
|
||||
const canvasInput = page.getByLabel("Canvas text input");
|
||||
const canvasInput = page.locator("textarea");
|
||||
await canvasInput.focus();
|
||||
await canvasInput.fill(letterContent);
|
||||
|
||||
// Click Seal
|
||||
await page.getByRole("button", { name: /seal/i }).click();
|
||||
await expect(page.getByText(/sealed & ready/i)).toBeVisible();
|
||||
// Click Seal (open menu, then confirm)
|
||||
await page.getByTestId("seal-trigger-btn").click();
|
||||
await page.getByTestId("seal-confirm-btn").click();
|
||||
|
||||
// Close modal
|
||||
await page.getByRole("button", { name: /close/i }).click();
|
||||
|
||||
// Navigate to Drawer - use ID or precise label
|
||||
logger.info(">> [Drawer] Navigating to Drawer...");
|
||||
await page.locator("button[aria-label='Open Drawer']").click();
|
||||
// Sealed modal should appear — click "Keep it" to go to Drawer
|
||||
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
|
||||
await page.getByTestId("keep-it-btn").click();
|
||||
|
||||
// Open "Kept" section - search for the section with id='kept' and click its toggle button
|
||||
logger.info(">> [Drawer] Opening Kept section...");
|
||||
const keptSection = page.locator("#kept");
|
||||
await keptSection.getByRole("button", { name: /kept/i }).click();
|
||||
await page.getByTestId("drawer-section-kept").click();
|
||||
|
||||
// Find the sealed letter in the drawer by recipient name and click it
|
||||
logger.info(">> [Drawer] Clicking sealed letter in drawer...");
|
||||
const sealedItem = page
|
||||
.getByRole("button", { name: new RegExp(recipientName, "i") })
|
||||
.getByTestId(/^letter-item-/)
|
||||
.filter({ hasText: recipientName })
|
||||
.first();
|
||||
await sealedItem.click();
|
||||
|
||||
// Verify it opens the Reader without a hash
|
||||
logger.info(">> [Drawer] Verifying Reader page...");
|
||||
// Give it a bit more time for decryption
|
||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
|
||||
|
||||
// Check decrypted content in Reader
|
||||
await expect(page.getByText(/decrypting/i)).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(new RegExp(`A sealed letter for ${recipientName}`, "i")),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/);
|
||||
// Reveal and check decrypted content in Reader
|
||||
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
|
||||
// Flip the envelope and reveal the letter
|
||||
await revealEnvelope(page);
|
||||
await expect(page.getByTestId("envelope-letter")).toBeHidden();
|
||||
|
||||
// Also check if we are redirected to the Reader if we manually go to the Editor URL
|
||||
const readerUrl = page.url();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import pino from "pino";
|
||||
import { MailpitHelper } from "./mailpit";
|
||||
import { handleWelcomeLetter } from "./envelope";
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
@@ -14,48 +15,46 @@ const logger = pino({
|
||||
/**
|
||||
* Completes the full registration -> activation -> login cycle.
|
||||
*/
|
||||
export async function registerAndLogin(
|
||||
async function registerAndLogin(
|
||||
page: Page,
|
||||
email: string,
|
||||
fullName: string,
|
||||
password: string,
|
||||
) {
|
||||
// 1. Registration
|
||||
// Register the User
|
||||
logger.info(`[Auth] Registering user: ${email}`);
|
||||
await page.goto("/onboard");
|
||||
await page.getByLabel(/full name/i).fill(fullName);
|
||||
await page.getByLabel("Email", { exact: true }).fill(email);
|
||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||
await page.getByLabel(/confirm password/i).fill(password);
|
||||
await page.getByRole("button", { name: /^register$/i }).click();
|
||||
await page.getByTestId("pen-name-input").fill(fullName);
|
||||
await page.getByTestId("email-input").fill(email);
|
||||
await page.getByTestId("password-input").fill(password);
|
||||
await page.getByTestId("confirm-password-input").fill(password);
|
||||
await page.getByTestId("register-submit-btn").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/verify-email/);
|
||||
|
||||
// 2. Activation via Mailpit
|
||||
// Get activation URL from Mailpit and activate user
|
||||
logger.info(`[Auth] Polling Mailpit for activation email...`);
|
||||
const activationLink = await MailpitHelper.getActivationLink(email);
|
||||
|
||||
await page.goto(activationLink);
|
||||
|
||||
await expect(page.getByText(/account activated/i)).toBeVisible();
|
||||
await page.getByRole("button", { name: /start writing/i }).click();
|
||||
await expect(page.getByTestId("activation-success-header")).toBeVisible();
|
||||
await page.getByTestId("start-writing-btn").click();
|
||||
|
||||
// 3. Login
|
||||
// Dismiss the Welcom Modal and Perform Login
|
||||
logger.info(`[Auth] Logging in...`);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
const welcomeButton = page.getByRole("button", { name: /i understand/i });
|
||||
await welcomeButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await welcomeButton.click();
|
||||
await expect(welcomeButton).toBeHidden();
|
||||
await page.getByTestId("welcome-dismiss-btn").click();
|
||||
await expect(page.getByTestId("welcome-dismiss-btn")).toBeHidden();
|
||||
|
||||
await page.getByLabel("Email", { exact: true }).fill(email);
|
||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
await page.getByTestId("email-input").fill(email);
|
||||
await page.getByTestId("password-input").fill(password);
|
||||
await page.getByTestId("login-submit-btn").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/drawer/);
|
||||
await handleWelcomeLetter(page);
|
||||
logger.info(`[Auth] Successfully authenticated ${email}`);
|
||||
}
|
||||
|
||||
// Maintain backward compatibility if needed, or update callers
|
||||
export const AuthHelper = { registerAndLogin };
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type Page, expect } from "@playwright/test";
|
||||
import pino from "pino";
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reveal a letter from an envelope.
|
||||
*/
|
||||
export async function revealEnvelope(page: Page) {
|
||||
logger.info("[Envelope] Revealing envelope...");
|
||||
// Click envelope to flip
|
||||
await page.getByTestId("envelope-front").click();
|
||||
|
||||
// Click seal to open flap
|
||||
await page.getByTestId("wax-seal").click();
|
||||
|
||||
// Click letter to reveal
|
||||
await page.getByTestId("envelope-letter").click({ position: { x: 30, y: 15 } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles and dismisses the first welcome letter
|
||||
*/
|
||||
export async function handleWelcomeLetter(page: Page) {
|
||||
logger.info("[Envelope] Handling Welcome Letter...");
|
||||
await revealEnvelope(page);
|
||||
|
||||
// Click "I'll see you" button
|
||||
await page.getByTestId("dismiss-welcome-letter-btn").click();
|
||||
await expect(page.getByTestId("dismiss-welcome-letter-btn")).toBeHidden();
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const MailpitHelper = {
|
||||
});
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
const data: { messages: MailpitMessage[] } = await response.json();
|
||||
if (data.messages?.length > 0) {
|
||||
const msgId = data.messages[0].ID;
|
||||
const detailRes = await requestContext.get(
|
||||
@@ -31,8 +31,8 @@ export const MailpitHelper = {
|
||||
);
|
||||
const details = await detailRes.json();
|
||||
|
||||
const body = details.HTML || details.Text || "";
|
||||
const match = body.match(/https?:\/\/\S+activate\/\S+/);
|
||||
const body = details.Text || "";
|
||||
const match = body.match(/https?:\/\/\S*activate\S*/);
|
||||
|
||||
if (match) return match[0];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>Pi. Ku. | A safe haven for your unsent letters</title>
|
||||
<title>Pi. Ku. | A safe haven for your unsaid and unsent letters</title>
|
||||
<meta name="description"
|
||||
content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal digital letters." />
|
||||
content="Pi. Ku. is a minimal, secure, and beautiful way to write and seal your unsaid words into digital letters." />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b & vite build",
|
||||
"build:prod": "vite build --mode production",
|
||||
"lint": "biome lint --write ./src",
|
||||
"format": "biome format --write ./src",
|
||||
"check": "biome check --write ./src",
|
||||
@@ -15,14 +16,18 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jost": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||
"@fontsource/architects-daughter": "^5.2.7",
|
||||
"@fontsource/cutive-mono": "^5.2.8",
|
||||
"@fontsource/kavivanar": "^5.2.8",
|
||||
"@fontsource/knewave": "^5.2.7",
|
||||
"@fontsource/redacted-script": "^5.2.8",
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
@@ -30,6 +35,8 @@
|
||||
"daisyui": "^5.5.19",
|
||||
"fabric": "^7.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"lenis": "^1.3.23",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
|
||||
@@ -14,9 +14,8 @@ const baseUrl = getBaseUrl(
|
||||
env.FRONTEND_PORT,
|
||||
);
|
||||
|
||||
console.log(baseUrl);
|
||||
export default defineConfig({
|
||||
timeout: 60000,
|
||||
timeout: 80000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
@@ -61,7 +60,8 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "bun run dev -- --mode e2e",
|
||||
// NOTE: using npm here for docker compat mainly
|
||||
command: "npm run build -- --mode e2e && npm run preview -- --mode e2e",
|
||||
url: getBaseUrl(
|
||||
process.env.SSL_ENABLED === "true",
|
||||
process.env.FRONTEND_DOMAIN,
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 755 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 47 KiB |
@@ -1,24 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -1,28 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||
import { AutoRedirectRoute, ProtectedRoute } from "./components/RouteGuards";
|
||||
import SplashScreen from "./components/SplashScreen";
|
||||
import { ROUTES } from "./config/routes";
|
||||
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";
|
||||
|
||||
let authInitialized = false;
|
||||
const Activate = lazy(() => import("./pages/Activate"));
|
||||
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() {
|
||||
const { initialize, isInitializing } = useAuth();
|
||||
const authInitialized = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (authInitialized) return;
|
||||
authInitialized = true;
|
||||
initialize();
|
||||
if (authInitialized.current) return;
|
||||
authInitialized.current = true;
|
||||
initialize().then();
|
||||
}, [initialize]);
|
||||
|
||||
if (isInitializing) {
|
||||
@@ -31,40 +31,48 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full">
|
||||
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-50 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]">
|
||||
<Suspense fallback={<SplashScreen />}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.HOME} element={<Home />} />
|
||||
<Route
|
||||
path={ROUTES.HOME}
|
||||
element={
|
||||
<AutoRedirectRoute>
|
||||
<Home />
|
||||
</AutoRedirectRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path={ROUTES.ONBOARD}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AutoRedirectRoute>
|
||||
<Register />
|
||||
</PublicRoute>
|
||||
</AutoRedirectRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.LOGIN}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AutoRedirectRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
</AutoRedirectRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.VERIFY_EMAIL}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AutoRedirectRoute>
|
||||
<VerifyEmail />
|
||||
</PublicRoute>
|
||||
</AutoRedirectRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.ACTIVATE}
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AutoRedirectRoute>
|
||||
<Activate />
|
||||
</PublicRoute>
|
||||
</AutoRedirectRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -85,8 +93,10 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route path={ROUTES.READ} element={<Reader />} />
|
||||
<Route path={ROUTES.ABOUT} element={<About />} />
|
||||
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
@@ -21,13 +13,10 @@ beforeEach(() => {
|
||||
user: null,
|
||||
isInitializing: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_API_URL", VITE_API_URL);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@ import axios from "axios";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
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)
|
||||
export const publicApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
baseURL: apiServerUrl,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// api for all authenticated requests
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
baseURL: apiServerUrl,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// auto-attach access token to authenticated requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
@@ -22,29 +22,28 @@ api.interceptors.request.use((config) => {
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle 401 errors by attempting a silent refresh
|
||||
// auto handle 401 errors by attempting a silent refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 and we haven't tried refreshing yet
|
||||
// if first time 401 and we haven't tried refreshing yet, we proceed with silent refresh
|
||||
// else it could mean the refresh also 401'd
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Attempt silent refresh
|
||||
const { data } = await publicApi.post(endpoints.REFRESH);
|
||||
const newAccessToken = data.access;
|
||||
|
||||
// Update store
|
||||
// Update store with the latest accesstoken
|
||||
const { user, setAuth } = useAuthStore.getState();
|
||||
if (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}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface LetterResponseData {
|
||||
public_id: string;
|
||||
type: "KEPT" | "SENT" | "VAULT";
|
||||
status: "DRAFT" | "SEALED" | "BURNED";
|
||||
encrypted_content: string;
|
||||
encrypted_metadata: string;
|
||||
encrypted_dek: string;
|
||||
unlock_at: string | null;
|
||||
sealed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
images: LetterImageData[];
|
||||
}
|
||||
|
||||
export interface LetterImageData {
|
||||
public_id: string;
|
||||
file: string;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
export interface LetterMetadata {
|
||||
recipient: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 738 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,26 +1,60 @@
|
||||
import { DotIcon } from "@phosphor-icons/react";
|
||||
import logo from "../assets/logo.svg";
|
||||
import "@fontsource/knewave/400.css";
|
||||
|
||||
export default function Logo() {
|
||||
interface LogoProps {
|
||||
scale?: number;
|
||||
type?: "inline" | "mono" | "logo" | null;
|
||||
ul?: boolean;
|
||||
}
|
||||
|
||||
export default function Logo({
|
||||
scale = 1,
|
||||
type = null,
|
||||
ul = false,
|
||||
}: LogoProps) {
|
||||
if (type === "inline") {
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label="Pi Ku"
|
||||
className="inline-flex items-baseline justify-center leading-none select-none"
|
||||
style={{ fontFamily: "'Knewave', serif" }}
|
||||
>
|
||||
<span className="text-2xl font-light text-accent">Pi</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-accent translate-y-[0.3em] -mx-px"
|
||||
/>
|
||||
<span className="text-2xl font-light text-accent">Ku</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-accent translate-y-[0.3em] -mx-px"
|
||||
/>
|
||||
<span className={"text-accent font-display italic "}>
|
||||
pi<span className="text-primary">.</span> ku
|
||||
<span className="text-primary">.</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "mono") {
|
||||
return (
|
||||
<span className="font-display italic font-bold border-b-3 border-dashed border-stone-800/50">
|
||||
pi. ku.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "logo") {
|
||||
return (
|
||||
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Pi. Ku. logo"
|
||||
className={`inline-flex items-baseline justify-center leading-none select-none ${ul ? "ul-wavy" : ""}`}
|
||||
style={{ fontFamily: "'Knewave', serif", scale }}
|
||||
>
|
||||
<span className="text-3xl font-light text-accent">Pi</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-primary translate-y-1 -mx-px"
|
||||
/>
|
||||
<span className="text-3xl font-light text-accent"> Ku</span>
|
||||
<DotIcon
|
||||
weight="fill"
|
||||
size={12}
|
||||
className="text-primary translate-y-1 -mx-px"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,20 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||
import { useAuthStore } from "../store/useAuthStore";
|
||||
import { ProtectedRoute, PublicRoute } from "./RouteGuards";
|
||||
import { AutoRedirectRoute, ProtectedRoute } from "./RouteGuards";
|
||||
|
||||
function renderGuard(ui: React.ReactNode, mountPath: "/protected" | "/public") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[mountPath]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route path="/drawer" element={<div>Drawer Page</div>} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={<div data-testid="login-page">Login Page</div>}
|
||||
/>
|
||||
<Route
|
||||
path="/drawer"
|
||||
element={<div data-testid="drawer-page">Drawer Page</div>}
|
||||
/>
|
||||
<Route path="/protected" element={ui} />
|
||||
<Route path="/public" element={ui} />
|
||||
</Routes>
|
||||
@@ -35,13 +41,13 @@ describe("ProtectedRoute", () => {
|
||||
});
|
||||
renderGuard(
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
<div data-testid="secret-page">Secret</div>
|
||||
</ProtectedRoute>,
|
||||
"/protected",
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("splash-screen")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("secret-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect unauthenticated users to /login", () => {
|
||||
@@ -52,12 +58,12 @@ describe("ProtectedRoute", () => {
|
||||
});
|
||||
renderGuard(
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
<div data-testid="secret-page">Secret</div>
|
||||
</ProtectedRoute>,
|
||||
"/protected",
|
||||
);
|
||||
expect(screen.getByText("Login Page")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("secret-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render page for authenticated users", () => {
|
||||
@@ -68,12 +74,12 @@ describe("ProtectedRoute", () => {
|
||||
});
|
||||
renderGuard(
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
<div data-testid="secret-page">Secret</div>
|
||||
</ProtectedRoute>,
|
||||
"/protected",
|
||||
);
|
||||
|
||||
expect(screen.getByText("Secret")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,13 +91,13 @@ describe("PublicRoute", () => {
|
||||
user: null,
|
||||
});
|
||||
renderGuard(
|
||||
<PublicRoute>
|
||||
<div>Login Page</div>
|
||||
</PublicRoute>,
|
||||
<AutoRedirectRoute>
|
||||
<div data-testid="mock-login-page">Login Page</div>
|
||||
</AutoRedirectRoute>,
|
||||
"/public",
|
||||
);
|
||||
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("splash-screen")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mock-login-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect authenticated users to /drawer", () => {
|
||||
@@ -101,13 +107,13 @@ describe("PublicRoute", () => {
|
||||
user: mockUser,
|
||||
});
|
||||
renderGuard(
|
||||
<PublicRoute>
|
||||
<div>Login Form</div>
|
||||
</PublicRoute>,
|
||||
<AutoRedirectRoute>
|
||||
<div data-testid="login-form">Login Form</div>
|
||||
</AutoRedirectRoute>,
|
||||
"/public",
|
||||
);
|
||||
expect(screen.getByText("Drawer Page")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Login Form")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("drawer-page")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("login-form")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render page for unauthenticated users", () => {
|
||||
@@ -117,11 +123,11 @@ describe("PublicRoute", () => {
|
||||
user: null,
|
||||
});
|
||||
renderGuard(
|
||||
<PublicRoute>
|
||||
<div>Login Form</div>
|
||||
</PublicRoute>,
|
||||
<AutoRedirectRoute>
|
||||
<div data-testid="login-form">Login Form</div>
|
||||
</AutoRedirectRoute>,
|
||||
"/public",
|
||||
);
|
||||
expect(screen.getByText("Login Form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("login-form")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useAuth } from "../hooks/useAuth";
|
||||
import SplashScreen from "./SplashScreen";
|
||||
|
||||
/**
|
||||
* Post-login routes.
|
||||
* Redirects to /login if not already authenticated.
|
||||
* Private route guard.
|
||||
* If not authenticated, capture the current url in route
|
||||
* state so the Login component can link them back after sign-in
|
||||
*/
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isInitializing } = useAuth();
|
||||
@@ -14,7 +15,6 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
if (isInitializing) return <SplashScreen />;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Save the intended location to redirect back after login
|
||||
return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-login flows.
|
||||
* Redirects to /drawer if already authenticated.
|
||||
* Auto-redirect - auth route guard.
|
||||
* If authenticated, redirect all the auth related flows to the drawer
|
||||
*/
|
||||
export function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
export function AutoRedirectRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isInitializing } = useAuth();
|
||||
|
||||
if (isInitializing) return <SplashScreen />;
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { EnvelopeOpenIcon } from "@phosphor-icons/react";
|
||||
import Logo from "./Logo";
|
||||
|
||||
export default function SplashScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
|
||||
<div
|
||||
data-testid="splash-screen"
|
||||
className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 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/textures/noise.gif')"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-6 animate-pulse">
|
||||
<Logo />
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="loading loading-ring loading-lg text-primary" />
|
||||
<p className="text-xs uppercase font-sans tracking-widest opacity-40">
|
||||
Unsealing...
|
||||
<EnvelopeOpenIcon
|
||||
weight="thin"
|
||||
className={"absolute text-primary/50"}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { GearFineIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface DrawerSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
subtext: string;
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DrawerSection({
|
||||
id,
|
||||
title,
|
||||
count,
|
||||
subtext,
|
||||
isOpen,
|
||||
onClick,
|
||||
children,
|
||||
icon,
|
||||
}: 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={`bg-neutral/10 transition-all duration-1000 ease-in-out overflow-visible ${isOpen ? "max-h-125" : "max-h-0 pointer-events-none"}`}
|
||||
>
|
||||
<div
|
||||
className={`transition-opacity ease-in-out ${
|
||||
isOpen
|
||||
? "opacity-100 py-3 border-b border-base-content/5 duration-700 delay-500"
|
||||
: "opacity-0 duration-100"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{count === 0 && (
|
||||
<p
|
||||
data-testid={`empty-drawer-message-${id}`}
|
||||
className="text-center text-base-content/20 mt-4"
|
||||
>
|
||||
This drawer remains silent
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-testid={`drawer-section-${id}`}
|
||||
className="w-full relative p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 overflow-hidden 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
|
||||
data-testid="drawer-section-title"
|
||||
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-xs text-base-content/20 mt-1">
|
||||
<span className="font-mono text-xs md:text-base -mt-1 absolute text-primary/30">
|
||||
{count}
|
||||
</span>
|
||||
|
||||
<span className="ml-3">{subtext}</span>
|
||||
</div>
|
||||
<div className="absolute right-5 -translate-y-15 text-base-content/4">
|
||||
{icon}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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}
|
||||
data-testid={`letter-item-${id}`}
|
||||
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-sm italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60">
|
||||
{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-xs text-base-content/20">
|
||||
{timestamp}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { HourglassSimpleMediumIcon } from "@phosphor-icons/react";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { Modal } from "../ui/Modal";
|
||||
|
||||
export function PasskeyModal() {
|
||||
const { unlock } = useAuth();
|
||||
|
||||
return (
|
||||
<Modal isOpen={true}>
|
||||
<HourglassSimpleMediumIcon
|
||||
size={48}
|
||||
className="text-primary mx-auto mb-8 animate-pulse"
|
||||
weight="duotone"
|
||||
/>
|
||||
<h3
|
||||
data-testid="passkey-modal-title"
|
||||
className="font-bold text-lg font-display text-primary"
|
||||
>
|
||||
You've been away a while.
|
||||
</h3>
|
||||
<p className="py-4 font-sans">
|
||||
Your letters are still there. Just need the key once more.
|
||||
</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">
|
||||
Nothing was lost.
|
||||
</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 unlock(password);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
placeholder="password"
|
||||
data-testid="passkey-input"
|
||||
className="font-sans validator input input-bordered rounded-r-none"
|
||||
/>
|
||||
<div className="validator-message text-xs text-error"></div>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="passkey-submit-btn"
|
||||
className="btn btn-primary rounded-l-none"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getWelcomeLetterContent } from "../../config/welcomeLetter";
|
||||
import { formatDate } from "../../utils/dateFormat";
|
||||
import { type CanvasTools, ComposeCanvas } from "../editor/ComposeCanvas";
|
||||
import { EnvelopeReveal } from "../reader/EnvelopeReveal";
|
||||
|
||||
export interface WelcomeLetterOverlayProps {
|
||||
onComplete: () => void;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export function WelcomeLetterOverlay({
|
||||
onComplete,
|
||||
userName,
|
||||
}: WelcomeLetterOverlayProps) {
|
||||
const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">(
|
||||
"SEALED",
|
||||
);
|
||||
const canvasRef = useRef<CanvasTools>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (revealState === "REVEALED" && canvasRef.current) {
|
||||
const welcomeContent = getWelcomeLetterContent(userName);
|
||||
canvasRef.current.loadData(welcomeContent);
|
||||
}
|
||||
}, [revealState, userName]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden">
|
||||
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
||||
|
||||
<div className="w-full max-w-4xl z-10 flex flex-col items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{revealState === "SEALED" && (
|
||||
<motion.div
|
||||
key="envelope"
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 0.8, opacity: 1 }}
|
||||
exit={{
|
||||
scale: 1,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.5, ease: "easeOut" },
|
||||
}}
|
||||
transition={{ duration: 4, delay: 1 }}
|
||||
>
|
||||
<EnvelopeReveal
|
||||
recipient={userName}
|
||||
date={formatDate(new Date())}
|
||||
onRevealComplete={() => setRevealState("REVEALED")}
|
||||
ignite={false}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`}
|
||||
>
|
||||
<div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180">
|
||||
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
|
||||
<ComposeCanvas ref={canvasRef} readOnly />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-12">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dismiss-welcome-letter-btn"
|
||||
onClick={onComplete}
|
||||
className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider"
|
||||
>
|
||||
I'll see you
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import * as fabric from "fabric";
|
||||
import type * as React from "react";
|
||||
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import "@fontsource/kavivanar/index.css";
|
||||
import "@fontsource/space-mono/index.css";
|
||||
import "@fontsource/cutive-mono/index.css";
|
||||
import "@fontsource/architects-daughter/index.css";
|
||||
import "@fontsource/redacted-script/index.css";
|
||||
|
||||
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;
|
||||
textboxRef.current?.initDimensions();
|
||||
|
||||
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,
|
||||
splitByGrapheme: false,
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
for (const img of canvas.getObjects("Image")) {
|
||||
img.set({
|
||||
hasControls: !readOnly,
|
||||
hasBorders: !readOnly,
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
|
||||
await document.fonts.ready;
|
||||
textbox.set("dirty", true);
|
||||
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 getInitialWidth = async () => {
|
||||
if (!wrapperRef.current) return BASE_WIDTH;
|
||||
let width = wrapperRef.current.clientWidth;
|
||||
if (width === 0) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
const initResizeOberver = () => {
|
||||
if (!wrapperRef.current) return null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = wrapperRef.current?.clientWidth;
|
||||
if (!nextWidth || nextWidth === lastWidth) return;
|
||||
lastWidth = nextWidth;
|
||||
syncViewport();
|
||||
});
|
||||
observer.observe(wrapperRef.current);
|
||||
return observer;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
const width = await getInitialWidth();
|
||||
|
||||
// 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 = initResizeOberver();
|
||||
};
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,88 @@
|
||||
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} data-testid="post-seal-modal">
|
||||
<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"
|
||||
data-testid="keep-it-btn"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => navigate(ROUTES.DRAWER)}
|
||||
>
|
||||
Keep it to myself
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="view-letter-btn"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
if (sealedTargetId) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
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";
|
||||
|
||||
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"],
|
||||
["Ink", "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"
|
||||
data-testid="draft-btn"
|
||||
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"
|
||||
data-testid="seal-trigger-btn"
|
||||
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"
|
||||
data-testid="seal-confirm-btn"
|
||||
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"
|
||||
data-testid="vault-trigger-btn"
|
||||
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"
|
||||
data-testid="vault-cancel-btn"
|
||||
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"
|
||||
data-testid="vault-confirm-btn"
|
||||
form="vault-form"
|
||||
>
|
||||
Take it
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
HandPalmIcon,
|
||||
ShieldCheckIcon,
|
||||
WarningIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import Logo from "../Logo";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import Saajan from "../ui/Saajan";
|
||||
|
||||
export default function WelcomeModal({
|
||||
setShowWelcome,
|
||||
}: {
|
||||
setShowWelcome: (show: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={true}>
|
||||
<div className="flex flex-col items-center text-center gap-2 md: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 type="inline" />
|
||||
</h3>
|
||||
<p className="inline text-sm md:text-base text-base-content/80">
|
||||
Before we begin, let me make a small promise.
|
||||
<HandPalmIcon
|
||||
size={18}
|
||||
className="inline text-primary"
|
||||
weight="fill"
|
||||
/>
|
||||
<span className="divider my-0"></span>
|
||||
Everything you write here is sealed with your password,
|
||||
<span className="font-display text-success">cryptographically</span>
|
||||
, before it leaves your hands.
|
||||
<br />
|
||||
<br />A fancy way of saying, no one else can read them without your
|
||||
key—not even me.
|
||||
</p>
|
||||
|
||||
<div className="alert alert-warning flex items-start gap-3 text-left py-3">
|
||||
<WarningIcon size={24} weight="fill" className="shrink-0" />
|
||||
<div className="text-xs md:text-sm font-medium text-primary-content tracking-tight">
|
||||
If you ever happen to forget your password, your letters are lost
|
||||
to time, forever.
|
||||
<span className="mt-2 block">
|
||||
I highly, <span className="font-bold italic">highly</span>
|
||||
recommend storing this password in your
|
||||
<a
|
||||
href="https://www.privacyguides.org/en/passwords/"
|
||||
target="_blank"
|
||||
className="link link-neutral!"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
password manager
|
||||
</a>
|
||||
or somewhere safe to remember it.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action w-full">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="welcome-dismiss-btn"
|
||||
onClick={() => setShowWelcome(false)}
|
||||
className="btn btn-primary w-full shadow-lg"
|
||||
>
|
||||
I'll remember
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="absolute bottom-0 md:right-5/12 z-1000 font-sans w-full flex justify-center">
|
||||
<Saajan
|
||||
position="left"
|
||||
message={"I've lost words before.\nI know what it feels like."}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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
|
||||
data-testid="wax-seal"
|
||||
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"
|
||||
data-testid="envelope-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"
|
||||
data-testid="envelope-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
|
||||
data-testid="envelope-recipient"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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)}
|
||||
data-testid="share-letter-modal"
|
||||
>
|
||||
<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}
|
||||
data-testid="copy-link-btn"
|
||||
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 md:right-5/11 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,503 +0,0 @@
|
||||
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 (
|
||||
<div className={`text-right flex flex-col gap-2 min-w-35 ${className}`}>
|
||||
<span className="text-[10px] uppercase tracking-[0.4em] text-accent font-bold">
|
||||
<span className="text-xxs uppercase tracking-widester text-accent font-bold">
|
||||
Date
|
||||
</span>
|
||||
<span className="text-sm font-serif text-secondary-content italic whitespace-nowrap">
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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,6 +6,8 @@ interface FormFieldProps {
|
||||
placeholder?: string;
|
||||
registration: UseFormRegisterReturn;
|
||||
error?: string;
|
||||
handleFocus?: () => void;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
@@ -14,23 +16,27 @@ export default function FormField({
|
||||
placeholder,
|
||||
registration,
|
||||
error,
|
||||
handleFocus,
|
||||
"data-testid": testId,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div className="form-control">
|
||||
<label
|
||||
htmlFor={registration.name}
|
||||
className="field-label font-display text-base-content/90 font-medium"
|
||||
className="field-label font-display text-neutral-content/80 font-medium"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
{...registration}
|
||||
id={registration.name}
|
||||
data-testid={testId}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
className={`input input-bordered focus:input-primary ${
|
||||
error ? "input-error" : ""
|
||||
}`}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,4 +1,5 @@
|
||||
import { WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { WarningIcon } from "@phosphor-icons/react";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface LogModalContent {
|
||||
status: "WARN" | "ERROR" | "RESET" | "SUCCESS";
|
||||
@@ -15,21 +16,17 @@ export const LogModal = ({
|
||||
onClose,
|
||||
status,
|
||||
}: LogModalContent) => {
|
||||
return status === "RESET" || !isOpen ? (
|
||||
<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">
|
||||
return (
|
||||
<Modal isOpen={isOpen && status !== "RESET"} onClose={onClose}>
|
||||
<div
|
||||
className={`alert ${status === "WARN" ? "alert-warning" : "alert-error"} flex flex-col items-center text-center gap-6 py-4`}
|
||||
>
|
||||
{status === "WARN" && (
|
||||
<WarningIcon className="text-warning" size={16} weight="bold" />
|
||||
<WarningIcon className="text-warning" size={16} weight="duotone" />
|
||||
)}
|
||||
{status === "ERROR" && (
|
||||
<XCircleIcon className="text-error" size={16} weight="bold" />
|
||||
)}
|
||||
{message}
|
||||
<span data-testid="log-modal-message">{message}</span>
|
||||
{log && (
|
||||
<>
|
||||
<div className="divider text-primary-content text-xs uppercase tracking-widest">
|
||||
Error Stack
|
||||
</div>
|
||||
@@ -38,17 +35,9 @@ export const LogModal = ({
|
||||
<code>{String(log)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<form method="dialog">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn btn-sm btn-circle btn-ghost absolute right-6 top-6"
|
||||
>
|
||||
<XIcon size={6} weight="bold" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { XCircleIcon } from "@phosphor-icons/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
"data-testid": testId,
|
||||
}: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
// render the modal top of all elements and position them to document viewport (/ the main wrapper).
|
||||
// NOTE: this is recommended approach for modals as it shouldn't be bound to the parent box.
|
||||
const mainContainer = document.querySelector("main") || document.body;
|
||||
return createPortal(
|
||||
<div
|
||||
data-testid={testId}
|
||||
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/textures/noise.gif')]"
|
||||
>
|
||||
<div className="modal-box border border-neutral/60 relative bg-base-100/60 flex flex-col items-center text-center gap-6">
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="modal-close-btn"
|
||||
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>,
|
||||
mainContainer,
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export const Navbar = ({ child }: { child?: React.ReactNode }) => {
|
||||
className="text-base-content/40 group-hover:text-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-sans text-[10px] tracking-[0.3em] uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
|
||||
<span className="font-sans text-xxs tracking-widester uppercase font-bold text-base-content/30 group-hover:text-base-content transition-colors">
|
||||
Drawer
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import sf_mini from "../../assets/sf_mini.png";
|
||||
|
||||
interface SaajanProps {
|
||||
message: string;
|
||||
position?: "top" | "left" | "right" | "bottom";
|
||||
}
|
||||
|
||||
export default function Saajan({ message, position = "right" }: SaajanProps) {
|
||||
const [animate, setAnimate] = useState<boolean>(false);
|
||||
const [tooltipPosition, setTooltipPosition] =
|
||||
useState<string>("tooltip-right");
|
||||
const [alignment, setAlignment] = useState<string>("justify-start");
|
||||
|
||||
useEffect(() => {
|
||||
setAnimate(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setAnimate(false);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTooltipPosition(`tooltip-${position}`);
|
||||
if (position === "top") {
|
||||
setAlignment("justify-center");
|
||||
}
|
||||
if (position === "right") {
|
||||
setAlignment("justify-start");
|
||||
}
|
||||
if (position === "left") {
|
||||
setAlignment("justify-end");
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
return (
|
||||
<div className={`relative w-full flex ${alignment}`}>
|
||||
<div
|
||||
className={`tooltip tooltip-open ${tooltipPosition} before:border before:border-dashed before:border-primary/40 before:max-w-xs before:whitespace-pre-line italic before:text-left`}
|
||||
data-tip={message}
|
||||
>
|
||||
<img
|
||||
src={sf_mini}
|
||||
alt="saajan"
|
||||
className={`sepia-20 w-35 -mb-6 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,14 @@ export const endpoints = {
|
||||
LETTERS: "/api/letters/",
|
||||
};
|
||||
|
||||
// simple utility to handle path params
|
||||
// constructs dynamic path params for activate flow
|
||||
export const replacePathParams = (
|
||||
url: string,
|
||||
params: Record<string, string>,
|
||||
): string => {
|
||||
let result = url;
|
||||
let constructedUrl = url;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
result = result.replace(`:${key}`, value);
|
||||
constructedUrl = constructedUrl.replace(`:${key}`, value);
|
||||
}
|
||||
return result;
|
||||
return constructedUrl;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Route PATTERNS
|
||||
// Page Route PATTERNS
|
||||
export const ROUTES = {
|
||||
HOME: "/",
|
||||
ONBOARD: "/onboard",
|
||||
@@ -6,13 +6,13 @@ export const ROUTES = {
|
||||
ACTIVATE: "/activate/:uidb64/:token",
|
||||
LOGIN: "/login",
|
||||
DRAWER: "/drawer",
|
||||
WRITE: "/quill/:public_id?", // ← static pattern
|
||||
WRITE: "/quill/:public_id?",
|
||||
READ: "/read/:public_id",
|
||||
ABOUT: "/know-piku",
|
||||
};
|
||||
|
||||
// Path BUILDERS
|
||||
// Dynamic path BUILDERS
|
||||
export const PATHS = {
|
||||
write: (public_id?: string) => `/quill/${public_id ?? ""}`,
|
||||
read: (public_id: string) => `/read/${public_id}`,
|
||||
activate: (uidb64: string, token: string) => `/activate/${uidb64}/${token}`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import trainImage from "../assets/screenshots/train.png";
|
||||
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
|
||||
|
||||
export function getWelcomeLetterContent(userName: string): CanvasJSON {
|
||||
return {
|
||||
objects: [
|
||||
{
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
fontFamily: "Kavivanar",
|
||||
fontStyle: "normal",
|
||||
lineHeight: 1.5,
|
||||
text: `\nDear ${userName}, \n\nYou made it this far, which means something already brought you here. \nA name, maybe. A feeling you haven't been able to shake. Something you typed and deleted too many times to count.\n\nMost people carry it quietly. They tell themselves it doesn't matter anymore, or that too much time has passed, or that the other person wouldn't understand anyway. And maybe they're right. \n\nBut the thing is — the unsaid thing doesn't really care about any of that. \nIt just stays.\n\nSo here you are.\n\nYou don't have to know what you want to say yet. \nYou don't have to have it figured out — who it's for, or why it still matters, or what you're hoping will happen after. \n\nA lot of letters written here start without any of that. They find their way.\n\nTake your time. \nNo one's watching. \n\nWhen you're ready, write a letter.\n\nSometimes the wrong train takes you to the right station.\n- S.F.`,
|
||||
charSpacing: 0,
|
||||
textAlign: "left",
|
||||
styles: [],
|
||||
pathStartOffset: 0,
|
||||
pathSide: "left",
|
||||
pathAlign: "baseline",
|
||||
underline: false,
|
||||
overline: false,
|
||||
linethrough: false,
|
||||
textBackgroundColor: "",
|
||||
direction: "ltr",
|
||||
textDecorationThickness: 66.667,
|
||||
minWidth: 20,
|
||||
splitByGrapheme: false,
|
||||
type: "Textbox",
|
||||
version: "7.2.0",
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
left: 36,
|
||||
top: 36,
|
||||
width: 720,
|
||||
height: 813.6,
|
||||
fill: "#111e67",
|
||||
stroke: null,
|
||||
strokeWidth: 1,
|
||||
strokeDashArray: null,
|
||||
strokeLineCap: "butt",
|
||||
strokeDashOffset: 0,
|
||||
strokeLineJoin: "miter",
|
||||
strokeUniform: false,
|
||||
strokeMiterLimit: 4,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
angle: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
opacity: 1,
|
||||
shadow: null,
|
||||
visible: true,
|
||||
backgroundColor: "",
|
||||
fillRule: "nonzero",
|
||||
paintFirst: "fill",
|
||||
globalCompositeOperation: "source-over",
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
},
|
||||
{
|
||||
cropX: 0,
|
||||
cropY: 0,
|
||||
type: "Image",
|
||||
version: "7.2.0",
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
left: 298.4065,
|
||||
top: 660.2853,
|
||||
width: 512,
|
||||
height: 400,
|
||||
fill: "rgb(0,0,0)",
|
||||
stroke: null,
|
||||
strokeWidth: 0,
|
||||
strokeDashArray: null,
|
||||
strokeLineCap: "butt",
|
||||
strokeDashOffset: 0,
|
||||
strokeLineJoin: "miter",
|
||||
strokeUniform: false,
|
||||
strokeMiterLimit: 4,
|
||||
scaleX: 0.4753,
|
||||
scaleY: 0.4753,
|
||||
angle: 355.5436,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
opacity: 1,
|
||||
shadow: null,
|
||||
visible: true,
|
||||
backgroundColor: "",
|
||||
fillRule: "nonzero",
|
||||
paintFirst: "fill",
|
||||
globalCompositeOperation: "source-over",
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
src: trainImage,
|
||||
crossOrigin: null,
|
||||
filters: [],
|
||||
},
|
||||
],
|
||||
canvasWidth: 700,
|
||||
canvasHeight: 900,
|
||||
};
|
||||
}
|
||||
@@ -14,20 +14,14 @@ import {
|
||||
} from "../utils/keystore";
|
||||
import { useAuth } from "./useAuth";
|
||||
|
||||
vi.mock("../utils/crypto");
|
||||
vi.mock("../utils/keystore");
|
||||
vi.mock("../utils/crypto");
|
||||
|
||||
const VITE_API_URL = "http://piku-server";
|
||||
|
||||
beforeEach(() => {
|
||||
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(saveMasterKey).mockResolvedValue("masterKey");
|
||||
vi.mocked(clearMasterKey).mockResolvedValue(undefined);
|
||||
@@ -38,6 +32,11 @@ beforeEach(() => {
|
||||
isInitializing: true,
|
||||
});
|
||||
useKeyStore.setState({ masterKey: null });
|
||||
|
||||
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
|
||||
masterKey: mockMasterKey,
|
||||
authHash: "mock-hash",
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
@@ -209,3 +208,68 @@ describe("initialize", () => {
|
||||
expect(useKeyStore.getState().masterKey).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlock", () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
accessToken: "valid-token",
|
||||
user: mockUser,
|
||||
isInitializing: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should derive the master key from the user password, validate it via API, and persist it", async () => {
|
||||
let loginCalled = false;
|
||||
server.use(
|
||||
http.post(`${VITE_API_URL}/api/auth/login/`, async () => {
|
||||
loginCalled = true;
|
||||
return HttpResponse.json({ access: "token", user: mockUser });
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unlock("password");
|
||||
});
|
||||
|
||||
expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith(
|
||||
"password",
|
||||
mockUser.email,
|
||||
);
|
||||
expect(loginCalled).toBe(true);
|
||||
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
|
||||
expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey);
|
||||
});
|
||||
|
||||
it("should logout if user is not present", async () => {
|
||||
useAuthStore.setState({ user: null });
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unlock("password");
|
||||
});
|
||||
|
||||
expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled();
|
||||
expect(saveMasterKey).not.toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||
expect(clearMasterKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw an error and not persist the key if validation fails", async () => {
|
||||
server.use(
|
||||
http.post(
|
||||
`${VITE_API_URL}/api/auth/login/`,
|
||||
() => new HttpResponse(null, { status: 400 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.unlock("wrong-password")).rejects.toThrow();
|
||||
});
|
||||
|
||||
expect(saveMasterKey).not.toHaveBeenCalled();
|
||||
expect(useKeyStore.getState().masterKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useAuth = () => {
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.post(endpoints.LOGOUT);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
} finally {
|
||||
clearAuth();
|
||||
setMasterKey(null);
|
||||
@@ -48,9 +48,7 @@ export const useAuth = () => {
|
||||
try {
|
||||
const masterKey = await loadMasterKey();
|
||||
if (masterKey) setMasterKey(masterKey);
|
||||
} catch {
|
||||
console.error("Master key restoration failed");
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// If session in memory, don't trigger refresh/me again
|
||||
if (accessToken && user) {
|
||||
@@ -59,7 +57,6 @@ export const useAuth = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// try session refresh
|
||||
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
|
||||
const { data: userData } = await api.get(endpoints.ME, {
|
||||
headers: { Authorization: `Bearer ${refreshData.access}` },
|
||||
@@ -73,18 +70,24 @@ export const useAuth = () => {
|
||||
}, [setMasterKey]);
|
||||
|
||||
const unlock = async (password: string) => {
|
||||
if (!user) return;
|
||||
if (!user) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { masterKey } = await CryptoUtils.deriveKeyBundle(
|
||||
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
||||
password,
|
||||
user.email,
|
||||
);
|
||||
|
||||
// Validate password by calling login endpoint
|
||||
await api.post(endpoints.LOGIN, {
|
||||
email: user.email,
|
||||
password: authHash,
|
||||
});
|
||||
|
||||
await saveMasterKey(masterKey);
|
||||
setMasterKey(masterKey);
|
||||
} catch {
|
||||
console.error("Master key restoration failed");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../api/apiClient";
|
||||
import type { LetterMetadata, LetterResponseData } from "../api/response";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
|
||||
export interface Letter {
|
||||
public_id: string;
|
||||
type: "KEPT" | "VAULT" | "SENT";
|
||||
status: "DRAFT" | "SEALED" | "BURNED";
|
||||
updated_at: string;
|
||||
sealed_at?: string;
|
||||
unlock_at?: string;
|
||||
encrypted_metadata: string;
|
||||
encrypted_content: string;
|
||||
encrypted_dek: string;
|
||||
}
|
||||
|
||||
export interface LetterMetadata {
|
||||
recipient: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ProcessedLetter extends Letter {
|
||||
export interface ProcessedLetter extends LetterResponseData {
|
||||
metadata: LetterMetadata;
|
||||
}
|
||||
|
||||
async function decryptLetters(
|
||||
letters: Letter[],
|
||||
async function decryptLettersMetadata(
|
||||
letters: LetterResponseData[],
|
||||
masterKey: CryptoKey,
|
||||
): Promise<ProcessedLetter[]> {
|
||||
const cryptoUtils = new CryptoUtils();
|
||||
@@ -43,7 +27,7 @@ async function decryptLetters(
|
||||
)) as LetterMetadata;
|
||||
|
||||
return { ...letter, metadata };
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
return {
|
||||
...letter,
|
||||
metadata: { recipient: "Encrypted Letter" },
|
||||
@@ -56,21 +40,34 @@ async function decryptLetters(
|
||||
export function useLetters() {
|
||||
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
||||
const { masterKey } = useKeyStore();
|
||||
|
||||
// to fetch the letters and decryypt the metadata on load
|
||||
useEffect(() => {
|
||||
if (!masterKey) {
|
||||
setIsAuthRequired(true);
|
||||
return;
|
||||
}
|
||||
setIsAuthRequired(false);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
api
|
||||
.get(endpoints.LETTERS)
|
||||
.then((res) => decryptLetters(res.data, masterKey))
|
||||
.then(setLetters)
|
||||
.catch((_err) => {})
|
||||
.then((res) => decryptLettersMetadata(res.data, masterKey))
|
||||
.then((decrypted) => {
|
||||
setLetters(
|
||||
decrypted.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updated_at).getTime() -
|
||||
new Date(a.updated_at).getTime(),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [masterKey]);
|
||||
|
||||
@@ -78,15 +75,18 @@ export function useLetters() {
|
||||
return {
|
||||
drafts: letters.filter((l) => l.status === "DRAFT"),
|
||||
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
|
||||
vault: letters.filter((l) => l.type === "VAULT"),
|
||||
vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"),
|
||||
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
||||
};
|
||||
}, [letters]);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
...drawerItems,
|
||||
loading,
|
||||
refreshLetters: () => setLoading(true),
|
||||
isAuthRequired,
|
||||
};
|
||||
}
|
||||
|
||||