Merge branch 'worktree-agent-a6308e0a'

This commit is contained in:
2026-04-05 10:21:22 -07:00
6 changed files with 286 additions and 270 deletions

View File

@@ -12,16 +12,11 @@
"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"
"react-router-dom": "^7.6.3"
},
"devDependencies": {
"@eslint/js": "^9.30.1",

View File

@@ -50,6 +50,29 @@ body {
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
.nav-bar {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 1rem;
}
.nav-link {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
font-weight: 500;
padding: 0.4rem 1rem;
border-radius: 20px;
transition: all 0.2s ease;
font-size: 0.95rem;
}
.nav-link:hover,
.nav-link.active {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.wave-decoration {
position: absolute;
bottom: 0;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import { useState, useEffect, useMemo, memo } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import './App.css';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
@@ -8,6 +8,7 @@ import AwardsPage from './AwardsPage';
import ManagementPage from './ManagementPage';
import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api';
import useEmoji from './hooks/useEmoji';
const defaultCenter = [32.7977, -117.2514];
const defaultZoom = 14;
@@ -31,6 +32,43 @@ const selectedIcon = new L.Icon({
className: 'selected-marker',
});
function ChangeView({ center, zoom }) {
const map = useMap();
useEffect(() => { map.setView(center, zoom); }, [map, center, zoom]);
return null;
}
const MapWithMarkers = memo(function MapWithMarkers({ mapAwards, selectedIdx }) {
const center = selectedIdx !== null && mapAwards[selectedIdx]
? [mapAwards[selectedIdx].lat, mapAwards[selectedIdx].lng]
: (mapAwards[0] ? [mapAwards[0].lat, mapAwards[0].lng] : defaultCenter);
return (
<MapContainer center={center} zoom={defaultZoom} style={{ width: '100%', height: 450, borderRadius: 15 }} scrollWheelZoom={false}>
<ChangeView center={center} zoom={defaultZoom} />
<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>
);
});
function NavBar() {
const location = useLocation();
return (
<nav className="nav-bar">
<Link to="/" className={`nav-link${location.pathname === '/' ? ' active' : ''}`}>Home</Link>
<Link to="/awards" className={`nav-link${location.pathname === '/awards' ? ' active' : ''}`}>All Awards</Link>
<Link to="/manage" className={`nav-link${location.pathname === '/manage' ? ' active' : ''}`}>Manage</Link>
</nav>
);
}
function HomePage() {
const [selectedIdx, setSelectedIdx] = useState(null);
const [awards, setAwards] = useState([]);
@@ -40,8 +78,18 @@ function HomePage() {
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 [voteSuccess, setVoteSuccess] = useState("");
const {
emojiPickerOpen,
setEmojiPickerOpen,
emojiPickerPosition,
handleEmojiButtonClick,
handleEmojiSelect,
handleEmojiRemove,
getSessionEmojis,
currentAward
} = useEmoji(awards, setAwards, '/awards/top');
// Fetch awards from backend
useEffect(() => {
@@ -68,100 +116,6 @@ function HomePage() {
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);
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
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(() => {
sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis));
return fetch(`${API_URL}/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;
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return;
fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => res.json())
.then(() => {
sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis));
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
.then(data => setAwards(data));
};
// Submit new award to backend
const handleVoteSubmit = (e) => {
e.preventDefault();
@@ -176,7 +130,7 @@ function HomePage() {
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
})
.then(res => {
if (!res.ok) throw new Error('Failed to add award');
if (!res.ok) return res.json().then(data => { throw new Error(data.error || 'Failed to add award'); });
return res.json();
})
.then(() => {
@@ -184,6 +138,8 @@ function HomePage() {
setVoteCategory("");
setVoteSubmittedBy("");
setVoteError("");
setVoteSuccess("Nomination submitted! It will appear after admin approval.");
setTimeout(() => setVoteSuccess(""), 5000);
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
@@ -191,14 +147,13 @@ function HomePage() {
.catch(err => setVoteError(err.message));
};
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
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>
<NavBar />
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>
@@ -211,25 +166,28 @@ function HomePage() {
{loading && <div>Loading awards...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className="categories-grid">
{awards.slice(0, 5).map((award, index) => (
{awards.slice(0, 5).map((award, index) => {
const sessionEmojis = getSessionEmojis(award.id);
return (
<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' }}
>
<div>
<h3 style={{ margin: 0 }}>{award.category}</h3>
<span style={{ fontSize: '0.85rem', color: '#1E90FF', fontStyle: 'italic' }}>{award.address}</span>
</div>
<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)}
onClick={(e) => { e.stopPropagation(); 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>}
@@ -246,7 +204,8 @@ function HomePage() {
</button>
</div>
</div>
))}
);
})}
</div>
<div className="see-all-awards-link">
<Link to="/awards" className="awards-link">See all awards </Link>
@@ -258,12 +217,12 @@ function HomePage() {
{selectedAward ? `Location: ${selectedAward.address}` : 'Pacific Beach, San Diego'}
</h2>
<div className="map-container">
<MemoizedMapWithMarkers selectedIdx={selectedIdx} />
<MapWithMarkers mapAwards={mapAwards} selectedIdx={selectedIdx} />
</div>
</section>
<section className="voting-section">
<h2 className="section-title">Nominate a Neighbor!</h2>
<h2 className="section-title">Nominate a Place</h2>
<form className="voting-form" onSubmit={handleVoteSubmit}>
<input
type="text"
@@ -291,6 +250,7 @@ function HomePage() {
<button type="submit" className="vote-submit">Submit Vote</button>
</form>
{voteError && <div className="vote-error">{voteError}</div>}
{voteSuccess && <div style={{ color: '#16a34a', background: '#f0fdf4', borderRadius: 8, padding: '0.5rem 1rem', marginBottom: '1rem', fontSize: '0.98rem', textAlign: 'center' }}>{voteSuccess}</div>}
</section>
</main>
@@ -318,3 +278,4 @@ function App() {
}
export default App;
export { NavBar };

View File

@@ -1,17 +1,26 @@
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';
import useEmoji from './hooks/useEmoji';
import { NavBar } from './App';
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();
const {
emojiPickerOpen,
setEmojiPickerOpen,
emojiPickerPosition,
handleEmojiButtonClick,
handleEmojiSelect,
handleEmojiRemove,
getSessionEmojis,
currentAward
} = useEmoji(awards, setAwards, '/awards');
useEffect(() => {
fetch(`${API_URL}/awards`)
@@ -29,66 +38,6 @@ function AwardsPage() {
});
}, []);
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);
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
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(() => {
sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis));
return fetch(`${API_URL}/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;
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return;
fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => res.json())
.then(() => {
sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis));
return fetch(`${API_URL}/awards`);
})
.then(res => res.json())
.then(data => setAwards(data));
};
const formatDate = (dateString) => {
if (!dateString) return null;
return new Date(dateString).toLocaleDateString('en-US', {
@@ -98,20 +47,12 @@ function AwardsPage() {
});
};
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
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>
<NavBar />
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>
@@ -126,7 +67,9 @@ function AwardsPage() {
{awards
.filter(a => a.approved_date)
.sort((a, b) => new Date(b.submitted_date) - new Date(a.submitted_date))
.map((award, index) => (
.map((award, index) => {
const sessionEmojis = getSessionEmojis(award.id);
return (
<div
key={award.id}
className={`award-card ${selectedAward === award.id ? 'selected' : ''}`}
@@ -152,8 +95,6 @@ function AwardsPage() {
{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
@@ -179,7 +120,8 @@ function AwardsPage() {
</button>
</div>
))}
);
})}
</div>
</main>
<EmojiPicker

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import './App.css';
import API_URL from './config/api';
import { NavBar } from './App';
function ManagementPage() {
const [pendingAwards, setPendingAwards] = useState([]);
@@ -61,6 +62,7 @@ function ManagementPage() {
};
const handleReject = (id) => {
if (!window.confirm('Reject this nomination?')) return;
fetch(`${API_URL}/awards/${id}`, {
method: 'DELETE',
headers: {
@@ -83,6 +85,7 @@ function ManagementPage() {
<div className="header-content">
<h1 className="main-title">Management Login</h1>
<p className="subtitle">Enter your credentials to access nominations</p>
<NavBar />
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>
@@ -121,6 +124,7 @@ function ManagementPage() {
<div className="header-content">
<h1 className="main-title">Manage Nominated Awards</h1>
<p className="subtitle">Accept or reject nominations below</p>
<NavBar />
<div className="wave-decoration">
<div className="wave wave1"></div>
<div className="wave wave2"></div>

View File

@@ -0,0 +1,91 @@
import { useState, useCallback } from 'react';
import API_URL from '../config/api';
export default function useEmoji(awards, setAwards, fetchUrl) {
const [emojiPickerOpen, setEmojiPickerOpen] = useState(null);
const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 });
const handleEmojiButtonClick = useCallback((e, idx) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setEmojiPickerPosition({ x: rect.left, y: rect.bottom + 5 });
setEmojiPickerOpen(idx);
}, []);
const refreshAwards = useCallback(() => {
return fetch(`${API_URL}${fetchUrl}`)
.then(res => res.json())
.then(data => setAwards(data))
.catch(err => console.error('Failed to refresh awards:', err));
}, [fetchUrl, setAwards]);
const handleEmojiSelect = useCallback((emoji) => {
if (emojiPickerOpen === null || !awards[emojiPickerOpen]) return;
const award = awards[emojiPickerOpen];
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null);
return;
}
// Server increments atomically — no count sent
fetch(`${API_URL}/awards/${award.id}/emojis`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => {
if (!res.ok) throw new Error('Failed to add emoji');
return res.json();
})
.then(() => {
sessionEmojis[emoji] = (sessionEmojis[emoji] || 0) + 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis));
return refreshAwards();
})
.catch(err => console.error('Emoji add error:', err));
setEmojiPickerOpen(null);
}, [emojiPickerOpen, awards, refreshAwards]);
const handleEmojiRemove = useCallback((awardIdx, emoji) => {
const award = awards[awardIdx];
if (!award || !award.emoji_tally?.[emoji]) return;
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return;
fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => {
if (!res.ok) throw new Error('Failed to remove emoji');
return res.json();
})
.then(() => {
const updated = { ...sessionEmojis };
updated[emoji] = (updated[emoji] || 1) - 1;
if (updated[emoji] <= 0) delete updated[emoji];
localStorage.setItem(key, JSON.stringify(updated));
return refreshAwards();
})
.catch(err => console.error('Emoji remove error:', err));
}, [awards, refreshAwards]);
const getSessionEmojis = useCallback((awardId) => {
return JSON.parse(localStorage.getItem(`award-emoji-${awardId}`) || '{}');
}, []);
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return {
emojiPickerOpen,
setEmojiPickerOpen,
emojiPickerPosition,
handleEmojiButtonClick,
handleEmojiSelect,
handleEmojiRemove,
getSessionEmojis,
currentAward
};
}