117 Commits

Author SHA1 Message Date
ramvignesh-b bea9b13249 style: adjust bottom margin of Saajan component image to -6
CI / Generate Certificates (push) Successful in 2m35s
CI / Frontend CI (push) Successful in 2m27s
CI / Backend CI (push) Failing after 1m43s
CI / E2E Tests (push) Failing after 4m22s
2026-05-04 06:46:02 +05:30
ramvignesh-b 84445f16b3 fix: implement scroll-to-top behavior for about navigation 2026-05-04 06:45:56 +05:30
ramvignesh-b fce0b5b539 feat: add navigation to About page on Tell me More button click 2026-05-04 06:33:19 +05:30
ramvignesh-b fb3cb2eb69 feat: add typography styles and complete about page 2026-05-04 06:23:10 +05:30
ramvignesh-b 2659f73577 feat: add inline verion of logo 2026-05-03 14:36:08 +05:30
ramvignesh-b bf6aa34536 feat: add learn more about component 2026-05-02 04:08:00 +05:30
ramvignesh-b dddda69c2f style: add additional fonts for editor and update metadada 2026-05-01 15:06:02 +05:30
ramvignesh-b 90b04f2397 feat: refactor ComposeCanvas to use reactive style props and use complete declarative approach for editor+toolbar 2026-05-01 11:32:23 +05:30
ramvignesh-b 5f56b21823 style: add additional fonts for editor and update metadada 2026-05-01 03:57:23 +05:30
ramvignesh-b e32c7a7982 style: add responsive offset for saajan in home 2026-05-01 02:02:27 +05:30
ramvignesh-b 34c6de47cc chore: update email placeholder 2026-05-01 01:41:13 +05:30
ramvignesh-b a77e88496b feat: apply UI refinements to home page and add saajan 2026-05-01 01:41:02 +05:30
ramvignesh-b a0cacfbc8c refactor: use state for flap ops instead of ref in envelope comp 2026-05-01 00:09:27 +05:30
ramvignesh-b 49cd21cffe style: add outro and letter mockup to homepage 2026-05-01 00:09:00 +05:30
ramvignesh-b 9910e44ee2 refactor: extract custom utility classes for text and bg css properties 2026-05-01 00:04:17 +05:30
ramvignesh-b 49177a5b12 refactor: optimize ComposeCanvas textbox pick and canvas focus logic 2026-04-30 06:00:48 +05:30
ramvignesh-b 3f81b7be3a test: update textarea selectors in e2e tests 2026-04-30 06:00:12 +05:30
ramvignesh-b b6f45aa93c fix: reduce pbkdf iteration in tests to prevent timeout nad keep them fast 2026-04-30 05:25:55 +05:30
ramvignesh-b 2bb77d1bed feat: add custom font styling to canvas text 2026-04-30 05:23:36 +05:30
ramvignesh-b 70a056a1d6 refactor: extract welcome modal for consistency 2026-04-29 23:12:39 +05:30
ramvignesh-b d9e1febfee refactor: update route related simplifications 2026-04-29 23:12:05 +05:30
ramvignesh-b df96cead93 refactor: simplify letterlogic by removing object mutation 2026-04-29 23:09:32 +05:30
ramvignesh-b b9716d368d refactor: simplify crypto utils 2026-04-29 22:19:00 +05:30
ramvignesh-b d9827c9e82 style: refactor email template 2026-04-29 15:14:39 +05:30
ramvignesh-b a6bde0258d refactor: enable proper progation for loggers 2026-04-29 15:14:19 +05:30
ramvignesh-b a987241120 refactor: update crypto to generate iv on demand 2026-04-29 03:11:04 +05:30
ramvignesh-b ebf7186b06 chore: add favicon assets and manifest 2026-04-29 03:07:31 +05:30
ramvignesh-b 150832419a feat: enable scheduler execution in Docker by checking UVICORN_MAIN environment variable 2026-04-29 02:20:18 +05:30
ramvignesh-b 4893c91c20 fix: update reader splash screen to be full width and height 2026-04-29 01:36:00 +05:30
ramvignesh-b dc0d688885 chore: import and apply custom logging configuration in settings 2026-04-29 01:14:11 +05:30
ramvignesh-b 46c7d9ffeb refactor: centralize log directory path handling and remove redundant log directory creation in Dockerfile 2026-04-29 00:34:16 +05:30
ramvignesh-b 16a04ae4b8 style: add dashed primary border to Saajan component tooltip 2026-04-28 23:23:04 +05:30
ramvignesh-b 574baa6860 feat: enhance homescreen with scroll motion 2026-04-28 23:22:57 +05:30
ramvignesh-b 35e8d6761e feat: add candle to ignite envelope 2026-04-28 23:09:25 +05:30
ramvignesh-b faee0b45d6 refactor: implement reusable Modal component 2026-04-28 22:52:06 +05:30
ramvignesh-b 8b28949d73 feat: re-use dek and copntent ivs for a letteer's metadata and content 2026-04-28 20:51:34 +05:30
RamVignesh B 6cf24731ce Feature/saajan persona (#3)
* feat: add template based email content (html + plaintext fallback)

* feat: init saajan component

* feat: add aesthetic noise background and implement Saajan component in register and login

* feat: add post seal modal for vault

* refactor: add proper props interfaces

* refactor: expose props on ui components

* feat: add ssajan in lots of flows

* fix: remove render test with no value and add aria helper for btn identification

* refactor: update email notification to account for proper arguments

* refactor:  refactor E2E auth helper and mail parsing logic

---------

Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
2026-04-28 20:51:23 +05:30
ramvignesh-b 8a9ded42b5 feat: add secure proxy configuration for http to https 2026-04-28 18:02:27 +05:30
ramvignesh-b f522a369ab chore: streamline frontend Docker build arguments 2026-04-28 17:17:24 +05:30
RamVignesh B 48b6a06571 Feature/s3 integration (#2)
* feat: add s3 storage for media

* refactor: update letter decryption test to look for key request properties

* fix: update db url to be ipv4 for ssl context match

* ci: output backend logs to the console

* ci: unset email host creds for local testing

---------

Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
2026-04-26 21:40:00 +05:30
ramvignesh-b 1f47b6f4dd fix: update registration name label to match with form 2026-04-26 19:25:31 +05:30
ramvignesh-b 25d5bf142a fix: resolve unintentional override of rest framework classes 2026-04-26 13:30:14 +05:30
ramvignesh-b fbd4bd4aec feat: update backend Docker configuration for log management 2026-04-26 13:20:18 +05:30
ramvignesh-b ce370a9fc6 feat: use raw json responses instead of templates 2026-04-26 13:16:15 +05:30
ramvignesh-b 2896c60c5f feat: implement better logging using structlog 2026-04-26 13:05:50 +05:30
ramvignesh-b 97e4d0be98 refactor: type cast the env configurations and add smpt tls/ssl config 2026-04-26 12:14:22 +05:30
ramvignesh-b 3e4a4512a3 chore: update docker cmd to dump application logs 2026-04-26 11:54:47 +05:30
ramvignesh-b b83a8d12f2 feat: add smtp creds configuration 2026-04-26 11:36:30 +05:30
ramvignesh-b 01bac9840f fix: update boolean env comparison logic 2026-04-26 11:16:42 +05:30
ramvignesh-b 73e1e64a33 fix: resolved oopsie of running migrate before copying the dir first 2026-04-26 11:07:51 +05:30
ramvignesh-b fe25231da7 fix: update backed docker to migrate db before running server 2026-04-26 11:01:21 +05:30
ramvignesh-b 94e024bd5f feat: add env based allow hosts for adding self in prod 2026-04-26 10:49:09 +05:30
ramvignesh-b dadb688c50 refactor: modularize envelope reveal and logo components for easier usage across the site 2026-04-26 00:55:19 +05:30
ramvignesh-b f352c298e7 fix: ssl_enabled flag consition fails due to unintentional space after in env 2026-04-25 23:15:44 +05:30
ramvignesh-b fca23c4fc8 fix: ssl_enabled flag consition fails due to unintentional space after in env 2026-04-25 18:41:03 +05:30
ramvignesh-b 7279798bd4 refactor: update Registration form field labels and placeholders for improved UI consistency 2026-04-24 23:55:03 +05:30
ramvignesh-b fa1b3f1bcf refactor: add pointer-events disabling for envelope states and update e2e tests to match UI interactions 2026-04-24 22:10:42 +05:30
ramvignesh-b 18e9af651d feat: add modular Burn, Share, and PostAction components to the Reader page flow 2026-04-24 18:59:13 +05:30
ramvignesh-b 42493a950c feat: implement dynamic redirection after login based on location state 2026-04-24 18:43:13 +05:30
ramvignesh-b db31be4ec8 refactor: reorganize directory structure by moving UI components into feature-specific folders 2026-04-24 17:01:35 +05:30
ramvignesh-b c562c99d3a refactor: extract Editor components into modular files and implement PostSealModal 2026-04-24 16:32:01 +05:30
ramvignesh-b 2f3d5161ed refactor: prevent re-renders by extracting components out 2026-04-24 16:30:13 +05:30
ramvignesh-b ae52a79bd0 feat: add a post-seal navigation flow to the reader page 2026-04-24 06:34:08 +05:30
ramvignesh-b 00c16627cc feat: implement on-demand sharing key derivation 2026-04-24 06:33:23 +05:30
ramvignesh-b a84d837942 feat: update letter patch to check for type change to sent and auto set time 2026-04-24 06:29:14 +05:30
ramvignesh-b 33995ffee1 chore: auto migrate on start script 2026-04-24 04:09:41 +05:30
ramvignesh-b 7eed38f27e chore: fix docker compose bin resolution in e2e script 2026-04-24 04:01:57 +05:30
ramvignesh-b b49dc69a25 fix: address missing bin auto-exit issue 2026-04-24 03:48:12 +05:30
ramvignesh-b 3111e14732 Merge branch 'main' of https://github.com/ramvignesh-b/pi-ku 2026-04-24 03:33:20 +05:30
ramvignesh-b d5444bd47f fix: remove extra whitespace in variables 2026-04-24 03:33:03 +05:30
ramvignesh-b 17564282e8 Merge branch 'main' of github.com:ramvignesh-b/pi-ku 2026-04-24 03:31:54 +05:30
ramvignesh-b a2aadb5d2b refactor: standardize script formatting and resolve command execution variables in run-e2e.sh 2026-04-24 03:31:39 +05:30
ramvignesh-b 6e0f300518 Merge branch 'main' of github.com:ramvignesh-b/pi-ku 2026-04-24 03:25:07 +05:30
ramvignesh-b 218ed42f00 refactor: implement dynamic URI scheme based on SSL_ENABLED setting 2026-04-24 03:23:50 +05:30
ramvignesh-b 7f61ce169e chore: update e2e env to force ssl and tweak setup to detect arm64 arch 2026-04-24 03:13:03 +05:30
ramvignesh-b d92590f764 chore: add automated project setup script and unify runner environment detection 2026-04-24 02:24:35 +05:30
ramvignesh-b 7dece74698 feat: add burn letter option and fix modal middle placement 2026-04-24 01:16:57 +05:30
ramvignesh-b 6872853125 feat: add backend support for burning letters 2026-04-23 19:38:05 +05:30
ramvignesh-b 6552783d64 chore: improve development startup reliability 2026-04-23 17:31:52 +05:30
ramvignesh-b e2ad5cef75 chore: update E2E script for better cleanup 2026-04-23 16:46:26 +05:30
ramvignesh-b f509d74f62 fix: stabilize e2e letter reveal spec, and optionally dockerize playwright execution 2026-04-23 04:42:17 +05:30
ramvignesh-b 7ff4c1de29 fix: apply overflow-hidden and invisible to EnvelopeReveal container when revealed 2026-04-22 19:15:20 +05:30
ramvignesh-b a986878a3b feat: containerize backend and frontend services 2026-04-22 19:14:21 +05:30
ramvignesh-b 59ea95a912 fix: correct syntax in default env config 2026-04-22 18:56:07 +05:30
ramvignesh-b 5355194026 feat: update dynamic cors origin population 2026-04-22 18:53:23 +05:30
ramvignesh-b c3a1e1e252 chore: no more distrobox for local e2e 2026-04-22 17:36:35 +05:30
ramvignesh-b 3cd516039a test: update e2e to pull letter 2026-04-22 17:35:11 +05:30
ramvignesh-b 6aff578ca5 feat: update CORS and auth cookie configurations 2026-04-22 17:33:02 +05:30
ramvignesh-b cb9d5e35fd test: update letter e2e spec to cover envelope reveal for reader 2026-04-22 17:22:40 +05:30
ramvignesh-b 625475b740 test: update letter e2e spec to cover envelope reveal for reader 2026-04-22 17:13:21 +05:30
ramvignesh-b 516991c33a chore: update example env files 2026-04-22 16:55:59 +05:30
ramvignesh-b 4a971a93b0 fix: update util path in test 2026-04-22 16:55:43 +05:30
ramvignesh-b 27b725e8ec refactor: format and lint react files 2026-04-22 16:43:43 +05:30
ramvignesh-b 694715a90c chore: refactor build configurations. 2026-04-22 16:42:53 +05:30
ramvignesh-b 11b9e8b04c feat: implement lazy loading for routes 2026-04-22 16:38:17 +05:30
ramvignesh-b b9d1880951 fix: handle type errors in build 2026-04-22 15:13:07 +05:30
ramvignesh-b 50bae8d2ce fix: update Reader tests to include envelope 2026-04-21 04:44:07 +05:30
ramvignesh-b 4c204b0a80 feat: ux enhancement for envelope reveal 2026-04-21 04:23:19 +05:30
ramvignesh-b b5d55bd258 feat: update splash screen elements 2026-04-21 01:49:15 +05:30
ramvignesh-b bd0f2e0171 fix: update e2e script to include e2e env source 2026-04-20 20:59:09 +05:30
ramvignesh-b 7bab2937bc feat: implement EnvelopeReveal component for letter interactions and integrate into reader workflow 2026-04-19 19:49:01 +05:30
ramvignesh-b ec769818f5 refactor: standardize navigation behavior by applying replace: true to authentication and activation flows 2026-04-19 02:19:53 +05:30
ramvignesh-b 1c1b5ea14e feat: implement vaulting functionality with unlock dates and locked UI states for letters 2026-04-19 01:20:55 +05:30
ramvignesh-b e68dcb068b refactor: use concrete type for mailpit helper 2026-04-18 18:52:03 +05:30
ramvignesh-b ad5bc57eee refactor: UX enhancement for drawer 2026-04-18 18:51:43 +05:30
ramvignesh-b d17d5c01e8 refactor: remove side effects from useMemo 2026-04-18 18:50:54 +05:30
ramvignesh-b 2db7e1f9f5 test: enhance tests to be specific 2026-04-18 18:49:54 +05:30
ramvignesh-b 428db97ba2 refactor: remove unused methods 2026-04-18 14:50:26 +05:30
ramvignesh-b 86f41a9d90 feat: drawer ux enhances 2026-04-18 14:18:35 +05:30
ramvignesh-b de46b2c631 feat: add expandable seal buttons 2026-04-18 04:39:43 +05:30
ramvignesh-b 78e2625883 refactor: handle exception on vault unlock date check for validation 2026-04-18 04:39:18 +05:30
ramvignesh-b 11780cdbd4 feat: add db indexes for vault letters for polling to be performant 2026-04-17 23:39:35 +05:30
ramvignesh-b 1e7a1c15c9 feat: introduce AP scheduler for polling vault 2026-04-17 23:37:48 +05:30
ramvignesh-b 3d764703dd feat: add notification field for vault letters and helper tasks 2026-04-17 21:54:56 +05:30
ramvignesh-b f124efd8c1 feat: restrict vault letter content access until unlock date 2026-04-17 13:05:03 +05:30
ramvignesh-b c9bb4799ce chore: remove temporary e2e backend log file 2026-04-17 02:16:04 +05:30
RamVignesh B 3c9c72d25f Feature/ssl integration (#1)
* feat: update E2E testing configuration to use ssl

* fix: add IPv6 loopback support to mkcert generation command in CI workflow

* feat: centralize SSL certificate generation into a reusable workflow job

* fix: use static ip  in mkcert command

* fix: correct mkcert command args

* feat: implement certificate caching in CI workflow to persist SSL files across jobs

* refactor: optimize CI workflow caching

* ci: implement certificate caching in workflow

* fix: correct certificate caching keys and fix environment file paths in CI workflows

* fix: correct environment file paths and parallelize frontend dependency installation in CI workflow

* test: set TZ environment variable to Asia/Kolkata in vitest configuration

* fix: force en-US locale in Intl formatters to ensure consistent date and time output

* fix: update MAILPIT_API_URL protocol from https to http in e2e environment example

* chore: set test timezone to Asia/Kolkata

* ci: add sll support and enhance e2e workflow

* ci: improve compatibility for docker-compose execution

* refactor: improve container orchestration detection and fallback logic in e2e test script

* feat: add container runtime validation and force docker usage in CI environment

* feat: add caching for Playwright dependencies in CI workflow

* chore: update restart policy to unless-stopped for postgres and mailpit services in e2e docker-compose

---------

Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
2026-04-17 02:04:11 +05:30
124 changed files with 6845 additions and 1905 deletions
+22 -19
View File
@@ -1,25 +1,28 @@
# Pi Ku E2E Environment Configuration Template
# DATABASE
DB_NAME=piku_test_db
DB_USER=test
DB_PASSWORD=password123
DB_HOST=127.0.0.1
DB_PORT=5433
# Database (Postgres)
E2E_DB_NAME=piku_e2e_db
E2E_DB_PORT=5433
E2E_DB_USER=piku_test
E2E_DB_PASS=piku_test
E2E_DB_DB=piku_e2e
# SSL
SSL_ENABLED=true
# Backend (Django)
E2E_BACKEND_PORT=8001
SECRET_KEY=e2e-secret-key-for-piku-testing
# DJANGO
DEBUG=True
ALLOWED_HOSTS=*
CORS_ALLOWED_ORIGINS=http://localhost:5173
FRONTEND_URL=http://localhost:5173
EMAIL_HOST=localhost
EMAIL_PORT=1025
SECRET_KEY=django-insecure-initial-key
BACKEND_DOMAIN=127.0.0.1
BACKEND_PORT=8001
# EMAIL
EMAIL_HOST=127.0.0.1
EMAIL_PORT=1026
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
FROM_EMAIL=testing@piku.local
MAILPIT_API_URL=http://localhost:8025/api/v1
FROM_EMAIL="Test <test@pi-ku.app>"
EMAIL_API_PORT=8026
# Frontend (Vite/Playwright)
VITE_API_URL=http://localhost:8001
# FRONTEND
FRONTEND_PORT=5199
FRONTEND_DOMAIN=127.0.0.1
VITE_API_URL=https://127.0.0.1:8001
+19 -10
View File
@@ -2,24 +2,33 @@
DB_NAME=piku
DB_USER=user
DB_PASSWORD=password123
DB_HOST=localhost
DB_HOST=127.0.0.1
DB_PORT=5432
# SSL
SSL_ENABLED=true
S3_ENABLED=false
# DJANGO
DEBUG=True
SECRET_KEY=django-secret-key
ALLOWED_HOSTS=localhost,127.0.0.1
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
BACKEND_DOMAIN=127.0.0.1
BACKEND_PORT=8000
# S3
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_REGION_NAME=
R2_ENDPOINT_URL=
R2_PUBLIC_URL=
# EMAIL
EMAIL_HOST=localhost
EMAIL_HOST=127.0.0.1
EMAIL_PORT=1025
FROM_EMAIL=Pi Ku <no-reply@piku.app>
EMAIL_HOST_USER=root
EMAIL_HOST_PASSWORD=password123
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
FROM_EMAIL="Pi Ku <no-reply@test.com>"
# FRONTEND
VITE_API_URL=http://localhost:8000
FRONTEND_PORT=5173
FRONTEND_URL=http://localhost:5173
FRONTEND_DOMAIN=localhost
FRONTEND_DOMAIN=127.0.0.1
VITE_API_URL=https://127.0.0.1:8000
+92 -47
View File
@@ -7,34 +7,59 @@ on:
branches: [ main ]
jobs:
setup-environment:
name: Generate Certificates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate SSL Certificates
run: |
sudo apt-get update && sudo apt-get install -y mkcert libnss3-tools
mkdir -p certs
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
with:
path: certs
key: certs-${{ runner.os }}-${{ github.sha }}
frontend:
name: Frontend CI
runs-on: ubuntu-latest
needs: setup-environment
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- name: Create .env from example
run: cp ../.env.example ../.env
- uses: oven-sh/setup-bun@v2
- name: Restore certificates
uses: actions/cache/restore@v4
with:
bun-version: latest
path: certs
key: certs-${{ runner.os }}-${{ github.sha }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Code Quality (Biome)
run: bun run check
- name: Code Quality
run: |
cp ../.env.example ../.env
bun run check
- name: Type Check & Build
run: bun run build
- name: Run Unit Tests
- name: Unit Tests
run: bun run test
backend:
name: Backend CI
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
needs: setup-environment
services:
postgres:
image: postgres:16-alpine
@@ -44,63 +69,83 @@ jobs:
POSTGRES_PASSWORD: password123
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 # wait till up before starting (integrating) django app
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v4
- name: Create .env from example
run: cp ../.env.example ../.env
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "latest"
enable-cache: true
cache-dependency-glob: backend/uv.lock
- name: Install dependencies
run: uv sync
- name: Lint (Ruff)
run: uv run ruff check
- name: Run Tests
run: uv run python manage.py test
cache-dependency-glob: "backend/uv.lock"
- name: Restore certificates
uses: actions/cache/restore@v4
with:
path: certs
key: certs-${{ runner.os }}-${{ github.sha }}
- name: Setup Environment
run: |
cp ../.env.example ../.env
uv sync
- name: Lint & Test
run: |
uv run ruff check
uv run python manage.py test
e2e:
name: E2E Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: piku_e2e
POSTGRES_USER: piku_test
POSTGRES_PASSWORD: piku_test
ports:
- 5433:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
mailpit:
image: axllent/mailpit:latest
ports:
- 8025:8025
- 1025:1025
needs: setup-environment
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Restore Certificates
uses: actions/cache/restore@v4
with:
version: "latest"
path: certs
key: certs-${{ runner.os }}-${{ github.sha }}
- name: Setup Tools
uses: astral-sh/setup-uv@v5
- uses: oven-sh/setup-bun@v2
- name: Install Frontend dependencies
run: cd frontend && bun install
- name: Install Playwright Browsers
run: cd frontend && bun x playwright install --with-deps
- name: Create .env.e2e
run: cp .env.e2e.example .env.e2e
- name: Run E2E Script
run: ./scripts/run-e2e.sh
- name: Cache Playwright
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/bun.lock') }}
- name: Install Dependencies
run: |
(cd frontend && bun install)
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" != "true" ]; then
(cd frontend && bun x playwright install --with-deps)
fi
- name: Run E2E
run: |
cp .env.e2e.example .env.e2e
chmod +x ./scripts/run-e2e.sh
./scripts/run-e2e.sh
env:
CI: "true"
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 30
retention-days: 10
- name: Print Backend Logs on Failure
if: failure()
run: cat tmp/logs/backend.log || true
+18
View File
@@ -9,3 +9,21 @@ dist/
# IDE
.vscode/
# 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
+1
View File
@@ -0,0 +1 @@
.venv
+1
View File
@@ -10,3 +10,4 @@ __pycache__/
docs/
encrypted-images/
logs/
+16
View File
@@ -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"]
+96
View File
@@ -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",
},
},
}
+82 -17
View File
@@ -16,15 +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 environment variables
# Load dotenv files
env = environ.Env()
# Allow overriding the .env file path (useful for E2E testing/CI)
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)
environ.Env.read_env(env_file, overwrite=False)
# Security Settings
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
# NOTE: Set to forward https when using reverse proxy
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
SSL_ENABLED = env.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/
@@ -33,22 +54,25 @@ if os.path.exists(env_file):
SECRET_KEY = env("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env("DEBUG")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") or []
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",
"letters",
"scripts",
]
MIDDLEWARE = [
@@ -60,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
@@ -81,7 +121,8 @@ DATABASES = {
}
}
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS")
CORS_ALLOWED_ORIGINS = FRONTEND_URLS
CSRF_TRUSTED_ORIGINS += FRONTEND_URLS
CORS_ALLOW_CREDENTIALS = True
AUTH_USER_MODEL = "users.User"
@@ -89,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 = {
@@ -105,8 +147,8 @@ NOTE: COOKIE_SAMESITE: Lax is used to allow cross-site redirection, like links
"""
AUTH_COOKIE = {
"NAME": "refresh_token",
"DOMAIN": None,
"SECURE": not DEBUG,
"DOMAIN": None if DEBUG else env("FRONTEND_DOMAIN"),
"SECURE": SSL_ENABLED if DEBUG else True,
"HTTPONLY": True,
"SAMESITE": "Lax",
}
@@ -114,15 +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")
FRONTEND_URL = env("FRONTEND_URL")
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -141,7 +181,6 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
@@ -153,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"
+18
View File
@@ -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),
),
]
+13 -1
View File
@@ -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}"
+23
View File
@@ -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
+74
View File
@@ -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()
+170
View File
@@ -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)
+21
View File
@@ -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)
+16 -3
View File
@@ -1,17 +1,30 @@
[project]
name = "backend"
name = "piku_backend"
version = "0.1.0"
description = "Add your description here"
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",
]
@@ -23,4 +36,4 @@ line-length = 120
select = ["E", "F", "W", "UP", "I"]
[tool.ruff.lint.per-file-ignores]
"**/migrations/*" = ["E501"] # boilerplate - ignore
"**/migrations/*" = ["E501"]
+256
View File
@@ -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
View File
@@ -0,0 +1,31 @@
import os
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
class Command(BaseCommand):
def handle(self, *args, **options):
"""
Check if SSL is enabled in the environment variables.
If SSL is enabled, use runserver_plus command.
If SSL is not enabled, use runserver command.
"""
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}"
if ssl_enabled:
self.stdout.write(self.style.SUCCESS(f"Starting with SSL on {addrport}..."))
call_command(
"runserver_plus",
addrport,
cert_file=settings.BASE_DIR / "../certs/localhost.pem",
key_file=settings.BASE_DIR / "../certs/localhost-key.pem",
)
else:
self.stdout.write(self.style.WARNING(f"Starting without SSL on {addrport}..."))
call_command("runserver", addrport)
+22
View File
@@ -0,0 +1,22 @@
{% extends 'email/base.html' %}
{% block content %}
<div style="padding: 15px; font-style: italic">
<p>{{ pen_name }},</p>
<p>
Your destination is one train away.
</p>
<p>I've been keeping a place for your words.<br/>
Come when you're ready.</p>
</div>
{% endblock %}
{% block footnote %}
This link expires in 24 hours.<br/>
I'm patient, but not endlessly so.
{% endblock %}
{% block footer %}
Didn't write to me? Then someone else did.<br/>
Ignore this. I'll forget you were ever here.
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
pi. ku.
-------------------------------------------
{{pen_name}},
Your destination is one train away.
I've been keeping a place for your words.
Come when you're ready.
{{ cta.title }} -> {{ cta.link }}
-------------------------------------------
This link expires in 24 hours.
I'm patient, but not endlessly so.
-------------------------------------------
Didn't write to me? Then someone else did.
Ignore this. I'll forget you were ever here.
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>pi. ku.</title>
</head>
<body style="margin:0; padding:0; background-color:#1a1712;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:#1a1712; font-family: 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif;">
<tr>
<td align="center" style="padding: 48px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="max-width:480px; width:100%;">
{# Logo #}
<tr>
<td align="left" style="padding-bottom: 24px;">
<img src="https://cdn.jsdelivr.net/gh/ramvignesh-b/cdn@main/pi-ku_logo.png" width="80"
alt="Pi.Ku" style="display:block; border:0;">
</td>
</tr>
{# Body #}
<tr>
<td style="font-family: 'Trebuchet MS', 'Lucida Sans Unicode', Arial, sans-serif;
font-size: 13px;
line-height: 1.9;
color: #cdccca;
font-style: italic;
padding-bottom: 24px;">
{% block content %}
{% endblock %}
</td>
</tr>
{# CTA #}
{% if cta %}
<tr>
<td align="left" style="padding-bottom: 24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color: #301e19; border-radius: 3px;">
<a href='{{ cta.link }}' style="display: inline-block;
padding: 12px 24px;
font-family: 'Trebuchet MS', Arial, sans-serif;
font-size: 13px;
color: #f5e6c8;
text-decoration: none;
letter-spacing: 0.04em;
font-weight: bold;">
{{ cta.title }}
</a>
</td>
</tr>
</table>
</td>
</tr>
{% endif %}
{% if footnote %}
<tr>
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 12px;
font-style: italic;
color: #7a7974;
padding-bottom: 40px;
line-height: 1.8;">
{% block footnote %}
{% endblock %}
</td>
</tr>
{% endif %}
{# Footer #}
<tr>
<td style="border-top: 1px solid #2e2c29; padding-bottom: 24px; font-size: 0; line-height: 0;">
&nbsp;</td>
</tr>
<tr>
<td style="font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 12px;
font-style: italic;
color: #5a5957;
line-height: 1.8;">
{% block footer %}
{% endblock %}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{% extends 'email/base.html' %}
{% block content %}
<p>
Time has a way of making things clearer.<br/>
Or heavier. Sometimes both.
</p>
<p>
You had something to say at this exact moment.<br/>
I kept it exactly as you left it. <br/>
Not a word changed. Not a word read.
</p>
{% endblock %}
{% block footnote %}
<p>
You're ready now. Or maybe you're still not.<br/>
Open it anyway. You won't regret it.
</p>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
pi. ku.
-------------------------------------------
{{pen_name}},
Time has a way of making things clearer.
Or heavier. Sometimes both.
You had something to say at this exact moment.
I kept it exactly as you left it.
Not a word changed. Not a word read.
{{ cta.title }} -> {{ cta.link }}
-------------------------------------------
You're ready now. Or maybe you're still not.
Open it anyway. You won't regret it.
+8 -4
View File
@@ -1,3 +1,5 @@
from unittest.mock import _patch_dict
from django.contrib.auth import get_user_model
from django.contrib.auth.tokens import default_token_generator
from django.urls import reverse
@@ -19,9 +21,10 @@ class AuthTests(APITestCase):
self.refresh_url = reverse("token_refresh")
self.logout_url = reverse("logout")
@_patch_dict("config.settings.AUTH_COOKIE", {"SECURE": True})
def test_login_sets_secure_cookie(self):
"""
Tests if the Login API can generate access token and set secure cookie for refresh token.
Tests if the Login API can generate access token and set secure cookie (when ssl is enabled) for refresh token.
"""
data = {"email": self.user.email, "password": self.password}
cookie_name = "refresh_token"
@@ -32,9 +35,10 @@ class AuthTests(APITestCase):
self.assertIn("access", response.data)
self.assertNotIn("refresh", response.data)
self.assertIn(cookie_name, response.cookies)
self.assertTrue(response.cookies[cookie_name].value)
self.assertTrue(response.cookies[cookie_name].httponly)
self.assertEqual(response.cookies[cookie_name]["samesite"], "Lax")
self.assertIsNotNone(response.cookies[cookie_name].value)
self.assertTrue(response.cookies[cookie_name].get("httponly"))
self.assertTrue(response.cookies[cookie_name].get("secure"))
self.assertEqual(response.cookies[cookie_name].get("samesite"), "Lax")
class ActivationTests(APITestCase):
+21 -11
View File
@@ -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
+515 -19
View File
@@ -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"
@@ -12,28 +24,117 @@ wheels = [
]
[[package]]
name = "backend"
version = "0.1.0"
source = { virtual = "." }
name = "boto3"
version = "1.42.96"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-cors-headers" },
{ name = "django-environ" },
{ name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" },
{ name = "psycopg2-binary" },
{ name = "ruff" },
{ 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.metadata]
requires-dist = [
{ name = "django", specifier = ">=6.0.4" },
{ name = "django-cors-headers", specifier = ">=4.9.0" },
{ name = "django-environ", specifier = ">=0.13.0" },
{ name = "djangorestframework", specifier = ">=3.17.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "ruff", specifier = ">=0.15.9" },
[[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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "cryptography"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
@@ -50,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"
@@ -72,6 +186,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/00/3767393ece946084e1c6830a33ffb8e39d68642e27ad5ac7d4c8bd5de866/django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e", size = 20682, upload-time = "2026-02-18T01:08:07.359Z" },
]
[[package]]
name = "django-extensions"
version = "4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
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"
@@ -98,6 +291,164 @@ 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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ 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" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
@@ -117,6 +468,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
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"
@@ -126,6 +495,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[[package]]
name = "pyopenssl"
version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" }
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"
@@ -151,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"
@@ -160,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"
@@ -168,3 +631,36 @@ sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40
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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]
+20
View File
@@ -0,0 +1,20 @@
name: piku_e2e
services:
db:
image: postgres:16-alpine
container_name: ${DB_NAME}
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "${DB_PORT}:5432"
restart: unless-stopped
mailpit:
image: axllent/mailpit
container_name: piku_test_mail
ports:
- "${EMAIL_API_PORT}:8025"
- "${EMAIL_PORT}:1025"
restart: unless-stopped
-2
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
node_modules
test-results
playwright-report
dist
coverage
+25
View File
@@ -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;"]
+29
View File
@@ -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",
@@ -34,6 +40,7 @@
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
"dotenv": "^17.4.2",
@@ -117,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=="],
@@ -257,6 +272,8 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.4", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.4", "vitest": "4.1.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w=="],
@@ -399,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=="],
@@ -473,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=="],
@@ -523,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=="],
@@ -709,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=="],
+94 -39
View File
@@ -34,7 +34,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
await recipientInput.fill(recipientName);
// Initial load: verify textarea value (populated by Fabric when focused)
const canvasInput = page.getByLabel("Canvas text input");
const canvasInput = page.locator("textarea");
await canvasInput.waitFor({ state: "attached" });
await canvasInput.focus();
await expect(canvasInput).toHaveValue(/Take a deep breath/i);
@@ -45,8 +45,8 @@ test.describe("Letter Drafting (Real Backend)", () => {
await page.keyboard.type("This is a secret draft");
await page.keyboard.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.getByRole("button", { name: /draft/i }).click();
// Verify Success Modal/Alert
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
@@ -60,8 +60,14 @@ 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 page
.getByText(/opening your draft/i)
.waitFor({ state: "visible", timeout: 2000 })
.catch(() => {});
await expect(page.getByText(/opening your draft/i)).toBeHidden({
timeout: 10000,
});
// Check recipient
await expect(page.locator("#recipient")).toHaveValue(recipientName);
@@ -76,7 +82,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
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 +92,67 @@ 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.locator("#write-letter-btn").click();
const recipientInput = page.locator("#recipient");
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
await recipientInput.waitFor({ state: "visible", timeout: 10000 });
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
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.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.getByText(/your letter is sealed/i)).toBeVisible({
timeout: 10000,
});
// Verify sharing link contains a hash (the key)
const linkInput = page.locator("input[readOnly]");
// Navigate to Reader via "View letter"
await page.getByRole("button", { name: /view letter/i }).click();
// Should be on Reader URL
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
// Open the envelope to reveal the letter
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
timeout: 10000,
});
// Flip the envelope to show the seal
await page.locator("#env-front").click();
await page.waitForTimeout(2500); // Wait for flip transition
await page.getByAltText("Seal").click();
await page.waitForTimeout(1500);
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
// Share on demand
logger.info(">> [Seal] Clicking Share button in Reader...");
await page.locator("#share-letter-btn").click();
// Verify share modal with a valid link
await expect(page.getByText(/send this letter/i)).toBeVisible();
const linkInput = page.locator("#share-link-input");
const linkValue = await linkInput.inputValue();
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.getByText(/send this letter/i)).toBeHidden();
});
test("should allow author to access sealed letter from drawer without sharing key", async ({
@@ -137,20 +173,25 @@ test.describe("Letter Drafting (Real Backend)", () => {
await recipientInput.waitFor({ state: "visible" });
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
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.click();
await page
.getByRole("button", { name: /seal/i })
.filter({ visible: true })
.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.getByText(/your letter is sealed/i)).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: /keep it to myself/i }).click();
// Open "Kept" section - search for the section with id='kept' and click its toggle button
logger.info(">> [Drawer] Opening Kept section...");
@@ -168,14 +209,28 @@ test.describe("Letter Drafting (Real Backend)", () => {
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({
// Reveal and check decrypted content in Reader
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
timeout: 10000,
});
await expect(
page.getByText(new RegExp(`A sealed letter for ${recipientName}`, "i")),
).toBeVisible();
// Check recipient on the front of the envelope
await expect(page.getByText(new RegExp(recipientName, "i"))).toBeVisible();
// Flip the envelope to the back
await page.getByText(new RegExp(recipientName, "i")).click();
// Wait for flip transition (2s)
await page.waitForTimeout(2500);
// Reveal the letter: click seal then click letter
await page.getByAltText("Seal").click();
// Wait for flap transition
await page.waitForTimeout(1500);
// Click the letter to pull it out
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
// Wait for reveal transition
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
// Also check if we are redirected to the Reader if we manually go to the Editor URL
const readerUrl = page.url();
+7 -8
View File
@@ -14,16 +14,16 @@ 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(/pen 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);
@@ -31,19 +31,20 @@ export async function registerAndLogin(
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();
// 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 });
const welcomeButton = page.getByRole("button", { name: /I'll remember/i });
await welcomeButton.waitFor({ state: "visible", timeout: 10000 });
await welcomeButton.click();
await expect(welcomeButton).toBeHidden();
@@ -55,6 +56,4 @@ export async function registerAndLogin(
await expect(page).toHaveURL(/\/drawer/);
logger.info(`[Auth] Successfully authenticated ${email}`);
}
// Maintain backward compatibility if needed, or update callers
export const AuthHelper = { registerAndLogin };
+4 -5
View File
@@ -7,7 +7,7 @@ export interface MailpitMessage {
To: { Address: string }[];
}
const MAILPIT_API_URL = process.env.MAILPIT_API_URL;
const MAILPIT_API_URL = `http://${process.env.EMAIL_HOST}:${process.env.EMAIL_API_PORT}/api/v1`;
export const MailpitHelper = {
getActivationLink: async (
@@ -18,13 +18,12 @@ export const MailpitHelper = {
const requestContext = await request.newContext();
while (Date.now() - startTime < timeout) {
// Search specifically for the recipient to reduce data transfer
const response = await requestContext.get(`${MAILPIT_API_URL}/search`, {
params: { query: `to:${email}`, limit: 1 },
});
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(
@@ -32,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];
}
+6 -2
View File
@@ -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>
+23
View File
@@ -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;
}
}
}
+10 -2
View File
@@ -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",
@@ -47,6 +54,7 @@
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
"dotenv": "^17.4.2",
+20 -4
View File
@@ -1,12 +1,19 @@
import path from "node:path";
import process from "node:process";
import process, { env } from "node:process";
import { defineConfig, devices } from "@playwright/test";
import dotenv from "dotenv";
import { getBaseUrl } from "./utils/url-builder";
/**
* Read environment variables from file.
*/
dotenv.config({ path: path.resolve(process.cwd(), "../.env.e2e") });
const baseUrl = getBaseUrl(
env.SSL_ENABLED === "true",
env.FRONTEND_DOMAIN,
env.FRONTEND_PORT,
);
export default defineConfig({
timeout: 60000,
expect: {
@@ -26,13 +33,17 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.FRONTEND_URL,
baseURL: baseUrl,
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 20000,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Capture screenshot on failure */
screenshot: "only-on-failure",
/* Capture video on failure */
video: "retain-on-failure",
/* Ignore HTTPS errors */
ignoreHTTPSErrors: true,
},
/* Configure projects for major browsers */
@@ -49,8 +60,13 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "bun run dev",
url: process.env.FRONTEND_URL,
command: "npm run dev -- --mode e2e",
url: getBaseUrl(
process.env.SSL_ENABLED === "true",
process.env.FRONTEND_DOMAIN,
process.env.FRONTEND_PORT,
),
reuseExistingServer: !process.env.CI,
ignoreHTTPSErrors: true,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 47 KiB

-24
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Pi. Ku.",
"short_name": "Pi. Ku.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#d4a24f",
"background_color": "#3b1d13",
"display": "standalone"
}
+25 -16
View File
@@ -1,28 +1,34 @@
import { useEffect } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useRef } from "react";
import {
BrowserRouter,
Navigate,
Route,
Routes,
ScrollRestoration,
} from "react-router-dom";
import { ProtectedRoute, PublicRoute } 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,7 +37,8 @@ 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-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<Suspense fallback={<SplashScreen />}>
<Routes>
<Route path={ROUTES.HOME} element={<Home />} />
@@ -85,8 +92,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>
);
+12 -23
View File
@@ -1,19 +1,11 @@
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";
import { api } from "./apiClient";
const API_URL = "http://piku-server";
const VITE_API_URL = "http://piku-server";
beforeEach(() => {
useAuthStore.setState({
@@ -21,13 +13,10 @@ beforeEach(() => {
user: null,
isInitializing: false,
});
vi.stubEnv("VITE_API_URL", VITE_API_URL);
});
beforeAll(() => {
vi.stubEnv("API_URL", API_URL);
});
afterAll(() => {
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -37,7 +26,7 @@ describe("request interceptor", () => {
let capturedAuthHeader = "";
server.use(
http.get(`${API_URL}/api/auth/me/`, ({ request }) => {
http.get(`${VITE_API_URL}/api/auth/me/`, ({ request }) => {
capturedAuthHeader = request.headers.get("Authorization") ?? "";
return HttpResponse.json(mockUser);
}),
@@ -51,7 +40,7 @@ describe("request interceptor", () => {
it("should not send Authorization header when the store has no token", async () => {
let capturedAuthHeader: string | null = "";
server.use(
http.get(`${API_URL}/api/auth/me/`, ({ request }) => {
http.get(`${VITE_API_URL}/api/auth/me/`, ({ request }) => {
capturedAuthHeader = request.headers.get("Authorization");
return HttpResponse.json({});
}),
@@ -70,14 +59,14 @@ describe("response interceptor", () => {
let _refreshApiCallCount = 0;
server.use(
http.get(`${API_URL}/api/auth/me/`, ({ request }) => {
http.get(`${VITE_API_URL}/api/auth/me/`, ({ request }) => {
meApiCallCount++;
if (request.headers.get("Authorization") === "Bearer expired-token") {
return new HttpResponse(null, { status: 401 });
}
return HttpResponse.json(mockUser);
}),
http.post(`${API_URL}/api/auth/refresh/`, () => {
http.post(`${VITE_API_URL}/api/auth/refresh/`, () => {
_refreshApiCallCount++;
return HttpResponse.json({ access: "refreshed-token" });
}),
@@ -94,13 +83,13 @@ describe("response interceptor", () => {
useAuthStore.getState().setAuth("expired-token", mockUser);
server.use(
http.get(`${API_URL}/api/auth/me/`, ({ request }) => {
http.get(`${VITE_API_URL}/api/auth/me/`, ({ request }) => {
if (request.headers.get("Authorization") === "Bearer expired-token") {
return new HttpResponse(null, { status: 401 });
}
return HttpResponse.json(mockUser);
}),
http.post(`${API_URL}/api/auth/refresh/`, () =>
http.post(`${VITE_API_URL}/api/auth/refresh/`, () =>
HttpResponse.json({ access: "refreshed-token" }),
),
);
@@ -115,14 +104,14 @@ describe("response interceptor", () => {
server.use(
http.get(
`${API_URL}/api/auth/me/`,
`${VITE_API_URL}/api/auth/me/`,
() =>
new HttpResponse(JSON.stringify({ detail: "Invalid token" }), {
status: 401,
}),
),
http.post(
`${API_URL}/api/auth/refresh/`,
`${VITE_API_URL}/api/auth/refresh/`,
() =>
new HttpResponse(JSON.stringify({ detail: "Refresh failed" }), {
status: 401,
+9 -10
View File
@@ -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) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

-1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

+43 -17
View File
@@ -1,26 +1,52 @@
import { DotIcon } from "@phosphor-icons/react";
import "@fontsource/knewave/400.css";
export default function Logo() {
interface LogoProps {
scale?: number;
type?: "inline" | "mono" | "logo";
}
export default function Logo({ scale = 1, type = "logo" }: 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" }}
className={
"text-accent font-serif italic drop-shadow-xs drop-shadow-base-200/60 "
}
>
<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"
/>
Pi<span className="text-primary">.</span>&nbsp;Ku
<span className="text-primary">.</span>&nbsp;
</span>
);
}
if (type === "mono") {
return (
<span className="font-mono italic font-bold border-b-3 border-dashed border-stone-800/50">
pi. ku.
</span>
);
}
return (
<div
role="img"
aria-label="Pi. Ku. logo"
className="inline-flex items-baseline justify-center leading-none select-none"
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`}>&nbsp;Ku</span>
<DotIcon
weight="fill"
size={12}
className={`text-primary translate-y-1 -mx-px`}
/>
</div>
);
}
+2 -2
View File
@@ -40,7 +40,7 @@ describe("ProtectedRoute", () => {
"/protected",
);
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
expect(screen.queryByText("Secret")).not.toBeInTheDocument();
});
@@ -90,7 +90,7 @@ describe("PublicRoute", () => {
</PublicRoute>,
"/public",
);
expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
expect(screen.getByText(/Unsealing/i)).toBeInTheDocument();
expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
});
+5 -5
View File
@@ -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,8 +22,8 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
}
/**
* Pre-login flows.
* Redirects to /drawer if already authenticated.
* Public - auth route guard.
* If authenticated, redirect all the auth related flows to the drawer
*/
export function PublicRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isInitializing } = useAuth();
+12 -4
View File
@@ -1,14 +1,22 @@
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 className="fixed w-screen h-screen inset-0 bg-base-100 flex flex-col items-center justify-center z-9999">
<div className="flex flex-col items-center gap-6 animate-pulse">
<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,77 @@
import { GearFineIcon } from "@phosphor-icons/react";
interface DrawerSectionProps {
id: string;
title: string;
count: string;
isOpen: boolean;
onClick: () => void;
children: React.ReactNode;
}
export function DrawerSection({
id,
title,
count,
isOpen,
onClick,
children,
}: DrawerSectionProps) {
return (
<div
id={id}
className={`join-item group flex flex-col transition-colors duration-3000 ease-in-out ${isOpen ? "bg-base-300/30" : ""}`}
>
<div
className={`transition-all duration-1500 ease-in-out bg-neutral/10 ${
isOpen
? "max-h-125 opacity-100 py-3 border-b border-base-content/5 overflow-visible"
: "max-h-0 opacity-0 pointer-events-none"
}`}
>
{children}
</div>
<button
type="button"
onClick={onClick}
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
>
<div className="flex-1">
<div
className={`font-sans text-xs tracking-widester uppercase transition-colors duration-800 ${
isOpen
? "text-base-content"
: "text-base-content/40 group-hover:text-base-content/80"
}`}
>
{title}
</div>
<div className="font-sans text-[0.6rem] text-base-content/20 mt-1">
{count}
</div>
</div>
{id === "vault" ? (
<GearFineIcon
className={
"-mt-3 group-hover:animate-[spin_8s_ease-in-out_1] group-hover:text-neutral-content text-neutral"
}
weight={"duotone"}
size={30}
/>
) : (
<div
className={`w-8 h-1 rounded-sm transition-all duration-300 bg-neutral ${
isOpen
? "bg-primary/80! opacity-80 scale-110"
: "group-hover:bg-primary"
}`}
>
<div className="absolute -top-1 left-1.75 w-5 h-px bg-base-content/5" />
</div>
)}
</button>
</div>
);
}
@@ -0,0 +1,61 @@
import { LockIcon, LockKeyOpenIcon } from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom";
import { PATHS } from "../../config/routes";
interface LetterItemProps {
preview: string;
timestamp: string;
id: string;
status: "DRAFT" | "SEALED" | "BURNED";
unlock_at?: string;
isLocked?: boolean;
}
export function LetterItem({
preview,
timestamp,
id,
status,
unlock_at,
isLocked = false,
}: LetterItemProps) {
const navigate = useNavigate();
function handleNavigate(): void {
if (isLocked) return;
if (status === "SEALED") {
navigate(PATHS.read(id));
} else {
navigate(PATHS.write(id));
}
}
return (
<button
type="button"
onClick={handleNavigate}
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all delay-75 duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
>
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
{preview}
</div>
{unlock_at ? (
<div className="flex flex-col items-end">
{isLocked ? (
<div className="font-sans text-xs badge badge-accent badge-soft rounded-2xl">
<LockIcon weight="duotone" size={16} />
Locked Until {unlock_at}
</div>
) : (
<div className="font-sans text-xs badge badge-primary badge-soft rounded-2xl">
<LockKeyOpenIcon weight="duotone" size={16} /> Unlocked
</div>
)}
</div>
) : (
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
{timestamp}
</div>
)}
</button>
);
}
@@ -0,0 +1,51 @@
import { LockKeyIcon } from "@phosphor-icons/react";
import { Modal } from "../ui/Modal";
interface PasskeyModalProps {
onUnlock: (password: string) => Promise<void>;
}
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
return (
<Modal isOpen={true}>
<LockKeyIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
/>
<h3 className="font-bold text-lg font-display text-primary">
Authentication Required
</h3>
<p className="py-4 font-sans">
We need your passkey to open your letters
</p>
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
<p className="text-xs text-neutral-content/30 font-mono italic">
Your passkey is used to decrypt your data locally.
</p>
<div className="modal-action items-center gap-4">
<form
className="form-control w-full inline-flex"
onSubmit={async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const password = formData.get("password") as string;
if (!password) return;
await onUnlock(password);
}}
>
<input
name="password"
required
type="password"
placeholder="password"
className="font-sans validator input input-bordered rounded-r-none"
/>
<div className="validator-message text-xs text-error"></div>
<button type="submit" className="btn btn-primary rounded-l-none">
Unlock
</button>
</form>
</div>
</Modal>
);
}
@@ -0,0 +1,393 @@
import * as fabric from "fabric";
import type * as React from "react";
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
const PAD = 36;
const BASE_WIDTH = 680;
const DEFAULT_LOGICAL_HEIGHT = 900;
const DEFAULT_FONT_FAMILY = "Playfair Display Variable";
const DEFAULT_FONT_COLOR = "#000";
export interface FabricObjectJSON {
type: string;
name?: string;
top: number;
left: number;
width: number;
height: number;
[key: string]: unknown;
}
export interface FabricImageJSON extends FabricObjectJSON {
type: "Image";
src: string;
_customRawFile?: File;
}
export interface CanvasJSON {
objects: (FabricObjectJSON | FabricImageJSON)[];
canvasWidth?: number;
canvasHeight?: number;
}
export interface CanvasStyle {
fontFamily: string;
fontColor: string;
}
export type CanvasTools = {
addImage: (url: string, file: File) => void;
getData: () => CanvasJSON;
getImages: () => { src: string; file: File }[];
loadData: (data: CanvasJSON) => Promise<void>;
getStyle: () => CanvasStyle;
};
export interface FabricImageWithFile extends fabric.FabricImage {
_customRawFile: File;
}
// NOTE: We use the same canvasData to render on both mobile and desktop viewports.
// Instead of calculating the entire objects pad again, we apply a zoom multiplier (scale down or up)
// over the last saved canvas size.
const applyResponsiveViewport = (
canvas: fabric.Canvas,
wrapper: HTMLDivElement,
logicalWidth: number,
logicalHeight: number,
) => {
const physicalWidth = wrapper.clientWidth || logicalWidth;
const zoomMultiplier = physicalWidth / logicalWidth;
const physicalHeight = Math.max(1, logicalHeight * zoomMultiplier);
canvas.setDimensions({
width: physicalWidth,
height: physicalHeight,
});
wrapper.style.height = `${physicalHeight}px`;
canvas.setViewportTransform([zoomMultiplier, 0, 0, zoomMultiplier, 0, 0]);
canvas.requestRenderAll();
};
// to find the maximum height of the content to dynamically resize the canvas
// would've been wayyy easier only if canvas supported fit-content like CSS property :)
const measureLogicalContentHeight = (
canvas: fabric.Canvas,
minimumHeight = DEFAULT_LOGICAL_HEIGHT,
) => {
const maxBottom = canvas.getObjects().reduce((maxHeight, currObj) => {
const top = currObj.top;
const height = currObj.getScaledHeight();
return Math.max(maxHeight, top + height);
}, 0);
return Math.max(minimumHeight, maxBottom + PAD);
};
const DEFAULT_INIT_TEXT = "Take a deep breath...";
interface ComposeCanvasProps {
readOnly?: boolean;
initialData?: CanvasJSON | null;
style?: CanvasStyle;
ref?: React.Ref<CanvasTools>;
}
export function ComposeCanvas({
readOnly = false,
initialData = null,
style,
ref,
}: ComposeCanvasProps) {
// wrapper is the parent div box
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null);
const textboxRef = useRef<fabric.Textbox | null>(null);
const deferredDataRef = useRef<CanvasJSON | null>(null);
const logicalSizeRef = useRef({
width: BASE_WIDTH,
height: DEFAULT_LOGICAL_HEIGHT,
});
// re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return;
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
fabricRef.current,
minHeight,
);
applyResponsiveViewport(
fabricRef.current,
wrapperRef.current,
logicalSizeRef.current.width,
logicalSizeRef.current.height,
);
fabricRef.current.requestRenderAll();
}, [initialData]);
// auto focus the cursor into the main textbox no matter the latest element added
const focusTextbox = useCallback(
(textbox: fabric.Textbox) => {
if (readOnly || !fabricRef.current) return;
fabricRef.current.setActiveObject(textbox);
textbox.enterEditing();
// move the cursor to the end of the text
const textLength = textbox.text?.length ?? 0;
textbox.selectionStart = textLength;
textbox.selectionEnd = textLength;
fabricRef.current.requestRenderAll();
},
[readOnly],
);
const loadContent = useCallback(
async (data: CanvasJSON | null) => {
const canvas = fabricRef.current;
const wrapper = wrapperRef.current;
if (!(canvas && wrapper)) return;
// clean the canvas everytime and set fresh
canvas.clear();
let textbox: fabric.Textbox | null = null;
// restore logical size from prev saved data if available (in case of existing letter)
logicalSizeRef.current = {
width: data?.canvasWidth ?? BASE_WIDTH,
height: data?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT,
};
if (data?.objects?.length) {
await canvas.loadFromJSON(data);
textbox = canvas.getObjects("Textbox")[0] as fabric.Textbox;
} else {
// Create a fresh letter if no data exists
textbox = new fabric.Textbox(DEFAULT_INIT_TEXT, {
name: "main-textbox",
originX: "left",
originY: "top",
left: PAD,
top: PAD,
width: BASE_WIDTH - PAD * 2,
fontSize: 18,
fontWeight: 500,
fontFamily: DEFAULT_FONT_FAMILY,
fill: DEFAULT_FONT_COLOR,
lineHeight: 1.5,
// NOTE: splitByGrapheme is required for word wrap and re-low
// but fabric asks to disable this for clear font?? So we disable it for read view
splitByGrapheme: !readOnly,
lockMovementX: true,
lockMovementY: true,
lockScalingX: true,
lockScalingY: true,
lockRotation: true,
hasControls: false,
hasBorders: false,
objectCaching: false,
noScaleCache: false,
});
canvas.add(textbox);
}
if (!textbox) return;
// readonly contraints applicable for post seal view
textbox.selectable = !readOnly;
textbox.evented = !readOnly;
textbox.editable = !readOnly;
textbox.hasBorders = false;
textboxRef.current = textbox;
// observe and auto-resize the canvas height whenever typed
textbox.on("changed", syncViewport);
// trapping the focus into the textbox wherever clicked on canvas (except images)
canvas.on("mouse:down", (e) => {
if (!e.target || e.target === textbox) {
focusTextbox(textbox);
}
});
syncViewport();
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
// otherwise it goes to the front
if (!readOnly) {
setTimeout(() => focusTextbox(textbox), 200);
}
},
[readOnly, syncViewport, focusTextbox],
);
useEffect(() => {
if (style && textboxRef.current) {
const textBox = textboxRef.current;
textBox.fontFamily = style.fontFamily || textBox.fontFamily;
textBox.fill = style.fontColor || textBox.fill;
syncViewport();
}
}, [style, syncViewport]);
useEffect(() => {
let isMounted = true;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
const initCanvas = async () => {
// HACK: actual font may change the text-width - small ux improvement
await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
// init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, {
width,
height: DEFAULT_LOGICAL_HEIGHT,
selection: !readOnly,
preserveObjectStacking: true,
allowTouchScrolling: true,
enableRetinaScaling: true,
objectCaching: false,
});
// remove default fabric background to let our CSS show through
// TODO: provision custom bg (color in scope, but how does img fit?)
const wrapperEl = canvas.getElement().parentElement;
if (wrapperEl) wrapperEl.style.background = "transparent";
fabricRef.current = canvas;
await loadContent(initialData);
// sometimes loadData() may be called before the canvas finished the init render
// so we retry that stashed render right after the init
if (deferredDataRef.current) {
await loadContent(deferredDataRef.current);
deferredDataRef.current = null;
}
// auto window resizing based width
lastWidth = wrapperRef.current.clientWidth;
resizeObserver = new ResizeObserver(() => {
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
resizeObserver.observe(wrapperRef.current!);
};
initCanvas().then();
return () => {
isMounted = false;
resizeObserver?.disconnect();
fabricRef.current?.dispose();
fabricRef.current = null;
textboxRef.current = null;
};
}, [initialData, loadContent, readOnly, syncViewport]);
// WHY?: fabric doesn't work like react with state and props based optimized re-renders.
// everytime we there's a change in the data, we should force the render,
// so we let the parent Editor component take control of this.
useImperativeHandle(ref, () => ({
addImage: (url: string, file: File) => {
if (!fabricRef.current) return;
fabric.FabricImage.fromURL(url).then((img) => {
img.scaleToWidth(Math.min(300, img.width));
img.set({
originX: "left",
originY: "top",
left: PAD,
top: PAD,
noScaleCache: false,
objectCaching: false,
// WHY?: after image object clean-up, its src becomes local blob://
// but browser won't let us parse this blob:// into file afterwards. so we hold a local copy
_customRawFile: file,
} as Partial<FabricImageWithFile>);
fabricRef.current?.add(img);
fabricRef.current?.setActiveObject(img);
syncViewport();
// clean up memory
URL.revokeObjectURL(url);
});
},
getData: () => {
if (!fabricRef.current) return { objects: [] };
syncViewport();
const json = fabricRef.current.toJSON() as CanvasJSON;
json.canvasWidth = logicalSizeRef.current.width;
json.canvasHeight = logicalSizeRef.current.height;
return json;
},
getImages: () => {
if (!fabricRef.current) return [];
const images = fabricRef.current.getObjects(
"Image",
) as FabricImageWithFile[];
return images.map((img) => ({
src: img.getSrc(),
file: img._customRawFile,
}));
},
loadData: async (data: CanvasJSON) => {
// if canvas isn't ready yet, stash the data and let the useEffect pick it up
if (!fabricRef.current) {
deferredDataRef.current = data;
return;
}
await loadContent(data);
},
getStyle: () => {
const textBox = textboxRef.current;
return {
fontFamily: textBox?.fontFamily || DEFAULT_FONT_FAMILY,
fontColor: (textBox?.fill as string) || DEFAULT_FONT_COLOR,
};
},
}));
return (
<div
ref={wrapperRef}
className="relative bg-paper shadow-primary-content rounded-sm w-full outline-none overflow-hidden cursor-text"
>
<canvas
ref={canvasRef}
className="absolute top-0 left-0"
style={{ background: "transparent" }}
/>
</div>
);
}
ComposeCanvas.displayName = "ComposeCanvas";
@@ -0,0 +1,81 @@
import { LockIcon } from "@phosphor-icons/react";
import type { NavigateFunction } from "react-router-dom";
import { PATHS, ROUTES } from "../../config/routes";
import { Modal } from "../ui/Modal";
interface PostSealModalProps {
sealedTargetId: string | null;
navigate: NavigateFunction;
type: "KEPT" | "VAULT";
}
export function PostSealModal({
sealedTargetId,
navigate,
type = "KEPT",
}: PostSealModalProps) {
return (
<Modal isOpen={!!sealedTargetId}>
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
<p className="text-base-content/60">
It's encrypted and always safe in your drawer.
</p>
{type === "KEPT" ? (
<p className="text-base-content/80 text-sm font-sans">
When you're ready,
<br />
you can{" "}
<span className="text-primary font-bold font-display">read</span> it,{" "}
<span className="text-accent font-bold font-display">send</span> it to
someone, or{" "}
<span className="text-error font-bold font-display">burn</span> it to
release
</p>
) : (
<p className="text-base-content/80 text-sm font-sans">
Be assured that the letter will find you when the time is right.
<br />
Till then,{" "}
<span className="font-bold font-display text-primary">
take a deep breath
</span>
, <span className="font-bold font-display text-accent">manifest</span>
, and{" "}
<span className="font-bold font-display text-success">
let it rest
</span>
.
</p>
)}
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
{type === "KEPT" ? (
<>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)}
>
Keep it to myself
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => navigate(PATHS.read(sealedTargetId!))}
>
View letter
</button>
</>
) : (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)}
>
Step Away...
</button>
)}
</div>
</Modal>
);
}
+315
View File
@@ -0,0 +1,315 @@
import {
CircleHalfTiltIcon,
ImageIcon,
LockIcon,
PaintBucketIcon,
QuestionIcon,
StampIcon,
TextAUnderlineIcon,
TrayIcon,
VaultIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { Modal } from "../ui/Modal";
import type { CanvasStyle } from "./ComposeCanvas.tsx";
interface ToolBarProps {
onAddImage: () => void;
sealBtnClicked: boolean;
setSealBtnClicked: (v: boolean) => void;
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
onFontChange: (style: CanvasStyle) => void;
latestFontStyle: CanvasStyle;
}
const FONT_FAMILIES: Map<string, string> = new Map([
["Serif", "Playfair Display Variable"],
["Sans", "Jost Variable"],
["Cursive", "Playwrite HR Lijeva Variable"],
["Handwriting", "Architects Daughter"],
["Slab", "Cutive Mono"],
["Mono", "Space Mono"],
["Tamil", "Kavivanar"],
["Crazy(pls no)", "Redacted Script"],
]);
const FONT_COLORS: Map<string, string> = new Map([
["Black", "#000"],
["Gold", "#866a0e"],
["Purple", "#711caf"],
["Green", "#1f5b1f"],
["Blue", "#111e67"],
]);
export function ToolBar({
onAddImage,
sealBtnClicked,
setSealBtnClicked,
onSave,
setConfirmModal,
onFontChange,
latestFontStyle,
}: ToolBarProps) {
return (
<div
id="writer-toolbar"
className="relative z-10 flex items-center justify-between mb-8 h-14 bg-base-100/50 backdrop-blur-md rounded-full border border-base-content/5 px-6"
>
<div className="flex gap-4">
{/* Image upload */}
<button
type="button"
className="btn btn-ghost btn-sm group"
onClick={onAddImage}
>
<ImageIcon size={18} weight="bold" />
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
Add Image
</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
{/* Font Family */}
<div className={"flex items-center gap-2 group"}>
<TextAUnderlineIcon
size={24}
weight="bold"
className={"hidden md:inline"}
/>
<select
className="select select-sm"
onChange={(e) => {
onFontChange({ ...latestFontStyle, fontFamily: e.target.value });
}}
value={latestFontStyle.fontFamily}
>
{Array.from(FONT_FAMILIES.entries()).map(
([fontFamily, fontName]) => {
return (
<option key={fontName} value={fontName}>
{fontFamily}
</option>
);
},
)}
</select>
</div>
<div className="w-px h-4 bg-base-content/10 mx-2 my-auto hidden md:inline" />
{/* Font Color */}
<div className="dropdown dropdown-bottom flex items-center gap-2 group">
<PaintBucketIcon
size={16}
weight="bold"
className={"hidden md:flex"}
/>
<button
className="btn btn-ghost btn-sm px-2 gap-2 flex items-center"
type={"button"}
>
<CircleHalfTiltIcon
size={18}
style={{ color: latestFontStyle.fontColor }}
weight="duotone"
/>
</button>
<ul className="dropdown-content z-50 menu p-2 shadow bg-base-200/95 rounded-full md:ml-4">
{Array.from(FONT_COLORS.entries()).map(([_, colorCode]) => (
<li key={colorCode}>
<button
type="button"
className={`${latestFontStyle.fontColor === colorCode ? "active" : ""}`}
onClick={() => {
onFontChange({ ...latestFontStyle, fontColor: colorCode });
(document.activeElement as HTMLButtonElement)?.blur();
}}
>
<CircleHalfTiltIcon
size={18}
style={{ color: colorCode }}
weight="fill"
/>
</button>
</li>
))}
</ul>
</div>
</div>
{/* Draft */}
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-ghost btn-sm text-xxs group tracking-widester uppercase font-bold text-base-content/60 hover:text-base-content"
title="Store in your private drawer"
onClick={() => onSave("DRAFT")}
>
<TrayIcon size={18} weight="bold" />
<span className="hidden md:inline group-hover:inline transition-all duration-1000">
Draft
</span>
</button>
<div className="w-px h-4 bg-base-content/10 mx-2 hidden md:inline" />
{/*Seal */}
<button
type="button"
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
onClick={() => setSealBtnClicked(true)}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span
className={`hidden md:inline ${sealBtnClicked ? "inline" : ""} group-hover:inline transition-all duration-1000`}
>
Seal
</span>
</button>
</div>
<div
className={`flex-col items-center gap-2 absolute right-0 z-10 bg-primary/20 rounded-full p-8 -m-2 ${sealBtnClicked ? "" : "hidden"}`}
>
<button
type="button"
className="btn btn-accent btn-sm rounded-full px-6 group"
onClick={() => onSave("SEALED")}
>
<StampIcon
size={16}
weight="fill"
className="mr-1 group-hover:animate-bounce"
/>
<span className="transition-all duration-1000">Seal</span>
</button>
<div className="w-full divider text-neutral-content/60 mt-2 mb-2">
or
</div>
<button
type="button"
className="btn btn-neutral btn-sm rounded-full px-6 group"
onClick={() => setConfirmModal("VAULT")}
>
<VaultIcon size={16} weight="fill" className="mr-1" />
<span className="transition-all duration-1000">Vault</span>
</button>
</div>
<button
className={`z-100001 absolute right-0 bg-transparent cursor-pointer ${sealBtnClicked ? "" : "hidden"}`}
type="button"
onClick={() => setSealBtnClicked(false)}
>
<XCircleIcon weight="duotone" size={20} className={"text-error"} />
</button>
<button
type="button"
aria-label="Help"
className={`bg-transparent cursor-pointer -mt-2 absolute z-100001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
>
<div className="tooltip tooltip-left">
<div className="tooltip-content -translate-x-38 text-left">
<span className="font-bold text-accent">Seal</span> puts the letter
in an envelope, ready to be read right away.
<div className="divider my-0"></div>
<span className="font-bold text-success">Vault</span> keeps it
locked away until the right moment, even from yourself.
</div>
<QuestionIcon
weight="duotone"
size={20}
className={"absolute -translate-x-38 -translate-y-3"}
/>
</div>
</button>
</div>
);
}
export function LetterHead() {
return (
<div className="flex items-center justify-center mb-8 h-14">
<div className="badge badge-outline border-primary/20 bg-primary/5 text-primary gap-2 p-4 rounded-full">
<LockIcon size={14} weight="fill" />
<span className="text-xxs uppercase tracking-widest font-bold">
Sealed & View Only
</span>
</div>
</div>
);
}
interface VaultConfirmModalProps {
onSave: (status: "SEALED" | "DRAFT" | "VAULT", date?: Date) => Promise<void>;
setConfirmModal: (v: "VAULT" | "SEAL" | null) => void;
setUnlockDate: (d: Date | null) => void;
}
export function VaultConfirmModal({
onSave,
setConfirmModal,
setUnlockDate,
}: VaultConfirmModalProps) {
return (
<Modal isOpen={true}>
<VaultIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
/>
<h3 className="font-serif text-3xl">Take it away, then?</h3>
<p className="text-base-content/60 text-sm text-center mt-4">
By vaulting this letter, you ask me to hold on to this.
<br />
I'll remember to mail you this on the unlock date.
<br />
<span className={"font-bold text-primary"}>
{" "}
But I won't let you read or rewrite this letter until then.
</span>
<br />
</p>
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const unlockDateStr = formData.get("vault-date") as string;
const newUnlockDate = new Date(unlockDateStr);
setUnlockDate(newUnlockDate);
await onSave("VAULT", newUnlockDate);
setConfirmModal(null);
}}
id="vault-form"
className="min-w-75"
>
<div className={"divider tracking-tightest font-display text-sm"}>
Set an unlock date
</div>
<input
required
type="date"
className="input input-bordered w-full"
name="vault-date"
/>
<div className="w-full flex justify-center gap-8 mt-4">
<button
type="button"
className="btn btn-ghost btn-sm mt-4"
onClick={() => setConfirmModal(null)}
>
I need time
</button>
<button
className="btn btn-primary btn-sm mt-4"
type="submit"
form="vault-form"
>
Take it
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,85 @@
import {
HandPalmIcon,
ShieldCheckIcon,
WarningIcon,
} from "@phosphor-icons/react";
import Logo from "../Logo.tsx";
import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan.tsx";
export default function WelcomeModal({
setShowWelcome,
}: {
setShowWelcome: (show: boolean) => void;
}) {
return (
<>
<Modal isOpen={true}>
<div className="flex flex-col items-center text-center gap-4">
<div className="bg-primary/10 p-4 rounded-full animate-pulse">
<ShieldCheckIcon
size={48}
weight="duotone"
className="text-primary"
/>
</div>
<h3 className="font-display text-2xl font-bold text-primary">
Welcome to &nbsp;
<Logo /> &nbsp;!
</h3>
<p className="text-base-content/80 leading-relaxed">
Before we begin, let me make a small promise.
<HandPalmIcon
size={18}
className="inline text-primary"
weight="fill"
/>
<div className="divider my-0"></div>
<br />
Everything you write here is sealed with your password,{" "}
<span className="font-display text-success">cryptographically</span>
, before it leaves your hands.
<br />A fancy way of saying, I couldn't if I tried.
</p>
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
<p className="text-sm font-medium text-primary-content">
If you ever happen to forget your password, your letters are lost
to time, forever.
<br />
<span className="font-bold mt-2">
I highly, highly recommend storing this password in your{" "}
<a
href="https://www.privacyguides.org/en/passwords/"
target="_blank"
className="link link-primary-content"
rel="noopener"
>
password manager
</a>{" "}
or somewhere safe to remember it.
</span>
</p>
</div>
<div className="modal-action w-full">
<button
type="button"
onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg"
>
I'll remember
</button>
</div>
</div>
</Modal>
<div className="absolute bottom-0 right-0 z-1000 font-sans w-full">
<Saajan
position="top"
message={"I've lost words before.\nI know what it feels like."}
/>
</div>
</>
);
}
@@ -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,160 @@
import { WavesIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import candle from "../../assets/envelope/candle.png";
import stamp from "../../assets/envelope/stamp.png";
import waxSeal from "../../assets/envelope/waxSeal.png";
export interface EnvelopeRevealProps {
recipient?: string;
date?: string;
onRevealComplete: () => void;
ignite: boolean;
isFlip?: boolean;
isInteractive?: boolean;
openFlap?: boolean;
}
export function EnvelopeReveal({
recipient,
date,
onRevealComplete,
ignite,
isFlip,
isInteractive = true,
openFlap = false,
}: EnvelopeRevealProps) {
const [revealLetter, setRevealLetter] = useState(false);
const [isFlipped, setIsFlipped] = useState(!!isFlip);
const [isFlapOpen, setIsFlapOpen] = useState(!!openFlap);
useEffect(() => {
setIsFlipped(!!isFlip);
}, [isFlip]);
const [burn, setBurn] = useState<{ width: number; height: number }>({
width: 0,
height: 0,
});
useEffect(() => {
setIsFlapOpen(openFlap);
}, [openFlap]);
useEffect(() => {
if (!ignite) {
setBurn({ width: 0, height: 0 });
return;
}
const burnInterval = setInterval(() => {
setBurn((prev) => ({ width: prev.width + 4, height: prev.height + 6 }));
}, 100);
return () => clearInterval(burnInterval);
}, [ignite]);
const handleClick = () => {
if (revealLetter) return;
setRevealLetter(true);
setTimeout(() => {
onRevealComplete();
}, 2500);
};
return (
<>
<div
className={`relative h-70 w-105 transform-3d transition-transform duration-2000 ${isFlipped ? "rotate-y-180" : ""}`}
>
<div
className={` flex backface-hidden rotate-y-180 justify-center transition-all duration-1000 ${isFlipped ? "" : "pointer-events-none"}`}
>
<div
id="env-top"
className="z-4 delay-500 transition-all duration-2000 absolute peer h-40 w-54 mt-0 bg-base-200 mask mask-triangle-2 scale-x-234 has-checked:scale-y-[-1] has-checked:-translate-y-full has-checked:z-1 has-checked:duration-1000"
>
<input
type="checkbox"
className="transition checkbox absolute h-full w-full text-transparent bg-transparent z-100"
checked={isFlapOpen}
onChange={() => setIsFlapOpen((prev) => !prev)}
disabled={!isInteractive}
/>
</div>
<img
className={
"translate-y-24 delay-2000 absolute z-6 peer-has-checked:pointer-events-none peer-has-checked:opacity-0 peer-has-checked:delay-0 transition-opacity duration-1000 cursor-pointer"
}
src={waxSeal}
alt="Seal"
onClick={() => setIsFlapOpen((prev) => !prev)}
onKeyDown={() => setIsFlapOpen((prev) => !prev)}
/>
<button
type="button"
id="letter"
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-3000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
onClick={handleClick}
></button>
<div
id="env-right"
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-3 -mr-48 z-3 pointer-events-none"
></div>
<div
id="env-left"
className="absolute h-70 w-105 bg-base-300 mask mask-triangle-4 -ml-48 z-3 pointer-events-none"
></div>
<button
type="button"
id="env-bottom"
className="absolute h-70 w-45 bg-base-200 mask mask-triangle-2 scale-y-[-1] mt-15 scale-x-240 z-3"
></button>
</div>
<button
id="env-front"
type="button"
disabled={!isInteractive}
className={`text-left p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2 ${isFlipped ? "pointer-events-none" : ""}`}
onClick={() => setIsFlipped((prev) => !prev)}
>
<span className={"text-neutral-content/60 font-xs font-display"}>
to
</span>
<h1 className="text-3xl font-bold text-base-content">{recipient}</h1>
<p className="text-base-content/60 font-display mt-8">{date}</p>
<img
src={stamp}
alt={"stamp"}
className={
"z-0 rotate-6 opacity-80 text-accent absolute mt-0 mr-1 top-4 right-0"
}
/>
<WavesIcon
className={"absolute mt-0 mr-12 top-18 right-8 text-primary"}
size={50}
/>
<WavesIcon
className={"absolute mt-0 mr-4 top-18 right-8 text-primary"}
size={50}
/>
</button>
</div>
{ignite && (
<>
<div className="absolute w-115 h-70 z-100 overflow-hidden flex align-baseline -translate-y-70 -translate-x-5">
<div
className="absolute z-1000 border-2 border-amber-200 -bottom-3 -right-3 w-0 h-0 transition-all duration-500 bg-base-100 rounded-tl-full rounded-bl-full origin-bottom-right"
style={{
width: 2 * burn.width,
height: 2 * burn.height,
}}
></div>
</div>
<div className="absolute z-1001 bottom-0 right-0 translate-x-15 translate-y-20">
<img src={candle} alt="candle" />
</div>
</>
)}
</>
);
}
@@ -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,74 @@
import { EyeSlashIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan";
interface ShareModalProps {
shareLink: string | null;
setShareLink: (link: string | null) => void;
}
export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
const copyToClipboard = async () => {
if (!shareLink) return;
await navigator.clipboard.writeText(shareLink);
};
return (
<>
<Modal isOpen={!!shareLink} onClose={() => setShareLink(null)}>
<div className="flex flex-col items-center justify-center text-center gap-6 py-4">
<div className="space-y-2">
<PaperPlaneTiltIcon
size={48}
weight="bold"
className="mb-4 text-primary mx-auto animate-[bounce_3s_ease-in-out_infinite]"
/>
<h3 className="font-serif text-3xl">Send this letter</h3>
<p className="text-base-content/80 text-sm font-sans mt-4">
You've carried these words long enough.
<br />
Send your letter now, and let the{" "}
<span className="text-accent font-display">unsaid</span> finally
find its home.
</p>
<div className="divider mx-auto" />
<blockquote className="text-sm info text-neutral-content/60 font-sans">
They'll receive it exactly as you're seeing it now.
<br />
Nothing more, nothing less.
</blockquote>
</div>
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl">
<input
id="share-link-input"
readOnly
value={shareLink ?? ""}
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
/>
<button
type="button"
onClick={copyToClipboard}
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
>
Copy
</button>
</div>
<div className="flex flex-col gap-1 uppercase tracking-widest text-base-content/30 font-sans">
<p className="textarea-xs flex items-center justify-center">
<EyeSlashIcon weight="duotone" size={18} className="mr-2" />{" "}
Zero-Knowledge Share:
</p>
<p className="textarea-xs font-mono text-center">
The key never leaves your or the recipient's browser.
</p>
</div>
</div>
</Modal>
<div className="absolute bottom-0 z-1000 font-sans w-full">
<Saajan
position="top"
message={`Someone once said,\n"To send a letter is a good way to go somewhere without moving anything but your heart."\nThey were not wrong.`}
/>
</div>
</>
);
}
@@ -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";
+1 -1
View File
@@ -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>
);
}
+3
View File
@@ -6,6 +6,7 @@ interface FormFieldProps {
placeholder?: string;
registration: UseFormRegisterReturn;
error?: string;
handleFocus?: () => void;
}
export default function FormField({
@@ -14,6 +15,7 @@ export default function FormField({
placeholder,
registration,
error,
handleFocus,
}: FormFieldProps) {
return (
<div className="form-control">
@@ -31,6 +33,7 @@ export default function FormField({
className={`input input-bordered focus:input-primary ${
error ? "input-error" : ""
}`}
onFocus={handleFocus}
/>
{error && <p className="text-error">{error}</p>}
</div>
-38
View File
@@ -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>
);
}
+10 -21
View File
@@ -1,4 +1,5 @@
import { WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react";
import { WarningIcon } from "@phosphor-icons/react";
import { Modal } from "./Modal";
interface LogModalContent {
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" />
)}
{status === "ERROR" && (
<XCircleIcon className="text-error" size={16} weight="bold" />
<WarningIcon className="text-warning" size={16} weight="duotone" />
)}
{message}
{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>
);
};
+30
View File
@@ -0,0 +1,30 @@
import { XCircleIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
interface ModalProps {
isOpen: boolean;
onClose?: () => void;
children: ReactNode;
}
export function Modal({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
{onClose && (
<button
type="button"
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
onClick={onClose}
aria-label="Close"
>
<XCircleIcon size={18} weight="bold" />
</button>
)}
{children}
</div>
</div>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ export const Navbar = ({ child }: { child?: React.ReactNode }) => {
className="text-base-content/40 group-hover:text-primary transition-colors"
/>
</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>
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import sf_mini from "../../assets/sf_mini.png";
interface SaajanProps {
message: string;
position?: "top" | "left" | "right" | "bottom";
}
export default function Saajan({ message, position = "right" }: SaajanProps) {
const [animate, setAnimate] = useState<boolean>(false);
const [tooltipPosition, setTooltipPosition] =
useState<string>("tooltip-right");
const [alignment, setAlignment] = useState<string>("justify-start");
useEffect(() => {
setAnimate(true);
const timeout = setTimeout(() => {
setAnimate(false);
}, 1000);
return () => {
clearTimeout(timeout);
};
}, []);
useEffect(() => {
setTooltipPosition(`tooltip-${position}`);
if (position === "top") {
setAlignment("justify-center");
}
if (position === "right") {
setAlignment("justify-start");
}
if (position === "left") {
setAlignment("justify-end");
}
}, [position]);
return (
<div className={`relative w-full flex ${alignment}`}>
<div
className={`tooltip tooltip-open ${tooltipPosition} before:border before:border-dashed before:border-primary/40 before:max-w-xs before:whitespace-pre-line italic before:text-left`}
data-tip={message}
>
<img
src={sf_mini}
alt="saajan"
className={`sepia-20 w-35 -mb-6 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
/>
</div>
</div>
);
}
+4 -4
View File
@@ -9,14 +9,14 @@ export const endpoints = {
LETTERS: "/api/letters/",
};
// 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;
};
+4 -4
View File
@@ -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}`,
};
+5 -13
View File
@@ -6,7 +6,6 @@ import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import {
clearMasterKey,
loadMasterKey,
@@ -14,20 +13,13 @@ import {
} from "../utils/keystore";
import { useAuth } from "./useAuth";
vi.mock("../utils/crypto");
vi.mock("../utils/keystore");
const API_URL = "http://piku-server";
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);
@@ -112,7 +104,7 @@ describe("logout", () => {
it("should call the logout API endpoint", async () => {
let logoutCalled = false;
server.use(
http.post(`${API_URL}/api/auth/logout/`, () => {
http.post(`${VITE_API_URL}/api/auth/logout/`, () => {
logoutCalled = true;
return HttpResponse.json({});
}),
@@ -139,7 +131,7 @@ describe("logout", () => {
it("should clear the auth store even if the API call fails", async () => {
server.use(
http.post(
`${API_URL}/api/auth/logout/`,
`${VITE_API_URL}/api/auth/logout/`,
() => new HttpResponse(null, { status: 500 }),
),
);
@@ -163,7 +155,7 @@ describe("initialize", () => {
});
let refreshCalled = false;
server.use(
http.post(`${API_URL}/api/auth/refresh/`, () => {
http.post(`${VITE_API_URL}/api/auth/refresh/`, () => {
refreshCalled = true;
return HttpResponse.json({ access: "new-token" });
}),
@@ -194,7 +186,7 @@ describe("initialize", () => {
it("should preserve the master key even if the refresh attempt fails", async () => {
server.use(
http.post(
`${API_URL}/api/auth/refresh/`,
`${VITE_API_URL}/api/auth/refresh/`,
() => new HttpResponse(null, { status: 401 }),
),
);
+2 -6
View File
@@ -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) {
@@ -82,9 +80,7 @@ export const useAuth = () => {
);
await saveMasterKey(masterKey);
setMasterKey(masterKey);
} catch {
console.error("Master key restoration failed");
}
} catch {}
};
return {
+113
View File
@@ -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");
});
});
+23 -7
View File
@@ -10,7 +10,7 @@ export interface Letter {
status: "DRAFT" | "SEALED" | "BURNED";
updated_at: string;
sealed_at?: string;
unlock_at?: string;
unlock_at: string;
encrypted_metadata: string;
encrypted_content: string;
encrypted_dek: string;
@@ -25,7 +25,7 @@ export interface ProcessedLetter extends Letter {
metadata: LetterMetadata;
}
async function decryptLetters(
async function decryptLettersMetadata(
letters: Letter[],
masterKey: CryptoKey,
): Promise<ProcessedLetter[]> {
@@ -56,21 +56,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 +91,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,
};
}
+25 -21
View File
@@ -7,50 +7,39 @@
prefersdark: true;
color-scheme: dark;
/* ── Base surfaces ── */
--color-base-100: oklch(14% 0.012 35); /* was 0.018 hue 50 */
--color-base-100: oklch(14% 0.012 35);
--color-base-200: oklch(18% 0.014 33);
--color-base-300: oklch(22% 0.016 32);
--color-base-content: oklch(
82% 0.02 70
); /* aged parchment, not crisp white */
--color-base-content: oklch(82% 0.02 70);
/* ── Primary: old lamp gold — warm, incandescent ── */
--color-primary: oklch(67% 0.11 78);
--color-primary-content: oklch(15% 0.03 70);
/* ── Secondary: dusty plum ── */
--color-secondary: oklch(48% 0.08 305);
--color-secondary-content: oklch(92% 0.01 305);
/* ── Accent: muted lavender-clay ── */
--color-accent: oklch(55% 0.06 325);
--color-accent-content: oklch(18% 0.03 295);
/* ── Neutral: warm stone ── */
--color-neutral: oklch(28% 0.02 45);
--color-neutral-content: oklch(80% 0.015 60);
/* ── Semantic — desaturated, no alarm ── */
--color-info: oklch(60% 0.07 240);
--color-info-content: oklch(95% 0.01 240);
--color-success: oklch(60% 0.08 150);
--color-success-content: oklch(16% 0.03 150);
--color-warning: oklch(68% 0.08 72); /* honey, not caution-sign amber */
--color-warning: oklch(68% 0.08 72);
--color-warning-content: oklch(18% 0.03 60);
--color-error: oklch(55% 0.1 22);
--color-error-content: oklch(92% 0.01 22);
/* ── Shape ── */
--radius-selector: 0.5rem;
--radius-field: 0.375rem;
--radius-box: 0.5rem;
/* ── Effects ── */
--depth: 1;
--noise: 0.03;
/* ── Border ── */
--border: 1px;
}
@@ -58,17 +47,32 @@
--font-display: "Playwrite HR Lijeva Variable", cursive;
--font-sans: "Jost Variable", sans-serif;
--font-serif: "Playfair Display Variable", serif;
--color-glass-bg: rgba(
28,
22,
16,
0.45
); /* slightly deeper to match new base */
--font-mono: "Space Mono", monospace;
--font-tamil: "Kavivanar", sans-serif;
--font-redact: "Redacted Script", cursive;
--font-slab: "Cutive Mono", monospace;
--font-hand: "Architects Daughter", cursive;
--color-glass-bg: rgba(28, 22, 16, 0.45);
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
--radius-xl: 1.5rem;
--color-paper: oklch(97% 0.008 80);
--text-xxs: 10px;
--tracking-widester: 0.5em;
--background-image-vig: radial-gradient(
circle at center,
transparent 0%,
rgba(0, 0, 0, 0.4) 100%
);
}
.glass-card {
@apply bg-glass-bg backdrop-blur-xl border border-white/5 shadow-warm rounded-xl;
@apply bg-glass-bg backdrop-blur-xl border border-white/5 shadow-warm rounded-xl m-4;
}
.ul-wavy {
@apply decoration-primary/40 underline decoration-wavy underline-offset-4;
}
a {
@apply text-primary underline decoration-base-content/20 underline-offset-4 hover:decoration-primary/60 transition-colors;
}
+3
View File
@@ -1,9 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
import "@fontsource-variable/jost/wght.css";
import "@fontsource-variable/playfair-display/wght.css";
import App from "./App.tsx";
const root = document.getElementById("root");
+896
View File
@@ -0,0 +1,896 @@
import {
ArrowArcLeftIcon,
ArrowBendDownLeftIcon,
ArrowBendDownRightIcon,
ArrowRightIcon,
CaretUpIcon,
FlowerTulipIcon,
GhostIcon,
InfoIcon,
LockLaminatedIcon,
LockOpenIcon,
PasswordIcon,
PersonArmsSpreadIcon,
PersonIcon,
ScrollIcon,
SmileyIcon,
SparkleIcon,
VaultIcon,
} from "@phosphor-icons/react";
import { ReactLenis } from "lenis/react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import stamp from "../assets/envelope/stamp.png";
import Logo from "../components/Logo.tsx";
import { Modal } from "../components/ui/Modal";
import "@fontsource/kavivanar/index.css";
import "@fontsource/space-mono/index.css";
import "@fontsource/redacted-script/index.css";
import "@fontsource/architects-daughter/index.css";
import { useNavigate } from "react-router-dom";
function HorizontalScroll({ children }: { children: React.ReactNode }) {
const ref = useRef(null);
const { scrollYProgress } = useScroll({ target: ref });
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
return (
<section ref={ref} className="relative h-[200dvh]">
<div className="sticky top-0 flex h-screen w-screen items-center overflow-x-hidden">
<motion.div style={{ x }} className="flex w-[200vw]">
{children}
</motion.div>
</div>
</section>
);
}
export default function About() {
useEffect(() => {
window.scrollTo(0, 0);
}, []);
return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
<div className="flex flex-col">
<StorySection />
<HorizontalScroll>
<ForWhoSection />
<ArchetypesSection />
</HorizontalScroll>
<PrivacySection />
<HorizontalScroll>
<SpecsSection />
<OSSSection />
</HorizontalScroll>
<AttributionSection />
</div>
</ReactLenis>
);
}
function PrivacySection() {
return (
<div className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif flex"
}
>
The &nbsp; Promise
<span className="absolute -translate-y-6 md:-translate-y-12 font-display italic text-4xl md:text-6xl text-success translate-x-6 md:translate-x-12 -rotate-6">
privacy
</span>
<CaretUpIcon
className="absolute translate-y-6 md:translate-y-12 translate-x-20 md:translate-x-36 text-neutral -rotate-6"
weight="bold"
/>
</h1>
<div className="flex flex-col items-center shrink-0 gap-8 max-w-11/12 w-200">
<p className="text-xxs md:text-sm tracking-widester text-neutral-content/80 font-semibold uppercase mt-6">
<span className="text-accent">Your letters.</span>{" "}
<span className="text-error">Nobody else's.</span>
</p>
<p className="text-sm md:text-lg">
When you write something here, it gets encrypted in your browser
before anything leaves your device. What reaches the server isn't your
letter. It's something unreadable &mdash; and the server has no way to
change that, because the key never left you.
</p>
<figure className="diff aspect-3/4 touch-pan-y select-none">
<div className="diff-item-1 z-1" role="img">
<div className="bg-primary text-primary-content grid place-content-center text-sm md:gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-3xl md:text-6xl uppercase font-bold tracking-widest mt-2 md:mt-8">
you see
</h1>
<PasswordIcon
className="text-neutral mx-auto -mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Password
</h2>
<p className="text-center md:text-2xl font-bold font-mono">
<br />
B@z1ng4A
</p>
</div>
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
<LockOpenIcon size={48} />
</div>
<div className="flex flex-col items-center md:gap-2">
<ScrollIcon
className="text-neutral mx-auto md:-mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Letter
</h2>
<div className="p-6 bg-paper w-82 md:w-150 h-200 flex flex-col gap-4 text-xs md:text-lg overflow-hidden max-h-68 md:max-h-full">
<p className="wrap-anywhere">Hello friend,</p>
<p>I've never told this to anyone...</p>
<p className="font-redact">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut
semper, justo eget vehicula vestibulum, enim enim suscipit
lectus, et sagittis nibh risus vel metus. Quisque eu ornare
ante, et gravida mauris. Vivamus massa justo, sagittis non
viverra sed, sodales non nisi. Nunc semper, massa a aliquet
dictum, enim nisi malesuada orci, et elementum lectus turpis
et velit. Nam vel felis vitae tortor dignissim malesuada.
Nam suscipit, justo eu elementum pulvinar, magna sem tempor
ex, vitae iaculis tellus odio non nisl. Duis dolor orci,
viverra ut finibus sed, aliquet vitae tortor. Proin sodales
ipsum ac ipsum hendrerit tempus. Nunc nec nibh nibh. Aenean
consequat auctor posuere. Integer sed magna volutpat,
efficitur nisl ut, dignissim neque. Vestibulum convallis nec
dui a euismod. Duis dignissim magna in mattis pulvinar. Sed
blandit nibh quis arcu ornare, sit amet fermentum nisi
rhoncus.
</p>
</div>
</div>
</div>
</div>
<div className="diff-item-2" role="img">
<div className="bg-neutral-content bg-[url('https://www.transparenttextures.com/patterns/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8">
server see
</h1>
<PasswordIcon
className="text-neutral mx-auto -mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Password
</h2>
<p className="text-center md:text-2xl font-bold font-mono max-w-150 break-all">
9e54d05f88bdd67a675b03bf1cd0a1647e2109b5aa18185ff6a9ba4c6959a19d
</p>
</div>
<div className="divider divider-neutral opacity-50 w-1/2 mx-auto">
<LockLaminatedIcon size={48} />
</div>
<div className="flex flex-col items-center md:gap-2">
<ScrollIcon
className="text-neutral mx-auto md:-mb-3"
size={32}
/>
<h2 className="text-xs md:text-sm tracking-widester text-center uppercase opacity-50">
Your Letter
</h2>
<div className="p-6 bg-paper w-82 md:w-150 h-200 text-xxs md:text-sm font-mono md:leading-loose overflow-hidden max-h-68 md:max-h-full">
<p className="break-all">
SZ0Mq9M9sCZsdDB8HGjk7JfWG56Kaot8Lgma74MCusDUYibUGoR7VviWgvc341pvFV9/IAyot9KtlDvwIX1ZmUw9Oh340JMaajRQ7iNgVjHgAwmJAr2cLbReNqlF6xzaf3mIYkiK9BXNQekk2h/9XufklsqoIXpaK1re7xWQ8mdddzy6z4EQFVH/Ev3np5ERW/ss7Z1kqYWUnANK7olWNL/7GgZmhU+L29rgbR52kcH9fng7gnEI3KEuISYExYCg81G1VaJYspkW3A4qwcet+jXdgmbKvkux5qNw6gyNi9d/YqKV7OUNrmoH190rHdJ5A7HOIv3/SvPhb3Zm4sNF5PcMxmhM0+T9m5PejV1GhV9bMBHbbgacay7hZJU3O0+q+7fBAE/+pqfvZdv78lLDFSdtHAXUpYOvHPrI5BNNwuS3T+FK1zjurLnUPThlOSYRICoZSUcxVswXz897PoRmFNNvbal0dpKUmCFrBwV5c/W3d1+iZor5msbm/JxpbNtys59e0StSTwHKsxvxm/rTuUAxWSOmzt13MDBxxd2zyVnX8rtQ7mEjMJ8IHHpvhKjONoa2S11VBJY68Ee1vNrw7htu+wajvmXhHAyfh1lYql8pu8VvPUG7leEQ9I0pMY35Y/C1cYCBLkDT5zf8NeZFtbp0BNgHd+QDVSFH+GSnvTskU2BCio3YE+zE6cDhvLUOMy3e5RAtPqsi5VzpEUcdCwph+Z+1pFlTxiEZ62i4wNpqw2lhS3b/E9ifJgnncSgRHLtfw/VxHZCRc4tBQ24xSZ507lSlQch+5lQeO7rx2htgd2D7aGNx/UN/xmeuEd4a28AxNOVS3uYh3wTDh8CSXyBRCRPxrANOV1ZBojdfK+v5fOJNPgDn3r5/pG80L3FTkecRB0zFuKNG8jIzi5ADx9k4SlhRNo17gPl2if8gRA6tzTae4kbzieG+woxhUWj/qvXg0MQmg59VTK2HHS34exdKDP9a561svlw+lJ2AtM1EL9srJk8i3kiyEPUeIlaLl3AfgbbSuC2RhlzFFAYuQ06rbsSvEoe4rrYeMXxL9jwVsXX0xrp8H25mOJu3ahn5pFYzADMSGf4L11H1vDArpefj/lW+8zcmogxxBktYYNF/qU4v+9367hp4MEn/84tQPpmb47TL+XpVnl9tQ3r9OfOaW3zX7NkWZbqoX7OgdgHOtTLP/euQujSs2MAzMO4BmbuCS7pR/GTZwDqF1sXiWAkunjo2qpKHieqlvSVmtwEhh6wsNwYTKEkddmTqvKSx0fHRvs3D9lMGJfg7wLSz/3Otx3G65tk9l/3B3r87qQTvbqXmcfnFdEIaR8mO/yMyCKnxtJkJb3lEzNUOrvnSxwL7Gyn54TLTWA==
</p>
</div>
</div>
</div>
</div>
<div className="diff-resizer"></div>
</figure>
</div>
</div>
);
}
function SpecsSection() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<section className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
<h1 className="relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-display z-10">
S'more Specs
</h1>
<div className="flex flex-col items-start shrink-0 gap-6 max-w-11/12 w-200 mt-4 md:mt-12">
<h2 className="text-xl md:text-3xl text-center mx-auto">
<Logo type={"inline"} /> uses{" "}
<span className="text-accent font-mono">Zero Knowledge</span>{" "}
<span className="group ul-wavy font-mono text-primary">
E
<span className="hidden group-hover:inline group-focus-within:inline">
nd&nbsp;
</span>
2
<span className="hidden group-hover:inline group-focus-within:inline">
&nbsp;
</span>
E
<span className="hidden group-hover:inline group-focus-within:inline">
nd
</span>
<span className="hidden group-hover:inline group-focus-within:inline">
&nbsp;<span>E</span>
<span className="hidden group-hover:inline group-focus-within:inline">
ncryption
</span>
</span>
</span>{" "}
with{" "}
<span className="font-mono text-primary">Envelope Encryption</span>
</h2>
<p className="text-sm md:text-xl leading-relaxed">
This means, both the encryption and decryption runs on your device, in
your browser.
<br />
Every letter has a{" "}
<span className="font-mono text-primary">unique key</span> which is
derived from your original password.
<br />
Both the letter and the key are encrypted securely and sent to the
server.
<br />
Now, the server holds{" "}
<span className="text-primary font-bold">the envelope</span>,{" "}
<span className="text-primary font-bold">the seal</span> and{" "}
<span className="text-primary font-bold">another locked box</span>{" "}
with a key inside that unseals your letter. But you,{" "}
<span className="italic text-primary">only you</span>, hold the only
thing that opens the box &mdash;{" "}
<span className="font-mono text-accent">your password</span>.
</p>
<p className="text-sm md:text-xl text-right w-full flex items-center justify-end gap-4 leading-relaxed">
Nothing on the server is readable without your actual password.
<br />
Even if someone were to breach in, all they'd find is encrypted noise.
<VaultIcon size={48} weight="duotone" />
</p>
<button
type={"button"}
className="btn btn-outline border-base-300 w-full justify-between font-medium opacity-80"
onClick={() => setIsModalOpen(true)}
>
<span className="text-sm md:text-lg font-mono ul-wavy font-bold">
Nerd Stuff
</span>
<ArrowRightIcon size={20} />
</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<div className="w-full bg-paper rounded-md p-6">
<img src="/screenshots/e2e.svg" alt="pi ku e2e diagram" />
</div>
</Modal>
<p className="text-sm md:text-lg">
This level of privacy comes with a catch.{" "}
<span className="text-error font-bold">No password reset.</span>
</p>
<p className="text-sm md:text-lg alert alert-warning font-semibold">
<InfoIcon weight="duotone" /> Your original password is never stored
on the server. Which means if it's lost, the letters stay sealed
forever.
</p>
</div>
</section>
);
}
function OSSSection() {
return (
<section className="flex flex-col h-screen w-screen items-center justify-center py-18 gap-4">
<h1
className={
"relative tracking-tighter text-4xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif text-center"
}
>
<span className="hidden absolute -translate-y-24 translate-x-45 font-display text-3xl md:text-6xl opacity-70 rotate-8">
only for
<br />
<span className="text-primary">your letters</span> <SmileyIcon />
<ArrowArcLeftIcon className="inline rotate-45 -translate-y-8" />
</span>
<Logo type={"inline"} /> is{" "}
<span className="line-through decoration-6 decoration-error">
&nbsp;private
</span>{" "}
<span className="text-success">open source !</span>
</h1>
<div className="flex flex-col items-center shrink-0 max-w-11/12 w-200 gap-4 p-4 md:p-6">
<p className="text-sm md:text-xl">
<Logo type={"mono"} /> is fully open source. Every claim about privacy
and encryption is publicly available in the code so you don't have to
take anyone's word for it.
</p>
<p className="text-sm md:text-lg">
You can also{" "}
<span className="uppercase font-bold text-primary">Self-host</span>{" "}
<Logo type={"inline"} /> in just 4 steps.
</p>
<div className="mockup-code w-full text-xs">
<pre data-prefix="$">
<code>git clone https://git.ramvignesh.dev/me/pi-ku.git</code>
</pre>
<pre data-prefix="$">
<code>cd pi-ku</code>
</pre>
<pre data-prefix="$">
<code>./scripts/setup.sh</code>
</pre>
<pre data-prefix="$">
<code>./scripts/start.sh</code>
</pre>
</div>
<div className="flex flex-wrap gap-4 w-full items-center justify-center">
<a
href="https://git.ramvignesh.dev/me/pi-ku"
target="_blank"
rel="noopener noreferrer"
className="text-primary"
>
View on GitHub
</a>
<p className="text-xs md:text-base opacity-70">
Found something to report or request?{" "}
<a
href="https://git.ramvignesh.dev/me/pi-ku/issues"
target="_blank"
rel="noopener noreferrer"
>
Please say so.
</a>
</p>
</div>
<div className="divider opacity-30 my-0"></div>
<p className="text-xxs md:text-sm tracking-widester font-semibold uppercase text-accent">
Built on the shoulders of open source.
</p>
<p className="text-sm md:text-lg">
<Logo type={"mono"} /> wouldn't exist without the work of people who
chose to build in the open.
</p>
<p className="text-sm md:text-lg">
<a
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API"
target="_blank"
rel="noopener noreferrer"
>
Web Crypto API
</a>{" "}
&mdash; the backbone of everything promised. Browser-native
cryptography that runs entirely on your device. Without it, none of
the privacy here would be possible &mdash; or credible.
</p>
<p className="text-sm md:text-lg">
<a
href="https://daisyui.com"
target="_blank"
rel="noopener noreferrer"
>
DaisyUI
</a>{" "}
·{" "}
<a
href="http://fabricjs.com"
target="_blank"
rel="noopener noreferrer"
>
Fabric.js
</a>{" "}
·{" "}
<a
href="https://phosphoricons.com"
target="_blank"
rel="noopener noreferrer"
>
Phosphor Icons
</a>{" "}
&mdash; the beautiful work by others that let me focus on the core
experience.
</p>
<p className="text-sm md:text-lg mt-4">
Open source is what made this possible. It felt right to give it back
the same way.
</p>
</div>
</section>
);
}
function StorySection() {
return (
<div className="flex flex-col min-h-dvh w-screen justify-center items-center py-18">
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif"
}
>
The Story
</h1>
<div className="flex flex-col items-center shrink-0">
<div className="translate-x-2">
<Logo />
</div>
<div className="flex ml-10 font-tamil text-2xl md:text-3xl group">
<div className={"flex flex-col flex-wrap ul-wavy"}>
ி
<span
className={
"font-sans transition-all duration-1000 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 text-xxs tracking-widester uppercase text-neutral-content/60 mt-2"
}
>
after
</span>
</div>
<ArrowBendDownLeftIcon className={"text-primary"} />
<ArrowBendDownRightIcon className="ml-8 text-primary" />
<div className={"flex flex-col flex-wrap group ul-wavy"}>
ி
<span
className={
"font-sans transition-all duration-1000 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 text-xxs tracking-[.2em] uppercase text-neutral-content/60 mt-2"
}
>
note. remark.
</span>
</div>
</div>
{/* Dict Card */}
<div className="hover-3d -my-8 md:m-4 scale-75 md:scale-100 md:my-12 cursor-pointer">
<div className="card w-96 bg-base-200 bg-[radial-gradient(circle_at_bottom_left,#ffffff04_35%,transparent_36%),radial-gradient(circle_at_top_right,#ffffff04_35%,transparent_36%)] bg-size-[1.95em_1.95em]">
<div className="card-body">
<div className="mb-3 flex justify-between">
<div className="text-lg">pin·ku·rip·pu</div>
</div>
<div className="mb-4 text-lg opacity-40">
/noun/ <span className={"tracking-widest text-sm"}>tamil</span>
</div>
<ol className="flex flex-col gap-4 list-decimal list-inside p-0 m-0">
<li>
postscript; a note written after the letter is signed.
<br />
<blockquote className="text-primary/50 italic mt-2 ml-2 border-l-primary/20 leading-none border-l">
"the most honest thing was always in the{" "}
<span className="font-tamil">பி. கு.</span>"
</blockquote>
</li>
<li>the thing you almost didn't say.</li>
</ol>
</div>
</div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div
className={
"max-w-200 md:text-xl p-6 flex flex-col gap-4 md:gap-8 text-base-content/70 leading-relaxed"
}
>
<p className={""}>
<Logo type={"inline"} /> is an abbreviated transliteration of the
Tamil word for{" "}
<span
className={
"group italic text-primary font-serif inline underline decoration-dotted underline-offset-2 decoration-primary/40"
}
>
P
<span
className={
"text-neutral hidden group-hover:inline group-focus-within:inline "
}
>
ost
</span>
. S
<span
className={
"text-neutral hidden group-hover:inline group-focus-within:inline"
}
>
cript
</span>
.
</span>{" "}
&mdash; the thing you add after you've already signed your name,
what you write when you thought you were finished, but weren't.
</p>
<p>
<span className={"font-medium text-primary"}>
Most of what we actually mean to say never gets said.
</span>
<br />
It sits in drafts , in half-written notes, in the pause before we
change the subject. <br />
Those words{" "}
<span
className={
"blur-sm hover:blur-none active:blur-none focus:blur-none focus:outline-none transition-all duration-500"
}
>
don't just disappear. They
</span>{" "}
stay <span className={"text-primary font-hand"}>unsaid</span>{" "}
&mdash; a quiet weight difficult to bear.
</p>
<p className={"italic text-primary"}>And that's okay...</p>
<p>
<Logo type={"inline"} />
<span className={"text-primary"}>
was built for putting that weight down.
</span>
<br />A space for the letters you meant to send, the afterthoughts
that deserved more than silence.
</p>
</div>
</div>
</div>
);
}
function ForWhoSection() {
return (
<div className="flex flex-col h-screen w-screen justify-center items-center py-18 bg-primary/80">
<div className="max-w-4xl z-10">
<h2 className="text-7xl md:text-9xl font-serif italic font-black tracking-tighter text-stone-900 leading-tightest mb-12">
Who is <br /> this for?
</h2>
<div className="space-y-6 max-w-200 p-4 text-base-200 text-xl md:text-2xl leading-relaxed">
<p>
<Logo type={"mono"} /> wasn't built for one kind of person, but a
particular kind of feeling &mdash;
<span className="italic font-serif text-stone-900">
{" "}
the one that lingers very quietly
</span>{" "}
&mdash; fragile, yet never breaks.
</p>
<div className="pt-8 flex items-center gap-4">
<span className="text-xs md:text-sm uppercase tracking-widest font-mono opacity-60">
See if any of these feel too familiar to you
</span>
<div className="w-24 animate-pulse">
<ArrowRightIcon size={24} />
</div>
</div>
</div>
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-64 h-64 rounded-full bg-white/5 blur-3xl"></div>
</div>
);
}
function ArchetypesSection() {
return (
<div className="flex flex-col h-screen w-screen items-center justify-center py-18 bg-primary/80">
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-base-300/80 font-extrabold italic font-serif"
}
>
The Archetypes
</h1>
<div className="flex flex-col items-center shrink-0 w-200 max-w-11/12 gap-2 md:gap-8 my-4">
<div className="relative w-full">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
open
>
<summary className="collapse-title md:text-xl leading-tight font-hand flex items-center gap-4">
<GhostIcon weight="duotone" className="text-accent" size={32} />{" "}
To someone you can't reach anymore.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
A person who left. A relationship that ended without a real
ending. Someone who's still in your life but will never know
what you felt. Some conversations just close before they're
finished.
<br />
</p>
<p className="font-serif font-medium opacity-70">
Write the letter anyway. Keep it close.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500 rotate">
01
</span>
</div>
<div className="relative w-full">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
>
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-center gap-4">
<FlowerTulipIcon
weight="duotone"
className="text-accent"
size={32}
/>{" "}
To someone who's still here.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
Not every letter is about distance. Sometimes you just need to
say something properly &mdash; without a text thread, without
the noise of a conversation already in motion. A letter slows it
down.
</p>
<p className="font-serif font-medium opacity-70">
Give people their due flowers while they can still smell them.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500">
02
</span>
</div>
<div className="relative w-full group">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
>
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-baseline gap-4">
<div className="flex items-center">
<PersonIcon
weight="duotone"
className="text-accent"
size={14}
/>{" "}
<PersonArmsSpreadIcon
weight="duotone"
className="text-accent"
size={24}
/>
</div>
To yourself, further along.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
Not a journal. Not a note-to-self. A proper letter &mdash; to
whoever you'll be in a year, or five, or ten.
<br />
Ask yourself of the healed wounds, forgotten fears, or the
things you finally learned to live with.
</p>
<p className="font-serif font-medium opacity-70">
Set a date and let a letter surprise you when you've long
forgotten writing it.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500">
03
</span>
</div>
<div className="relative w-full">
<details
className="collapse shadow-xs glass opacity-75 open:opacity-100 text-base-300 peer"
name="my-accordion-det-1"
>
<summary className="collapse-title text-lg md:text-xl leading-tight font-hand flex items-center gap-4">
<SparkleIcon weight="duotone" className="text-accent" size={32} />{" "}
For liberation.
</summary>
<div className="collapse-content text-sm md:text-lg flex flex-col gap-4">
<p>
Some unsaid words just need to leave your headspace. There's no
recipient, no subject line, no send button. Just the act of
putting it somewhere outside of yourself. <br />
That's sometimes enough.
</p>
<p className="font-serif font-medium opacity-70">
Say it once. All of it. Then let it fade.
</p>
</div>
</details>
<span className="absolute md:-right-8 md:-top-10 -top-4 -right-2 md:text-8xl text-6xl font-bold font-mono opacity-20 peer-open:opacity-60 pointer-events-none z-10 transition-all duration-500">
04
</span>
</div>
<div className="flex items-center justify-center gap-2 group mt-12">
<img
src={stamp}
alt="stamp"
className="rotate-6 group-hover:rotate-0 group-focus-within:rotate-0 transition-all duration-1000"
/>
<p className="md:text-xl mt-4">
If any of these felt familiar,
<br />
no matter how little,
<br />
this is for you.
</p>
</div>
</div>
</div>
);
}
function AttributionSection() {
const [hover, setHover] = useState<{
visible: boolean;
x: number;
y: number;
}>({ visible: false, x: 0, y: 0 });
const navigate = useNavigate();
return (
<div className="flex flex-col min-h-screen w-screen items-center py-18">
{/* Saajan hover image */}
<AnimatePresence>
{hover.visible && (
<motion.img
src="/saajan.png"
alt="Saajan Fernandes from The Lunchbox, cutout"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="pointer-events-none fixed z-50 w-56 md:w-72 rounded-lg shadow-warm object-cover"
style={{
left: hover.x + 16,
top: hover.y - 32,
}}
/>
)}
</AnimatePresence>
<h1
className={
"relative tracking-tighter text-5xl md:text-8xl text-neutral-content/80 font-extrabold italic font-serif"
}
>
Honest Speak
</h1>
<div className="flex flex-col items-center shrink-0">
<div
className={
"max-w-200 m-2 md:m-8 text-sm md:text-lg px-4 md:px-8 py-6 md:py-12 flex flex-col gap-4 md:gap-8 text-base-100 leading-relaxed bg-paper font-mono tracking-tight"
}
>
Hi.
<p>Thank you so much for making it this far. Really.</p>
<p>
<Logo type={"inline"} /> took a while to exist.
<br />
This started as a{" "}
<a
href="https://cs50.harvard.edu/web/"
target="_blank"
rel="noopener noreferrer"
>
CS50W
</a>{" "}
capstone, one I kept postponing until I ran out of reasons not to.
When I eventually sat down to build, I knew it had to be more than a
deadline; it had to be something that outlasted the grade. I wanted
to create a space for the feelings we usually keep to ourselves and
every hour spent on it was worth it. I've shared the edges of{" "}
<Logo type={"inline"} /> here, but the heart of it is best found by
exploring it yourself.
</p>
<p>
I kept coming back to{" "}
<span
role="tooltip"
className="cursor-default ul-wavy text-accent"
onMouseEnter={(e) =>
setHover({
visible: true,
x: e.clientX,
y: e.clientY,
})
}
onMouseMove={(e) =>
setHover((h) => ({
...h,
x: e.clientX,
y: e.clientY,
}))
}
onMouseLeave={() => setHover((h) => ({ ...h, visible: false }))}
>
Saajan
</span>{" "}
from{" "}
<a
href="https://www.imdb.com/title/tt2350496/"
target="_blank"
rel="noopener noreferrer"
>
The Lunchbox
</a>{" "}
&mdash;{" "}
<span className="italic">
one of the most subtle yet brilliant portrayals by Irrfan Khan
</span>{" "}
&mdash; the quiet emotional weight he carries throughout the film,
going through the motions of a lonely life, until those letters
arrive and something inside him finally loosens. Of course, the
ending felt like a deep sigh of "it is what it is". But something
about the act of writing and letting the unsaid out eased it, even
briefly. I think about that a lot.
</p>
<p>
There's a lot that goes{" "}
<span className={"text-primary font-hand text-lg md:text-xl"}>
unsaid
</span>{" "}
now. Not that people feel less or for the lack of time, but because
the ways we reach each other have quietly changed. We're always
reachable <span className="italic">digitally,</span> yet somehow the
things that matter most end up staying inside.
<br />
Maybe writing will help with that. Maybe something about putting
words somewhere deliberate makes them feel less like something
you're carrying.
</p>
<p>Or maybe it won't, but it's worth a try.</p>
<p>
<Logo type={"inline"} /> is for that try. I hope it helps.
</p>
<p
className={
"text-right font-hand text-base-content text-lg md:text-xl"
}
>
&mdash; Ram
</p>
</div>
<blockquote className="text-primary/50 italic mt-8 md:mt-12 mx-auto border-l-primary/20 leading-relaxed border-l pl-4 max-w-11/12 text-lg">
"I think we forget things if there is nobody to tell them."
<span className="block mt-2 text-sm not-italic text-base-content/30 w-full text-right">
~ Saajan Fernandes, <span className="italic">The Lunchbox</span>
</span>
</blockquote>
</div>
<div className="mt-40 mb-44 w-full justify-center flex">
<button
type={"button"}
onClick={() => navigate("/onboard")}
className="btn btn-primary btn-wide rounded-full px-14 font-mono"
>
Begin
</button>
</div>
</div>
);
}
+11 -10
View File
@@ -16,8 +16,6 @@ export default function Activate() {
useEffect(() => {
if (!(uidb64 && token) || hasCalled.current) return;
// prevent double api calls
hasCalled.current = true;
const activateAccount = async () => {
@@ -46,7 +44,7 @@ export default function Activate() {
)}
{status === "success" && (
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
<div className="flex flex-col items-center gap-6 duration-500">
<div className="bg-success/10 p-4 rounded-full">
<CheckCircleIcon
size={64}
@@ -57,18 +55,20 @@ export default function Activate() {
<h2 className="font-display text-xl text-success">
Account Activated!
</h2>
<p className="opacity-70 mb-8 leading-relaxed">
Welcome to <Logo />
<p className="opacity-70 leading-relaxed">
Welcome to <Logo scale={1} />
<br />
Your identity is now verified and ready for timeless letters.
</p>
<div className="divider opacity-10"></div>
<div className="divider opacity-10 my-0"></div>
<button
type="button"
className="btn btn-primary w-full shadow-lg"
onClick={() =>
navigate(ROUTES.LOGIN, { state: { firstTime: true } })
navigate(ROUTES.LOGIN, {
state: { firstTime: true },
replace: true,
})
}
>
Start Writing
@@ -82,16 +82,17 @@ export default function Activate() {
<XCircleIcon size={64} weight="duotone" className="text-error" />
</div>
<h2 className="font-display text-xl text-error">Activation Failed</h2>
<p className="opacity-70 mb-8 leading-relaxed">
<p className="opacity-70 leading-relaxed">
The link might be expired or already used. Please try registering
again.
</p>
<div className="divider opacity-10 my-0"></div>
<button
type="button"
className="btn btn-ghost w-full"
onClick={() => navigate(ROUTES.ONBOARD)}
>
Back to Registration
Register Again
</button>
</div>
)}
+52 -1
View File
@@ -1,10 +1,13 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mockUser } from "../../test/fixtures/user.fixture";
import { useLetters } from "../hooks/useLetters";
import { useAuthStore } from "../store/useAuthStore";
import Drawer from "./Drawer";
vi.mock("../hooks/useLetters");
describe("Drawer Page", () => {
beforeEach(() => {
// Setup authenticated state for the test
@@ -13,6 +16,15 @@ describe("Drawer Page", () => {
accessToken: "fake-token",
isInitializing: false,
});
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: false,
});
});
it("renders the cabinet sections and empty state message", () => {
@@ -27,4 +39,43 @@ describe("Drawer Page", () => {
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
});
it("renders the loading state", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: true,
isAuthRequired: false,
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
});
it("renders the authentication required modal when api requires auth", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: true,
});
render(
<MemoryRouter>
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More