Compare commits
8 Commits
f077bf1f0f
...
046e84e996
| Author | SHA1 | Date | |
|---|---|---|---|
| 046e84e996 | |||
| 3281811b50 | |||
| 5a1f4ca7fa | |||
| 7caa899137 | |||
| 70beda9eeb | |||
| bede2873ea | |||
| 0d4a4d8ef2 | |||
| 261bf8c5dc |
+65
-3
@@ -233,6 +233,23 @@
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.idea-card.decided {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
border-color: rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
.idea-card.decided:hover {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
border-color: rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.decided-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.idea-card h3 {
|
||||
@@ -532,7 +549,7 @@
|
||||
<div id="tourInfo" class="tour-info"></div>
|
||||
|
||||
<!-- Add Idea Section -->
|
||||
<div class="glass-card">
|
||||
<div id="addIdeaCard" class="glass-card">
|
||||
<h3>Add New Idea</h3>
|
||||
<form id="addIdeaForm">
|
||||
<div class="form-group">
|
||||
@@ -801,6 +818,9 @@
|
||||
|
||||
// Render ideas
|
||||
function renderIdeas() {
|
||||
const decidedIdea = currentTour.ideas.find(i => i.decided);
|
||||
document.getElementById("addIdeaCard").classList.toggle("hidden", !!decidedIdea);
|
||||
sortIdeasByVotes();
|
||||
if (currentView === 'cards') {
|
||||
renderCardsView();
|
||||
} else {
|
||||
@@ -812,10 +832,13 @@
|
||||
function renderCardsView() {
|
||||
const container = document.getElementById('cardsView');
|
||||
container.innerHTML = '';
|
||||
const decidedIdea = currentTour.ideas.find(i => i.decided);
|
||||
|
||||
currentTour.ideas.forEach(idea => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'idea-card';
|
||||
if (idea.decided) card.classList.add('decided');
|
||||
const badgeHTML = idea.decided ? '<span class="decided-badge">✅</span>' : '';
|
||||
|
||||
let timeBadge = "";
|
||||
if (idea.start_time && idea.end_time) {
|
||||
@@ -859,12 +882,14 @@
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
${badgeHTML}
|
||||
<h3 class="idea-title">${idea.name}</h3>
|
||||
${timeBadge}
|
||||
<p class="idea-description" style="margin-top: 8px;">${linkify(idea.description)}</p>
|
||||
<div class="vote-section">
|
||||
<span class="vote-count">👍 ${idea.voters.length} votes</span>
|
||||
<button class="btn btn-secondary btn-small" onclick="voteForIdea('${idea.id}')">Vote</button>
|
||||
${decidedIdea ? '' : `<button class="btn btn-secondary btn-small" onclick="voteForIdea('${idea.id}')">Vote</button>`}
|
||||
${decidedIdea ? '' : `<button class="btn btn-secondary btn-small" onclick="decideIdea('${idea.id}')">Decide</button>`}
|
||||
</div>
|
||||
<div class="voters-list">
|
||||
${idea.voters.map(voter => `<span class="voter-chip">${voter}</span>`).join('')}
|
||||
@@ -872,12 +897,18 @@
|
||||
`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#addIdeaForm input, #addIdeaForm textarea, #addIdeaForm button').forEach(el => {
|
||||
el.disabled = !!decidedIdea;
|
||||
});
|
||||
}
|
||||
|
||||
// Render matrix view
|
||||
function renderMatrixView() {
|
||||
const container = document.getElementById('matrixView');
|
||||
|
||||
const decidedIdea = currentTour.ideas.find(i => i.decided);
|
||||
|
||||
const allVoters = [...new Set(currentTour.ideas.flatMap(idea => idea.voters))];
|
||||
|
||||
let html = '<table class="matrix-table"><thead><tr>'
|
||||
@@ -912,7 +943,8 @@
|
||||
timeCell = `<td style="color: var(--text-secondary);">—</td>`;
|
||||
}
|
||||
|
||||
html += `<tr>`
|
||||
let rowStyle = idea.decided ? ' style="background: rgba(0,255,0,0.1)"' : '';
|
||||
html += `<tr${rowStyle}>`
|
||||
+ `<td><strong>${idea.name}</strong><br><small>${linkify(idea.description)}</small></td>`
|
||||
+ timeCell;
|
||||
allVoters.forEach(voter => {
|
||||
@@ -923,6 +955,10 @@
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
document.querySelectorAll('#addIdeaForm input, #addIdeaForm textarea, #addIdeaForm button').forEach(el => {
|
||||
el.disabled = !!decidedIdea;
|
||||
});
|
||||
}
|
||||
|
||||
// Switch view
|
||||
@@ -939,6 +975,15 @@
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function sortIdeasByVotes() {
|
||||
if (!currentTour) return;
|
||||
currentTour.ideas.sort((a, b) => {
|
||||
if (a.decided) return -1;
|
||||
if (b.decided) return 1;
|
||||
return b.voters.length - a.voters.length;
|
||||
});
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
@@ -969,6 +1014,23 @@
|
||||
setTimeout(() => voterNameInput.focus(), 100);
|
||||
}
|
||||
|
||||
async function decideIdea(ideaId) {
|
||||
if (!confirm('Mark this idea as decided?')) return;
|
||||
showLoader();
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/tours/${currentTour.id}/ideas/${ideaId}/decide`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Failed');
|
||||
const decidedIdea = await resp.json();
|
||||
currentTour.ideas = currentTour.ideas.map(i => i.id === decidedIdea.id ? decidedIdea : i);
|
||||
renderIdeas();
|
||||
} catch (err) {
|
||||
console.error('Decide error', err);
|
||||
alert('Failed to decide idea');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
voterNameInput.addEventListener('input', () => {
|
||||
voteSubmitBtn.disabled = voterNameInput.value.trim().length === 0;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
pydantic
|
||||
filelock
|
||||
@@ -20,6 +20,7 @@ class Idea(BaseModel):
|
||||
end_time: Optional[datetime] = None
|
||||
description: str
|
||||
voters: list[str] = Field(default_factory=list)
|
||||
decided: bool = False
|
||||
|
||||
class Tour(BaseModel):
|
||||
id: str
|
||||
@@ -127,6 +128,9 @@ def add_idea(tour_id: str, idea: IdeaCreate):
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
|
||||
if any(i.get("decided") for i in data["ideas"]):
|
||||
raise HTTPException(status_code=403, detail="Idea already decided")
|
||||
|
||||
idea_id = str(uuid.uuid4())
|
||||
new_idea = Idea(
|
||||
id=idea_id,
|
||||
@@ -135,6 +139,7 @@ def add_idea(tour_id: str, idea: IdeaCreate):
|
||||
voters=[],
|
||||
start_time=idea.start_time,
|
||||
end_time=idea.end_time,
|
||||
decided=False,
|
||||
)
|
||||
|
||||
data["ideas"].append(new_idea.model_dump())
|
||||
@@ -159,6 +164,8 @@ def vote_idea(tour_id: str, idea_id: str, vote: Vote):
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
|
||||
if any(i.get("decided") for i in data["ideas"]):
|
||||
raise HTTPException(status_code=403, detail="Idea already decided")
|
||||
for idea in data["ideas"]:
|
||||
if idea["id"] == idea_id:
|
||||
if vote.voterName not in idea["voters"]:
|
||||
@@ -170,6 +177,32 @@ def vote_idea(tour_id: str, idea_id: str, vote: Vote):
|
||||
|
||||
raise HTTPException(status_code=404, detail="Idea not found")
|
||||
|
||||
# ─── ENDPOINT: DECIDE IDEA ────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/tour/v1/tours/{tour_id}/ideas/{idea_id}/decide", response_model=Idea)
|
||||
def decide_idea(tour_id: str, idea_id: str):
|
||||
"""Mark an idea as decided. Only one idea can be decided per tour."""
|
||||
filepath = get_tour_filepath(tour_id)
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Tour not found")
|
||||
|
||||
lock = FileLock(filepath + ".lock")
|
||||
with lock:
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
|
||||
if any(i.get("decided") for i in data["ideas"]):
|
||||
raise HTTPException(status_code=403, detail="Idea already decided")
|
||||
|
||||
for idea in data["ideas"]:
|
||||
if idea["id"] == idea_id:
|
||||
idea["decided"] = True
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f, default=str)
|
||||
return Idea(**idea)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Idea not found")
|
||||
|
||||
# ─── MAIN: RUN UVIDORN WITH COMMAND-LINE DATA DIR ──────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user