Add editable option approval flow

This commit is contained in:
TopherMayor
2026-04-30 22:39:51 -07:00
parent 1674930435
commit 1e36d45976
3 changed files with 82 additions and 8 deletions

View File

@@ -120,6 +120,8 @@
.btn-reject { background: var(--red); color: #fff; } .btn-reject { background: var(--red); color: #fff; }
.btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); } .btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
.btn-delete:hover { border-color: var(--red); color: var(--red); } .btn-delete:hover { border-color: var(--red); color: var(--red); }
.btn-edit { background: transparent; border: 1px solid var(--border); color: var(--amber); }
.btn-edit:hover { border-color: var(--amber); color: #fcd34d; }
.btn-row { display: flex; gap: 6px; flex-shrink: 0; } .btn-row { display: flex; gap: 6px; flex-shrink: 0; }
/* Toast */ /* Toast */
@@ -278,6 +280,7 @@ function renderPending() {
<div class="name">${o.name}</div> <div class="name">${o.name}</div>
<div class="meta">by ${o.addedBy || 'unknown'}</div> <div class="meta">by ${o.addedBy || 'unknown'}</div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-edit" onclick="editOption('${o.id}')">✎ Edit</button>
<button class="btn btn-approve" onclick="approve('${o.id}')">✓ Approve</button> <button class="btn btn-approve" onclick="approve('${o.id}')">✓ Approve</button>
<button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button> <button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button>
</div> </div>
@@ -295,12 +298,45 @@ function renderAll() {
<div class="votes-badge">${o.votes.length} vote${o.votes.length !== 1 ? 's' : ''}</div> <div class="votes-badge">${o.votes.length} vote${o.votes.length !== 1 ? 's' : ''}</div>
<div class="meta">${o.addedBy !== 'system' ? 'by ' + o.addedBy : 'system'}</div> <div class="meta">${o.addedBy !== 'system' ? 'by ' + o.addedBy : 'system'}</div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-edit" onclick="editOption('${o.id}')">✎ Edit</button>
<button class="btn btn-delete" onclick="deleteOption('${o.id}')" title="Delete option">🗑</button> <button class="btn btn-delete" onclick="deleteOption('${o.id}')" title="Delete option">🗑</button>
</div> </div>
</div> </div>
`).join(''); `).join('');
} }
async function editOption(id) {
const current = allData.options.find(o => o.id === id);
if (!current) return;
const nextName = prompt('Option name', current.name);
if (nextName === null) return;
const nextDesc = prompt('Short description', current.desc || '');
if (nextDesc === null) return;
const nextDetails = prompt('Details, one per line', Array.isArray(current.details) ? current.details.join('\\n') : '');
if (nextDetails === null) return;
const nextUrl = prompt('Website URL', current.url || '');
if (nextUrl === null) return;
const nextCategory = prompt('Category id', current.categoryId);
if (nextCategory === null) return;
const nextApproved = confirm('Approve this option now? Click OK to approve, Cancel to keep it pending.');
try {
await fetch(API + '/api/options/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: nextName.trim(),
desc: nextDesc.trim(),
details: nextDetails,
url: nextUrl.trim(),
categoryId: nextCategory.trim(),
approved: nextApproved,
})
});
toast('Option updated', 'success');
await loadData();
} catch(e) { toast('Failed to update option', 'error'); }
}
async function togglePolls() { async function togglePolls() {
try { try {
// Get current state first // Get current state first

View File

@@ -1638,6 +1638,7 @@
<div class="form-grid"> <div class="form-grid">
<input type="text" id="addName" placeholder="Name of the place (required)" maxlength="80" /> <input type="text" id="addName" placeholder="Name of the place (required)" maxlength="80" />
<input type="text" id="addDesc" placeholder="Short description — price, vibe, what to expect…" maxlength="200" /> <input type="text" id="addDesc" placeholder="Short description — price, vibe, what to expect…" maxlength="200" />
<textarea id="addDetails" placeholder="Details on separate lines — price, inclusions, caveats, or notes…" maxlength="500" rows="3" style="background:transparent;border:1px solid #252a38;border-radius:10px;color:#e0e6f0;padding:10px;resize:vertical;min-height:84px;"></textarea>
<input type="url" id="addUrl" placeholder="Website URL (optional)" /> <input type="url" id="addUrl" placeholder="Website URL (optional)" />
<div class="btn-row"> <div class="btn-row">
<select id="addCategory"> <select id="addCategory">
@@ -1909,13 +1910,15 @@
}); });
render(); render();
if (mapInitialized) mapRefreshMarkers(); if (mapInitialized) mapRefreshMarkers();
} else if (msg.type === 'option_added' || msg.type === 'option_approved') { } else if (msg.type === 'option_added' || msg.type === 'option_approved' || msg.type === 'option_updated') {
if (!state.options.find(o => o.id === msg.option.id)) { if (!state.options.find(o => o.id === msg.option.id)) {
state.options.push(msg.option); state.options.push(msg.option);
} else {
state.options = state.options.map(o => o.id === msg.option.id ? { ...o, ...msg.option } : o);
}
renderTabs(); renderTabs();
render(); render();
if (mapInitialized) mapRefreshMarkers(); if (mapInitialized) mapRefreshMarkers();
}
} else if (msg.type === 'option_deleted') { } else if (msg.type === 'option_deleted') {
state.options = state.options.filter(o => o.id !== msg.id); state.options = state.options.filter(o => o.id !== msg.id);
renderTabs(); renderTabs();
@@ -2968,6 +2971,7 @@
function submitNewOption() { function submitNewOption() {
const name = document.getElementById('addName').value.trim(); const name = document.getElementById('addName').value.trim();
const desc = document.getElementById('addDesc').value.trim(); const desc = document.getElementById('addDesc').value.trim();
const details = document.getElementById('addDetails').value.trim();
const url = document.getElementById('addUrl').value.trim(); const url = document.getElementById('addUrl').value.trim();
const catId = document.getElementById('addCategory').value; const catId = document.getElementById('addCategory').value;
@@ -2980,11 +2984,12 @@
return; return;
} }
wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName, authToken: state.guestAuthToken }); wsSend({ type: 'add_option', categoryId: catId, name, desc, details, url, voterName: state.voterName, authToken: state.guestAuthToken });
// Clear form // Clear form
document.getElementById('addName').value = ''; document.getElementById('addName').value = '';
document.getElementById('addDesc').value = ''; document.getElementById('addDesc').value = '';
document.getElementById('addDetails').value = '';
document.getElementById('addUrl').value = ''; document.getElementById('addUrl').value = '';
showToast(`Submitted "${name}" for approval!`, 'success'); showToast(`Submitted "${name}" for approval!`, 'success');
} }

View File

@@ -704,7 +704,20 @@ function buildRealtimeSnapshot() {
}; };
} }
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) { function normalizeDetails(details) {
if (Array.isArray(details)) {
return details.map((item) => String(item || '').trim()).filter(Boolean);
}
if (typeof details === 'string') {
return details
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved, details }) {
return { return {
id: uuidv4(), id: uuidv4(),
seedKey: null, seedKey: null,
@@ -718,7 +731,7 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
addedBy: voterName, addedBy: voterName,
approved, approved,
votes: [], votes: [],
details: [], details: normalizeDetails(details),
categoryColor: CATEGORY_META[categoryId]?.color || '#888', categoryColor: CATEGORY_META[categoryId]?.color || '#888',
}; };
} }
@@ -877,7 +890,7 @@ app.delete('/api/vote/:optionId', (req, res) => {
}); });
app.post('/api/options', (req, res) => { app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, lat, lng } = req.body; const { categoryId, name, desc, url, lat, lng, details } = req.body;
const guest = requireGuestAuth(req, res, req.body); const guest = requireGuestAuth(req, res, req.body);
if (!categoryId || !name) { if (!categoryId || !name) {
@@ -896,6 +909,7 @@ app.post('/api/options', (req, res) => {
voterName: guest.name, voterName: guest.name,
lat, lat,
lng, lng,
details,
approved: false, approved: false,
}); });
@@ -915,6 +929,25 @@ app.post('/api/options/:id/approve', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
app.patch('/api/options/:id', (req, res) => {
const option = data.options.find((candidate) => candidate.id === req.params.id);
if (!option) return res.status(404).json({ error: 'Not found' });
const { categoryId, name, desc, url, lat, lng, details, approved } = req.body;
if (categoryId !== undefined) option.categoryId = categoryId;
if (name !== undefined) option.name = String(name || '').trim();
if (desc !== undefined) option.desc = String(desc || '').trim();
if (url !== undefined) option.url = String(url || '').trim() || null;
if (lat !== undefined) option.lat = Number.isFinite(Number(lat)) ? Number(lat) : null;
if (lng !== undefined) option.lng = Number.isFinite(Number(lng)) ? Number(lng) : null;
if (details !== undefined) option.details = normalizeDetails(details);
if (approved !== undefined) option.approved = Boolean(approved);
saveData(data);
broadcast({ type: 'option_updated', option });
res.json({ success: true, option });
});
app.delete('/api/options/:id', (req, res) => { app.delete('/api/options/:id', (req, res) => {
const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id); const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
if (optionIndex === -1) return res.status(404).json({ error: 'Not found' }); if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });