initial commit

This commit is contained in:
Scott Hatlen
2025-11-05 09:06:54 -08:00
commit e3941089e1
27 changed files with 31423 additions and 0 deletions

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
## Deploy
// make sure podman is running
./scripts/build-images.sh
minikube start --driver=podman --container-runtime=containerd
minikube start --driver=podman --container-runtime=cri-o
minikube image load backend.tar
minikube image load frontend.tar
kubectl apply -f k8s.yaml
## Destroy
minikube stop
minikube delete --all

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# --- Build Stage ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
# --- Run Stage ---
FROM node:20-alpine AS run
WORKDIR /app
COPY --from=build /app /app
EXPOSE 4000
CMD ["node", "index.js"]

BIN
backend/awards.db Normal file

Binary file not shown.

14
backend/backup-db.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -e
BACKUP_DIR="$(dirname "$0")/backups"
DB_FILE="$(dirname "$0")/awards.db"
DATE=$(date +%Y-%m-%d)
BACKUP_FILE="$BACKUP_DIR/awards-$DATE.db"
mkdir -p "$BACKUP_DIR"
cp "$DB_FILE" "$BACKUP_FILE"
# Remove backups older than 7 days
ls -1t "$BACKUP_DIR"/awards-*.db | tail -n +8 | xargs -r rm --
echo "Backup complete: $BACKUP_FILE"

220
backend/index.js Normal file
View File

@@ -0,0 +1,220 @@
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
const app = express();
const PORT = 4000;
// Middleware
app.use(cors());
app.use(express.json());
// SQLite setup
const dbPath = path.join(__dirname, 'awards.db');
const db = new sqlite3.Database(dbPath);
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
)`);
});
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
// Basic auth middleware for management endpoints
const basicAuth = (req, res, next) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Basic ')) {
res.set('WWW-Authenticate', 'Basic realm="Management"');
return res.status(401).send('Authentication required.');
}
const credentials = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
const [user, pass] = credentials;
if (user === 'admin' && pass === 'password') return next();
res.set('WWW-Authenticate', 'Basic realm="Management"');
return res.status(401).send('Invalid credentials.');
};
// 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
const awards = rows.map(row => ({
...row,
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
}));
res.json(awards);
});
});
// Add a new award
app.post('/awards', 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 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.postcode === '92109'
) {
isPB = true;
}
}
} catch (e) { /* ignore geocode errors */ }
if (!isPB) {
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 });
db.get('SELECT * FROM awards WHERE id = ?', [this.lastID], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
row.emoji_tally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
res.status(201).json(row);
});
}
);
});
// Update emoji tally for an award
app.patch('/awards/:id/emojis', (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.' });
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 });
res.json({ id, emoji_tally: emojiTally });
}
);
});
});
// Remove or decrement an emoji tally for an award
app.patch('/awards/:id/emojis/remove', (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.' });
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 });
res.json({ id, emoji_tally: emojiTally });
}
);
} else {
res.json({ id, emoji_tally: emojiTally });
}
});
});
// Approve an award
app.patch('/awards/:id/approve', basicAuth, (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.' });
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 });
const awards = rows.map(row => ({
...row,
emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {}
}));
res.json(awards);
});
});
// Reject (delete) a nomination
app.delete('/awards/:id', basicAuth, (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.' });
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`,
[],
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
// Parse emoji_tally and calculate total emojis for sorting
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));
}
);
});
// Start server
app.listen(PORT, () => {
console.log(`Backend server running on http://localhost:${PORT}`);
});

16
backend/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "pb-backend",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js",
"seed": "node seed.js"
},
"dependencies": {
"express": "^5.1.0",
"cors": "^2.8.5",
"sqlite3": "^5.1.7",
"node-fetch": "^3.3.2"
}
}

89
backend/seed.js Normal file
View File

@@ -0,0 +1,89 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.join(__dirname, 'awards.db');
const db = new sqlite3.Database(dbPath);
const awardTemplates = [
"Most Dog Friendly", "Biggest Cactus", "Most Likely to be a Jungle", "Best Tacos", "Most Colorful House",
"Best Surf Spot", "Most Palm Trees", "Best Sunset View", "Most Beach Vibes", "Best Coffee Shop",
"Most Instagrammable", "Best Pizza", "Most Relaxing", "Best Ice Cream", "Most Artistic",
"Best Seafood", "Most Tropical", "Best Breakfast", "Most Peaceful", "Best Mexican Food",
"Most Oceanfront", "Best Smoothies", "Most Lively", "Best Burgers", "Most Zen",
"Best Sushi", "Most Vibrant", "Best Acai Bowl", "Most Welcoming", "Best Thai Food",
"Most Scenic", "Best Juice Bar", "Most Energetic", "Best Italian", "Most Serene",
"Best Fish Tacos", "Most Dynamic", "Best Smoothie Bowl", "Most Friendly", "Best Indian Food",
"Most Picturesque", "Best Tea House", "Most Exciting", "Best Greek Food", "Most Tranquil",
"Best Burritos", "Most Active", "Best Poke Bowl", "Most Hospitable", "Best Mediterranean"
];
const realPBAddresses = [
"4500 Ocean Blvd, San Diego, CA 92109",
"710 Garnet Ave, San Diego, CA 92109",
"976 Felspar St, San Diego, CA 92109",
"4325 Mission Blvd, San Diego, CA 92109",
"5010 Cass St, San Diego, CA 92109",
"744 Ventura Pl, San Diego, CA 92109",
"4150 Mission Blvd, San Diego, CA 92109",
"909 Garnet Ave, San Diego, CA 92109",
"3704 Mission Blvd, San Diego, CA 92109",
"1860 Grand Ave, San Diego, CA 92109"
];
const realPBCoords = [
{ lat: 32.7977, lng: -117.2551 }, // 4500 Ocean Blvd
{ lat: 32.7979, lng: -117.2542 }, // 710 Garnet Ave
{ lat: 32.7975, lng: -117.2520 }, // 976 Felspar St
{ lat: 32.7972, lng: -117.2525 }, // 4325 Mission Blvd
{ lat: 32.8002, lng: -117.2527 }, // 5010 Cass St
{ lat: 32.7701, lng: -117.2523 }, // 744 Ventura Pl
{ lat: 32.7970, lng: -117.2540 }, // 4150 Mission Blvd
{ lat: 32.7978, lng: -117.2530 }, // 909 Garnet Ave
{ lat: 32.7973, lng: -117.2522 }, // 3704 Mission Blvd
{ lat: 32.8010, lng: -117.2420 } // 1860 Grand Ave
];
function seedAwards() {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS awards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
address TEXT NOT NULL,
emoji_tally TEXT DEFAULT '{}',
submitted_by VARCHAR(255),
submitted_date DATETIME DEFAULT CURRENT_TIMESTAMP,
approved_date DATETIME,
lat REAL,
lng REAL
)`);
let remaining = awardTemplates.length;
awardTemplates.forEach((title, index) => {
const address = realPBAddresses[index % realPBAddresses.length];
const coords = realPBCoords[index % realPBCoords.length];
db.get('SELECT * FROM awards WHERE category = ? AND address = ?', [title, address], (err, row) => {
if (err) console.error(err);
if (!row) {
const approvedDate = title.startsWith('Best') ? null : 'CURRENT_TIMESTAMP';
const sql = approvedDate
? 'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, approved_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?, ?)'
: 'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, approved_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL, ?, ?)';
db.run(
sql,
[title, address, null, '{}', coords.lat, coords.lng],
(err) => {
if (err) console.error(err);
else console.log(`Inserted: ${title}`);
if (--remaining === 0) db.close();
}
);
} else {
if (--remaining === 0) db.close();
}
});
});
if (awardTemplates.length === 0) db.close();
});
}
seedAwards();

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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?

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# --- Build Stage ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
# --- Serve Stage ---
FROM nginx:alpine AS serve
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/blue-ribbon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Best of PB</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

5442
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "react-awards-map",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "concurrently \"vite\" \"node backend/index.js\"",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"backend": "node backend/index.js",
"seed": "node backend/seed.js",
"prod": "(crontab -l 2>/dev/null | grep -v backup-db.sh; echo '0 2 * * * /bin/bash $(pwd)/backend/backup-db.sh') | sort | uniq | crontab - && concurrently \"vite\" \"node backend/index.js\""
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"leaflet": "^1.9.4",
"node-emoji": "^2.2.0",
"node-fetch": "^3.3.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.6.3",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"concurrently": "^8.2.2",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"vite": "^7.0.4"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 147 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

724
frontend/src/App.css Normal file
View File

@@ -0,0 +1,724 @@
/* Beach-themed styles for Best of Pacific Beach Awards */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #87CEEB 0%, #98D8E8 50%, #B0E0E6 100%);
min-height: 100vh;
}
.app {
min-height: 100vh;
}
.beachy-header {
background: linear-gradient(135deg, #1E90FF 0%, #00BFFF 50%, #87CEEB 100%);
padding: 2rem 0;
position: relative;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
text-align: center;
position: relative;
z-index: 2;
}
.main-title {
font-size: 3.5rem;
font-weight: 700;
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
margin-bottom: 0.5rem;
letter-spacing: 2px;
}
.subtitle {
font-size: 1.2rem;
color: #E6F3FF;
font-weight: 300;
margin-bottom: 2rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
.wave-decoration {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 60px;
overflow: hidden;
}
.wave {
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: wave 6s ease-in-out infinite;
}
.wave1 {
animation-delay: 0s;
opacity: 0.7;
}
.wave2 {
animation-delay: 2s;
opacity: 0.5;
}
.wave3 {
animation-delay: 4s;
opacity: 0.3;
}
@keyframes wave {
0%, 100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(-10px);
}
}
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem;
}
.categories-section {
text-align: left;
margin-bottom: 4rem;
}
.section-title {
font-size: 2.5rem;
color: #2C3E50;
margin-bottom: 3rem;
font-weight: 600;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
text-align: center;
}
/* Force .categories-grid to a single column (one card per row) */
.categories-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1rem;
width: 100%;
margin-left: auto;
margin-right: auto;
}
.category-card {
background: linear-gradient(135deg, #FFFFFF 0%, #F8F9FA 100%);
border-radius: 20px;
padding: 0.5rem 1rem;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease, border-color 0.3s ease, background 0.3s ease, color 0.3s ease;
border: 2px solid rgba(30, 144, 255, 0.1);
position: relative;
overflow: hidden;
}
.category-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #1E90FF, #00BFFF, #87CEEB);
border-radius: 20px 20px 0 0;
}
.category-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
border-color: rgba(30, 144, 255, 0.3);
}
.category-card.selected {
border-color: #1E3A8A;
border-width: 2px;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
background: linear-gradient(135deg, #1E3A8A 0%, #2563EB 100%);
}
.category-card.selected h3,
.category-card.selected .emoji-display,
.category-card.selected {
color: #fff !important;
}
.category-card h3 {
font-size: 1.3rem;
color: #2C3E50;
font-weight: 600;
margin: 0;
line-height: 1.4;
}
.address-hint {
font-size: 0.8rem;
color: #1E90FF;
margin: 0.5rem 0 0 0;
font-style: italic;
opacity: 0.8;
}
.map-section {
text-align: center;
margin-top: 4rem;
}
.map-container {
background: white;
border-radius: 20px;
padding: 1rem;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border: 2px solid rgba(30, 144, 255, 0.1);
overflow: hidden;
}
.map-container iframe {
border-radius: 15px;
}
.voting-section {
margin-top: 4rem;
background: linear-gradient(135deg, #e0f7fa 0%, #f8fffe 100%);
border-radius: 20px;
box-shadow: 0 8px 25px rgba(30, 144, 255, 0.08);
padding: 2.5rem 2rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.voting-form {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: stretch;
margin-bottom: 2rem;
}
.vote-input {
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1.5px solid #b2ebf2;
font-size: 1rem;
outline: none;
transition: border 0.2s;
background: #f8ffff;
color: black
}
.vote-input:focus {
border-color: #1e90ff;
background: #e0f7fa;
}
.vote-submit {
background: linear-gradient(90deg, #1e90ff 0%, #00bfff 100%);
color: white;
font-weight: 600;
border: none;
border-radius: 12px;
padding: 0.75rem 1rem;
font-size: 1.1rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(30, 144, 255, 0.08);
transition: background 0.2s, box-shadow 0.2s;
}
.vote-submit:hover {
background: linear-gradient(90deg, #00bfff 0%, #1e90ff 100%);
box-shadow: 0 4px 16px rgba(30, 144, 255, 0.15);
}
.vote-error {
color: #d32f2f;
background: #ffebee;
border-radius: 8px;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
font-size: 0.98rem;
text-align: center;
}
.votes-list {
margin-top: 2rem;
background: #f8ffff;
border-radius: 12px;
padding: 1.5rem 1rem;
box-shadow: 0 2px 8px rgba(30, 144, 255, 0.05);
}
.votes-list h3 {
margin-bottom: 1rem;
color: #1e90ff;
font-size: 1.2rem;
font-weight: 600;
}
.vote-item {
font-size: 1.05rem;
margin-bottom: 0.7rem;
color: #2c3e50;
}
.vote-item span {
color: #009688;
font-weight: 500;
}
.emoji-reactions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.emoji-display {
font-size: 1.2rem;
background: rgba(30, 144, 255, 0.1);
padding: 0.3rem 0.6rem;
border-radius: 20px;
border: 1px solid rgba(30, 144, 255, 0.2);
transition: all 0.2s ease;
}
.emoji-display:hover {
background: rgba(30, 144, 255, 0.15);
transform: scale(1.05);
}
.emoji-count {
font-size: 0.8rem;
color: #1e90ff;
font-weight: 600;
}
.emoji-input {
width: 40px;
height: 35px;
border: 1px solid #b2ebf2;
border-radius: 8px;
text-align: center;
font-size: 1.1rem;
background: #f8ffff;
outline: none;
transition: border 0.2s;
}
.emoji-input:focus {
border-color: #1e90ff;
background: #e0f7fa;
}
.emoji-input::placeholder {
color: #b2ebf2;
font-size: 1rem;
}
.emoji-picker-button {
background: rgba(30, 144, 255, 0.1);
border: 1px solid rgba(30, 144, 255, 0.2);
border-radius: 8px;
padding: 0.3rem 0.6rem;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s ease;
color: #1e90ff;
}
.emoji-picker-button:hover {
background: rgba(30, 144, 255, 0.2);
transform: scale(1.05);
}
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.emoji-picker {
position: fixed;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(30, 144, 255, 0.1);
z-index: 1001;
max-width: 400px;
max-height: 500px;
overflow: hidden;
}
.emoji-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: linear-gradient(135deg, #f8ffff 0%, #e0f7fa 100%);
border-bottom: 1px solid rgba(30, 144, 255, 0.1);
font-weight: 600;
color: #1e90ff;
}
.emoji-picker-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #1e90ff;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
}
.emoji-picker-close:hover {
background: rgba(30, 144, 255, 0.1);
}
.emoji-picker-content {
max-height: 400px;
overflow-y: auto;
padding: 1rem;
}
.emoji-category {
margin-bottom: 1.5rem;
}
.emoji-category-title {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.5rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5rem;
}
.emoji-option {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-option:hover {
background: rgba(30, 144, 255, 0.1);
transform: scale(1.1);
}
/* Responsive adjustments for emoji picker */
@media (max-width: 768px) {
.emoji-picker {
max-width: 90vw;
max-height: 70vh;
}
.emoji-grid {
grid-template-columns: repeat(6, 1fr);
}
.emoji-option {
font-size: 1.3rem;
padding: 0.4rem;
}
}
/* Responsive design */
@media (max-width: 768px) {
.main-title {
font-size: 2.5rem;
}
.subtitle {
font-size: 1rem;
}
.header-content {
padding: 0 1rem;
}
.main-content {
padding: 2rem 1rem;
}
.section-title {
font-size: 2rem;
}
.category-card {
padding: 1rem;
}
.category-card h3 {
font-size: 1.1rem;
}
.map-container {
padding: 0.5rem;
}
.voting-section {
padding: 1.5rem 0.5rem;
}
.votes-list {
padding: 1rem 0.5rem;
}
}
.see-all-awards-link {
text-align: center;
margin-top: 2rem;
}
.awards-link {
display: inline-block;
background: linear-gradient(90deg, #1e90ff 0%, #00bfff 100%);
color: white;
text-decoration: none;
padding: 0.75rem 1.5rem;
border-radius: 25px;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(30, 144, 255, 0.2);
}
.awards-link:hover {
background: linear-gradient(90deg, #00bfff 0%, #1e90ff 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(30, 144, 255, 0.3);
color: white;
}
.back-link {
display: inline-block;
color: white;
text-decoration: none;
font-weight: 500;
margin-top: 1rem;
padding: 0.5rem 1rem;
border-radius: 20px;
background: rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
font-family: inherit;
position: relative;
z-index: 10;
}
.back-link:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
transform: translateX(-5px);
}
.back-link:active {
transform: translateX(-5px) scale(0.95);
}
.awards-page-content {
max-width: 1200px;
width: 100vw;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
padding: 2rem 0;
display: flex;
flex-direction: column;
align-items: center;
}
.awards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
width: 100%;
max-width: 1200px;
align-items: stretch;
}
.award-card {
background: linear-gradient(135deg, #FFFFFF 0%, #F8F9FA 100%);
border-radius: 15px;
padding: 1.5rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border: 2px solid rgba(30, 144, 255, 0.1);
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.award-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #1E90FF, #00BFFF, #87CEEB);
border-radius: 15px 15px 0 0;
}
.award-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: rgba(30, 144, 255, 0.3);
}
.award-card.selected {
border-color: #1E90FF;
box-shadow: 0 8px 25px rgba(30, 144, 255, 0.3);
background: linear-gradient(135deg, #F0F8FF 0%, #E6F3FF 100%);
}
.award-number {
position: absolute;
top: 1rem;
right: 1rem;
background: linear-gradient(135deg, #1E90FF, #00BFFF);
color: white;
font-weight: bold;
font-size: 0.9rem;
padding: 0.3rem 0.6rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(30, 144, 255, 0.3);
}
.award-title {
font-size: 1.3rem;
color: #2C3E50;
font-weight: 600;
margin: 0 0 0.5rem 0;
line-height: 1.3;
}
.award-description {
color: #666;
font-size: 0.95rem;
margin: 0 0 1rem 0;
line-height: 1.4;
}
.award-address {
color: #1E90FF;
font-size: 0.9rem;
font-weight: 500;
font-style: italic;
margin-bottom: 0.5rem;
}
.award-dates {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.5rem;
line-height: 1.3;
}
.date-label {
font-weight: 600;
color: #1E90FF;
}
.award-emojis {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
/* Responsive design for awards page */
@media (max-width: 768px) {
.awards-page-content {
padding: 1rem 0;
width: 100vw;
}
.awards-grid {
grid-template-columns: 1fr;
gap: 1rem;
max-width: 100vw;
}
.award-card {
padding: 1.2rem;
}
.award-title {
font-size: 1.1rem;
}
.award-description {
font-size: 0.9rem;
}
}
.main-content, .awards-page-content {
max-width: 1200px;
width: 100%;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
padding-left: 2rem;
padding-right: 2rem;
}
@media (max-width: 768px) {
.main-content, .awards-page-content {
padding-left: 1rem;
padding-right: 1rem;
}
}
/* Ensure category-card and category-card.selected have the same width and do not resize on selection */
.category-card,
.category-card.selected {
width: 100% !important;
min-width: 0 !important;
max-width: none !important;
box-sizing: border-box;
}

442
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,442 @@
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'
const defaultCenter = [32.7977, -117.2514];
const defaultZoom = 14;
const defaultIcon = new L.Icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
shadowSize: [41, 41],
});
const selectedIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
iconSize: [35, 55],
iconAnchor: [17, 55],
popupAnchor: [1, -34],
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
shadowSize: [55, 55],
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([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [voteAddress, setVoteAddress] = useState("");
const [voteCategory, setVoteCategory] = useState("");
const [voteError, setVoteError] = useState("");
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')
.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));
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
// 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
const mapAwards = useMemo(() =>
awards.slice(0, 5).filter(a => a.lat && a.lng).map((award) => ({ ...award, lat: award.lat, lng: award.lng }))
, [awards]);
// Memoize MapWithMarkers to prevent unnecessary remounts and tile requests
const MemoizedMapWithMarkers = useMemo(() => {
return function MapWithMarkers({ selectedIdx }) {
const center = selectedIdx !== null && mapAwards[selectedIdx]
? [mapAwards[selectedIdx].lat, mapAwards[selectedIdx].lng]
: (mapAwards[0] ? [mapAwards[0].lat, mapAwards[0].lng] : defaultCenter);
function ChangeView({ center }) {
const map = useMap();
map.setView(center, defaultZoom);
return null;
}
return (
<MapContainer center={center} zoom={defaultZoom} style={{ width: '100%', height: 450, borderRadius: 15 }} scrollWheelZoom={false}>
<ChangeView center={center} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{mapAwards.map((loc, idx) => (
<Marker
key={loc.id}
position={[loc.lat, loc.lng]}
icon={selectedIdx === idx ? selectedIcon : defaultIcon}
>
<Popup>
<b>{loc.category}</b><br />
{loc.address}
</Popup>
</Marker>
))}
</MapContainer>
);
}
}, [mapAwards, selectedIdx]);
// Handle emoji picker
const handleEmojiButtonClick = (e, idx) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setEmojiPickerPosition({ x: rect.left, y: rect.bottom + 5 });
setEmojiPickerOpen(idx);
};
// PATCH emoji tally in backend
const handleEmojiSelect = (emoji) => {
if (emojiPickerOpen !== null && awards[emojiPickerOpen]) {
const award = awards[emojiPickerOpen];
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/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');
})
.then(res => res.json())
.then(data => setAwards(data));
setEmojiPickerOpen(null);
}
};
// Remove or decrement emoji tally in backend
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`, {
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');
})
.then(res => res.json())
.then(data => setAwards(data));
};
// Submit new award to backend
const handleVoteSubmit = (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));
};
return (
<div className="app">
<header className="beachy-header">
<div className="header-content">
<h1 className="main-title">Best of Pacific Beach</h1>
<p className="subtitle">Celebrating the finest spots in PB</p>
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>
<div className="wave wave3"></div>
</div>
</div>
</header>
<main className="main-content">
<section className="categories-section">
{loading && <div>Loading awards...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className="categories-grid">
{awards.slice(0, 5).map((award, index) => (
<div
key={award.id}
className={`category-card ${selectedIdx === index ? 'selected' : ''}`}
onClick={() => setSelectedIdx(selectedIdx === index ? null : index)}
style={{ cursor: award.address ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<h3 style={{ margin: 0 }}>{award.category}</h3>
<div className="emoji-reactions">
{award.emoji_tally && Object.entries(award.emoji_tally).map(([emoji, count]) => {
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
const canRemove = sessionEmojis[emoji] > 0;
return (
<span
key={emoji}
className="emoji-display"
style={{ cursor: canRemove ? 'pointer' : 'not-allowed', opacity: canRemove ? 1 : 0.5 }}
onClick={() => canRemove && handleEmojiRemove(index, emoji)}
title={canRemove ? "Click to remove your emoji reaction" : "You can only remove emojis you added in this session"}
>
{emoji} {count > 1 && <span className="emoji-count">({count})</span>}
</span>
);
})}
<button
className="emoji-picker-button"
onClick={(e) => handleEmojiButtonClick(e, index)}
type="button"
aria-label="Add emoji reaction"
>
</button>
</div>
</div>
))}
</div>
<div className="see-all-awards-link">
<Link to="/awards" className="awards-link">See all awards </Link>
</div>
</section>
<section className="map-section">
<h2 className="section-title">
{selectedAward ? `Location: ${selectedAward.address}` : 'Pacific Beach, San Diego'}
</h2>
<div className="map-container">
<MemoizedMapWithMarkers selectedIdx={selectedIdx} />
</div>
</section>
<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));
}}>
<input
type="text"
placeholder="Address (e.g. 123 Main St)"
value={voteAddress}
onChange={e => setVoteAddress(e.target.value)}
className="vote-input"
required
/>
<input
type="text"
placeholder="Category/Description (e.g. Biggest Tree)"
value={voteCategory}
onChange={e => setVoteCategory(e.target.value)}
className="vote-input"
required
/>
<input
type="text"
placeholder="Your Name (optional)"
value={voteSubmittedBy}
onChange={e => setVoteSubmittedBy(e.target.value)}
className="vote-input"
/>
<button type="submit" className="vote-submit">Submit Vote</button>
</form>
{voteError && <div className="vote-error">{voteError}</div>}
</section>
</main>
<EmojiPicker
isOpen={emojiPickerOpen !== null}
onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect}
position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen}
awards={awards}
/>
</div>
)
}
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/awards" element={<AwardsPage />} />
<Route path="/manage" element={<ManagementPage />} />
</Routes>
</Router>
)
}
export default App

261
frontend/src/AwardsPage.jsx Normal file
View File

@@ -0,0 +1,261 @@
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>
</>
);
}
function AwardsPage() {
const [selectedAward, setSelectedAward] = useState(null);
const [awards, setAwards] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(null);
const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 });
const navigate = useNavigate();
useEffect(() => {
fetch('http://localhost:4000/awards')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch awards');
return res.json();
})
.then(data => {
setAwards(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
const handleBackToHome = () => {
navigate('/');
};
const handleEmojiButtonClick = (e, idx) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setEmojiPickerPosition({ x: rect.left, y: rect.bottom + 5 });
setEmojiPickerOpen(idx);
};
const handleEmojiSelect = (emoji) => {
if (emojiPickerOpen !== null && awards[emojiPickerOpen]) {
const award = awards[emojiPickerOpen];
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/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');
})
.then(res => res.json())
.then(data => setAwards(data));
setEmojiPickerOpen(null);
}
};
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`, {
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');
})
.then(res => res.json())
.then(data => setAwards(data));
};
const formatDate = (dateString) => {
if (!dateString) return null;
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
return (
<div className="app">
<header className="beachy-header">
<div className="header-content">
<h1 className="main-title">All Pacific Beach Awards</h1>
<button
onClick={handleBackToHome}
className="back-link"
type="button"
>
Back to Home
</button>
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>
<div className="wave wave3"></div>
</div>
</div>
</header>
<main className="awards-page-content">
{loading && <div>Loading awards...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className="awards-grid">
{awards
.filter(a => a.approved_date)
.sort((a, b) => new Date(b.submitted_date) - new Date(a.submitted_date))
.map((award, index) => (
<div
key={award.id}
className={`award-card ${selectedAward === award.id ? 'selected' : ''}`}
onClick={() => setSelectedAward(award.id)}
>
<h3 className="award-title">{award.category}</h3>
<p className="award-description">{award.description || ''}</p>
<div className="award-address">{award.address}</div>
{award.submitted_by && (
<div style={{ color: '#888', fontSize: '0.95rem', marginBottom: '0.5rem', fontStyle: 'italic' }}>
Submitted by: {award.submitted_by}
</div>
)}
<div className="award-dates">
<span className="date-label">Submitted:</span> {formatDate(award.submitted_date)}
{award.approved_date && (
<>
<br />
<span className="date-label">Approved:</span> {formatDate(award.approved_date)}
</>
)}
</div>
{award.emoji_tally && Object.keys(award.emoji_tally).length > 0 && (
<div className="award-emojis">
{Object.entries(award.emoji_tally).map(([emoji, count]) => {
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
const canRemove = sessionEmojis[emoji] > 0;
return (
<span
key={emoji}
className="emoji-display"
style={{ cursor: canRemove ? 'pointer' : 'not-allowed', opacity: canRemove ? 1 : 0.5 }}
onClick={() => canRemove && handleEmojiRemove(index, emoji)}
title={canRemove ? "Click to remove your emoji reaction" : "You can only remove emojis you added in this session"}
>
{emoji} {count > 1 && <span className="emoji-count">({count})</span>}
</span>
);
})}
</div>
)}
<button
className="emoji-picker-button"
onClick={e => handleEmojiButtonClick(e, index)}
type="button"
aria-label="Add emoji reaction"
style={{ marginTop: '0.5rem' }}
>
</button>
</div>
))}
</div>
</main>
<EmojiPicker
isOpen={emojiPickerOpen !== null}
onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect}
position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen}
awards={awards}
/>
</div>
)
}
export default AwardsPage

View File

@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import './App.css';
function ManagementPage() {
const [pendingAwards, setPendingAwards] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [loggedIn, setLoggedIn] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState('');
const [credentials, setCredentials] = useState(null);
const fetchPending = (creds) => {
setLoading(true);
fetch('http://localhost:4000/awards/pending', {
headers: {
'Authorization': 'Basic ' + btoa(`${creds.username}:${creds.password}`)
}
})
.then(res => {
if (res.status === 401) throw new Error('Unauthorized');
if (!res.ok) throw new Error('Failed to fetch pending awards');
return res.json();
})
.then(data => {
setPendingAwards(data);
setLoading(false);
setLoggedIn(true);
setLoginError('');
})
.catch(err => {
setError(null);
setLoading(false);
if (err.message === 'Unauthorized') {
setLoginError('Invalid username or password.');
setLoggedIn(false);
} else {
setError(err.message);
}
});
};
useEffect(() => {
if (credentials) {
fetchPending(credentials);
}
// eslint-disable-next-line
}, [credentials]);
const handleApprove = (id) => {
fetch(`http://localhost:4000/awards/${id}/approve`, {
method: 'PATCH',
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)
}
})
.then(res => res.json())
.then(() => fetchPending(credentials));
};
const handleReject = (id) => {
fetch(`http://localhost:4000/awards/${id}`, {
method: 'DELETE',
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)
}
})
.then(res => res.json())
.then(() => fetchPending(credentials));
};
const handleLogin = (e) => {
e.preventDefault();
setCredentials({ username, password });
};
if (!loggedIn) {
return (
<div className="app">
<header className="beachy-header">
<div className="header-content">
<h1 className="main-title">Management Login</h1>
<p className="subtitle">Enter your credentials to access nominations</p>
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>
<div className="wave wave3"></div>
</div>
</div>
</header>
<main className="main-content">
<form className="voting-form" onSubmit={handleLogin} style={{ maxWidth: 400, margin: '2rem auto' }}>
<input
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
className="vote-input"
autoFocus
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
className="vote-input"
/>
<button type="submit" className="vote-submit">Login</button>
{loginError && <div className="vote-error">{loginError}</div>}
</form>
</main>
</div>
);
}
return (
<div className="app">
<header className="beachy-header">
<div className="header-content">
<h1 className="main-title">Manage Nominated Awards</h1>
<p className="subtitle">Accept or reject nominations below</p>
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>
<div className="wave wave3"></div>
</div>
</div>
</header>
<main className="main-content">
{loading && <div>Loading nominations...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className="categories-grid">
{pendingAwards.length === 0 && !loading && <div>No pending nominations.</div>}
{pendingAwards.map((award) => (
<div key={award.id} className="category-card">
<h3>{award.category}</h3>
<div
style={{
fontStyle: 'italic',
color: award.address && award.address.includes('92109') ? '#1E90FF' : '#ef4444',
fontWeight: award.address && award.address.includes('92109') ? 'normal' : 'bold',
}}
>
{award.address}
{award.address && !award.address.includes('92109') && (
<span style={{ marginLeft: 8, fontWeight: 'bold' }}>(Not 92109)</span>
)}
</div>
<div style={{ fontSize: '0.9rem', color: '#888', margin: '0.5rem 0' }}>
Nominated: {award.submitted_date ? new Date(award.submitted_date).toLocaleString() : ''}
</div>
<div className="emoji-reactions">
{award.emoji_tally && Object.entries(award.emoji_tally).map(([emoji, count]) => (
<span key={emoji} className="emoji-display">
{emoji} {count > 1 && <span className="emoji-count">({count})</span>}
</span>
))}
</div>
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem' }}>
<button className="vote-submit" style={{ background: 'linear-gradient(90deg, #22c55e 0%, #16a34a 100%)' }} onClick={() => handleApprove(award.id)}>Accept</button>
<button className="vote-submit" style={{ background: 'linear-gradient(90deg, #ef4444 0%, #b91c1c 100%)' }} onClick={() => handleReject(award.id)}>Reject</button>
</div>
</div>
))}
</div>
</main>
</div>
);
}
export default ManagementPage;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

23479
frontend/src/emoji-data.json Normal file

File diff suppressed because it is too large Load Diff

68
frontend/src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

120
k8s.yaml Normal file
View File

@@ -0,0 +1,120 @@
# Kubernetes manifest for Best of PB app
# Includes frontend, backend, services, and ingress
---
# Backend Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: pb-backend
labels:
app: pb-backend
spec:
replicas: 1
selector:
matchLabels:
app: pb-backend
template:
metadata:
labels:
app: pb-backend
spec:
containers:
- name: pb-backend
image: harbor.scottyah.com/bestofpb/pb-backend:latest
imagePullPolicy: Always
ports:
- containerPort: 4000
env:
- name: NODE_ENV
value: "production"
# Add volume mounts for persistent storage if needed
---
# Backend Service
apiVersion: v1
kind: Service
metadata:
name: pb-backend
spec:
selector:
app: pb-backend
ports:
- protocol: TCP
port: 4000
targetPort: 4000
---
# 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
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod-cloudflare
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
tls:
- hosts:
- awards.scottyah.com
secretName: awards-scottyah-com-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
port:
number: 80

60
scripts/build-images.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/bin/sh
# Usage: ./scripts/build-images.sh [version]
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]+' | \
sed -E 's/.*:(v[0-9]+\.[0-9]+\.[0-9]+)$/\1/' | \
sort -V | tail -n 1
}
increment_minor_version() {
# Takes a version string like v1.2.3 and increments the minor version
local version=$1
local major_minor_patch=$(echo $version | sed -E 's/v([0-9]+)\.([0-9]+)\.([0-9]+)/\1 \2 \3/')
set -- $major_minor_patch
local major=$1
local minor=$2
local patch=$3
minor=$((minor + 1))
echo "v${major}.${minor}.0"
}
if [ -z "$1" ]; then
LATEST=$(get_latest_version)
if [ -z "$LATEST" ]; then
VERSION="v1.0.0"
else
VERSION=$(increment_minor_version $LATEST)
fi
echo "No version argument provided. Using auto-incremented version: $VERSION"
else
VERSION=$1
fi
# Build backend image
echo "Building backend image..."
podman build --platform=linux/arm64 -t ${HARBOR_REGISTRY}/pb-backend:$VERSION ./backend
podman tag ${HARBOR_REGISTRY}/pb-backend:$VERSION ${HARBOR_REGISTRY}/pb-backend:latest
echo "Building frontend image..."
podman build --platform=linux/arm64 -t ${HARBOR_REGISTRY}/pb-frontend:$VERSION ./frontend
podman tag ${HARBOR_REGISTRY}/pb-frontend:$VERSION ${HARBOR_REGISTRY}/pb-frontend:latest
echo "\nBuild complete. Images:"
echo " ${HARBOR_REGISTRY}/pb-backend:$VERSION"
echo " ${HARBOR_REGISTRY}/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"