This commit is contained in:
2025-12-23 17:41:30 -08:00
parent d6ac68f7d3
commit ff534eb448
18 changed files with 3989 additions and 461 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# Backend Configuration
NODE_ENV=production
PORT=4000
# Database
DATABASE_PATH=./backend/awards.db
# Admin Credentials (use bcrypt hash for password)
# Generate hash with: node -e "require('bcryptjs').hash('your-password', 10).then(console.log)"
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=
# CORS Configuration
CORS_ORIGIN=https://awards.scottyah.com
# Nominatim API Configuration
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
NOMINATIM_USER_AGENT=BestOfPBAwardsApp/1.0
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_NOMINATION_MAX=10
RATE_LIMIT_EMOJI_MAX=50

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.production
.env.production.local
# Database
*.db
*.db-shm
*.db-wal
# Backups
backups/
*.sql

304
CLAUDE.md Normal file
View File

@@ -0,0 +1,304 @@
# Best of Pacific Beach Awards - Technical Documentation
## Overview
A full-stack web application for nominating and voting on the best places in Pacific Beach, San Diego. Features a React frontend with interactive maps, emoji reactions, and an Express backend with SQLite database.
**Live URL:** https://awards.scottyah.com
## Architecture
### Technology Stack
- **Frontend:** React 19, Vite, React Leaflet (maps), React Router
- **Backend:** Express 5, SQLite3, Winston (logging)
- **Security:** bcryptjs, express-rate-limit, express-validator
- **Deployment:** Docker, Kubernetes, Harbor registry, Traefik ingress
### Key Features
- Interactive map showing award locations
- Emoji reactions with session tracking
- Admin management panel with basic auth
- Address validation (Pacific Beach 92109 only)
- Rate limiting and input validation
- Geocoding via Nominatim API
## Project Structure
```
react-awards-map/
├── backend/
│ ├── index.js # Main Express server
│ ├── migrate.js # Database migration script
│ ├── package.json
│ └── awards.db # SQLite database (gitignored)
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── EmojiPicker.jsx
│ │ │ └── EmojiPicker.css
│ │ ├── config/
│ │ │ └── api.js # API URL configuration
│ │ ├── App.jsx
│ │ ├── AwardsPage.jsx
│ │ └── ManagementPage.jsx
│ └── package.json
├── Dockerfile # Production container
├── k8s.yaml # Kubernetes manifests
├── deploy.sh # Deployment script
├── .env.example # Environment template
└── .env.local # Local environment (gitignored)
```
## Environment Configuration
### Backend (.env.local)
```bash
NODE_ENV=development
PORT=4000
DATABASE_PATH=./backend/awards.db
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=<bcrypt_hash>
CORS_ORIGIN=http://localhost:5173
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
NOMINATIM_USER_AGENT=BestOfPBAwardsApp/1.0
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_NOMINATION_MAX=10
RATE_LIMIT_EMOJI_MAX=50
```
### Frontend (frontend/.env.local)
```bash
VITE_API_URL=http://localhost:4000
```
### Production Environment
The Kubernetes deployment uses secrets for sensitive values:
- `ADMIN_USERNAME` and `ADMIN_PASSWORD_HASH` are stored in `awards-secret`
- Other environment variables are set directly in k8s.yaml
## Development
### Setup
```bash
# Install backend dependencies
cd backend
npm install
# Install frontend dependencies
cd ../frontend
npm install
```
### Running Locally
```bash
# Terminal 1: Start backend
cd backend
node index.js
# Terminal 2: Start frontend
cd frontend
npm run dev
```
Access the app at http://localhost:5173
### Database Operations
```bash
# Create tables
node backend/migrate.js create
# Seed with sample data
node backend/migrate.js seed
# Reset and seed
node backend/migrate.js reset:seed
# Backup database
node backend/migrate.js backup
```
## Production Deployment
### Prerequisites
- Docker installed and logged into harbor.scottyah.com
- kubectl configured for your Kubernetes cluster
- `awards` namespace created in Kubernetes
- Traefik ingress controller installed
- TLS secret `awards-tls` configured for awards.scottyah.com
### Deployment Process
```bash
# 1. Ensure .env.local contains production secrets
# (ADMIN_USERNAME and ADMIN_PASSWORD_HASH)
# 2. Run deployment script
./deploy.sh [tag]
# The script will:
# - Build Docker image with frontend and backend
# - Push to harbor.scottyah.com/secure/awards
# - Create Kubernetes secrets from .env.local
# - Apply k8s.yaml manifests
# - Restart the deployment
```
### Manual Deployment Steps
If you need to deploy manually:
```bash
# Build and push
docker build -t harbor.scottyah.com/secure/awards:latest .
docker push harbor.scottyah.com/secure/awards:latest
# Create namespace (if not exists)
kubectl create namespace awards
# Create secrets
kubectl create secret generic awards-secret -n awards \
--from-literal=admin-username='admin' \
--from-literal=admin-password-hash='$2b$10$...'
# Apply manifests
kubectl apply -f k8s.yaml
# Restart deployment
kubectl rollout restart deployment/awards-dep -n awards
```
## API Endpoints
### Public Endpoints
- `GET /awards` - Get all approved awards
- `GET /awards/top` - Get top 5 awards by emoji reactions
- `POST /awards` - Submit new award nomination
- `PATCH /awards/:id/emojis` - Add emoji reaction
- `PATCH /awards/:id/emojis/remove` - Remove emoji reaction
- `GET /health` - Health check endpoint
### Admin Endpoints (require Basic Auth)
- `GET /awards/pending` - Get pending (unapproved) awards
- `PATCH /awards/:id/approve` - Approve an award
- `DELETE /awards/:id` - Reject/delete an award
### Rate Limits
- General: 100 requests per 15 minutes
- Nominations: 10 per 15 minutes
- Emoji reactions: 50 per 15 minutes
## Security Features
### Implemented (Priority 1 & 2)
- ✅ Environment-based configuration (no hardcoded values)
- ✅ CORS restricted to awards.scottyah.com
- ✅ Bcrypt password hashing for admin auth
- ✅ Rate limiting on all endpoints
- ✅ Input validation with express-validator
- ✅ Structured logging with Winston
- ✅ Graceful shutdown handling
- ✅ Health check endpoint
- ✅ Production static file serving
- ✅ Removed duplicate geocoding (uses server lat/lng)
- ✅ Extracted shared EmojiPicker component
### Not Yet Implemented (Priority 3)
- ❌ Automated tests
- ❌ Geocoding result caching
- ❌ Pagination for awards endpoint
- ❌ TypeScript migration
- ❌ Database migrations system
## Database Schema
### awards table
```sql
CREATE TABLE awards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
address TEXT NOT NULL,
submitted_by VARCHAR(255),
emoji_tally TEXT DEFAULT '{}', -- JSON string
submitted_date DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_date DATETIME,
lat REAL,
lng REAL
);
```
## Monitoring & Logs
### Application Logs
- **Production:** Logs written to `/app/error.log` and `/app/combined.log`
- **Development:** Logs output to console
### Kubernetes Logs
```bash
# View pod logs
kubectl logs -n awards -l app=awards --tail=100 -f
# Check deployment status
kubectl get pods -n awards
kubectl describe deployment awards-dep -n awards
```
### Health Checks
- Liveness probe: `GET /health` every 30s
- Readiness probe: Same as liveness
## Troubleshooting
### Common Issues
**Issue:** Geocoding returns "not in Pacific Beach"
- **Solution:** Address must contain "92109" zip code or "Pacific Beach" in the result
**Issue:** Admin login fails
- **Solution:** Check ADMIN_PASSWORD_HASH is correctly set in secrets
```bash
kubectl get secret awards-secret -n awards -o yaml
```
**Issue:** Frontend shows "Failed to fetch"
- **Solution:** Check CORS_ORIGIN matches the frontend domain
- Verify backend is running: `kubectl get pods -n awards`
**Issue:** Database resets after pod restart
- **Solution:** Ensure PersistentVolumeClaim is properly mounted
```bash
kubectl get pvc -n awards
kubectl describe pvc awards-pvc -n awards
```
## Performance Considerations
1. **Geocoding:** Server geocodes addresses once on submission; frontend uses cached lat/lng
2. **Emoji Tally:** Stored as JSON string; consider dedicated table if tallies grow large
3. **Map Rendering:** Memoized to prevent unnecessary re-renders
4. **Static Assets:** Frontend built and served from Express in production
## Future Enhancements
- Add caching for geocoding results
- Implement pagination for large award lists
- Add user accounts and authentication
- Create admin dashboard with analytics
- Add email notifications for new nominations
- Implement search and filtering
- Add categories system
- Mobile app version
## Admin Credentials
**Development:**
- Username: `admin`
- Password: `AwardsPB2024!Secure`
**Production:**
- Stored in Kubernetes secret `awards-secret`
- Generate new hash: `node -e "import('bcryptjs').then(b => b.default.hash('your-password', 10).then(console.log))"`
## Support & Maintenance
For issues or questions, contact the development team or create an issue in the repository.
Last updated: December 2024

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# Multi-stage build for React Awards Map
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
COPY frontend/package*.json ./
# Install frontend dependencies
RUN npm ci
# Copy frontend source
COPY frontend/ ./
# Build frontend for production
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy backend package files
COPY backend/package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy backend source
COPY backend/ ./
# Copy built frontend from builder stage
COPY --from=frontend-builder /app/frontend/dist ./dist
# Create directory for database
RUN mkdir -p /app/data
# Expose port
EXPOSE 4000
# Set production environment
ENV NODE_ENV=production
ENV DATABASE_PATH=/app/data/awards.db
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:4000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application
CMD ["node", "index.js"]

View File

@@ -1,18 +1,67 @@
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
import 'dotenv/config';
import express from 'express';
import sqlite3 from 'sqlite3';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import bcrypt from 'bcryptjs';
import rateLimit from 'express-rate-limit';
import { body, param, validationResult } from 'express-validator';
import winston from 'winston';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Logger setup
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: path.join(__dirname, 'error.log'), level: 'error' }),
new winston.transports.File({ filename: path.join(__dirname, 'combined.log') }),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
const app = express();
const PORT = 4000;
const PORT = process.env.PORT || 4000;
// Middleware
app.use(cors());
app.use(express.json());
// CORS Configuration
const corsOptions = {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
app.use(express.json({ limit: '10mb' }));
// Serve static frontend files in production
if (process.env.NODE_ENV === 'production') {
const frontendPath = path.join(__dirname, '..', 'dist');
app.use(express.static(frontendPath));
}
// SQLite setup
const dbPath = path.join(__dirname, 'awards.db');
const db = new sqlite3.Database(dbPath);
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'awards.db');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
logger.error('Failed to connect to database:', err);
process.exit(1);
}
logger.info('Connected to SQLite database');
});
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS awards (
@@ -28,27 +77,86 @@ db.serialize(() => {
)`);
});
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
// Rate limiters
const generalLimiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
message: { error: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// Basic auth middleware for management endpoints
const basicAuth = (req, res, next) => {
const nominationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: parseInt(process.env.RATE_LIMIT_NOMINATION_MAX) || 10,
message: { error: 'Too many nominations, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
const emojiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_EMOJI_MAX) || 50,
message: { error: 'Too many emoji reactions, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
app.use('/awards', generalLimiter);
// Basic auth middleware with bcrypt
const basicAuth = async (req, res, next) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Basic ')) {
res.set('WWW-Authenticate', 'Basic realm="Management"');
logger.warn('Authentication required - no credentials provided');
return res.status(401).send('Authentication required.');
}
try {
const credentials = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
const [user, pass] = credentials;
if (user === 'admin' && pass === 'password') return next();
const expectedUser = process.env.ADMIN_USERNAME || 'admin';
const expectedHash = process.env.ADMIN_PASSWORD_HASH;
if (!expectedHash) {
logger.error('ADMIN_PASSWORD_HASH not configured');
return res.status(500).json({ error: 'Server configuration error' });
}
if (user === expectedUser && await bcrypt.compare(pass, expectedHash)) {
logger.info('Successful admin authentication');
return next();
}
logger.warn('Failed admin authentication attempt');
res.set('WWW-Authenticate', 'Basic realm="Management"');
return res.status(401).send('Invalid credentials.');
} catch (error) {
logger.error('Error in authentication:', error);
res.set('WWW-Authenticate', 'Basic realm="Management"');
return res.status(401).send('Authentication error.');
}
};
// Validation middleware
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.warn('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
next();
};
// Get all awards
app.get('/awards', (req, res) => {
db.all('SELECT * FROM awards ORDER BY submitted_date DESC', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
// Parse emoji_tally JSON for each row
db.all('SELECT * FROM awards WHERE approved_date IS NOT NULL ORDER BY submitted_date DESC', [], (err, rows) => {
if (err) {
logger.error('Error fetching awards:', err);
return res.status(500).json({ error: 'Failed to fetch awards' });
}
const awards = rows.map(row => ({
...row,
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
@@ -58,94 +166,145 @@ app.get('/awards', (req, res) => {
});
// Add a new award
app.post('/awards', async (req, res) => {
app.post('/awards',
nominationLimiter,
body('category').trim().isLength({ min: 1, max: 200 }).withMessage('Category must be 1-200 characters'),
body('address').trim().isLength({ min: 1, max: 500 }).withMessage('Address must be 1-500 characters'),
body('submitted_by').optional().trim().isLength({ max: 255 }).withMessage('submitted_by must be max 255 characters'),
validate,
async (req, res) => {
const { category, address, submitted_by } = req.body;
if (!category || !address) {
return res.status(400).json({ error: 'Category and address are required.' });
}
let lat = null, lng = null;
let isPB = false;
try {
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}`,
{ headers: { 'User-Agent': 'react-awards-map/1.0 (contact@yourdomain.com)' } });
const nominatimUrl = process.env.NOMINATIM_BASE_URL || 'https://nominatim.openstreetmap.org';
const userAgent = process.env.NOMINATIM_USER_AGENT || 'BestOfPBAwardsApp/1.0';
const resp = await fetch(
`${nominatimUrl}/search?format=json&q=${encodeURIComponent(address)}`,
{ headers: { 'User-Agent': userAgent } }
);
const data = await resp.json();
if (data && data[0]) {
lat = parseFloat(data[0].lat);
lng = parseFloat(data[0].lon);
// Check if address is in Pacific Beach
const displayName = data[0].display_name || '';
const addressObj = data[0].address || {};
// Accept if display_name contains 'Pacific Beach' or zip is 92109
if (
displayName.toLowerCase().includes('pacific beach') ||
addressObj.suburb === 'Pacific Beach' ||
addressObj.neighbourhood === 'Pacific Beach' ||
addressObj.city === 'San Diego' && addressObj.postcode === '92109' ||
(addressObj.city === 'San Diego' && addressObj.postcode === '92109') ||
addressObj.postcode === '92109'
) {
isPB = true;
}
}
} catch (e) { /* ignore geocode errors */ }
} catch (e) {
logger.error('Geocoding error:', e);
}
if (!isPB) {
logger.warn('Invalid address submitted:', address);
return res.status(400).json({ error: 'Address must be in Pacific Beach (92109, San Diego, CA).' });
}
db.run(
'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)',
[category, address, submitted_by || null, '{}', lat, lng],
function (err) {
if (err) return res.status(500).json({ error: err.message });
if (err) {
logger.error('Error inserting award:', err);
return res.status(500).json({ error: 'Failed to create award' });
}
db.get('SELECT * FROM awards WHERE id = ?', [this.lastID], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
if (err) {
logger.error('Error fetching created award:', err);
return res.status(500).json({ error: 'Award created but failed to fetch' });
}
row.emoji_tally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
logger.info('Award created:', { id: row.id, category, address });
res.status(201).json(row);
});
}
);
});
}
);
// Update emoji tally for an award
app.patch('/awards/:id/emojis', (req, res) => {
app.patch('/awards/:id/emojis',
emojiLimiter,
param('id').isInt().withMessage('Invalid award ID'),
body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'),
body('count').isInt({ min: 0, max: 1000 }).withMessage('Count must be between 0 and 1000'),
validate,
(req, res) => {
const { id } = req.params;
const { emoji, count } = req.body;
if (!emoji || typeof count !== 'number') {
return res.status(400).json({ error: 'Emoji and count are required.' });
}
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => {
if (err || !row) return res.status(404).json({ error: 'Award not found.' });
if (err || !row) {
logger.warn('Award not found for emoji update:', id);
return res.status(404).json({ error: 'Award not found.' });
}
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
emojiTally[emoji] = count;
db.run(
'UPDATE awards SET emoji_tally = ? WHERE id = ?',
[JSON.stringify(emojiTally), id],
function (err) {
if (err) return res.status(500).json({ error: err.message });
if (err) {
logger.error('Error updating emoji tally:', err);
return res.status(500).json({ error: 'Failed to update emoji' });
}
res.json({ id, emoji_tally: emojiTally });
}
);
});
});
}
);
// Remove or decrement an emoji tally for an award
app.patch('/awards/:id/emojis/remove', (req, res) => {
app.patch('/awards/:id/emojis/remove',
emojiLimiter,
param('id').isInt().withMessage('Invalid award ID'),
body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'),
validate,
(req, res) => {
const { id } = req.params;
const { emoji } = req.body;
if (!emoji) {
return res.status(400).json({ error: 'Emoji is required.' });
}
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => {
if (err || !row) return res.status(404).json({ error: 'Award not found.' });
if (err || !row) {
logger.warn('Award not found for emoji removal:', id);
return res.status(404).json({ error: 'Award not found.' });
}
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
if (emojiTally[emoji]) {
emojiTally[emoji] = emojiTally[emoji] - 1;
if (emojiTally[emoji] <= 0) {
delete emojiTally[emoji];
}
db.run(
'UPDATE awards SET emoji_tally = ? WHERE id = ?',
[JSON.stringify(emojiTally), id],
function (err) {
if (err) return res.status(500).json({ error: err.message });
if (err) {
logger.error('Error removing emoji:', err);
return res.status(500).json({ error: 'Failed to remove emoji' });
}
res.json({ id, emoji_tally: emojiTally });
}
);
@@ -153,26 +312,42 @@ app.patch('/awards/:id/emojis/remove', (req, res) => {
res.json({ id, emoji_tally: emojiTally });
}
});
});
}
);
// Approve an award
app.patch('/awards/:id/approve', basicAuth, (req, res) => {
app.patch('/awards/:id/approve',
basicAuth,
param('id').isInt().withMessage('Invalid award ID'),
validate,
(req, res) => {
const { id } = req.params;
db.run(
'UPDATE awards SET approved_date = CURRENT_TIMESTAMP WHERE id = ?',
[id],
function (err) {
if (err) return res.status(500).json({ error: err.message });
if (this.changes === 0) return res.status(404).json({ error: 'Award not found.' });
if (err) {
logger.error('Error approving award:', err);
return res.status(500).json({ error: 'Failed to approve award' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Award not found.' });
}
logger.info('Award approved:', id);
res.json({ id, approved_date: new Date().toISOString() });
}
);
});
}
);
// Get all pending (unapproved) awards
app.get('/awards/pending', basicAuth, (req, res) => {
db.all('SELECT * FROM awards WHERE approved_date IS NULL ORDER BY submitted_date DESC', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
if (err) {
logger.error('Error fetching pending awards:', err);
return res.status(500).json({ error: 'Failed to fetch pending awards' });
}
const awards = rows.map(row => ({
...row,
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
@@ -182,39 +357,85 @@ app.get('/awards/pending', basicAuth, (req, res) => {
});
// Reject (delete) a nomination
app.delete('/awards/:id', basicAuth, (req, res) => {
app.delete('/awards/:id',
basicAuth,
param('id').isInt().withMessage('Invalid award ID'),
validate,
(req, res) => {
const { id } = req.params;
db.run('DELETE FROM awards WHERE id = ?', [id], function (err) {
if (err) return res.status(500).json({ error: err.message });
if (this.changes === 0) return res.status(404).json({ error: 'Award not found.' });
if (err) {
logger.error('Error deleting award:', err);
return res.status(500).json({ error: 'Failed to delete award' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Award not found.' });
}
logger.info('Award deleted:', id);
res.json({ id, deleted: true });
});
});
}
);
// Get top 5 awards approved, sorted by emoji tally (descending)
app.get('/awards/top', (req, res) => {
db.all(
`SELECT * FROM awards WHERE approved_date IS NOT NULL`,
'SELECT * FROM awards WHERE approved_date IS NOT NULL',
[],
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
// Parse emoji_tally and calculate total emojis for sorting
if (err) {
logger.error('Error fetching top awards:', err);
return res.status(500).json({ error: 'Failed to fetch top awards' });
}
const awards = rows.map(row => {
const emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
const emojiTotal = Object.values(emojiTally).reduce((sum, n) => sum + Number(n), 0);
return { ...row, emoji_tally: emojiTally, emojiTotal };
});
// Sort by emojiTotal descending, then by most recently approved
awards.sort((a, b) => {
if (b.emojiTotal !== a.emojiTotal) return b.emojiTotal - a.emojiTotal;
return new Date(b.approved_date) - new Date(a.approved_date);
});
res.json(awards.slice(0, 5));
}
);
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve frontend in production
if (process.env.NODE_ENV === 'production') {
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});
}
// Graceful shutdown
const gracefulShutdown = () => {
logger.info('Received shutdown signal, closing database connection...');
db.close((err) => {
if (err) {
logger.error('Error closing database:', err);
process.exit(1);
}
logger.info('Database connection closed, exiting...');
process.exit(0);
});
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
// Start server
app.listen(PORT, () => {
console.log(`Backend server running on http://localhost:${PORT}`);
logger.info(`Backend server running on http://localhost:${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
logger.info(`CORS origin: ${corsOptions.origin}`);
});

220
backend/migrate.js Normal file
View File

@@ -0,0 +1,220 @@
import 'dotenv/config';
import sqlite3 from 'sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'awards.db');
// Migration commands
const command = process.argv[2];
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Failed to connect to database:', err);
process.exit(1);
}
});
// Create tables
function createTables() {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS awards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
address TEXT NOT NULL,
submitted_by VARCHAR(255),
emoji_tally TEXT DEFAULT '{}',
submitted_date DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_date DATETIME,
lat REAL,
lng REAL
)`, (err) => {
if (err) {
console.error('Error creating awards table:', err);
reject(err);
} else {
console.log('✓ Created awards table');
resolve();
}
});
});
});
}
// Seed sample data
async function seed() {
const seedData = [
{
category: 'Best Pizza',
address: '1188 Garnet Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7977,
lng: -117.2514,
approved: true
},
{
category: 'Best Coffee Shop',
address: '4876 Santa Monica Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7959,
lng: -117.2346,
approved: true
},
{
category: 'Best Taco Shop',
address: '1830 Garnet Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7980,
lng: -117.2420,
approved: true
},
{
category: 'Best Sunset View',
address: 'Crystal Pier, Pacific Beach, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7965,
lng: -117.2547,
approved: true
},
{
category: 'Best Ice Cream',
address: '1025 Garnet Ave, San Diego, CA 92109',
submitted_by: 'admin',
lat: 32.7975,
lng: -117.2470,
approved: true
},
{
category: 'Best Breakfast Spot',
address: '4970 Cass St, San Diego, CA 92109',
submitted_by: 'local_foodie',
lat: 32.7962,
lng: -117.2580,
approved: false
}
];
console.log('Seeding database...');
for (const item of seedData) {
await new Promise((resolve, reject) => {
const approvedDate = item.approved ? new Date().toISOString() : null;
db.run(
'INSERT INTO awards (category, address, submitted_by, emoji_tally, lat, lng, approved_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
[item.category, item.address, item.submitted_by, '{}', item.lat, item.lng, approvedDate],
function (err) {
if (err) {
console.error(`✗ Failed to seed "${item.category}":`, err.message);
reject(err);
} else {
console.log(`✓ Seeded: ${item.category}`);
resolve();
}
}
);
});
}
console.log('✓ Database seeded successfully');
}
// Reset database (drop all tables)
function reset() {
return new Promise((resolve, reject) => {
db.run('DROP TABLE IF EXISTS awards', (err) => {
if (err) {
console.error('Error dropping awards table:', err);
reject(err);
} else {
console.log('✓ Dropped awards table');
resolve();
}
});
});
}
// Backup database
function backup() {
const backupDir = path.join(__dirname, 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(backupDir, `awards-${timestamp}.db`);
fs.copyFileSync(dbPath, backupPath);
console.log(`✓ Database backed up to: ${backupPath}`);
}
// Main execution
async function main() {
try {
switch (command) {
case 'create':
console.log('Creating tables...');
await createTables();
console.log('✓ Migration complete');
break;
case 'seed':
console.log('Seeding database...');
await seed();
console.log('✓ Seed complete');
break;
case 'reset':
console.log('Resetting database...');
await reset();
await createTables();
console.log('✓ Database reset complete');
break;
case 'reset:seed':
console.log('Resetting and seeding database...');
await reset();
await createTables();
await seed();
console.log('✓ Reset and seed complete');
break;
case 'backup':
console.log('Backing up database...');
backup();
break;
default:
console.log(`
Usage: node migrate.js <command>
Commands:
create Create database tables
seed Seed database with sample data
reset Drop all tables and recreate them
reset:seed Reset database and seed with sample data
backup Create a backup of the database
Examples:
node migrate.js create
node migrate.js seed
node migrate.js reset:seed
node migrate.js backup
`);
process.exit(0);
}
db.close();
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
db.close();
process.exit(1);
}
}
main();

2617
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,14 @@
"seed": "node seed.js"
},
"dependencies": {
"express": "^5.1.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"node-fetch": "^3.3.2",
"sqlite3": "^5.1.7",
"node-fetch": "^3.3.2"
"winston": "^3.19.0"
}
}

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# API Configuration
VITE_API_URL=http://localhost:4000

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useMemo } from 'react'
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'
import './App.css'
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import AwardsPage from './AwardsPage'
import ManagementPage from './ManagementPage'
import emojiData from './emoji-data.json'
import { useState, useEffect, useMemo } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import './App.css';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import AwardsPage from './AwardsPage';
import ManagementPage from './ManagementPage';
import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api';
const defaultCenter = [32.7977, -117.2514];
const defaultZoom = 14;
@@ -30,69 +31,6 @@ const selectedIcon = new L.Icon({
className: 'selected-marker',
});
// Dynamically generate emoji categories from emojiData (browser compatible)
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
// Optionally, limit the number of categories/emojis for UI sanity
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags') // skip less useful
.slice(0, 6) // limit to 6 categories
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)]) // limit to 30 per category
);
function EmojiPicker({ isOpen, onClose, onSelect, position, emojiPickerOpen, awards }) {
if (!isOpen) return null;
const award = typeof emojiPickerOpen === 'number' && awards && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
function HomePage() {
const [selectedIdx, setSelectedIdx] = useState(null);
const [awards, setAwards] = useState([]);
@@ -104,18 +42,16 @@ function HomePage() {
const [voteSubmittedBy, setVoteSubmittedBy] = useState("");
const [emojiPickerOpen, setEmojiPickerOpen] = useState(null);
const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 });
const [awardCoords, setAwardCoords] = useState({});
// Fetch awards from backend
useEffect(() => {
fetch('http://localhost:4000/awards/top')
fetch(`${API_URL}/awards/top`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch awards');
return res.json();
})
.then(data => {
// Only show approved awards (approved_date not null)
setAwards(data.filter(a => a.approved_date));
setAwards(data);
setLoading(false);
})
.catch(err => {
@@ -124,36 +60,10 @@ function HomePage() {
});
}, []);
// Geocode addresses for map
useEffect(() => {
const fetchCoords = async () => {
const coords = {};
for (const award of awards.slice(0, 5)) {
if (award.address && !awardCoords[award.address]) {
try {
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(award.address)}`);
const data = await resp.json();
if (data && data[0]) {
coords[award.address] = {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon)
};
}
} catch (e) { /* ignore */ }
} else if (awardCoords[award.address]) {
coords[award.address] = awardCoords[award.address];
}
}
setAwardCoords(prev => ({ ...prev, ...coords }));
};
if (awards.length > 0) fetchCoords();
// eslint-disable-next-line
}, [awards]);
// Find the selected award for the map
const selectedAward = selectedIdx !== null ? awards[selectedIdx] : null;
// Dynamically create mapAwards from backend lat/lng only
// Use server's lat/lng for map markers (no duplicate geocoding)
const mapAwards = useMemo(() =>
awards.slice(0, 5).filter(a => a.lat && a.lng).map((award) => ({ ...award, lat: award.lat, lng: award.lng }))
, [awards]);
@@ -208,22 +118,20 @@ function HomePage() {
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji
setEmojiPickerOpen(null);
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/awards/${award.id}/emojis`, {
fetch(`${API_URL}/awards/${award.id}/emojis`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji, count: tally[emoji] })
})
.then(res => res.json())
.then(() => {
// Track in localStorage
sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards/top');
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -235,23 +143,20 @@ function HomePage() {
const handleEmojiRemove = (awardIdx, emoji) => {
const award = awards[awardIdx];
if (!award || !award.emoji_tally[emoji]) return;
// Only allow if user added this emoji in this session
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return;
fetch(`http://localhost:4000/awards/${award.id}/emojis/remove`, {
fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => res.json())
.then(() => {
// Update localStorage
sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards/top');
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -265,7 +170,7 @@ function HomePage() {
return;
}
const submittedBy = voteSubmittedBy.trim() ? voteSubmittedBy.trim() : "anonymous";
fetch('http://localhost:4000/awards', {
fetch(`${API_URL}/awards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
@@ -279,14 +184,15 @@ function HomePage() {
setVoteCategory("");
setVoteSubmittedBy("");
setVoteError("");
// Refresh awards
return fetch('http://localhost:4000/awards');
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
.then(data => setAwards(data))
.catch(err => setVoteError(err.message));
};
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return (
<div className="app">
<header className="beachy-header">
@@ -358,34 +264,7 @@ function HomePage() {
<section className="voting-section">
<h2 className="section-title">Nominate a Neighbor!</h2>
<form className="voting-form" onSubmit={e => {
e.preventDefault();
if (!voteAddress.trim() || !voteCategory.trim()) {
setVoteError("Please enter both an address and a category/description.");
return;
}
const submittedBy = voteSubmittedBy.trim() ? voteSubmittedBy.trim() : "anonymous";
fetch('http://localhost:4000/awards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
})
.then(res => {
if (!res.ok) throw new Error('Failed to add award');
return res.json();
})
.then(() => {
setVoteAddress("");
setVoteCategory("");
setVoteSubmittedBy("");
setVoteError("");
// Refresh awards
return fetch('http://localhost:4000/awards');
})
.then(res => res.json())
.then(data => setAwards(data))
.catch(err => setVoteError(err.message));
}}>
<form className="voting-form" onSubmit={handleVoteSubmit}>
<input
type="text"
placeholder="Address (e.g. 123 Main St)"
@@ -420,8 +299,7 @@ function HomePage() {
onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect}
position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen}
awards={awards}
award={currentAward}
/>
</div>
)
@@ -439,4 +317,4 @@ function App() {
)
}
export default App
export default App;

View File

@@ -1,69 +1,8 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import './App.css'
import emojiData from './emoji-data.json'
// Emoji picker logic (copied from App.jsx)
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags')
.slice(0, 6)
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)])
);
function EmojiPicker({ isOpen, onClose, onSelect, position, emojiPickerOpen, awards }) {
if (!isOpen) return null;
const award = emojiPickerOpen !== null ? awards[emojiPickerOpen] : null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import './App.css';
import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api';
function AwardsPage() {
const [selectedAward, setSelectedAward] = useState(null);
@@ -75,7 +14,7 @@ function AwardsPage() {
const navigate = useNavigate();
useEffect(() => {
fetch('http://localhost:4000/awards')
fetch(`${API_URL}/awards`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch awards');
return res.json();
@@ -107,22 +46,20 @@ function AwardsPage() {
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji
setEmojiPickerOpen(null);
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/awards/${award.id}/emojis`, {
fetch(`${API_URL}/awards/${award.id}/emojis`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji, count: tally[emoji] })
})
.then(res => res.json())
.then(() => {
// Track in localStorage
sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards');
return fetch(`${API_URL}/awards`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -133,23 +70,20 @@ function AwardsPage() {
const handleEmojiRemove = (awardIdx, emoji) => {
const award = awards[awardIdx];
if (!award || !award.emoji_tally[emoji]) return;
// Only allow if user added this emoji in this session
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return;
fetch(`http://localhost:4000/awards/${award.id}/emojis/remove`, {
fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => res.json())
.then(() => {
// Update localStorage
sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards');
return fetch(`${API_URL}/awards`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -164,6 +98,8 @@ function AwardsPage() {
});
};
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return (
<div className="app">
<header className="beachy-header">
@@ -251,11 +187,10 @@ function AwardsPage() {
onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect}
position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen}
awards={awards}
award={currentAward}
/>
</div>
)
}
export default AwardsPage
export default AwardsPage;

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import './App.css';
import API_URL from './config/api';
function ManagementPage() {
const [pendingAwards, setPendingAwards] = useState([]);
@@ -13,7 +14,7 @@ function ManagementPage() {
const fetchPending = (creds) => {
setLoading(true);
fetch('http://localhost:4000/awards/pending', {
fetch(`${API_URL}/awards/pending`, {
headers: {
'Authorization': 'Basic ' + btoa(`${creds.username}:${creds.password}`)
}
@@ -49,7 +50,7 @@ function ManagementPage() {
}, [credentials]);
const handleApprove = (id) => {
fetch(`http://localhost:4000/awards/${id}/approve`, {
fetch(`${API_URL}/awards/${id}/approve`, {
method: 'PATCH',
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)
@@ -60,7 +61,7 @@ function ManagementPage() {
};
const handleReject = (id) => {
fetch(`http://localhost:4000/awards/${id}`, {
fetch(`${API_URL}/awards/${id}`, {
method: 'DELETE',
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)

View File

@@ -0,0 +1,105 @@
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.emoji-picker {
position: fixed;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
max-width: 400px;
max-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.emoji-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #eee;
background: #f9f9f9;
}
.emoji-picker-header span {
font-weight: 600;
font-size: 14px;
}
.emoji-picker-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-picker-close:hover {
color: #000;
}
.emoji-picker-content {
overflow-y: auto;
padding: 12px;
}
.emoji-category {
margin-bottom: 16px;
}
.emoji-category-title {
margin: 0 0 8px 0;
font-size: 12px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
gap: 4px;
}
.emoji-option {
background: none;
border: 1px solid transparent;
border-radius: 4px;
font-size: 20px;
cursor: pointer;
padding: 4px;
transition: all 0.2s;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-option:hover:not(:disabled) {
background: #f0f0f0;
border-color: #ddd;
transform: scale(1.1);
}
.emoji-option:disabled {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -0,0 +1,70 @@
import emojiData from '../emoji-data.json';
import './EmojiPicker.css';
// Dynamically generate emoji categories from emojiData
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
// Limit categories and emojis for UI
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags')
.slice(0, 6)
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)])
);
function EmojiPicker({ isOpen, onClose, onSelect, position, award }) {
if (!isOpen) return null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
export default EmojiPicker;
export { emojiCategories };

View File

@@ -0,0 +1,4 @@
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
export default API_URL;

153
k8s.yaml
View File

@@ -1,120 +1,123 @@
# Kubernetes manifest for Best of PB app
# Includes frontend, backend, services, and ingress
---
apiVersion: v1
kind: Service
metadata:
name: awards-svc
namespace: awards
spec:
ports:
- port: 80
targetPort: 4000
protocol: TCP
selector:
app: awards
---
# Backend Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: pb-backend
labels:
app: pb-backend
name: awards-dep
namespace: awards
spec:
replicas: 1
selector:
matchLabels:
app: pb-backend
app: awards
replicas: 2
template:
metadata:
labels:
app: pb-backend
app: awards
spec:
imagePullSecrets:
- name: harborcred
containers:
- name: pb-backend
image: harbor.scottyah.com/bestofpb/pb-backend:latest
- name: awards
image: harbor.scottyah.com/secure/awards:latest
imagePullPolicy: Always
ports:
- containerPort: 4000
env:
- name: NODE_ENV
value: "production"
# Add volume mounts for persistent storage if needed
- name: PORT
value: "4000"
- name: DATABASE_PATH
value: "/app/data/awards.db"
- name: CORS_ORIGIN
value: "https://awards.scottyah.com"
- name: ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: awards-secret
key: admin-username
- name: ADMIN_PASSWORD_HASH
valueFrom:
secretKeyRef:
name: awards-secret
key: admin-password-hash
- name: NOMINATIM_BASE_URL
value: "https://nominatim.openstreetmap.org"
- name: NOMINATIM_USER_AGENT
value: "BestOfPBAwardsApp/1.0"
- name: RATE_LIMIT_WINDOW_MS
value: "900000"
- name: RATE_LIMIT_MAX_REQUESTS
value: "100"
- name: RATE_LIMIT_NOMINATION_MAX
value: "10"
- name: RATE_LIMIT_EMOJI_MAX
value: "50"
volumeMounts:
- name: data
mountPath: /app/data
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: data
persistentVolumeClaim:
claimName: awards-pvc
---
# Backend Service
apiVersion: v1
kind: Service
kind: PersistentVolumeClaim
metadata:
name: pb-backend
name: awards-pvc
namespace: awards
spec:
selector:
app: pb-backend
ports:
- protocol: TCP
port: 4000
targetPort: 4000
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
# Frontend Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: pb-frontend
labels:
app: pb-frontend
spec:
replicas: 1
selector:
matchLabels:
app: pb-frontend
template:
metadata:
labels:
app: pb-frontend
spec:
containers:
- name: pb-frontend
image: harbor.scottyah.com/bestofpb/pb-frontend:latest
imagePullPolicy: Always
ports:
- containerPort: 80
---
# Frontend Service
apiVersion: v1
kind: Service
metadata:
name: pb-frontend
spec:
selector:
app: pb-frontend
ports:
- protocol: TCP
port: 80
targetPort: 80
---
# Ingress (requires ingress controller, e.g., traefik)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bestofpb-ingress
name: awards-ingress
namespace: awards
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod-cloudflare
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.entrypoints: web, websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- awards.scottyah.com
secretName: awards-scottyah-com-tls
secretName: awards-tls
rules:
- host: awards.scottyah.com
http:
paths:
# Route /api to backend
- path: /api
pathType: Prefix
backend:
service:
name: pb-backend
port:
number: 4000
# Route / to frontend
- path: /
pathType: Prefix
backend:
service:
name: pb-frontend
name: awards-svc
port:
number: 80

View File

@@ -4,12 +4,10 @@
set -e
HARBOR_REGISTRY="harbor.scottyah.com/bestofpb"
get_latest_version() {
# Get the latest version tag from podman images for pb-backend or pb-frontend
podman images --format '{{.Repository}}:{{.Tag}}' | \
grep -E '(pb-(backend|frontend)|harbor\.scottyah\.com/bestofpb/pb-(backend|frontend)):v[0-9]+\.[0-9]+\.[0-9]+' | \
grep -E 'pb-(backend|frontend):v[0-9]+\.[0-9]+\.[0-9]+' | \
sed -E 's/.*:(v[0-9]+\.[0-9]+\.[0-9]+)$/\1/' | \
sort -V | tail -n 1
}
@@ -40,21 +38,15 @@ fi
# Build backend image
echo "Building backend image..."
podman build -t ${HARBOR_REGISTRY}/pb-backend:$VERSION ./backend
podman tag ${HARBOR_REGISTRY}/pb-backend:$VERSION ${HARBOR_REGISTRY}/pb-backend:latest
podman build --platform=linux/arm64 -t pb-backend:$VERSION ./backend
echo "Building frontend image..."
podman build -t ${HARBOR_REGISTRY}/pb-frontend:$VERSION ./frontend
podman tag ${HARBOR_REGISTRY}/pb-frontend:$VERSION ${HARBOR_REGISTRY}/pb-frontend:latest
podman build --platform=linux/arm64 -t pb-frontend:$VERSION ./frontend
echo "\nBuild complete. Images:"
echo " ${HARBOR_REGISTRY}/pb-backend:$VERSION"
echo " ${HARBOR_REGISTRY}/pb-frontend:$VERSION"
echo " pb-backend:$VERSION"
echo " pb-frontend:$VERSION"
echo "\nPushing images to Harbor..."
podman push ${HARBOR_REGISTRY}/pb-backend:$VERSION
podman push ${HARBOR_REGISTRY}/pb-backend:latest
podman push ${HARBOR_REGISTRY}/pb-frontend:$VERSION
podman push ${HARBOR_REGISTRY}/pb-frontend:latest
echo "\nImages pushed successfully to Harbor registry"
echo "\nSaving images to tar files:"
podman image save -o frontend.tar pb-frontend:$VERSION
podman image save -o backend.tar pb-backend:$VERSION

58
ship.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/bash
set -e
REGISTRY="harbor.scottyah.com/secure"
IMAGE_NAME="awards"
TAG="${1:-latest}"
echo "Building Docker image..."
docker build -t "${REGISTRY}/${IMAGE_NAME}:${TAG}" .
echo "Pushing image to registry..."
docker push "${REGISTRY}/${IMAGE_NAME}:${TAG}"
echo "Creating secrets from .env.local..."
if [ -f .env.local ]; then
# Create temporary directory for secret files
SECRET_TMPDIR=$(mktemp -d)
trap "rm -rf $SECRET_TMPDIR" EXIT
# Extract variables from .env.local into files
HAS_SECRET_VARS=false
while IFS= read -r line || [ -n "$line" ]; do
# Skip comments and empty lines
[[ "$line" =~ ^#.*$ ]] && continue
[[ -z "$line" ]] && continue
# Extract ADMIN_USERNAME
if [[ "$line" =~ ^ADMIN_USERNAME=(.*)$ ]]; then
echo -n "${BASH_REMATCH[1]}" > "$SECRET_TMPDIR/admin-username"
HAS_SECRET_VARS=true
fi
# Extract ADMIN_PASSWORD_HASH
if [[ "$line" =~ ^ADMIN_PASSWORD_HASH=(.*)$ ]]; then
echo -n "${BASH_REMATCH[1]}" > "$SECRET_TMPDIR/admin-password-hash"
HAS_SECRET_VARS=true
fi
done < .env.local
if [ "$HAS_SECRET_VARS" = true ]; then
kubectl delete secret awards-secret -n awards --ignore-not-found
kubectl create secret generic awards-secret -n awards --from-file="$SECRET_TMPDIR"
echo "Awards secret created from .env.local"
else
echo "No secret variables found in .env.local"
fi
else
echo "No .env.local file found, skipping secret creation"
fi
echo "Applying Kubernetes configuration..."
kubectl apply -f k8s.yaml
echo "Restarting deployment..."
kubectl rollout restart deployment/awards-dep -n awards
echo "Deployment complete!"
echo "Image: ${REGISTRY}/${IMAGE_NAME}:${TAG}"