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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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='© <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='© <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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
91
frontend/src/hooks/useEmoji.js
Normal file
91
frontend/src/hooks/useEmoji.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user