This commit is contained in:
2025-12-23 17:41:30 -08:00
parent d6ac68f7d3
commit ff534eb448
18 changed files with 3989 additions and 461 deletions

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# API Configuration
VITE_API_URL=http://localhost:4000

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useMemo } from 'react'
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'
import './App.css'
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import AwardsPage from './AwardsPage'
import ManagementPage from './ManagementPage'
import emojiData from './emoji-data.json'
import { useState, useEffect, useMemo } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import './App.css';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import AwardsPage from './AwardsPage';
import ManagementPage from './ManagementPage';
import EmojiPicker from './components/EmojiPicker';
import API_URL from './config/api';
const defaultCenter = [32.7977, -117.2514];
const defaultZoom = 14;
@@ -30,69 +31,6 @@ const selectedIcon = new L.Icon({
className: 'selected-marker',
});
// Dynamically generate emoji categories from emojiData (browser compatible)
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
// Optionally, limit the number of categories/emojis for UI sanity
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags') // skip less useful
.slice(0, 6) // limit to 6 categories
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)]) // limit to 30 per category
);
function EmojiPicker({ isOpen, onClose, onSelect, position, emojiPickerOpen, awards }) {
if (!isOpen) return null;
const award = typeof emojiPickerOpen === 'number' && awards && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
function HomePage() {
const [selectedIdx, setSelectedIdx] = useState(null);
const [awards, setAwards] = useState([]);
@@ -104,18 +42,16 @@ function HomePage() {
const [voteSubmittedBy, setVoteSubmittedBy] = useState("");
const [emojiPickerOpen, setEmojiPickerOpen] = useState(null);
const [emojiPickerPosition, setEmojiPickerPosition] = useState({ x: 0, y: 0 });
const [awardCoords, setAwardCoords] = useState({});
// Fetch awards from backend
useEffect(() => {
fetch('http://localhost:4000/awards/top')
fetch(`${API_URL}/awards/top`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch awards');
return res.json();
})
.then(data => {
// Only show approved awards (approved_date not null)
setAwards(data.filter(a => a.approved_date));
setAwards(data);
setLoading(false);
})
.catch(err => {
@@ -124,36 +60,10 @@ function HomePage() {
});
}, []);
// Geocode addresses for map
useEffect(() => {
const fetchCoords = async () => {
const coords = {};
for (const award of awards.slice(0, 5)) {
if (award.address && !awardCoords[award.address]) {
try {
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(award.address)}`);
const data = await resp.json();
if (data && data[0]) {
coords[award.address] = {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon)
};
}
} catch (e) { /* ignore */ }
} else if (awardCoords[award.address]) {
coords[award.address] = awardCoords[award.address];
}
}
setAwardCoords(prev => ({ ...prev, ...coords }));
};
if (awards.length > 0) fetchCoords();
// eslint-disable-next-line
}, [awards]);
// Find the selected award for the map
const selectedAward = selectedIdx !== null ? awards[selectedIdx] : null;
// Dynamically create mapAwards from backend lat/lng only
// Use server's lat/lng for map markers (no duplicate geocoding)
const mapAwards = useMemo(() =>
awards.slice(0, 5).filter(a => a.lat && a.lng).map((award) => ({ ...award, lat: award.lat, lng: award.lng }))
, [awards]);
@@ -208,22 +118,20 @@ function HomePage() {
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji
setEmojiPickerOpen(null);
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/awards/${award.id}/emojis`, {
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(() => {
// Track in localStorage
sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards/top');
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -235,23 +143,20 @@ function HomePage() {
const handleEmojiRemove = (awardIdx, emoji) => {
const award = awards[awardIdx];
if (!award || !award.emoji_tally[emoji]) return;
// Only allow if user added this emoji in this session
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return;
fetch(`http://localhost:4000/awards/${award.id}/emojis/remove`, {
fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => res.json())
.then(() => {
// Update localStorage
sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards/top');
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -265,7 +170,7 @@ function HomePage() {
return;
}
const submittedBy = voteSubmittedBy.trim() ? voteSubmittedBy.trim() : "anonymous";
fetch('http://localhost:4000/awards', {
fetch(`${API_URL}/awards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
@@ -279,14 +184,15 @@ function HomePage() {
setVoteCategory("");
setVoteSubmittedBy("");
setVoteError("");
// Refresh awards
return fetch('http://localhost:4000/awards');
return fetch(`${API_URL}/awards/top`);
})
.then(res => res.json())
.then(data => setAwards(data))
.catch(err => setVoteError(err.message));
};
const currentAward = emojiPickerOpen !== null && awards[emojiPickerOpen] ? awards[emojiPickerOpen] : null;
return (
<div className="app">
<header className="beachy-header">
@@ -306,7 +212,7 @@ function HomePage() {
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className="categories-grid">
{awards.slice(0, 5).map((award, index) => (
<div
<div
key={award.id}
className={`category-card ${selectedIdx === index ? 'selected' : ''}`}
onClick={() => setSelectedIdx(selectedIdx === index ? null : index)}
@@ -346,7 +252,7 @@ function HomePage() {
<Link to="/awards" className="awards-link">See all awards </Link>
</div>
</section>
<section className="map-section">
<h2 className="section-title">
{selectedAward ? `Location: ${selectedAward.address}` : 'Pacific Beach, San Diego'}
@@ -358,34 +264,7 @@ function HomePage() {
<section className="voting-section">
<h2 className="section-title">Nominate a Neighbor!</h2>
<form className="voting-form" onSubmit={e => {
e.preventDefault();
if (!voteAddress.trim() || !voteCategory.trim()) {
setVoteError("Please enter both an address and a category/description.");
return;
}
const submittedBy = voteSubmittedBy.trim() ? voteSubmittedBy.trim() : "anonymous";
fetch('http://localhost:4000/awards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: voteCategory.trim(), address: voteAddress.trim(), submitted_by: submittedBy })
})
.then(res => {
if (!res.ok) throw new Error('Failed to add award');
return res.json();
})
.then(() => {
setVoteAddress("");
setVoteCategory("");
setVoteSubmittedBy("");
setVoteError("");
// Refresh awards
return fetch('http://localhost:4000/awards');
})
.then(res => res.json())
.then(data => setAwards(data))
.catch(err => setVoteError(err.message));
}}>
<form className="voting-form" onSubmit={handleVoteSubmit}>
<input
type="text"
placeholder="Address (e.g. 123 Main St)"
@@ -420,8 +299,7 @@ function HomePage() {
onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect}
position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen}
awards={awards}
award={currentAward}
/>
</div>
)
@@ -439,4 +317,4 @@ function App() {
)
}
export default App
export default App;

View File

@@ -1,69 +1,8 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import './App.css'
import emojiData from './emoji-data.json'
// Emoji picker logic (copied from App.jsx)
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags')
.slice(0, 6)
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)])
);
function EmojiPicker({ isOpen, onClose, onSelect, position, emojiPickerOpen, awards }) {
if (!isOpen) return null;
const award = emojiPickerOpen !== null ? awards[emojiPickerOpen] : null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
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';
function AwardsPage() {
const [selectedAward, setSelectedAward] = useState(null);
@@ -75,7 +14,7 @@ function AwardsPage() {
const navigate = useNavigate();
useEffect(() => {
fetch('http://localhost:4000/awards')
fetch(`${API_URL}/awards`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch awards');
return res.json();
@@ -107,22 +46,20 @@ function AwardsPage() {
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (sessionEmojis[emoji]) {
setEmojiPickerOpen(null); // Already added this emoji
setEmojiPickerOpen(null);
return;
}
const tally = { ...award.emoji_tally, [emoji]: (award.emoji_tally?.[emoji] || 0) + 1 };
fetch(`http://localhost:4000/awards/${award.id}/emojis`, {
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(() => {
// Track in localStorage
sessionEmojis[emoji] = 1;
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards');
return fetch(`${API_URL}/awards`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -133,23 +70,20 @@ function AwardsPage() {
const handleEmojiRemove = (awardIdx, emoji) => {
const award = awards[awardIdx];
if (!award || !award.emoji_tally[emoji]) return;
// Only allow if user added this emoji in this session
const key = `award-emoji-${award.id}`;
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
if (!sessionEmojis[emoji]) return;
fetch(`http://localhost:4000/awards/${award.id}/emojis/remove`, {
fetch(`${API_URL}/awards/${award.id}/emojis/remove`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
})
.then(res => res.json())
.then(() => {
// Update localStorage
sessionEmojis[emoji] = sessionEmojis[emoji] - 1;
if (sessionEmojis[emoji] <= 0) delete sessionEmojis[emoji];
localStorage.setItem(key, JSON.stringify(sessionEmojis));
// Refresh awards
return fetch('http://localhost:4000/awards');
return fetch(`${API_URL}/awards`);
})
.then(res => res.json())
.then(data => setAwards(data));
@@ -164,13 +98,15 @@ 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}
<button
onClick={handleBackToHome}
className="back-link"
type="button"
>
@@ -191,7 +127,7 @@ function AwardsPage() {
.filter(a => a.approved_date)
.sort((a, b) => new Date(b.submitted_date) - new Date(a.submitted_date))
.map((award, index) => (
<div
<div
key={award.id}
className={`award-card ${selectedAward === award.id ? 'selected' : ''}`}
onClick={() => setSelectedAward(award.id)}
@@ -251,11 +187,10 @@ function AwardsPage() {
onClose={() => setEmojiPickerOpen(null)}
onSelect={handleEmojiSelect}
position={emojiPickerPosition}
emojiPickerOpen={emojiPickerOpen}
awards={awards}
award={currentAward}
/>
</div>
)
}
export default AwardsPage
export default AwardsPage;

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import './App.css';
import API_URL from './config/api';
function ManagementPage() {
const [pendingAwards, setPendingAwards] = useState([]);
@@ -13,7 +14,7 @@ function ManagementPage() {
const fetchPending = (creds) => {
setLoading(true);
fetch('http://localhost:4000/awards/pending', {
fetch(`${API_URL}/awards/pending`, {
headers: {
'Authorization': 'Basic ' + btoa(`${creds.username}:${creds.password}`)
}
@@ -49,7 +50,7 @@ function ManagementPage() {
}, [credentials]);
const handleApprove = (id) => {
fetch(`http://localhost:4000/awards/${id}/approve`, {
fetch(`${API_URL}/awards/${id}/approve`, {
method: 'PATCH',
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)
@@ -60,7 +61,7 @@ function ManagementPage() {
};
const handleReject = (id) => {
fetch(`http://localhost:4000/awards/${id}`, {
fetch(`${API_URL}/awards/${id}`, {
method: 'DELETE',
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`)
@@ -169,4 +170,4 @@ function ManagementPage() {
);
}
export default ManagementPage;
export default ManagementPage;

View File

@@ -0,0 +1,105 @@
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
}
.emoji-picker {
position: fixed;
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
max-width: 400px;
max-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.emoji-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #eee;
background: #f9f9f9;
}
.emoji-picker-header span {
font-weight: 600;
font-size: 14px;
}
.emoji-picker-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-picker-close:hover {
color: #000;
}
.emoji-picker-content {
overflow-y: auto;
padding: 12px;
}
.emoji-category {
margin-bottom: 16px;
}
.emoji-category-title {
margin: 0 0 8px 0;
font-size: 12px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
gap: 4px;
}
.emoji-option {
background: none;
border: 1px solid transparent;
border-radius: 4px;
font-size: 20px;
cursor: pointer;
padding: 4px;
transition: all 0.2s;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.emoji-option:hover:not(:disabled) {
background: #f0f0f0;
border-color: #ddd;
transform: scale(1.1);
}
.emoji-option:disabled {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -0,0 +1,70 @@
import emojiData from '../emoji-data.json';
import './EmojiPicker.css';
// Dynamically generate emoji categories from emojiData
const emojiCategoryMap = {};
emojiData.forEach(e => {
const cat = e.category || 'Other';
if (!emojiCategoryMap[cat]) emojiCategoryMap[cat] = [];
emojiCategoryMap[cat].push(e.emoji);
});
// Limit categories and emojis for UI
const emojiCategories = Object.fromEntries(
Object.entries(emojiCategoryMap)
.filter(([cat]) => cat !== 'Component' && cat !== 'Flags')
.slice(0, 6)
.map(([cat, emojis]) => [cat, emojis.slice(0, 30)])
);
function EmojiPicker({ isOpen, onClose, onSelect, position, award }) {
if (!isOpen) return null;
let sessionEmojis = {};
if (award) {
const key = `award-emoji-${award.id}`;
sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
}
return (
<>
<div className="emoji-picker-overlay" onClick={onClose} />
<div
className="emoji-picker"
style={{
top: position.y,
left: position.x
}}
>
<div className="emoji-picker-header">
<span>Choose an emoji</span>
<button className="emoji-picker-close" onClick={onClose}>×</button>
</div>
<div className="emoji-picker-content">
{Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} className="emoji-category">
<h4 className="emoji-category-title">{category}</h4>
<div className="emoji-grid">
{emojis.map((emoji, index) => (
<button
key={index}
className="emoji-option"
onClick={() => !sessionEmojis[emoji] && onSelect(emoji)}
type="button"
disabled={!!sessionEmojis[emoji]}
style={sessionEmojis[emoji] ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</div>
</>
);
}
export default EmojiPicker;
export { emojiCategories };

View File

@@ -0,0 +1,4 @@
// API Configuration
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
export default API_URL;