28 Commits
Author SHA1 Message Date
Michilis 6ec8b8400f Merge pull request 'dev' (#28) from dev into main
Reviewed-on: #28
2026-07-26 05:38:23 +00:00
Michilis 19461098a0 Merge pull request 'Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.' (#27) from tpago-fix into main
Reviewed-on: #27
2026-07-20 03:55:01 +00:00
Michilis 74dcc8e5ac Merge pull request 'Normalize RUC formatting to base-checkdigit across booking and admin views.' (#26) from RUC-fix into main
Reviewed-on: #26
2026-07-13 03:48:13 +00:00
Michilis bd1043a3f4 Merge pull request 'speed' (#25) from speed into main
Reviewed-on: #25
2026-07-13 01:18:23 +00:00
Michilis 107cd9753e Merge pull request 'Redesign user dashboard with overview tab and i18n payment copy.' (#24) from dashboard-update into main
Reviewed-on: #24
2026-06-29 17:00:15 +00:00
Michilis 194604004e Merge pull request 'security-update' (#23) from security-update into main
Reviewed-on: #23
2026-06-29 06:47:19 +00:00
Michilis bb920ce6f0 Merge pull request 'Add per-quantity TPago payment links for multi-ticket checkout.' (#22) from tpago-update into main
Reviewed-on: #22
2026-06-19 00:24:00 +00:00
Michilis c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis 1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
Michilis 91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
Michilis a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
Michilis f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis 15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis 194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
Michilis 833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
Michilis ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
Michilis 3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
Michilis 8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
4 changed files with 58 additions and 148 deletions
+14 -47
View File
@@ -4,24 +4,17 @@ A full-stack web app for organizing and managing language exchange events (Asunc
## Features
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
- Stable `/next` and `/featured` URLs that redirect to the current event
- Human-readable event URL slugs (with legacy-ID redirect support)
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
- Lightning invoice reuse and re-payment straight from the dashboard
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
- One unified ticket-creation modal with first-class payment status
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
- **User dashboard**: profile, tickets, payments, sessions/security
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
## Tech stack
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
- **Email**: `nodemailer` (SMTP) with optional provider config
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
## Local development
@@ -29,8 +22,6 @@ A full-stack web app for organizing and managing language exchange events (Asunc
- Node.js 18+
- npm
- Go 1.26+ (for the `photo-api` service)
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
### Setup
@@ -38,14 +29,12 @@ A full-stack web app for organizing and managing language exchange events (Asunc
npm install
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
```
### Initialize database (SQLite by default)
```bash
npm run db:migrate # backend (Drizzle) tables
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
npm run db:migrate
```
### Run
@@ -54,13 +43,10 @@ npm run migrate:photos # photo-api owns only the photos_* tables via its own mi
npm run dev
```
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
Default URLs:
- Frontend: `http://localhost:3002`
- Backend API: `http://localhost:3001`
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
- API docs: `http://localhost:3001/api-docs`
### First user becomes admin
@@ -72,20 +58,14 @@ The first user to register becomes the **admin**. Register at `/register`.
Run these from the repo root:
```bash
npm run dev # backend + frontend + photo-api
npm run build # build all workspaces
npm run dev
npm run build
npm run start
npm run db:generate
npm run db:migrate
npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
# Photo service (Go)
npm run dev:photos # go run ./cmd/photo-api
npm run build:photos # go build -o bin/photo-api
npm run migrate:photos # apply photos_* migrations
npm run test:photos # go test ./...
npm run db:export # Backup database
npm run db:import # Restore from backup
```
You can also run per workspace:
@@ -105,19 +85,9 @@ Key settings (see `backend/.env.example` for the full list):
- **Auth**: `JWT_SECRET` (change in production)
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
### Photo service (`photo-api/.env`)
Key settings (see `photo-api/.env.example`):
- **Port**: `PORT=3003` (dev)
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
- **Auth**: `JWT_SECRET` — must match `backend/.env` (the service validates the backend's JWTs)
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
### Frontend (`frontend/.env`)
Key settings (see `frontend/.env.example`):
@@ -173,13 +143,12 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
This repo includes example configs in `deploy/`:
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
- Backend needs write access to `backend/data` and `backend/uploads`
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
- **nginx**:
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
Typical production flow:
@@ -187,9 +156,7 @@ Typical production flow:
```bash
npm ci
npm run build
npm run build:photos
npm run db:migrate
npm run migrate:photos
```
Then install/enable the systemd services and nginx configs for your server.
@@ -149,7 +149,6 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
@@ -266,7 +266,6 @@ export default function AdminGalleryDetailPage() {
previewUrl: p.urls.preview || p.urls.original,
downloadUrl: p.urls.original,
filename: p.originalFilename,
thumbUrl: p.urls.thumb,
}));
const openLightboxFor = (photoId: string) => {
const idx = readyPhotos.findIndex((p) => p.id === photoId);
+44 -99
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useCallback, useRef, useState } from 'react';
import { useEffect, useCallback, useState } from 'react';
import {
XMarkIcon,
ChevronLeftIcon,
@@ -13,8 +13,6 @@ export interface LightboxItem {
previewUrl: string;
downloadUrl: string;
filename?: string;
/** Small thumbnail for the filmstrip; falls back to previewUrl. */
thumbUrl?: string;
}
interface LightboxProps {
@@ -31,10 +29,8 @@ interface LightboxProps {
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
*/
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
const touchStart = useRef<{ x: number; y: number } | null>(null);
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const item = items[index];
const hasMultiple = items.length > 1;
const prev = useCallback(() => {
onNavigate(index > 0 ? index - 1 : items.length - 1);
@@ -57,33 +53,19 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
};
}, [onClose, prev, next]);
// Keep the active thumbnail centred in the filmstrip as you navigate.
useEffect(() => {
activeThumbRef.current?.scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'nearest',
});
}, [index]);
if (!item) return null;
return (
<div
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
onClick={onClose}
onTouchStart={(e) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}}
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
onTouchEnd={(e) => {
if (touchStart.current === null) return;
const dx = e.changedTouches[0].clientX - touchStart.current.x;
const dy = e.changedTouches[0].clientY - touchStart.current.y;
touchStart.current = null;
// Only treat as a swipe when it's clearly horizontal.
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
if (dx > 0) prev();
else next();
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (dx > 60) prev();
if (dx < -60) next();
setTouchStartX(null);
}}
>
<button
@@ -109,84 +91,47 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
{renderActions?.(item, index)}
</div>
{/* Main image area */}
<div className="relative flex-1 min-h-0 flex items-center justify-center">
{hasMultiple && (
<>
<button
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{items.length > 1 && (
<>
<button
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-full object-contain select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
</div>
{/* Counter + thumbnail filmstrip you can skip through. */}
<div
className="shrink-0 pb-3 pt-2"
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
<div className="text-center text-white/70 text-sm mb-2">
{index + 1} / {items.length}
</div>
{hasMultiple && (
<div className="flex gap-2 overflow-x-auto px-4 pb-1 justify-start sm:justify-center [scrollbar-width:thin]">
{items.map((it, i) => (
<button
key={it.id}
ref={i === index ? activeThumbRef : null}
onClick={() => onNavigate(i)}
aria-label={`Photo ${i + 1}`}
aria-current={i === index}
className={`relative shrink-0 overflow-hidden rounded transition ${
i === index
? 'ring-2 ring-white opacity-100'
: 'opacity-50 hover:opacity-90'
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={it.thumbUrl || it.previewUrl}
alt=""
className="h-14 w-14 sm:h-16 sm:w-16 object-cover"
draggable={false}
/>
</button>
))}
</div>
)}
draggable={false}
/>
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
{index + 1} / {items.length}
</div>
{/* Preload neighbours so prev/next feels instant. */}
<div className="hidden" aria-hidden>
{hasMultiple && (
{items.length > 1 && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index + 1) % items.length].previewUrl} alt="" />