Compare commits
6 Commits
0ae66139f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dc394590f | |||
| eb5692994d | |||
| bf9af5f44c | |||
| 04c32e7938 | |||
| 2d04e2081b | |||
| 2dc267c969 |
@@ -13,6 +13,7 @@ RUN npm ci
|
|||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
# Build frontend for production
|
# Build frontend for production
|
||||||
|
ENV VITE_API_URL=""
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ app.post('/awards',
|
|||||||
const userAgent = process.env.NOMINATIM_USER_AGENT || 'BestOfPBAwardsApp/1.0';
|
const userAgent = process.env.NOMINATIM_USER_AGENT || 'BestOfPBAwardsApp/1.0';
|
||||||
|
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`${nominatimUrl}/search?format=json&q=${encodeURIComponent(address)}`,
|
`${nominatimUrl}/search?format=json&addressdetails=1&q=${encodeURIComponent(address)}`,
|
||||||
{ headers: { 'User-Agent': userAgent } }
|
{ headers: { 'User-Agent': userAgent } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -243,11 +243,10 @@ app.patch('/awards/:id/emojis',
|
|||||||
emojiLimiter,
|
emojiLimiter,
|
||||||
param('id').isInt().withMessage('Invalid award ID'),
|
param('id').isInt().withMessage('Invalid award ID'),
|
||||||
body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'),
|
body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'),
|
||||||
body('count').isInt({ min: 0, max: 1000 }).withMessage('Count must be between 0 and 1000'),
|
|
||||||
validate,
|
validate,
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { emoji, count } = req.body;
|
const { emoji } = req.body;
|
||||||
|
|
||||||
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => {
|
db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => {
|
||||||
if (err || !row) {
|
if (err || !row) {
|
||||||
@@ -256,7 +255,7 @@ app.patch('/awards/:id/emojis',
|
|||||||
}
|
}
|
||||||
|
|
||||||
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
|
let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {};
|
||||||
emojiTally[emoji] = count;
|
emojiTally[emoji] = (emojiTally[emoji] || 0) + 1;
|
||||||
|
|
||||||
db.run(
|
db.run(
|
||||||
'UPDATE awards SET emoji_tally = ? WHERE id = ?',
|
'UPDATE awards SET emoji_tally = ? WHERE id = ?',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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='© <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='© <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,42 +166,46 @@ 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) => {
|
||||||
<div
|
const sessionEmojis = getSessionEmojis(award.id);
|
||||||
key={award.id}
|
return (
|
||||||
className={`category-card ${selectedIdx === index ? 'selected' : ''}`}
|
<div
|
||||||
onClick={() => setSelectedIdx(selectedIdx === index ? null : index)}
|
key={award.id}
|
||||||
style={{ cursor: award.address ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
className={`category-card ${selectedIdx === index ? 'selected' : ''}`}
|
||||||
>
|
onClick={() => setSelectedIdx(selectedIdx === index ? null : index)}
|
||||||
<h3 style={{ margin: 0 }}>{award.category}</h3>
|
style={{ cursor: award.address ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||||
<div className="emoji-reactions">
|
>
|
||||||
{award.emoji_tally && Object.entries(award.emoji_tally).map(([emoji, count]) => {
|
<div>
|
||||||
const key = `award-emoji-${award.id}`;
|
<h3 style={{ margin: 0 }}>{award.category}</h3>
|
||||||
const sessionEmojis = JSON.parse(localStorage.getItem(key) || '{}');
|
<span style={{ fontSize: '0.85rem', color: '#1E90FF', fontStyle: 'italic' }}>{award.address}</span>
|
||||||
const canRemove = sessionEmojis[emoji] > 0;
|
</div>
|
||||||
return (
|
<div className="emoji-reactions">
|
||||||
<span
|
{award.emoji_tally && Object.entries(award.emoji_tally).map(([emoji, count]) => {
|
||||||
key={emoji}
|
const canRemove = sessionEmojis[emoji] > 0;
|
||||||
className="emoji-display"
|
return (
|
||||||
style={{ cursor: canRemove ? 'pointer' : 'not-allowed', opacity: canRemove ? 1 : 0.5 }}
|
<span
|
||||||
onClick={() => canRemove && handleEmojiRemove(index, emoji)}
|
key={emoji}
|
||||||
title={canRemove ? "Click to remove your emoji reaction" : "You can only remove emojis you added in this session"}
|
className="emoji-display"
|
||||||
>
|
style={{ cursor: canRemove ? 'pointer' : 'not-allowed', opacity: canRemove ? 1 : 0.5 }}
|
||||||
{emoji} {count > 1 && <span className="emoji-count">({count})</span>}
|
onClick={(e) => { e.stopPropagation(); canRemove && handleEmojiRemove(index, emoji); }}
|
||||||
</span>
|
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>}
|
||||||
<button
|
</span>
|
||||||
className="emoji-picker-button"
|
);
|
||||||
onClick={(e) => handleEmojiButtonClick(e, index)}
|
})}
|
||||||
type="button"
|
<button
|
||||||
aria-label="Add emoji reaction"
|
className="emoji-picker-button"
|
||||||
>
|
onClick={(e) => handleEmojiButtonClick(e, index)}
|
||||||
➕
|
type="button"
|
||||||
</button>
|
aria-label="Add emoji reaction"
|
||||||
|
>
|
||||||
|
➕
|
||||||
|
</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 };
|
||||||
|
|||||||
@@ -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,60 +67,61 @@ 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) => {
|
||||||
<div
|
const sessionEmojis = getSessionEmojis(award.id);
|
||||||
key={award.id}
|
return (
|
||||||
className={`award-card ${selectedAward === award.id ? 'selected' : ''}`}
|
<div
|
||||||
onClick={() => setSelectedAward(award.id)}
|
key={award.id}
|
||||||
>
|
className={`award-card ${selectedAward === award.id ? 'selected' : ''}`}
|
||||||
<h3 className="award-title">{award.category}</h3>
|
onClick={() => setSelectedAward(award.id)}
|
||||||
<p className="award-description">{award.description || ''}</p>
|
|
||||||
<div className="award-address">{award.address}</div>
|
|
||||||
{award.submitted_by && (
|
|
||||||
<div style={{ color: '#888', fontSize: '0.95rem', marginBottom: '0.5rem', fontStyle: 'italic' }}>
|
|
||||||
Submitted by: {award.submitted_by}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="award-dates">
|
|
||||||
<span className="date-label">Submitted:</span> {formatDate(award.submitted_date)}
|
|
||||||
{award.approved_date && (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
<span className="date-label">Approved:</span> {formatDate(award.approved_date)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{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
|
|
||||||
key={emoji}
|
|
||||||
className="emoji-display"
|
|
||||||
style={{ cursor: canRemove ? 'pointer' : 'not-allowed', opacity: canRemove ? 1 : 0.5 }}
|
|
||||||
onClick={() => 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>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="emoji-picker-button"
|
|
||||||
onClick={e => handleEmojiButtonClick(e, index)}
|
|
||||||
type="button"
|
|
||||||
aria-label="Add emoji reaction"
|
|
||||||
style={{ marginTop: '0.5rem' }}
|
|
||||||
>
|
>
|
||||||
➕
|
<h3 className="award-title">{award.category}</h3>
|
||||||
</button>
|
<p className="award-description">{award.description || ''}</p>
|
||||||
</div>
|
<div className="award-address">{award.address}</div>
|
||||||
))}
|
{award.submitted_by && (
|
||||||
|
<div style={{ color: '#888', fontSize: '0.95rem', marginBottom: '0.5rem', fontStyle: 'italic' }}>
|
||||||
|
Submitted by: {award.submitted_by}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="award-dates">
|
||||||
|
<span className="date-label">Submitted:</span> {formatDate(award.submitted_date)}
|
||||||
|
{award.approved_date && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span className="date-label">Approved:</span> {formatDate(award.approved_date)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{award.emoji_tally && Object.keys(award.emoji_tally).length > 0 && (
|
||||||
|
<div className="award-emojis">
|
||||||
|
{Object.entries(award.emoji_tally).map(([emoji, count]) => {
|
||||||
|
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)}
|
||||||
|
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>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="emoji-picker-button"
|
||||||
|
onClick={e => handleEmojiButtonClick(e, index)}
|
||||||
|
type="button"
|
||||||
|
aria-label="Add emoji reaction"
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
>
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// API Configuration
|
// API Configuration
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
export default API_URL;
|
export default API_URL;
|
||||||
|
|||||||
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
|
||||||
|
};
|
||||||
|
}
|
||||||
18
k8s.yaml
18
k8s.yaml
@@ -22,7 +22,7 @@ spec:
|
|||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: awards
|
app: awards
|
||||||
replicas: 2
|
replicas: 1
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -77,6 +77,22 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
memory: "512Mi"
|
memory: "512Mi"
|
||||||
cpu: "500m"
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 4000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 4000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
Reference in New Issue
Block a user