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) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 10:21:17 -07:00
parent ff534eb448
commit 04c32e7938
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\"" "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": { "dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"node-emoji": "^2.2.0",
"node-fetch": "^3.3.2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-router-dom": "^7.6.3", "react-router-dom": "^7.6.3"
"sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.1", "@eslint/js": "^9.30.1",

View File

@@ -50,6 +50,29 @@ body {
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2); 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 { .wave-decoration {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, memo } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import './App.css'; import './App.css';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
@@ -8,6 +8,7 @@ import AwardsPage from './AwardsPage';
import ManagementPage from './ManagementPage'; import ManagementPage from './ManagementPage';
import EmojiPicker from './components/EmojiPicker'; import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api'; import API_URL from './config/api';
import useEmoji from './hooks/useEmoji';
const defaultCenter = [32.7977, -117.2514]; const defaultCenter = [32.7977, -117.2514];
const defaultZoom = 14; const defaultZoom = 14;
@@ -31,6 +32,43 @@ const selectedIcon = new L.Icon({
className: 'selected-marker', 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() { function HomePage() {
const [selectedIdx, setSelectedIdx] = useState(null); const [selectedIdx, setSelectedIdx] = useState(null);
const [awards, setAwards] = useState([]); const [awards, setAwards] = useState([]);
@@ -40,8 +78,18 @@ function HomePage() {
const [voteCategory, setVoteCategory] = useState(""); const [voteCategory, setVoteCategory] = useState("");
const [voteError, setVoteError] = useState(""); const [voteError, setVoteError] = useState("");
const [voteSubmittedBy, setVoteSubmittedBy] = useState(""); const [voteSubmittedBy, setVoteSubmittedBy] = useState("");
const [emojiPickerOpen, setEmojiPickerOpen] = useState(null); const [voteSuccess, setVoteSuccess] = useState("");
const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 });
const {
emojiPickerOpen,
setEmojiPickerOpen,
emojiPickerPosition,
handleEmojiButtonClick,
handleEmojiSelect,
handleEmojiRemove,
getSessionEmojis,
currentAward
} = useEmoji(awards, setAwards, '/awards/top');
// Fetch awards from backend // Fetch awards from backend
useEffect(() => { 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.slice(0, 5).filter(a => a.lat && a.lng).map((award) => ({ ...award, lat: award.lat, lng: award.lng }))
, [awards]); , [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 // Submit new award to backend
const handleVoteSubmit = (e) => { const handleVoteSubmit = (e) => {
e.preventDefault(); e.preventDefault();
@@ -176,7 +130,7 @@ function HomePage() {
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy }) body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
}) })
.then(res => { .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(); return res.json();
}) })
.then(() => { .then(() => {
@@ -184,6 +138,8 @@ function HomePage() {
setVoteCategory(""); setVoteCategory("");
setVoteSubmittedBy(""); setVoteSubmittedBy("");
setVoteError(""); setVoteError("");
setVoteSuccess("Nomination submitted! It will appear after admin approval.");
setTimeout(() => setVoteSuccess(""), 5000);
return fetch(`${API_URL}/awards/top`); return fetch(`${API_URL}/awards/top`);
}) })
.then(res => res.json()) .then(res => res.json())
@@ -191,14 +147,13 @@ function HomePage() {
.catch(err => setVoteError(err.message)); .catch(err => setVoteError(err.message));
}; };
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return ( return (
<div className="app"> <div className="app">
<header className="beachy-header"> <header className="beachy-header">
<div className="header-content"> <div className="header-content">
<h1 className="main-title">Best of Pacific Beach</h1> <h1 className="main-title">Best of Pacific Beach</h1>
<p className="subtitle">Celebrating the finest spots in PB</p> <p className="subtitle">Celebrating the finest spots in PB</p>
<NavBar />
<div className="wave-decoration"> <div className="wave-decoration">
<div className="wave wave1"></div> <div className="wave wave1"></div>
<div className="wave wave2"></div> <div className="wave wave2"></div>
@@ -211,25 +166,28 @@ function HomePage() {
{loading && <div>Loading awards...</div>} {loading && <div>Loading awards...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>} {error && <div style={{ color: 'red' }}>{error}</div>}
<div className="categories-grid"> <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 <div
key={award.id} key={award.id}
className={`category-card ${selectedIdx === index ? 'selected' : ''}`} className={`category-card ${selectedIdx === index ? 'selected' : ''}`}
onClick={() => setSelectedIdx(selectedIdx === index ? null : index)} onClick={() => setSelectedIdx(selectedIdx === index ? null : index)}
style={{ cursor: award.address ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }} style={{ cursor: award.address ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
> >
<div>
<h3 style={{ margin: 0 }}>{award.category}</h3> <h3 style={{ margin: 0 }}>{award.category}</h3>
<span style={{ fontSize: '0.85rem', color: '#1E90FF', fontStyle: 'italic' }}>{award.address}</span>
</div>
<div className="emoji-reactions"> <div className="emoji-reactions">
{award.emoji_tally && Object.entries(award.emoji_tally).map(([emoji, count]) => { {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; const canRemove = sessionEmojis[emoji] > 0;
return ( return (
<span <span
key={emoji} key={emoji}
className="emoji-display" className="emoji-display"
style={{ cursor: canRemove ? 'pointer' : 'not-allowed', opacity: canRemove ? 1 : 0.5 }} 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"} 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>} {emoji} {count > 1 && <span className="emoji-count">({count})</span>}
@@ -246,7 +204,8 @@ function HomePage() {
</button> </button>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
<div className="see-all-awards-link"> <div className="see-all-awards-link">
<Link to="/awards" className="awards-link">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'} {selectedAward ? `Location: ${selectedAward.address}` : 'Pacific Beach, San Diego'}
</h2> </h2>
<div className="map-container"> <div className="map-container">
<MemoizedMapWithMarkers selectedIdx={selectedIdx} /> <MapWithMarkers mapAwards={mapAwards} selectedIdx={selectedIdx} />
</div> </div>
</section> </section>
<section className="voting-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}> <form className="voting-form" onSubmit={handleVoteSubmit}>
<input <input
type="text" type="text"
@@ -291,6 +250,7 @@ function HomePage() {
<button type="submit" className="vote-submit">Submit Vote</button> <button type="submit" className="vote-submit">Submit Vote</button>
</form> </form>
{voteError && <div className="vote-error">{voteError}</div>} {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> </section>
</main> </main>
@@ -318,3 +278,4 @@ function App() {
} }
export default App; export default App;
export { NavBar };

View File

@@ -1,17 +1,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import './App.css'; import './App.css';
import EmojiPicker from './components/EmojiPicker'; import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api'; import API_URL from './config/api';
import useEmoji from './hooks/useEmoji';
import { NavBar } from './App';
function AwardsPage() { function AwardsPage() {
const [selectedAward, setSelectedAward] = useState(null); const [selectedAward, setSelectedAward] = useState(null);
const [awards, setAwards] = useState([]); const [awards, setAwards] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(null);
const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 }); const {
const navigate = useNavigate(); emojiPickerOpen,
setEmojiPickerOpen,
emojiPickerPosition,
handleEmojiButtonClick,
handleEmojiSelect,
handleEmojiRemove,
getSessionEmojis,
currentAward
} = useEmoji(awards, setAwards, '/awards');
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/awards`) 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) => { const formatDate = (dateString) => {
if (!dateString) return null; if (!dateString) return null;
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString('en-US', {
@@ -98,20 +47,12 @@ function AwardsPage() {
}); });
}; };
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return ( return (
<div className="app"> <div className="app">
<header className="beachy-header"> <header className="beachy-header">
<div className="header-content"> <div className="header-content">
<h1 className="main-title">All Pacific Beach Awards</h1> <h1 className="main-title">All Pacific Beach Awards</h1>
<button <NavBar />
onClick={handleBackToHome}
className="back-link"
type="button"
>
Back to Home
</button>
<div className="wave-decoration"> <div className="wave-decoration">
<div className="wave wave1"></div> <div className="wave wave1"></div>
<div className="wave wave2"></div> <div className="wave wave2"></div>
@@ -126,7 +67,9 @@ function AwardsPage() {
{awards {awards
.filter(a => a.approved_date) .filter(a => a.approved_date)
.sort((a, b) => new Date(b.submitted_date) - new Date(a.submitted_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 <div
key={award.id} key={award.id}
className={`award-card ${selectedAward === award.id ? 'selected' : ''}`} className={`award-card ${selectedAward === award.id ? 'selected' : ''}`}
@@ -152,8 +95,6 @@ function AwardsPage() {
{award.emoji_tally && Object.keys(award.emoji_tally).length > 0 && ( {award.emoji_tally && Object.keys(award.emoji_tally).length > 0 && (
<div className="award-emojis"> <div className="award-emojis">
{Object.entries(award.emoji_tally).map(([emoji, count]) => { {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; const canRemove = sessionEmojis[emoji] > 0;
return ( return (
<span <span
@@ -179,7 +120,8 @@ function AwardsPage() {
</button> </button>
</div> </div>
))} );
})}
</div> </div>
</main> </main>
<EmojiPicker <EmojiPicker

View File

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