Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93476ac72a | ||
|
|
c4094630d9 |
@@ -4,17 +4,24 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
|
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
|
||||||
- **User dashboard**: profile, tickets, payments, sessions/security
|
- Stable `/next` and `/featured` URLs that redirect to the current event
|
||||||
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
|
- 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).
|
||||||
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
- **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)
|
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
|
||||||
- **Email**: `nodemailer` (SMTP) with optional provider config
|
- **Email**: `nodemailer` (SMTP) with optional provider config
|
||||||
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
|
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -22,6 +29,8 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm
|
- npm
|
||||||
|
- Go 1.26+ (for the `photo-api` service)
|
||||||
|
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -29,12 +38,14 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
npm install
|
npm install
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
cp frontend/.env.example frontend/.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)
|
### Initialize database (SQLite by default)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:migrate
|
npm run db:migrate # backend (Drizzle) tables
|
||||||
|
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
@@ -43,10 +54,13 @@ npm run db:migrate
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
|
||||||
|
|
||||||
Default URLs:
|
Default URLs:
|
||||||
|
|
||||||
- Frontend: `http://localhost:3002`
|
- Frontend: `http://localhost:3002`
|
||||||
- Backend API: `http://localhost:3001`
|
- 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`
|
- API docs: `http://localhost:3001/api-docs`
|
||||||
|
|
||||||
### First user becomes admin
|
### First user becomes admin
|
||||||
@@ -58,14 +72,20 @@ The first user to register becomes the **admin**. Register at `/register`.
|
|||||||
Run these from the repo root:
|
Run these from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev # backend + frontend + photo-api
|
||||||
npm run build
|
npm run build # build all workspaces
|
||||||
npm run start
|
npm run start
|
||||||
npm run db:generate
|
npm run db:generate
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run db:studio
|
npm run db:studio
|
||||||
npm run db:export # Backup database
|
npm run db:export # Backup database
|
||||||
npm run db:import # Restore from backup
|
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 ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run per workspace:
|
You can also run per workspace:
|
||||||
@@ -85,9 +105,19 @@ Key settings (see `backend/.env.example` for the full list):
|
|||||||
- **Auth**: `JWT_SECRET` (change in production)
|
- **Auth**: `JWT_SECRET` (change in production)
|
||||||
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
||||||
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
||||||
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
|
- **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.
|
||||||
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
|
- **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`)
|
### Frontend (`frontend/.env`)
|
||||||
|
|
||||||
Key settings (see `frontend/.env.example`):
|
Key settings (see `frontend/.env.example`):
|
||||||
@@ -143,12 +173,13 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
|
|||||||
|
|
||||||
This repo includes example configs in `deploy/`:
|
This repo includes example configs in `deploy/`:
|
||||||
|
|
||||||
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
|
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
|
||||||
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
|
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
|
||||||
- Backend needs write access to `backend/data` and `backend/uploads`
|
- 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**:
|
- **nginx**:
|
||||||
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
|
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
|
||||||
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
|
- `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/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
||||||
|
|
||||||
Typical production flow:
|
Typical production flow:
|
||||||
@@ -156,7 +187,9 @@ Typical production flow:
|
|||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run build:photos
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
|
npm run migrate:photos
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install/enable the systemd services and nginx configs for your server.
|
Then install/enable the systemd services and nginx configs for your server.
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
previewUrl: p.urls.preview || p.urls.original,
|
previewUrl: p.urls.preview || p.urls.original,
|
||||||
downloadUrl: p.urls.original,
|
downloadUrl: p.urls.original,
|
||||||
filename: p.originalFilename,
|
filename: p.originalFilename,
|
||||||
|
thumbUrl: p.urls.thumb,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
|
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export default function AdminGalleryDetailPage() {
|
|||||||
previewUrl: p.urls.preview || p.urls.original,
|
previewUrl: p.urls.preview || p.urls.original,
|
||||||
downloadUrl: p.urls.original,
|
downloadUrl: p.urls.original,
|
||||||
filename: p.originalFilename,
|
filename: p.originalFilename,
|
||||||
|
thumbUrl: p.urls.thumb,
|
||||||
}));
|
}));
|
||||||
const openLightboxFor = (photoId: string) => {
|
const openLightboxFor = (photoId: string) => {
|
||||||
const idx = readyPhotos.findIndex((p) => p.id === photoId);
|
const idx = readyPhotos.findIndex((p) => p.id === photoId);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useCallback, useState } from 'react';
|
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
@@ -13,6 +13,8 @@ export interface LightboxItem {
|
|||||||
previewUrl: string;
|
previewUrl: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
|
/** Small thumbnail for the filmstrip; falls back to previewUrl. */
|
||||||
|
thumbUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LightboxProps {
|
interface LightboxProps {
|
||||||
@@ -29,8 +31,10 @@ interface LightboxProps {
|
|||||||
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
|
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
|
||||||
*/
|
*/
|
||||||
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
|
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
|
||||||
const [touchStartX, setTouchStartX] = useState<number | null>(null);
|
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
|
const hasMultiple = items.length > 1;
|
||||||
|
|
||||||
const prev = useCallback(() => {
|
const prev = useCallback(() => {
|
||||||
onNavigate(index > 0 ? index - 1 : items.length - 1);
|
onNavigate(index > 0 ? index - 1 : items.length - 1);
|
||||||
@@ -53,19 +57,33 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
|
|||||||
};
|
};
|
||||||
}, [onClose, prev, next]);
|
}, [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;
|
if (!item) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
|
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
|
onTouchStart={(e) => {
|
||||||
|
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
}}
|
||||||
onTouchEnd={(e) => {
|
onTouchEnd={(e) => {
|
||||||
if (touchStartX === null) return;
|
if (touchStart.current === null) return;
|
||||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
const dx = e.changedTouches[0].clientX - touchStart.current.x;
|
||||||
if (dx > 60) prev();
|
const dy = e.changedTouches[0].clientY - touchStart.current.y;
|
||||||
if (dx < -60) next();
|
touchStart.current = null;
|
||||||
setTouchStartX(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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -91,47 +109,84 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi
|
|||||||
{renderActions?.(item, index)}
|
{renderActions?.(item, index)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 1 && (
|
{/* Main image area */}
|
||||||
<>
|
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||||
<button
|
{hasMultiple && (
|
||||||
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
|
<>
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
|
||||||
prev();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
aria-label="Previous"
|
prev();
|
||||||
>
|
}}
|
||||||
<ChevronLeftIcon className="w-9 h-9" />
|
aria-label="Previous"
|
||||||
</button>
|
>
|
||||||
<button
|
<ChevronLeftIcon className="w-9 h-9" />
|
||||||
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
|
</button>
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white z-10 p-2"
|
||||||
next();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
aria-label="Next"
|
next();
|
||||||
>
|
}}
|
||||||
<ChevronRightIcon className="w-9 h-9" />
|
aria-label="Next"
|
||||||
</button>
|
>
|
||||||
</>
|
<ChevronRightIcon className="w-9 h-9" />
|
||||||
)}
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={item.previewUrl}
|
src={item.previewUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
draggable={false}
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
/>
|
onTouchEnd={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
|
<div className="text-center text-white/70 text-sm mb-2">
|
||||||
{index + 1} / {items.length}
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preload neighbours so prev/next feels instant. */}
|
{/* Preload neighbours so prev/next feels instant. */}
|
||||||
<div className="hidden" aria-hidden>
|
<div className="hidden" aria-hidden>
|
||||||
{items.length > 1 && (
|
{hasMultiple && (
|
||||||
<>
|
<>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
|
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
|
||||||
|
|||||||
Reference in New Issue
Block a user