A private relationship dashboard for anniversaries, schedules, period tracking, and a fast CDN-friendly photo gallery.
React + TypeScript + Vite frontend with an Express API backed by SQLite.
- Features
- Stack
- Project Layout
- Requirements
- Environment
- Local Development
- Gallery Images
- API Reference
- Frontend Deployment: EdgeOne Pages
- API Deployment: VPS
- EdgeOne CDN Rules For API Domain
- Troubleshooting
- Relationship profile and anniversary countdowns.
- Calendar events and recurring reminders.
- Schedule and period tracker data stored in SQLite.
- Gallery upload, delete, lazy loading, CDN-friendly image routes, and client-side image compression.
- PIN unlock flow backed by JWT.
- Frontend: React 19, TypeScript, Vite, Tailwind CSS.
- API: Express 5, SQLite through
better-sqlite3. - Uploads: Multer,
image-size. - Deployment target: EdgeOne Pages for frontend, VPS + EdgeOne CDN for API.
src/ Frontend application
server/index.ts Express API and SQLite schema
server/data/ Local SQLite database, ignored by Git
server/uploads/gallery/ Gallery uploads, ignored by Git
scripts/ Maintenance scripts
edgeone.json EdgeOne Pages build and cache config- Node.js 20+ recommended for frontend builds.
- Node.js 18+ can run the API, but native dependencies must be installed on the same server and Node version that runs it.
- Python 3 with Pillow is required only for
npm run compress.
Copy .env.example to .env for local API settings:
cp .env.example .envAvailable variables:
| Variable | Default | Purpose |
|---|---|---|
PORT |
3001 |
API port |
HOST |
0.0.0.0 |
API listen host |
PIN_CODE |
1314 |
Unlock PIN |
JWT_SECRET |
change-this-local-secret |
JWT signing secret |
DATABASE_PATH |
./server/data/kuromi.sqlite |
SQLite file path |
VITE_API_BASE_URL |
/api |
Frontend API base URL |
VITE_UMAMI_BASE_URL |
https://umami.chuzoux.top |
Umami instance base URL |
VITE_UMAMI_SCRIPT_URL |
https://umami.chuzoux.top/script.js |
Umami analytics script URL |
VITE_UMAMI_WEBSITE_ID |
2ff203ed-6094-4e37-b401-f6ab0f17c662 |
Umami website ID |
VITE_UMAMI_SHARE_ID |
empty | Umami share ID used to display public visit stats |
VITE_UMAMI_TIMEZONE |
Asia/Shanghai |
Timezone for Umami stats queries |
For separated frontend/API domains, set this before building the frontend:
VITE_API_BASE_URL=https://loveapi.chuzoux.top/apiInstall dependencies:
npm ciStart API and Vite together:
npm run dev:allOpen:
http://127.0.0.1:5173/Useful commands:
| Command | Description |
|---|---|
npm run dev |
Start Vite only |
npm run server |
Start API in watch mode |
npm run server:start |
Start API once |
npm run dev:all |
Start API and frontend together |
npm run build |
Type-check and build frontend |
npm run lint |
Run ESLint |
npm run preview |
Preview built frontend |
npm run compress |
Compress gallery images in place |
Uploaded gallery files are stored in:
server/uploads/gallery/The API serves them from:
/api/gallery/files/:filenameThe frontend normalizes returned image URLs against VITE_API_BASE_URL, so this works for both same-domain and separated frontend/API deployments.
New uploads are compressed in the browser before upload:
- GIF and SVG are skipped.
- Other image types are rendered through canvas and uploaded as JPEG when that makes the file smaller.
- Longest side is limited to
1600px. - JPEG quality is
0.78.
Existing gallery images can be compressed in place:
npm run compressThis keeps every image path and filename unchanged. The command tries python3, python, and Windows py automatically.
If Pillow is missing on a Linux server with an externally managed Python environment, prefer a virtual environment:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install pillow
npm run compressBase URL:
/apiFor separated frontend/API domains:
https://loveapi.chuzoux.top/apiAll write endpoints require:
Authorization: Bearer <token>Tokens are returned by POST /api/auth/unlock and expire after 12 hours. Error responses use this shape:
{
"error": "message"
}| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/health |
No | Health check |
POST |
/api/auth/unlock |
No | Exchange PIN for JWT |
GET |
/api/profile |
No | Read relationship profile |
PUT |
/api/profile |
Yes | Update relationship profile |
GET |
/api/events |
No | List calendar events |
POST |
/api/events |
Yes | Create calendar event |
PUT |
/api/events/:id |
Yes | Update calendar event |
DELETE |
/api/events/:id |
Yes | Delete calendar event |
GET |
/api/gallery |
No | List gallery images |
POST |
/api/gallery |
Yes | Upload gallery image |
DELETE |
/api/gallery/:id |
Yes | Delete gallery image |
GET |
/api/gallery/files/:filename |
No | Read gallery image file |
GET |
/api/schedule |
No | Read schedule |
PUT |
/api/schedule |
Yes | Replace schedule |
GET |
/api/period |
No | Read period tracker data |
PUT |
/api/period/config |
Yes | Update period config |
POST |
/api/period/records |
Yes | Create period record |
PUT |
/api/period/records/:id |
Yes | Update period record |
DELETE |
/api/period/records/:id |
Yes | Delete period record |
POST /api/auth/unlock
Request:
{
"pin": "1314"
}Response:
{
"token": "jwt-token"
}Status codes:
200: PIN accepted.401: PIN is incorrect.
Profile object:
{
"relationshipStartDate": "2023-10-20",
"herName": "Alice",
"himName": "Bob"
}GET /api/profile
- Returns the profile object.
PUT /api/profile
- Requires auth.
relationshipStartDatemust useYYYY-MM-DD.herNameandhimNamecan be strings ornull.
Event object:
{
"id": 1,
"name": "Anniversary",
"date": "2023-10-20",
"calendarType": "solar",
"lunarMonth": null,
"lunarDay": null,
"lunarIsLeapMonth": 0,
"time": "20:00",
"startTime": "20:00",
"endTime": null,
"recurrence": "yearly",
"icon": "heart",
"description": "First day together",
"color": "bg-pink-100 text-pink-600",
"tag": "ANNIVERSARY",
"sortOrder": 1
}Rules:
dateusesYYYY-MM-DD.calendarTypeissolarorlunar.- Lunar events require
lunarMonthfrom1to12andlunarDayfrom1to30. time,startTime, andendTimeuseHH:mmornull.recurrenceisnoneoryearly.
Endpoints:
GET /api/events: returnsEvent[], ordered bysortOrder,date, andid.POST /api/events: requires auth, creates an event, returns201.PUT /api/events/:id: requires auth, updates an event.DELETE /api/events/:id: requires auth, returns204.
Gallery image object:
{
"id": 1,
"filename": "1779307067238-edc4244fa93cb.jpg",
"originalName": "photo.jpg",
"mimeType": "image/jpeg",
"width": 1600,
"height": 1200,
"size": 135610,
"createdAt": "2026-05-22 08:30:00",
"aspectRatio": 1.3333333333333333,
"url": "/api/gallery/files/1779307067238-edc4244fa93cb.jpg"
}Endpoints:
GET /api/gallery: returnsGalleryImage[], ordered by aspect ratio and creation time.POST /api/gallery: requires auth andmultipart/form-data.DELETE /api/gallery/:id: requires auth, deletes the database row and file, returns204.GET /api/gallery/files/:filename: returns the static image file with long cache headers.
Upload request:
Content-Type: multipart/form-data
field name: image
max file size: 12 MB
accepted type: image/*Schedule object:
{
"days": ["Monday", "Tuesday"],
"times": ["08:00", "10:10"],
"items": [
{
"id": 1,
"dayIndex": 0,
"timeIndex": 0,
"subject": "Math",
"person": "her",
"duration": 2
}
]
}Rules:
personisher,him, orboth.dayIndexmust be valid for thedaysarray.timeIndexmust be valid for thetimesarray.durationmust be an integer from1to10.
Endpoints:
GET /api/schedule: returns the schedule.PUT /api/schedule: requires auth, replaces the whole schedule.
Period response:
{
"config": {
"id": 1,
"cycleDays": 28,
"periodDays": 5
},
"records": [
{
"id": 1,
"startDate": "2026-05-10",
"endDate": null,
"note": null,
"symptoms": [],
"createdAt": "2026-05-22 08:30:00"
}
],
"prediction": {
"nextStartDate": "2026-06-07",
"daysUntilNext": 16,
"ovulationDate": "2026-05-24",
"ovulationWindowStart": "2026-05-19",
"ovulationWindowEnd": "2026-05-25",
"daysUntilOvulation": 2,
"daysUntilOvulationWindow": 0,
"currentPhase": "ovulation",
"currentPhaseLabel": "ovulation"
}
}Rules:
cycleDaysmust be from15to60.periodDaysmust be from1to14.startDateandendDateuseYYYY-MM-DD.symptomsis a string array.currentPhaseismenstrual,follicular,ovulation,luteal, ornull.
Endpoints:
GET /api/period: returns config, records, and prediction.PUT /api/period/config: requires auth, updatescycleDaysandperiodDays.POST /api/period/records: requires auth, creates a record, returns201.PUT /api/period/records/:id: requires auth, updates a record.DELETE /api/period/records/:id: requires auth, returns204.
The repository includes edgeone.json:
- Install command:
npm ci - Build command:
npm run build - Output directory:
dist - Node version:
20.18.0 /assets/*:Cache-Control: public, max-age=31536000, immutable- HTML and other entry paths:
Cache-Control: no-cache
Set this Pages environment variable when the API is hosted on loveapi.chuzoux.top:
VITE_API_BASE_URL=https://loveapi.chuzoux.top/api
VITE_UMAMI_BASE_URL=https://umami.chuzoux.top
VITE_UMAMI_SCRIPT_URL=https://umami.chuzoux.top/script.js
VITE_UMAMI_WEBSITE_ID=2ff203ed-6094-4e37-b401-f6ab0f17c662
VITE_UMAMI_SHARE_ID=your-share-id
VITE_UMAMI_TIMEZONE=Asia/ShanghaiUmami analytics is loaded only when both VITE_UMAMI_SCRIPT_URL and VITE_UMAMI_WEBSITE_ID are present at build time. The footer visit counter also requires VITE_UMAMI_SHARE_ID; this is the ID from the Umami share URL, not the website ID.
Do not upload a Windows node_modules directory to Linux. This project uses native dependencies such as better-sqlite3, so dependencies must be installed on the Linux server.
Recommended deployment steps:
cd /www/wwwroot/loveapi.chuzoux.top/kuromi-app
git pull
npm ci
pm2 restart yz-love-apiIf native modules fail after changing Node versions:
rm -rf node_modules
npm ci
pm2 restart yz-love-apiThe API listens on HOST=0.0.0.0 by default. Use HOST=127.0.0.1 only when the API is meant to be reachable only from local Nginx or another local reverse proxy.
For loveapi.chuzoux.top, configure rules in this order.
Gallery files:
^/api/gallery/files/[^?]+\.(jpg|jpeg|png|webp|gif|bmp|avif|JPG|JPEG|PNG|WEBP|GIF|BMP|AVIF)$Recommended actions:
- Node cache TTL: 30 to 90 days.
- Browser cache TTL: 7 to 30 days.
- Cache key query string: ignore all.
Dynamic API:
^/api/.+Recommended actions:
- Node cache TTL: no cache.
- Browser cache TTL: no cache.
- Keep query strings, cookies, and
Authorizationheaders. - Enable dynamic acceleration or smart routing if available.
tsx: Permission denied
- Cause: Linux is trying to execute a copied
node_modules/.bin/tsxshim. - Fix: pull the latest code and install dependencies on Linux with
npm ci. Scripts usenode --import tsx.
better_sqlite3.node was compiled against a different Node.js version
- Cause: native module was compiled for a different Node ABI.
- Fix:
rm -rf node_modules
npm ci
pm2 restart yz-love-api502 Bad Gateway
- The CDN or Nginx can reach the request path, but the upstream API is not responding.
- Check PM2 logs:
pm2 logs yz-love-api --lines 100git pull reports local changes would be overwritten
- Inspect server edits first:
git status
git diff- If server-only edits are disposable:
git restore server/index.ts
git pull