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 (