From 04c32e7938e9b8d59fbc6e3a821cc13b18ba8800 Mon Sep 17 00:00:00 2001 From: scott Date: Sun, 5 Apr 2026 10:21:17 -0700 Subject: [PATCH] refactor: shared emoji hook, nav bar, UX improvements - Extract duplicated emoji logic into useEmoji hook - Fix map memoization anti-pattern (proper React.memo component) - Add NavBar with links to all pages - Add success/error feedback on nomination submit - Show addresses on homepage award cards - Add confirmation dialog before rejecting nominations - Remove backend-only deps from frontend package.json - Change "Nominate a Neighbor" to "Nominate a Place" Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package.json | 7 +- frontend/src/App.css | 23 ++++ frontend/src/App.jsx | 237 +++++++++++++------------------- frontend/src/AwardsPage.jsx | 194 +++++++++----------------- frontend/src/ManagementPage.jsx | 4 + frontend/src/hooks/useEmoji.js | 91 ++++++++++++ 6 files changed, 286 insertions(+), 270 deletions(-) create mode 100644 frontend/src/hooks/useEmoji.js diff --git a/frontend/package.json b/frontend/package.json index a6d477f..768a2c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.css b/frontend/src/App.css index 605196b..f6dcd80 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8e3240d..fabfe4e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( + + + + {mapAwards.map((loc, idx) => ( + + {loc.category}
{loc.address}
+
+ ))} +
+ ); +}); + +function NavBar() { + const location = useLocation(); + return ( + + ); +} + 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 ( - - - - {mapAwards.map((loc, idx) => ( - - - {loc.category}
- {loc.address} -
-
- ))} -
- ); - } - }, [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 (

Best of Pacific Beach

Celebrating the finest spots in PB

+
@@ -211,42 +166,46 @@ function HomePage() { {loading &&
Loading awards...
} {error &&
{error}
}
- {awards.slice(0, 5).map((award, index) => ( -
setSelectedIdx(selectedIdx === index ? null : index)} - style={{ cursor: award.address ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }} - > -

{award.category}

-
- {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 ( - 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 && ({count})} - - ); - })} - + {awards.slice(0, 5).map((award, index) => { + const sessionEmojis = getSessionEmojis(award.id); + return ( +
setSelectedIdx(selectedIdx === index ? null : index)} + style={{ cursor: award.address ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }} + > +
+

{award.category}

+ {award.address} +
+
+ {award.emoji_tally && Object.entries(award.emoji_tally).map(([emoji, count]) => { + const canRemove = sessionEmojis[emoji] > 0; + return ( + { 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 && ({count})} + + ); + })} + +
-
- ))} + ); + })}
See all awards → @@ -258,12 +217,12 @@ function HomePage() { {selectedAward ? `Location: ${selectedAward.address}` : 'Pacific Beach, San Diego'}
- +
-

Nominate a Neighbor!

+

Nominate a Place

Submit Vote
{voteError &&
{voteError}
} + {voteSuccess &&
{voteSuccess}
}
@@ -318,3 +278,4 @@ function App() { } export default App; +export { NavBar }; diff --git a/frontend/src/AwardsPage.jsx b/frontend/src/AwardsPage.jsx index d0da5ee..13b0041 100644 --- a/frontend/src/AwardsPage.jsx +++ b/frontend/src/AwardsPage.jsx @@ -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 (

All Pacific Beach Awards

- +
@@ -126,60 +67,61 @@ function AwardsPage() { {awards .filter(a => a.approved_date) .sort((a, b) => new Date(b.submitted_date) - new Date(a.submitted_date)) - .map((award, index) => ( -
setSelectedAward(award.id)} - > -

{award.category}

-

{award.description || ''}

-
{award.address}
- {award.submitted_by && ( -
- Submitted by: {award.submitted_by} -
- )} -
- Submitted: {formatDate(award.submitted_date)} - {award.approved_date && ( - <> -
- Approved: {formatDate(award.approved_date)} - - )} -
- {award.emoji_tally && Object.keys(award.emoji_tally).length > 0 && ( -
- {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 ( - 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 && ({count})} - - ); - })} -
- )} - -
- ))} +

{award.category}

+

{award.description || ''}

+
{award.address}
+ {award.submitted_by && ( +
+ Submitted by: {award.submitted_by} +
+ )} +
+ Submitted: {formatDate(award.submitted_date)} + {award.approved_date && ( + <> +
+ Approved: {formatDate(award.approved_date)} + + )} +
+ {award.emoji_tally && Object.keys(award.emoji_tally).length > 0 && ( +
+ {Object.entries(award.emoji_tally).map(([emoji, count]) => { + const canRemove = sessionEmojis[emoji] > 0; + return ( + 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 && ({count})} + + ); + })} +
+ )} + +
+ ); + })}
{ + if (!window.confirm('Reject this nomination?')) return; fetch(`${API_URL}/awards/${id}`, { method: 'DELETE', headers: { @@ -83,6 +85,7 @@ function ManagementPage() {

Management Login

Enter your credentials to access nominations

+
@@ -121,6 +124,7 @@ function ManagementPage() {

Manage Nominated Awards

Accept or reject nominations below

+
diff --git a/frontend/src/hooks/useEmoji.js b/frontend/src/hooks/useEmoji.js new file mode 100644 index 0000000..631464f --- /dev/null +++ b/frontend/src/hooks/useEmoji.js @@ -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 + }; +}