first commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
.gitea
|
||||
*.md
|
||||
.env
|
||||
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Server
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
BASE_URL=https://loot-hunt.com
|
||||
|
||||
# Session
|
||||
SESSION_SECRET=change-me-to-a-random-string
|
||||
|
||||
# Database (SQLite file path)
|
||||
DB_PATH=./data/loot-hunt.db
|
||||
|
||||
# Uploads directory
|
||||
UPLOADS_DIR=./data/uploads
|
||||
89
.gitea/workflows/rebuild-prod.yaml
Normal file
89
.gitea/workflows/rebuild-prod.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
|
||||
name: Build Images and Deploy
|
||||
run-name: ${{ gitea.actor }} is building new PROD images and redeploying the existing stack 🚀
|
||||
on:
|
||||
push:
|
||||
# not working right now https://github.com/actions/runner/issues/2324
|
||||
# paths-ignore:
|
||||
# - **.yml
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
STACK_NAME: loot-hunt
|
||||
DOT_ENV: ${{ secrets.PROD_ENV }}
|
||||
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
|
||||
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
|
||||
ENDPOINT_NAME: "mini" #sometimes "primary"
|
||||
IMAGE_TAG: "reg.dev.nervesocket.com/loot-hunt:latest"
|
||||
|
||||
jobs:
|
||||
Update-PROD-Stack:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# if: contains(github.event.pull_request.head.ref, 'init-stack')
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Build and push PROD Docker image
|
||||
run: |
|
||||
echo $DOT_ENV | base64 -d > .env
|
||||
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
|
||||
|
||||
- name: Get the endpoint ID
|
||||
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
|
||||
run: |
|
||||
ENDPOINT_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/endpoints" | jq -r ".[] | select(.Name==\"$ENDPOINT_NAME\") | .Id")
|
||||
echo "ENDPOINT_ID=$ENDPOINT_ID" >> $GITHUB_ENV
|
||||
echo "Got stack Endpoint ID: $ENDPOINT_ID"
|
||||
|
||||
- name: Fetch stack ID from Portainer
|
||||
run: |
|
||||
STACK_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks" | jq -r ".[] | select(.Name==\"$STACK_NAME\" and .EndpointId==$ENDPOINT_ID) | .Id")
|
||||
|
||||
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
|
||||
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
|
||||
|
||||
- name: Fetch Stack
|
||||
run: |
|
||||
# Get the stack details (including env vars)
|
||||
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
|
||||
|
||||
# Extract environment variables from the stack
|
||||
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
|
||||
|
||||
echo "Existing stack environment variables:"
|
||||
cat stack_env.json
|
||||
|
||||
- name: Redeploy stack in Portainer
|
||||
run: |
|
||||
# Read stack file content
|
||||
STACK_FILE_CONTENT=$(echo "$(<prod-compose.yml )")
|
||||
|
||||
# Read existing environment variables from the fetched stack
|
||||
ENV_VARS=$(cat stack_env.json)
|
||||
|
||||
# Prepare JSON payload with environment variables
|
||||
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
|
||||
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
|
||||
|
||||
echo "About to push the following JSON payload:"
|
||||
echo $JSON_PAYLOAD
|
||||
|
||||
# Update stack in Portainer (this redeploys it)
|
||||
DEPLOY_RESPONSE=$(curl -X PUT "$PORTAINER_API_URL/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
|
||||
-H "X-API-Key: $PORTAINER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$JSON_PAYLOAD")
|
||||
|
||||
echo "Redeployed stack in Portainer. Response:"
|
||||
echo $DEPLOY_RESPONSE
|
||||
|
||||
- name: Status check
|
||||
run: |
|
||||
echo "📋 This job's status is ${{ job.status }}. Make sure you delete the init file to avoid issues."
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data/uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/app.js"]
|
||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Loot Hunt
|
||||
|
||||
A digital alternate reality game — find and scan hidden QR codes in real life to earn points and climb the leaderboard.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Organizers** create a "hunt" and generate printable QR code cards
|
||||
2. **Players** scan hidden QR codes to earn points — first find gets the most points
|
||||
3. **Leaderboards** track top players per hunt and globally
|
||||
|
||||
### Point System
|
||||
|
||||
| Scan Order | Points |
|
||||
|-----------|--------|
|
||||
| 1st scan | 500 |
|
||||
| 2nd scan | 250 |
|
||||
| 3rd scan | 100 |
|
||||
| 4th+ | 50 |
|
||||
|
||||
Players earn points only once per package. Re-scanning lets you update the package hint.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Node.js** + Express
|
||||
- **SQLite** via better-sqlite3
|
||||
- **EJS** templates
|
||||
- **PDFKit** + qrcode for printable QR sheets
|
||||
- **Docker** deployment via Portainer
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
node src/setup-admin.js admin yourpassword
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000`
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The project deploys via Gitea Actions → Docker build → Portainer stack update.
|
||||
|
||||
```bash
|
||||
# Build locally
|
||||
docker build -t loot-hunt .
|
||||
|
||||
# Run
|
||||
docker run -p 3000:3000 -v loot-data:/app/data \
|
||||
-e SESSION_SECRET=your-secret \
|
||||
-e BASE_URL=https://loot-hunt.com \
|
||||
loot-hunt
|
||||
|
||||
# Create admin user inside container
|
||||
docker exec -it loot-hunt node src/setup-admin.js admin yourpassword
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `3000` |
|
||||
| `NODE_ENV` | Environment | `production` |
|
||||
| `BASE_URL` | Public URL (for QR codes) | `http://localhost:3000` |
|
||||
| `SESSION_SECRET` | Session encryption key | (required) |
|
||||
| `DB_PATH` | SQLite database path | `./data/loot-hunt.db` |
|
||||
| `UPLOADS_DIR` | Image uploads directory | `./data/uploads` |
|
||||
| `TRUST_PROXY` | Trust reverse proxy headers | `false` |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.js # Express application entry point
|
||||
├── setup-admin.js # CLI tool to create/promote admin users
|
||||
├── config/
|
||||
│ └── database.js # SQLite initialization & schema
|
||||
├── middleware/
|
||||
│ └── auth.js # Auth & admin middleware
|
||||
├── models/
|
||||
│ └── index.js # All database operations
|
||||
├── routes/
|
||||
│ ├── auth.js # Login/register/logout
|
||||
│ ├── admin.js # Hunt management & PDF download
|
||||
│ ├── loot.js # QR scan handling, image upload, hints
|
||||
│ └── hunts.js # Public hunt profiles & leaderboards
|
||||
├── utils/
|
||||
│ └── pdf.js # QR code PDF generation
|
||||
└── views/ # EJS templates
|
||||
```
|
||||
2001
package-lock.json
generated
Normal file
2001
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "loot-hunt",
|
||||
"version": "1.0.0",
|
||||
"description": "Digital treasure hunt ARG - find and scan hidden QR codes to earn points",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.21.1",
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pdfkit": "^0.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sql.js": "^1.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.7"
|
||||
}
|
||||
}
|
||||
23
prod-compose.yml
Normal file
23
prod-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
loot-hunt:
|
||||
image: reg.dev.nervesocket.com/loot-hunt:latest
|
||||
container_name: loot-hunt
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- BASE_URL=${BASE_URL}
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- DB_PATH=/app/data/loot-hunt.db
|
||||
- UPLOADS_DIR=/app/data/uploads
|
||||
- TRUST_PROXY=true
|
||||
volumes:
|
||||
- loot-hunt-data:/app/data
|
||||
|
||||
volumes:
|
||||
loot-hunt-data:
|
||||
driver: local
|
||||
517
public/css/style.css
Normal file
517
public/css/style.css
Normal file
@@ -0,0 +1,517 @@
|
||||
:root {
|
||||
--primary: #6c5ce7;
|
||||
--primary-dark: #5a4bd1;
|
||||
--accent: #fdcb6e;
|
||||
--dark: #1a1a2e;
|
||||
--darker: #16213e;
|
||||
--light: #f8f9fa;
|
||||
--success: #00b894;
|
||||
--danger: #d63031;
|
||||
--muted: #636e72;
|
||||
--card-bg: #ffffff;
|
||||
--body-bg: #f0f2f5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: var(--body-bg);
|
||||
color: #2d3436;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Navigation ──────────────────────────────────────── */
|
||||
.navbar {
|
||||
background: var(--dark);
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
font-size: 1.4rem;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.navbar-brand:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navbar-nav a {
|
||||
color: #b2bec3;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.navbar-nav a:hover,
|
||||
.navbar-nav a.active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ─── Layout ──────────────────────────────────────────── */
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.container-narrow {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Cards ──────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid var(--body-bg);
|
||||
}
|
||||
|
||||
/* ─── Buttons ─────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #00a381;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 2px solid var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ─── Forms ──────────────────────────────────────────── */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 2px solid #dfe6e9;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* ─── Alerts ──────────────────────────────────────────── */
|
||||
.alert {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: #ffeaa7;
|
||||
border: 1px solid #fdcb6e;
|
||||
color: #6c5ce7;
|
||||
}
|
||||
|
||||
.alert-danger.error {
|
||||
background: #fab1a0;
|
||||
border: 1px solid #e17055;
|
||||
color: #2d3436;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #dfe6e9;
|
||||
border: 1px solid var(--success);
|
||||
color: #00b894;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #dfe6e9;
|
||||
border: 1px solid #74b9ff;
|
||||
color: #0984e3;
|
||||
}
|
||||
|
||||
/* ─── Points Badge ────────────────────────────────────── */
|
||||
.points-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--accent);
|
||||
color: var(--dark);
|
||||
font-weight: 800;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.points-badge.large {
|
||||
font-size: 2rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ─── Tables ──────────────────────────────────────────── */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: rgba(108, 92, 231, 0.03);
|
||||
}
|
||||
|
||||
.rank-cell {
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary);
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.rank-1 { color: #f1c40f; }
|
||||
.rank-2 { color: #95a5a6; }
|
||||
.rank-3 { color: #e17055; }
|
||||
|
||||
/* ─── Package Grid ────────────────────────────────────── */
|
||||
.package-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.package-card:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(108, 92, 231, 0.15);
|
||||
}
|
||||
|
||||
.package-card .card-num {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.package-card .code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.package-card .scan-info {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.package-card.scanned {
|
||||
border-color: var(--success);
|
||||
background: linear-gradient(135deg, #fff 0%, #f0fff4 100%);
|
||||
}
|
||||
|
||||
.package-card.unscanned {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ─── Scan Result ─────────────────────────────────────── */
|
||||
.scan-result {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.scan-result h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.scan-result .emoji {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ─── Package Profile ─────────────────────────────────── */
|
||||
.package-hero {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.package-hero .card-number {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.package-hero .hunt-name {
|
||||
font-size: 1.2rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.package-image {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* ─── Stats Row ───────────────────────────────────────── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stat-box .value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-box .label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ─── Hero Section ────────────────────────────────────── */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--dark);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted);
|
||||
max-width: 500px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
/* ─── Hunt Cards List ─────────────────────────────────── */
|
||||
.hunt-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.hunt-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hunt-card .hunt-info h3 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hunt-card .hunt-info .meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hunt-card .badge {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 15px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hunt-card .badge.expired {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Footer ──────────────────────────────────────────── */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
border-top: 1px solid #dfe6e9;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ─── Responsive ──────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
.navbar {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.package-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hunt-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
150
src/app.js
Normal file
150
src/app.js
Normal file
@@ -0,0 +1,150 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Load .env if present
|
||||
try {
|
||||
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (match) {
|
||||
process.env[match[1].trim()] = match[2].trim();
|
||||
}
|
||||
});
|
||||
} catch (e) { /* .env file is optional */ }
|
||||
|
||||
// Ensure data dirs exist
|
||||
const dataDir = path.dirname(process.env.DB_PATH || './data/loot-hunt.db');
|
||||
const uploadsDir = process.env.UPLOADS_DIR || './data/uploads';
|
||||
[dataDir, uploadsDir].forEach(dir => {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
// Initialize database (async — schema creation happens in background)
|
||||
const db = require('./config/database');
|
||||
|
||||
const { loadUser } = require('./middleware/auth');
|
||||
|
||||
// ─── SQLite Session Store ─────────────────────────────────
|
||||
class SQLiteSessionStore extends session.Store {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
get(sid, callback) {
|
||||
try {
|
||||
const row = db.prepare('SELECT sess FROM sessions WHERE sid = ? AND expired > datetime(\'now\')').get(sid);
|
||||
if (row) {
|
||||
callback(null, JSON.parse(row.sess));
|
||||
} else {
|
||||
callback(null, null);
|
||||
}
|
||||
} catch (err) { callback(err); }
|
||||
}
|
||||
|
||||
set(sid, sess, callback) {
|
||||
try {
|
||||
const maxAge = sess.cookie && sess.cookie.maxAge ? sess.cookie.maxAge : 86400000;
|
||||
const expiredDate = new Date(Date.now() + maxAge).toISOString();
|
||||
const sessStr = JSON.stringify(sess);
|
||||
// upsert
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
db.prepare('INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?)').run(sid, sessStr, expiredDate);
|
||||
callback(null);
|
||||
} catch (err) { callback(err); }
|
||||
}
|
||||
|
||||
destroy(sid, callback) {
|
||||
try {
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
callback(null);
|
||||
} catch (err) { callback(err); }
|
||||
}
|
||||
|
||||
// Clean up expired sessions periodically
|
||||
touch(sid, sess, callback) {
|
||||
this.set(sid, sess, callback);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Start app once DB is ready ───────────────────────────
|
||||
async function start() {
|
||||
await db.ready;
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// View engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// Body parsing
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
// Static files
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
app.use('/uploads', express.static(path.resolve(uploadsDir)));
|
||||
|
||||
// Sessions
|
||||
app.use(session({
|
||||
store: new SQLiteSessionStore(),
|
||||
secret: process.env.SESSION_SECRET || 'loot-hunt-dev-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production' && process.env.TRUST_PROXY === 'true'
|
||||
}
|
||||
}));
|
||||
|
||||
// Trust proxy if behind nginx
|
||||
if (process.env.TRUST_PROXY === 'true') {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// Load current user into all views
|
||||
app.use(loadUser);
|
||||
|
||||
// Flash-like messages via session
|
||||
app.use((req, res, next) => {
|
||||
res.locals.flash = req.session.flash || null;
|
||||
delete req.session.flash;
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/auth', require('./routes/auth'));
|
||||
app.use('/admin', require('./routes/admin'));
|
||||
app.use('/loot', require('./routes/loot'));
|
||||
app.use('/', require('./routes/hunts'));
|
||||
|
||||
// Home page
|
||||
app.get('/', (req, res) => {
|
||||
const { Hunts } = require('./models');
|
||||
const hunts = Hunts.getAll();
|
||||
res.render('home', { title: 'Loot Hunt', hunts });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).render('error', { title: 'Not Found', message: 'The page you are looking for does not exist.' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).render('error', { title: 'Error', message: 'Something went wrong.' });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🎯 Loot Hunt running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch(err => {
|
||||
console.error('Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
219
src/config/database.js
Normal file
219
src/config/database.js
Normal file
@@ -0,0 +1,219 @@
|
||||
const initSqlJs = require('sql.js');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const dbPath = process.env.DB_PATH || './data/loot-hunt.db';
|
||||
const dbDir = path.dirname(dbPath);
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// ─── Compatibility wrapper ────────────────────────────────
|
||||
// Wraps sql.js to provide a better-sqlite3–compatible API so models
|
||||
// can call db.prepare(sql).get(...), .all(...), .run(...) seamlessly.
|
||||
|
||||
let _db = null; // raw sql.js Database instance
|
||||
let _saveTimer = null; // debounced persistence timer
|
||||
|
||||
function save() {
|
||||
if (!_db) return;
|
||||
const data = _db.export();
|
||||
fs.writeFileSync(dbPath, Buffer.from(data));
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (_saveTimer) return; // already scheduled
|
||||
_saveTimer = setTimeout(() => {
|
||||
_saveTimer = null;
|
||||
save();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/** Convert sql.js result set to array of plain objects */
|
||||
function rowsToObjects(stmt) {
|
||||
const cols = stmt.getColumnNames();
|
||||
const rows = [];
|
||||
while (stmt.step()) {
|
||||
const values = stmt.get();
|
||||
const obj = {};
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
obj[cols[i]] = values[i];
|
||||
}
|
||||
rows.push(obj);
|
||||
}
|
||||
stmt.free();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The public API that models import */
|
||||
const db = {
|
||||
/** Execute raw SQL (DDL, multi-statement) */
|
||||
exec(sql) {
|
||||
_db.run(sql);
|
||||
scheduleSave();
|
||||
},
|
||||
|
||||
/** Set a pragma */
|
||||
pragma(str) {
|
||||
_db.run(`PRAGMA ${str}`);
|
||||
},
|
||||
|
||||
/** Prepare a statement — returns object with get/all/run */
|
||||
prepare(sql) {
|
||||
return {
|
||||
/** Return first matching row as object, or undefined */
|
||||
get(...params) {
|
||||
const stmt = _db.prepare(sql);
|
||||
if (params.length) stmt.bind(params);
|
||||
const rows = rowsToObjects(stmt);
|
||||
return rows[0] || undefined;
|
||||
},
|
||||
|
||||
/** Return all matching rows as array of objects */
|
||||
all(...params) {
|
||||
const stmt = _db.prepare(sql);
|
||||
if (params.length) stmt.bind(params);
|
||||
return rowsToObjects(stmt);
|
||||
},
|
||||
|
||||
/** Execute a write statement; returns { lastInsertRowid, changes } */
|
||||
run(...params) {
|
||||
const stmt = _db.prepare(sql);
|
||||
if (params.length) stmt.bind(params);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
const changes = _db.getRowsModified();
|
||||
// sql.js uses last_insert_rowid() for the last rowid
|
||||
const idResult = _db.exec('SELECT last_insert_rowid() as id');
|
||||
const lastInsertRowid = idResult.length > 0 ? idResult[0].values[0][0] : 0;
|
||||
scheduleSave();
|
||||
return { lastInsertRowid, changes };
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/** Wrap a function in a transaction */
|
||||
transaction(fn) {
|
||||
return (...args) => {
|
||||
_db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
const result = fn(...args);
|
||||
_db.run('COMMIT');
|
||||
scheduleSave();
|
||||
return result;
|
||||
} catch (err) {
|
||||
_db.run('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/** Force an immediate save to disk */
|
||||
forceSave() {
|
||||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||||
save();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Initialisation (synchronous-style via top-level await alternative) ───
|
||||
// sql.js init is async, so we expose a ready promise that app.js awaits.
|
||||
|
||||
let _readyResolve;
|
||||
const ready = new Promise(resolve => { _readyResolve = resolve; });
|
||||
|
||||
(async () => {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
// Load existing DB from disk or create new
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const fileBuffer = fs.readFileSync(dbPath);
|
||||
_db = new SQL.Database(fileBuffer);
|
||||
} else {
|
||||
_db = new SQL.Database();
|
||||
}
|
||||
|
||||
// Enable foreign keys
|
||||
_db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Initialize schema
|
||||
_db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hunts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
short_name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
package_count INTEGER NOT NULL,
|
||||
expiry_date DATETIME,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hunt_id INTEGER NOT NULL,
|
||||
card_number INTEGER NOT NULL,
|
||||
unique_code TEXT NOT NULL,
|
||||
first_scanned_by INTEGER,
|
||||
first_scan_image TEXT,
|
||||
last_scanned_by INTEGER,
|
||||
last_scan_hint TEXT,
|
||||
scan_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (hunt_id) REFERENCES hunts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (first_scanned_by) REFERENCES users(id),
|
||||
FOREIGN KEY (last_scanned_by) REFERENCES users(id),
|
||||
UNIQUE(hunt_id, card_number),
|
||||
UNIQUE(hunt_id, unique_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packages_idx1 (id INTEGER);
|
||||
DROP TABLE IF EXISTS packages_idx1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
package_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
points_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expired DATETIME NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all
|
||||
// versions, so wrap in try/catch)
|
||||
const indexes = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_packages_hunt ON packages(hunt_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_packages_code ON packages(unique_code)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)'
|
||||
];
|
||||
for (const idx of indexes) {
|
||||
try { _db.run(idx); } catch (e) { /* index may already exist */ }
|
||||
}
|
||||
|
||||
save(); // persist initial schema
|
||||
_readyResolve();
|
||||
})();
|
||||
|
||||
db.ready = ready;
|
||||
module.exports = db;
|
||||
37
src/middleware/auth.js
Normal file
37
src/middleware/auth.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Authentication middleware
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.userId) {
|
||||
return next();
|
||||
}
|
||||
// Save the original URL so we can redirect back after login
|
||||
req.session.returnTo = req.originalUrl;
|
||||
res.redirect('/auth/login');
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.userId && req.session.isAdmin) {
|
||||
return next();
|
||||
}
|
||||
if (req.session && req.session.userId) {
|
||||
return res.status(403).render('error', { title: 'Forbidden', message: 'You do not have admin access.' });
|
||||
}
|
||||
req.session.returnTo = req.originalUrl;
|
||||
res.redirect('/auth/login');
|
||||
}
|
||||
|
||||
function loadUser(req, res, next) {
|
||||
if (req.session && req.session.userId) {
|
||||
res.locals.currentUser = {
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
isAdmin: req.session.isAdmin
|
||||
};
|
||||
} else {
|
||||
res.locals.currentUser = null;
|
||||
}
|
||||
res.locals.baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, requireAdmin, loadUser };
|
||||
246
src/models/index.js
Normal file
246
src/models/index.js
Normal file
@@ -0,0 +1,246 @@
|
||||
const db = require('../config/database');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
function generateCode(length = 5) {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no I/O/0/1 to avoid confusion
|
||||
let code = '';
|
||||
const bytes = crypto.randomBytes(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function getPointsForScanNumber(scanNumber) {
|
||||
if (scanNumber === 1) return 500;
|
||||
if (scanNumber === 2) return 250;
|
||||
if (scanNumber === 3) return 100;
|
||||
return 50;
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────
|
||||
const Users = {
|
||||
create(username, password) {
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||
const result = stmt.run(username, hash);
|
||||
return result.lastInsertRowid;
|
||||
},
|
||||
|
||||
findByUsername(username) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return db.prepare('SELECT id, username, is_admin, created_at FROM users WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
verifyPassword(user, password) {
|
||||
return bcrypt.compareSync(password, user.password_hash);
|
||||
},
|
||||
|
||||
makeAdmin(userId) {
|
||||
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
|
||||
},
|
||||
|
||||
getTotalPoints(userId) {
|
||||
const row = db.prepare('SELECT COALESCE(SUM(points_awarded), 0) as total FROM scans WHERE user_id = ?').get(userId);
|
||||
return row.total;
|
||||
},
|
||||
|
||||
getProfile(userId) {
|
||||
const user = this.findById(userId);
|
||||
if (!user) return null;
|
||||
const totalPoints = this.getTotalPoints(userId);
|
||||
const scanCount = db.prepare('SELECT COUNT(*) as count FROM scans WHERE user_id = ? AND points_awarded > 0').get(userId).count;
|
||||
return { ...user, totalPoints, scanCount };
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Hunts ────────────────────────────────────────────────
|
||||
const Hunts = {
|
||||
create(name, shortName, description, packageCount, expiryDate, createdBy) {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO hunts (name, short_name, description, package_count, expiry_date, created_by) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(name, shortName.toUpperCase(), description, packageCount, expiryDate || null, createdBy);
|
||||
const huntId = result.lastInsertRowid;
|
||||
|
||||
// Generate packages
|
||||
const insertPkg = db.prepare('INSERT INTO packages (hunt_id, card_number, unique_code) VALUES (?, ?, ?)');
|
||||
const usedCodes = new Set();
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (let i = 1; i <= packageCount; i++) {
|
||||
let code;
|
||||
do {
|
||||
code = generateCode(5);
|
||||
} while (usedCodes.has(code));
|
||||
usedCodes.add(code);
|
||||
insertPkg.run(huntId, i, code);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
return huntId;
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return db.prepare('SELECT * FROM hunts WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
findByShortName(shortName) {
|
||||
return db.prepare('SELECT * FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
|
||||
},
|
||||
|
||||
getAll() {
|
||||
return db.prepare('SELECT h.*, u.username as creator_name FROM hunts h JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all();
|
||||
},
|
||||
|
||||
getByCreator(userId) {
|
||||
return db.prepare('SELECT * FROM hunts WHERE created_by = ? ORDER BY created_at DESC').all(userId);
|
||||
},
|
||||
|
||||
isExpired(hunt) {
|
||||
if (!hunt.expiry_date) return false;
|
||||
return new Date(hunt.expiry_date) < new Date();
|
||||
},
|
||||
|
||||
getLeaderboard(huntId) {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
||||
GROUP BY u.id
|
||||
ORDER BY total_points DESC
|
||||
`).all(huntId);
|
||||
},
|
||||
|
||||
shortNameExists(shortName) {
|
||||
const row = db.prepare('SELECT id FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
|
||||
return !!row;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Packages ─────────────────────────────────────────────
|
||||
const Packages = {
|
||||
findById(id) {
|
||||
return db.prepare('SELECT * FROM packages WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
findByHuntAndCode(shortName, uniqueCode) {
|
||||
return db.prepare(`
|
||||
SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, h.expiry_date
|
||||
FROM packages p
|
||||
JOIN hunts h ON p.hunt_id = h.id
|
||||
WHERE h.short_name = ? COLLATE NOCASE AND p.unique_code = ? COLLATE NOCASE
|
||||
`).get(shortName, uniqueCode);
|
||||
},
|
||||
|
||||
getByHunt(huntId) {
|
||||
return db.prepare(`
|
||||
SELECT p.*,
|
||||
u1.username as first_scanner_name,
|
||||
u2.username as last_scanner_name
|
||||
FROM packages p
|
||||
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
|
||||
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
|
||||
WHERE p.hunt_id = ?
|
||||
ORDER BY p.card_number ASC
|
||||
`).all(huntId);
|
||||
},
|
||||
|
||||
getProfile(packageId) {
|
||||
return db.prepare(`
|
||||
SELECT p.*,
|
||||
h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id,
|
||||
u1.username as first_scanner_name,
|
||||
u2.username as last_scanner_name
|
||||
FROM packages p
|
||||
JOIN hunts h ON p.hunt_id = h.id
|
||||
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
|
||||
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
|
||||
WHERE p.id = ?
|
||||
`).get(packageId);
|
||||
},
|
||||
|
||||
getScanHistory(packageId) {
|
||||
return db.prepare(`
|
||||
SELECT s.*, u.username
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.package_id = ?
|
||||
ORDER BY s.scanned_at ASC
|
||||
`).all(packageId);
|
||||
},
|
||||
|
||||
updateFirstScanImage(packageId, imagePath) {
|
||||
db.prepare('UPDATE packages SET first_scan_image = ? WHERE id = ?').run(imagePath, packageId);
|
||||
},
|
||||
|
||||
updateLastScanHint(packageId, userId, hint) {
|
||||
db.prepare('UPDATE packages SET last_scanned_by = ?, last_scan_hint = ? WHERE id = ?').run(userId, hint, packageId);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Scans ────────────────────────────────────────────────
|
||||
const Scans = {
|
||||
hasUserScanned(packageId, userId) {
|
||||
const row = db.prepare('SELECT id FROM scans WHERE package_id = ? AND user_id = ? AND points_awarded > 0').get(packageId, userId);
|
||||
return !!row;
|
||||
},
|
||||
|
||||
recordScan(packageId, userId) {
|
||||
const pkg = Packages.findById(packageId);
|
||||
if (!pkg) return { error: 'Package not found' };
|
||||
|
||||
const alreadyScanned = this.hasUserScanned(packageId, userId);
|
||||
|
||||
if (alreadyScanned) {
|
||||
// No points, but update last_scanned_by so they can edit the hint
|
||||
db.prepare('UPDATE packages SET last_scanned_by = ? WHERE id = ?').run(userId, packageId);
|
||||
// Record the scan with 0 points
|
||||
db.prepare('INSERT INTO scans (package_id, user_id, points_awarded) VALUES (?, ?, 0)').run(packageId, userId);
|
||||
return { points: 0, alreadyScanned: true, isFirst: false };
|
||||
}
|
||||
|
||||
const scanNumber = pkg.scan_count + 1;
|
||||
const points = getPointsForScanNumber(scanNumber);
|
||||
|
||||
const doScan = db.transaction(() => {
|
||||
// Record the scan
|
||||
db.prepare('INSERT INTO scans (package_id, user_id, points_awarded) VALUES (?, ?, ?)').run(packageId, userId, points);
|
||||
|
||||
// Update package
|
||||
const isFirst = scanNumber === 1;
|
||||
if (isFirst) {
|
||||
db.prepare('UPDATE packages SET first_scanned_by = ?, last_scanned_by = ?, scan_count = ? WHERE id = ?')
|
||||
.run(userId, userId, scanNumber, packageId);
|
||||
} else {
|
||||
db.prepare('UPDATE packages SET last_scanned_by = ?, scan_count = ? WHERE id = ?')
|
||||
.run(userId, scanNumber, packageId);
|
||||
}
|
||||
|
||||
return { points, alreadyScanned: false, isFirst, scanNumber };
|
||||
});
|
||||
|
||||
return doScan();
|
||||
},
|
||||
|
||||
getGlobalLeaderboard() {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.points_awarded > 0
|
||||
GROUP BY u.id
|
||||
ORDER BY total_points DESC
|
||||
`).all();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { Users, Hunts, Packages, Scans, generateCode };
|
||||
96
src/routes/admin.js
Normal file
96
src/routes/admin.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
const { Hunts, Packages } = require('../models');
|
||||
const { generateHuntPDF } = require('../utils/pdf');
|
||||
|
||||
// All admin routes require admin access
|
||||
router.use(requireAdmin);
|
||||
|
||||
// Admin dashboard
|
||||
router.get('/', (req, res) => {
|
||||
const hunts = Hunts.getByCreator(req.session.userId);
|
||||
res.render('admin/dashboard', { title: 'Admin Dashboard', hunts });
|
||||
});
|
||||
|
||||
// Create hunt form
|
||||
router.get('/hunts/new', (req, res) => {
|
||||
res.render('admin/create-hunt', { title: 'Create New Hunt', error: null });
|
||||
});
|
||||
|
||||
// Create hunt
|
||||
router.post('/hunts', (req, res) => {
|
||||
const { name, short_name, description, package_count, expiry_date } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || !short_name || !package_count) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Name, short name, and number of packages are required.'
|
||||
});
|
||||
}
|
||||
|
||||
const shortName = short_name.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
if (shortName.length < 2 || shortName.length > 12) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Short name must be 2-12 uppercase alphanumeric characters.'
|
||||
});
|
||||
}
|
||||
|
||||
if (Hunts.shortNameExists(shortName)) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'That short name is already taken.'
|
||||
});
|
||||
}
|
||||
|
||||
const count = parseInt(package_count, 10);
|
||||
if (isNaN(count) || count < 1 || count > 10000) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Package count must be between 1 and 10,000.'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const huntId = Hunts.create(name, shortName, description, count, expiry_date, req.session.userId);
|
||||
res.redirect(`/admin/hunts/${huntId}`);
|
||||
} catch (err) {
|
||||
console.error('Hunt creation error:', err);
|
||||
res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Failed to create hunt. Please try again.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Manage hunt
|
||||
router.get('/hunts/:id', (req, res) => {
|
||||
const hunt = Hunts.findById(req.params.id);
|
||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages });
|
||||
});
|
||||
|
||||
// Download PDF of QR codes
|
||||
router.get('/hunts/:id/pdf', async (req, res) => {
|
||||
const hunt = Hunts.findById(req.params.id);
|
||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
try {
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${hunt.short_name}-packages.pdf"`);
|
||||
|
||||
await generateHuntPDF(hunt, packages, baseUrl, res);
|
||||
} catch (err) {
|
||||
console.error('PDF generation error:', err);
|
||||
res.status(500).render('error', { title: 'Error', message: 'Failed to generate PDF.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
82
src/routes/auth.js
Normal file
82
src/routes/auth.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Users } = require('../models');
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
res.render('auth/login', { title: 'Login', error: null });
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.render('auth/login', { title: 'Login', error: 'Username and password are required.' });
|
||||
}
|
||||
|
||||
const user = Users.findByUsername(username);
|
||||
if (!user || !Users.verifyPassword(user, password)) {
|
||||
return res.render('auth/login', { title: 'Login', error: 'Invalid username or password.' });
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.isAdmin = !!user.is_admin;
|
||||
|
||||
const returnTo = req.session.returnTo || '/';
|
||||
delete req.session.returnTo;
|
||||
res.redirect(returnTo);
|
||||
});
|
||||
|
||||
router.get('/register', (req, res) => {
|
||||
res.render('auth/register', { title: 'Register', error: null });
|
||||
});
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { username, password, password_confirm } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username and password are required.' });
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 24) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username must be 3-24 characters.' });
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username can only contain letters, numbers, hyphens and underscores.' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Password must be at least 6 characters.' });
|
||||
}
|
||||
|
||||
if (password !== password_confirm) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Passwords do not match.' });
|
||||
}
|
||||
|
||||
if (Users.findByUsername(username)) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username is already taken.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = Users.create(username, password);
|
||||
req.session.userId = userId;
|
||||
req.session.username = username;
|
||||
req.session.isAdmin = false;
|
||||
|
||||
const returnTo = req.session.returnTo || '/';
|
||||
delete req.session.returnTo;
|
||||
res.redirect(returnTo);
|
||||
} catch (err) {
|
||||
console.error('Registration error:', err);
|
||||
res.render('auth/register', { title: 'Register', error: 'Registration failed. Try a different username.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.redirect('/');
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
41
src/routes/hunts.js
Normal file
41
src/routes/hunts.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Hunts, Packages, Scans } = require('../models');
|
||||
|
||||
// ─── Hunt profile ─────────────────────────────────────────
|
||||
router.get('/hunt/:shortName', (req, res) => {
|
||||
const hunt = Hunts.findByShortName(req.params.shortName);
|
||||
if (!hunt) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
}
|
||||
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
const isExpired = Hunts.isExpired(hunt);
|
||||
|
||||
res.render('hunt/profile', { title: hunt.name, hunt, packages, isExpired });
|
||||
});
|
||||
|
||||
// ─── Hunt leaderboard ─────────────────────────────────────
|
||||
router.get('/hunt/:shortName/leaderboard', (req, res) => {
|
||||
const hunt = Hunts.findByShortName(req.params.shortName);
|
||||
if (!hunt) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
}
|
||||
|
||||
const leaderboard = Hunts.getLeaderboard(hunt.id);
|
||||
res.render('hunt/leaderboard', { title: `${hunt.name} - Leaderboard`, hunt, leaderboard });
|
||||
});
|
||||
|
||||
// ─── Global leaderboard ──────────────────────────────────
|
||||
router.get('/leaderboard', (req, res) => {
|
||||
const leaderboard = Scans.getGlobalLeaderboard();
|
||||
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
|
||||
});
|
||||
|
||||
// ─── Browse all hunts ─────────────────────────────────────
|
||||
router.get('/hunts', (req, res) => {
|
||||
const hunts = Hunts.getAll();
|
||||
res.render('hunt/list', { title: 'All Hunts', hunts });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
140
src/routes/loot.js
Normal file
140
src/routes/loot.js
Normal file
@@ -0,0 +1,140 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { Packages, Scans, Hunts } = require('../models');
|
||||
|
||||
// Configure multer for image uploads
|
||||
const uploadsDir = process.env.UPLOADS_DIR || './data/uploads';
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadsDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const name = `pkg-${req.params.shortName}-${req.params.code}-${Date.now()}${ext}`;
|
||||
cb(null, name);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (allowed.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Scan a package (QR code landing page) ────────────────
|
||||
router.get('/:shortName/:code', (req, res) => {
|
||||
const { shortName, code } = req.params;
|
||||
const pkg = Packages.findByHuntAndCode(shortName, code);
|
||||
|
||||
if (!pkg) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'This loot package was not found.' });
|
||||
}
|
||||
|
||||
// Check if hunt is expired
|
||||
if (pkg.expiry_date && new Date(pkg.expiry_date) < new Date()) {
|
||||
return res.render('loot/expired', { title: 'Hunt Expired', pkg });
|
||||
}
|
||||
|
||||
// If not logged in, save this URL and redirect to auth
|
||||
if (!req.session.userId) {
|
||||
req.session.returnTo = req.originalUrl;
|
||||
return res.redirect('/auth/login');
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
const result = Scans.recordScan(pkg.id, req.session.userId);
|
||||
|
||||
// Reload package with full profile
|
||||
const fullPkg = Packages.getProfile(pkg.id);
|
||||
const scanHistory = Packages.getScanHistory(pkg.id);
|
||||
const isFirstScanner = fullPkg.first_scanned_by === req.session.userId;
|
||||
const isLastScanner = fullPkg.last_scanned_by === req.session.userId;
|
||||
|
||||
res.render('loot/scanned', {
|
||||
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`,
|
||||
pkg: fullPkg,
|
||||
scanResult: result,
|
||||
scanHistory,
|
||||
isFirstScanner,
|
||||
isLastScanner
|
||||
});
|
||||
});
|
||||
|
||||
// ─── View package profile (non-scan view) ─────────────────
|
||||
router.get('/:shortName/:code/profile', (req, res) => {
|
||||
const { shortName, code } = req.params;
|
||||
const pkg = Packages.findByHuntAndCode(shortName, code);
|
||||
|
||||
if (!pkg) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'This loot package was not found.' });
|
||||
}
|
||||
|
||||
const fullPkg = Packages.getProfile(pkg.id);
|
||||
const scanHistory = Packages.getScanHistory(pkg.id);
|
||||
const isFirstScanner = req.session.userId && fullPkg.first_scanned_by === req.session.userId;
|
||||
const isLastScanner = req.session.userId && fullPkg.last_scanned_by === req.session.userId;
|
||||
|
||||
res.render('loot/profile', {
|
||||
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`,
|
||||
pkg: fullPkg,
|
||||
scanHistory,
|
||||
isFirstScanner,
|
||||
isLastScanner
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Upload first-scan image ──────────────────────────────
|
||||
router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req, res) => {
|
||||
const { shortName, code } = req.params;
|
||||
const pkg = Packages.findByHuntAndCode(shortName, code);
|
||||
|
||||
if (!pkg) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
|
||||
}
|
||||
|
||||
if (pkg.first_scanned_by !== req.session.userId) {
|
||||
return res.status(403).render('error', { title: 'Forbidden', message: 'Only the first scanner can upload an image.' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.redirect(`/loot/${shortName}/${code}/profile`);
|
||||
}
|
||||
|
||||
const imagePath = `/uploads/${req.file.filename}`;
|
||||
Packages.updateFirstScanImage(pkg.id, imagePath);
|
||||
res.redirect(`/loot/${shortName}/${code}/profile`);
|
||||
});
|
||||
|
||||
// ─── Update hint/message ──────────────────────────────────
|
||||
router.post('/:shortName/:code/hint', requireAuth, (req, res) => {
|
||||
const { shortName, code } = req.params;
|
||||
const pkg = Packages.findByHuntAndCode(shortName, code);
|
||||
|
||||
if (!pkg) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
|
||||
}
|
||||
|
||||
if (pkg.last_scanned_by !== req.session.userId) {
|
||||
return res.status(403).render('error', { title: 'Forbidden', message: 'Only the most recent scanner can update the hint.' });
|
||||
}
|
||||
|
||||
const hint = (req.body.hint || '').trim().substring(0, 500);
|
||||
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
|
||||
res.redirect(`/loot/${shortName}/${code}/profile`);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
55
src/setup-admin.js
Normal file
55
src/setup-admin.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Admin Setup Script
|
||||
*
|
||||
* Run this to create the first admin user (or promote an existing one).
|
||||
* Usage: node src/setup-admin.js <username> <password>
|
||||
* node src/setup-admin.js <username> (promote existing user)
|
||||
*/
|
||||
|
||||
// Load env
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
try {
|
||||
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (match) process.env[match[1].trim()] = match[2].trim();
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
const db = require('./config/database');
|
||||
|
||||
async function main() {
|
||||
await db.ready;
|
||||
|
||||
const { Users } = require('./models');
|
||||
|
||||
const username = process.argv[2];
|
||||
const password = process.argv[3];
|
||||
|
||||
if (!username) {
|
||||
console.log('Usage:');
|
||||
console.log(' Create new admin: node src/setup-admin.js <username> <password>');
|
||||
console.log(' Promote existing: node src/setup-admin.js <username>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = Users.findByUsername(username);
|
||||
|
||||
if (existing) {
|
||||
Users.makeAdmin(existing.id);
|
||||
console.log(`✅ User "${username}" has been promoted to admin.`);
|
||||
} else if (password) {
|
||||
const userId = Users.create(username, password);
|
||||
Users.makeAdmin(userId);
|
||||
console.log(`✅ Admin user "${username}" created successfully (ID: ${userId}).`);
|
||||
} else {
|
||||
console.log(`❌ User "${username}" not found. Provide a password to create a new admin.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.forceSave();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
80
src/utils/pdf.js
Normal file
80
src/utils/pdf.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
/**
|
||||
* Generate a printable PDF with QR codes for all packages in a hunt.
|
||||
* Layout: 3 cards per page, each card has QR on left and info on right.
|
||||
*/
|
||||
async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
|
||||
const doc = new PDFDocument({
|
||||
size: 'LETTER',
|
||||
margins: { top: 36, bottom: 36, left: 36, right: 36 }
|
||||
});
|
||||
|
||||
doc.pipe(outputStream);
|
||||
|
||||
const pageWidth = 612 - 72; // letter width minus margins
|
||||
const cardHeight = 200;
|
||||
const qrSize = 160;
|
||||
const cardsPerPage = 3;
|
||||
|
||||
for (let i = 0; i < packages.length; i++) {
|
||||
const pkg = packages[i];
|
||||
const cardIndex = i % cardsPerPage;
|
||||
|
||||
if (i > 0 && cardIndex === 0) {
|
||||
doc.addPage();
|
||||
}
|
||||
|
||||
const yOffset = 36 + cardIndex * (cardHeight + 24);
|
||||
|
||||
// Draw card border
|
||||
doc.save();
|
||||
doc.roundedRect(36, yOffset, pageWidth, cardHeight, 8).stroke('#cccccc');
|
||||
doc.restore();
|
||||
|
||||
// Generate QR code as data URL
|
||||
const url = `${baseUrl}/loot/${hunt.short_name}/${pkg.unique_code}`;
|
||||
const qrDataUrl = await QRCode.toDataURL(url, {
|
||||
width: qrSize,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M'
|
||||
});
|
||||
|
||||
// Convert data URL to buffer for PDFKit
|
||||
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
|
||||
|
||||
// QR code on the left
|
||||
const qrX = 48;
|
||||
const qrY = yOffset + (cardHeight - qrSize) / 2;
|
||||
doc.image(qrBuffer, qrX, qrY, { width: qrSize, height: qrSize });
|
||||
|
||||
// Info on the right
|
||||
const textX = 48 + qrSize + 24;
|
||||
const textY = yOffset + 20;
|
||||
|
||||
// Card number
|
||||
doc.fontSize(28).font('Helvetica-Bold').fillColor('#1a1a1a');
|
||||
doc.text(`#${pkg.card_number}`, textX, textY, { width: pageWidth - qrSize - 60 });
|
||||
|
||||
// Hunt short name
|
||||
doc.fontSize(16).font('Helvetica').fillColor('#666666');
|
||||
doc.text(hunt.short_name, textX, textY + 38, { width: pageWidth - qrSize - 60 });
|
||||
|
||||
// Unique code
|
||||
doc.fontSize(22).font('Helvetica-Bold').fillColor('#333333');
|
||||
doc.text(pkg.unique_code, textX, textY + 62, { width: pageWidth - qrSize - 60 });
|
||||
|
||||
// Dashed line separator
|
||||
doc.fontSize(9).font('Helvetica').fillColor('#999999');
|
||||
doc.text(url, textX, textY + 100, { width: pageWidth - qrSize - 60 });
|
||||
|
||||
// Hunt name at bottom of card
|
||||
doc.fontSize(11).font('Helvetica').fillColor('#444444');
|
||||
doc.text(hunt.name, textX, textY + 128, { width: pageWidth - qrSize - 60 });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
}
|
||||
|
||||
module.exports = { generateHuntPDF };
|
||||
48
src/views/admin/create-hunt.ejs
Normal file
48
src/views/admin/create-hunt.ejs
Normal file
@@ -0,0 +1,48 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container-narrow" style="padding-top: 2rem;">
|
||||
<h1 style="margin-bottom: 1.5rem;">Create New Hunt</h1>
|
||||
|
||||
<div class="card">
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/admin/hunts">
|
||||
<div class="form-group">
|
||||
<label for="name">Hunt Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" required placeholder="e.g. Veld Music Festival 2026">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="short_name">Short Name (Code)</label>
|
||||
<input type="text" id="short_name" name="short_name" class="form-control" required
|
||||
maxlength="12" placeholder="e.g. VELD26" style="text-transform: uppercase; font-family: monospace; font-size: 1.1rem;">
|
||||
<div class="form-hint">2-12 uppercase alphanumeric characters. Used in QR code URLs.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3"
|
||||
placeholder="Describe this hunt for participants..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="package_count">Number of Hidden Packages</label>
|
||||
<input type="number" id="package_count" name="package_count" class="form-control"
|
||||
required min="1" max="10000" placeholder="e.g. 50">
|
||||
<div class="form-hint">Each package gets a unique QR code and printable card.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiry_date">Expiry Date (optional)</label>
|
||||
<input type="datetime-local" id="expiry_date" name="expiry_date" class="form-control">
|
||||
<div class="form-hint">Leave blank for no expiry.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Create Hunt</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
29
src/views/admin/dashboard.ejs
Normal file
29
src/views/admin/dashboard.ejs
Normal file
@@ -0,0 +1,29 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<a href="/admin/hunts/new" class="btn btn-primary">+ New Hunt</a>
|
||||
</div>
|
||||
|
||||
<% if (hunts.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted); font-size: 1.1rem;">You haven't created any hunts yet.</p>
|
||||
<a href="/admin/hunts/new" class="btn btn-primary" style="margin-top: 1rem;">Create Your First Hunt</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<% hunts.forEach(hunt => { %>
|
||||
<a href="/admin/hunts/<%= hunt.id %>" class="hunt-card">
|
||||
<div class="hunt-info">
|
||||
<h3><%= hunt.name %></h3>
|
||||
<span class="meta"><%= hunt.short_name %> · <%= hunt.package_count %> packages
|
||||
<% if (hunt.expiry_date) { %> · Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %><% } %>
|
||||
</span>
|
||||
</div>
|
||||
<span class="badge">Manage</span>
|
||||
</a>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
73
src/views/admin/manage-hunt.ejs
Normal file
73
src/views/admin/manage-hunt.ejs
Normal file
@@ -0,0 +1,73 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<h1 style="margin: 0;"><%= hunt.name %></h1>
|
||||
<span style="color: var(--muted); font-family: monospace; font-size: 1rem;"><%= hunt.short_name %></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<a href="/admin/hunts/<%= hunt.id %>/pdf" class="btn btn-success">📥 Download PDF</a>
|
||||
<a href="/hunt/<%= hunt.short_name %>" class="btn btn-outline">View Public Page</a>
|
||||
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (hunt.description) { %>
|
||||
<div class="card">
|
||||
<p style="margin: 0; color: var(--muted);"><%= hunt.description %></p>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= hunt.package_count %></div>
|
||||
<div class="label">Packages</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.filter(p => p.scan_count > 0).length %></div>
|
||||
<div class="label">Found</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.reduce((sum, p) => sum + p.scan_count, 0) %></div>
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= hunt.expiry_date ? new Date(hunt.expiry_date).toLocaleDateString() : '—' %></div>
|
||||
<div class="label">Expires</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Code</th>
|
||||
<th>Scans</th>
|
||||
<th>First Scanner</th>
|
||||
<th>Last Scanner</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% packages.forEach(pkg => { %>
|
||||
<tr>
|
||||
<td><strong><%= pkg.card_number %></strong></td>
|
||||
<td style="font-family: monospace;"><%= pkg.unique_code %></td>
|
||||
<td><%= pkg.scan_count %></td>
|
||||
<td><%= pkg.first_scanner_name || '—' %></td>
|
||||
<td><%= pkg.last_scanner_name || '—' %></td>
|
||||
<td>
|
||||
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-sm btn-outline">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
31
src/views/auth/login.ejs
Normal file
31
src/views/auth/login.ejs
Normal file
@@ -0,0 +1,31 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container-narrow" style="padding-top: 3rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">Login</div>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/auth/login">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Login</button>
|
||||
</form>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--muted);">
|
||||
Don't have an account? <a href="/auth/register">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
39
src/views/auth/register.ejs
Normal file
39
src/views/auth/register.ejs
Normal file
@@ -0,0 +1,39 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container-narrow" style="padding-top: 3rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">Register</div>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/auth/register">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus
|
||||
minlength="3" maxlength="24" pattern="[a-zA-Z0-9_-]+" autocomplete="username">
|
||||
<div class="form-hint">3-24 characters: letters, numbers, hyphens, underscores</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required minlength="6" autocomplete="new-password">
|
||||
<div class="form-hint">At least 6 characters</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Confirm Password</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Create Account</button>
|
||||
</form>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--muted);">
|
||||
Already have an account? <a href="/auth/login">Login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
11
src/views/error.ejs
Normal file
11
src/views/error.ejs
Normal file
@@ -0,0 +1,11 @@
|
||||
<%- include('partials/header') %>
|
||||
|
||||
<div class="container" style="display: flex; align-items: center; justify-content: center; min-height: 60vh;">
|
||||
<div class="card" style="text-align: center; max-width: 400px;">
|
||||
<h1 style="font-size: 3rem; margin-bottom: 0.5rem;"><%= typeof title !== 'undefined' ? title : 'Error' %></h1>
|
||||
<p style="color: var(--muted); margin-bottom: 1.5rem;"><%= typeof message !== 'undefined' ? message : 'Something went wrong.' %></p>
|
||||
<a href="/" class="btn btn-primary">Go Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
52
src/views/home.ejs
Normal file
52
src/views/home.ejs
Normal file
@@ -0,0 +1,52 @@
|
||||
<%- include('partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Find. Scan. Conquer.</h1>
|
||||
<p>Hunt for hidden QR codes in the real world, earn points, climb the leaderboard. Be the first to find a package for maximum points!</p>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="/hunts" class="btn btn-primary">Browse Hunts</a>
|
||||
<a href="/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value">500</div>
|
||||
<div class="label">1st Find</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">250</div>
|
||||
<div class="label">2nd Find</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">100</div>
|
||||
<div class="label">3rd Find</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">50</div>
|
||||
<div class="label">4th+ Finds</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (hunts && hunts.length > 0) { %>
|
||||
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Active Hunts</h2>
|
||||
<% hunts.forEach(hunt => { %>
|
||||
<a href="/hunt/<%= hunt.short_name %>" class="hunt-card">
|
||||
<div class="hunt-info">
|
||||
<h3><%= hunt.name %></h3>
|
||||
<span class="meta"><%= hunt.short_name %> · <%= hunt.package_count %> packages · by <%= hunt.creator_name %></span>
|
||||
</div>
|
||||
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
|
||||
<span class="badge expired">Expired</span>
|
||||
<% } else { %>
|
||||
<span class="badge"><%= hunt.package_count %> packages</span>
|
||||
<% } %>
|
||||
</a>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
42
src/views/hunt/leaderboard.ejs
Normal file
42
src/views/hunt/leaderboard.ejs
Normal file
@@ -0,0 +1,42 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="/hunt/<%= hunt.short_name %>" style="color: var(--muted); text-decoration: none;">← Back to <%= hunt.name %></a>
|
||||
</div>
|
||||
|
||||
<h1><%= hunt.name %> — Leaderboard</h1>
|
||||
|
||||
<% if (leaderboard.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted);">No scans yet. Be the first to find a package!</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="card">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Scans</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% leaderboard.forEach((entry, i) => { %>
|
||||
<tr>
|
||||
<td class="rank-cell rank-<%= i + 1 %>"><%= i + 1 %></td>
|
||||
<td><strong><%= entry.username %></strong></td>
|
||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||
<td><%= entry.scans %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
31
src/views/hunt/list.ejs
Normal file
31
src/views/hunt/list.ejs
Normal file
@@ -0,0 +1,31 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<h1 style="margin-bottom: 1.5rem;">All Hunts</h1>
|
||||
|
||||
<% if (hunts.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted);">No hunts have been created yet.</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<% hunts.forEach(hunt => { %>
|
||||
<a href="/hunt/<%= hunt.short_name %>" class="hunt-card">
|
||||
<div class="hunt-info">
|
||||
<h3><%= hunt.name %></h3>
|
||||
<span class="meta"><%= hunt.short_name %> · <%= hunt.package_count %> packages · by <%= hunt.creator_name %>
|
||||
<% if (hunt.expiry_date) { %>
|
||||
· Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %>
|
||||
<% } %>
|
||||
</span>
|
||||
</div>
|
||||
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
|
||||
<span class="badge expired">Expired</span>
|
||||
<% } else { %>
|
||||
<span class="badge"><%= hunt.package_count %> packages</span>
|
||||
<% } %>
|
||||
</a>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
65
src/views/hunt/profile.ejs
Normal file
65
src/views/hunt/profile.ejs
Normal file
@@ -0,0 +1,65 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<h1 style="margin: 0;"><%= hunt.name %></h1>
|
||||
<span style="color: var(--muted); font-family: monospace;"><%= hunt.short_name %></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-primary">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (hunt.description) { %>
|
||||
<div class="card">
|
||||
<p style="margin: 0;"><%= hunt.description %></p>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (isExpired) { %>
|
||||
<div class="alert alert-danger">This hunt has expired. Scanning is no longer available.</div>
|
||||
<% } %>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= hunt.package_count %></div>
|
||||
<div class="label">Packages</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.filter(p => p.scan_count > 0).length %></div>
|
||||
<div class="label">Found</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.filter(p => p.scan_count === 0).length %></div>
|
||||
<div class="label">Unfound</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">Packages</h2>
|
||||
|
||||
<div class="package-grid">
|
||||
<% packages.forEach(pkg => { %>
|
||||
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile"
|
||||
class="package-card <%= pkg.scan_count > 0 ? 'scanned' : 'unscanned' %>">
|
||||
<div class="card-num">#<%= pkg.card_number %></div>
|
||||
<div class="code"><%= pkg.unique_code %></div>
|
||||
<div class="scan-info">
|
||||
<% if (pkg.scan_count > 0) { %>
|
||||
✅ Found · <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>
|
||||
<% if (pkg.first_scanner_name) { %> · First: <%= pkg.first_scanner_name %><% } %>
|
||||
<% } else { %>
|
||||
🔍 Not yet found
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<div style="margin-top: 0.5rem; font-size: 0.85rem; font-style: italic; color: #555;">
|
||||
"<%= pkg.last_scan_hint %>"
|
||||
</div>
|
||||
<% } %>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
40
src/views/leaderboard/global.ejs
Normal file
40
src/views/leaderboard/global.ejs
Normal file
@@ -0,0 +1,40 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<h1 style="margin-bottom: 1.5rem;">🏆 Global Leaderboard</h1>
|
||||
|
||||
<% if (leaderboard.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted);">No scans recorded yet. Start hunting!</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="card">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Packages Found</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% leaderboard.forEach((entry, i) => { %>
|
||||
<tr>
|
||||
<td class="rank-cell rank-<%= i + 1 %>">
|
||||
<% if (i === 0) { %>🥇<% } else if (i === 1) { %>🥈<% } else if (i === 2) { %>🥉<% } else { %><%= i + 1 %><% } %>
|
||||
</td>
|
||||
<td><strong><%= entry.username %></strong></td>
|
||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||
<td><%= entry.scans %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
13
src/views/loot/expired.ejs
Normal file
13
src/views/loot/expired.ejs
Normal file
@@ -0,0 +1,13 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container" style="text-align: center; padding-top: 3rem;">
|
||||
<div class="emoji" style="font-size: 4rem;">⏰</div>
|
||||
<h1>Hunt Expired</h1>
|
||||
<p style="color: var(--muted);">This hunt has ended. Scanning is no longer available.</p>
|
||||
<p style="color: var(--muted);">
|
||||
<strong><%= pkg.hunt_name %></strong> · Package #<%= pkg.card_number %>
|
||||
</p>
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline" style="margin-top: 1rem;">View Hunt Results</a>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
106
src/views/loot/profile.ejs
Normal file
106
src/views/loot/profile.ejs
Normal file
@@ -0,0 +1,106 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>" style="color: var(--muted); text-decoration: none;">← Back to <%= pkg.hunt_name %></a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="package-hero">
|
||||
<div class="card-number">#<%= pkg.card_number %></div>
|
||||
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
|
||||
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.scan_count %></div>
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.first_scanner_name || '—' %></div>
|
||||
<div class="label">First Finder</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.last_scanner_name || '—' %></div>
|
||||
<div class="label">Most Recent</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">📸 First Finder's Photo</div>
|
||||
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%/* First scanner can upload image */%>
|
||||
<% if (isFirstScanner && !pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">📸 Upload a Photo</div>
|
||||
<p style="color: var(--muted); font-size: 0.9rem;">As the first finder, you can upload a photo for this package.</p>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<input type="file" name="image" accept="image/*" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Upload Photo</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">💬 Package Hint</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
|
||||
<% } else { %>
|
||||
<p style="color: var(--muted);">No hint has been left yet.</p>
|
||||
<% } %>
|
||||
|
||||
<% if (isLastScanner) { %>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label>Update Hint/Message</label>
|
||||
<textarea name="hint" class="form-control" rows="2" maxlength="500" placeholder="Leave a hint or message for the next finder..."><%= pkg.last_scan_hint || '' %></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (scanHistory.length > 0) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">Scan History</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% scanHistory.forEach((scan, i) => { %>
|
||||
<tr>
|
||||
<td><%= i + 1 %></td>
|
||||
<td><%= scan.username %></td>
|
||||
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">View All Packages</a>
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
122
src/views/loot/scanned.ejs
Normal file
122
src/views/loot/scanned.ejs
Normal file
@@ -0,0 +1,122 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div class="scan-result">
|
||||
<% if (scanResult.alreadyScanned) { %>
|
||||
<div class="emoji">🔄</div>
|
||||
<h1>Already Scanned!</h1>
|
||||
<p style="color: var(--muted);">You've already found this package. No additional points, but you're now the most recent scanner and can update the hint below.</p>
|
||||
<% } else if (scanResult.isFirst) { %>
|
||||
<div class="emoji">🌟</div>
|
||||
<h1>FIRST FIND!</h1>
|
||||
<div class="points-badge large">+<%= scanResult.points %> pts</div>
|
||||
<p style="color: var(--muted); margin-top: 0.5rem;">You're the first person to discover this package! You can upload an image below.</p>
|
||||
<% } else { %>
|
||||
<div class="emoji">✅</div>
|
||||
<h1>Package Found!</h1>
|
||||
<div class="points-badge large">+<%= scanResult.points %> pts</div>
|
||||
<p style="color: var(--muted); margin-top: 0.5rem;">Scan #<%= scanResult.scanNumber %> — nice find!</p>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="package-hero">
|
||||
<div class="card-number">#<%= pkg.card_number %></div>
|
||||
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
|
||||
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.scan_count %></div>
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.first_scanner_name || '—' %></div>
|
||||
<div class="label">First Finder</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.last_scanner_name || '—' %></div>
|
||||
<div class="label">Most Recent</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">First Finder's Photo</div>
|
||||
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%/* First scanner can upload image */%>
|
||||
<% if (isFirstScanner && !pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">📸 Upload a Photo</div>
|
||||
<p style="color: var(--muted); font-size: 0.9rem;">As the first finder, you can upload a photo for this package.</p>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<input type="file" name="image" accept="image/*" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Upload Photo</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%/* Hint/message section */%>
|
||||
<div class="card">
|
||||
<div class="card-header">💬 Package Hint</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
|
||||
<% } else { %>
|
||||
<p style="color: var(--muted);">No hint has been left yet.</p>
|
||||
<% } %>
|
||||
|
||||
<% if (isLastScanner) { %>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label>Update Hint/Message</label>
|
||||
<textarea name="hint" class="form-control" rows="2" maxlength="500" placeholder="Leave a hint or message for the next finder..."><%= pkg.last_scan_hint || '' %></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%/* Scan history */%>
|
||||
<% if (scanHistory.length > 0) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">Scan History</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% scanHistory.forEach((scan, i) => { %>
|
||||
<tr>
|
||||
<td><%= i + 1 %></td>
|
||||
<td><%= scan.username %></td>
|
||||
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<a href="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-outline">View Package Profile</a>
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">Back to Hunt</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
5
src/views/partials/footer.ejs
Normal file
5
src/views/partials/footer.ejs
Normal file
@@ -0,0 +1,5 @@
|
||||
<footer class="footer">
|
||||
© <%= new Date().getFullYear() %> Loot Hunt — Find. Scan. Conquer.
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
30
src/views/partials/header.ejs
Normal file
30
src/views/partials/header.ejs
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= typeof title !== 'undefined' ? title + ' | Loot Hunt' : 'Loot Hunt' %></title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="navbar-brand">🎯 Loot Hunt</a>
|
||||
<ul class="navbar-nav">
|
||||
<li><a href="/hunts">Hunts</a></li>
|
||||
<li><a href="/leaderboard">Leaderboard</a></li>
|
||||
<% if (currentUser) { %>
|
||||
<% if (currentUser.isAdmin) { %>
|
||||
<li><a href="/admin">Admin</a></li>
|
||||
<% } %>
|
||||
<li><a href="/auth/logout">Logout (<%= currentUser.username %>)</a></li>
|
||||
<% } else { %>
|
||||
<li><a href="/auth/login">Login</a></li>
|
||||
<li><a href="/auth/register">Register</a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</nav>
|
||||
<% if (typeof flash !== 'undefined' && flash) { %>
|
||||
<div class="container">
|
||||
<div class="alert alert-<%= flash.type %>"><%= flash.message %></div>
|
||||
</div>
|
||||
<% } %>
|
||||
Reference in New Issue
Block a user