First version.
This commit is contained in:
commit
0331bdb9a9
861
index.html
Normal file
861
index.html
Normal file
@ -0,0 +1,861 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TourVote - Futuristic Tour Planning</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: rgba(255, 255, 255, 0.03);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0b8;
|
||||
--accent-primary: #00d4ff;
|
||||
--accent-secondary: #7b2ff7;
|
||||
--accent-gradient: linear-gradient(135deg, #00d4ff, #7b2ff7);
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--glow: 0 0 20px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated background */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 50%, rgba(0, 212, 255, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(123, 47, 247, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 20%, rgba(0, 212, 255, 0.05) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 20px 40px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 60px 0 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 3px;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: var(--glow);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Glassmorphism cards */
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-card h2 {
|
||||
margin-bottom: 20px; /* pushes the next form‐group down */
|
||||
}
|
||||
|
||||
.glass-card h3 {
|
||||
margin-bottom: 16px; /* pushes the next form‐group down */
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 12px 40px rgba(0, 212, 255, 0.2);
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.date-row {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.date-row .form-group {
|
||||
-webkit-flex: 0 0 calc((100% - 42px) / 2);
|
||||
flex: 0 0 calc((100% - 42px) / 2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box; /* ← ensure padding is included in width */
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--glow);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 2px solid var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ideas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.idea-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.idea-card h3 {
|
||||
margin-bottom: 20px; /* pushes the next form‐group down */
|
||||
}
|
||||
|
||||
.idea-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.idea-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.idea-description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.voters-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.voter-chip {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.vote-section {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.vote-count {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Tour info */
|
||||
.tour-info {
|
||||
background: rgba(123, 47, 247, 0.1);
|
||||
border: 1px solid rgba(123, 47, 247, 0.3);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.tour-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tour-date {
|
||||
color: var(--accent-primary);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Hidden class */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loader {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top: 3px solid var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.logo {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ideas-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Matrix view */
|
||||
.matrix-view {
|
||||
overflow-x: auto;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.matrix-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.matrix-table th, .matrix-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.matrix-table th {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.matrix-table td {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.vote-cell {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.toggle-view {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.date-badge {
|
||||
background: var(--accent-gradient);
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--bg-primary);
|
||||
margin-right: 8px;
|
||||
text-shadow: var(--glow);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.date-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<h1 class="logo"><a href=".">TourVote</a></h1>
|
||||
<p class="subtitle">Collaborative Tour Planning Platform</p>
|
||||
</header>
|
||||
|
||||
<div id="createTourSection" class="glass-card">
|
||||
<h2>Create a New Tour</h2>
|
||||
<form id="createTourForm">
|
||||
<div class="form-group">
|
||||
<label for="tourName">Tour Name</label>
|
||||
<input type="text" id="tourName" required placeholder="Enter tour name">
|
||||
</div>
|
||||
<div class="date-row">
|
||||
<div class="form-group">
|
||||
<label class="date-label" for="tourStartDate">Start Date</label>
|
||||
<input type="date" id="tourStartDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="date-label" for="tourEndDate">End Date</label>
|
||||
<input type="date" id="tourEndDate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tourDescription">Description</label>
|
||||
<textarea id="tourDescription" required placeholder="Describe your tour"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Tour</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tour View Section -->
|
||||
<div id="tourViewSection" class="hidden">
|
||||
<!-- Tour Info -->
|
||||
<div id="tourInfo" class="tour-info"></div>
|
||||
|
||||
<!-- Add Idea Section -->
|
||||
<div class="glass-card">
|
||||
<h3>Add New Idea</h3>
|
||||
<form id="addIdeaForm">
|
||||
<div class="form-group">
|
||||
<label for="ideaName">Idea Name</label>
|
||||
<input type="text" id="ideaName" required placeholder="Enter idea name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ideaDescription">Description</label>
|
||||
<textarea id="ideaDescription" required placeholder="Describe your idea"></textarea>
|
||||
</div>
|
||||
<div class="date-row">
|
||||
<div class="form-group">
|
||||
<label class="date-label" for="ideaStartTime">Start Time</label>
|
||||
<input type="datetime-local" id="ideaStartTime">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="date-label" for="ideaEndTime">End Time</label>
|
||||
<input type="datetime-local" id="ideaEndTime">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">Add Idea</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<div class="toggle-view">
|
||||
<button class="btn btn-secondary btn-small" onclick="showView('cards')">Card View</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="showView('matrix')">Matrix View</button>
|
||||
</div>
|
||||
|
||||
<!-- Ideas Display -->
|
||||
<div id="ideasContainer">
|
||||
<div id="cardsView" class="ideas-grid"></div>
|
||||
<div id="matrixView" class="matrix-view hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="loader" class="loader hidden"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mock API URL - Replace with your actual API endpoint
|
||||
const API_URL = 'https://kaizenkodo.no/tour/v1';
|
||||
|
||||
// State management
|
||||
let currentTour = null;
|
||||
let currentView = 'cards';
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkForTourId();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// Check if tour ID is in URL
|
||||
function checkForTourId() {
|
||||
const tourId = window.location.hash.slice(1);
|
||||
if (tourId) {
|
||||
loadTour(tourId);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
document.getElementById('createTourForm').addEventListener('submit', createTour);
|
||||
document.getElementById('addIdeaForm').addEventListener('submit', addIdea);
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', checkForTourId);
|
||||
}
|
||||
|
||||
// Create new tour
|
||||
async function createTour(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const startDate = document.getElementById('tourStartDate').value;
|
||||
const endDate = document.getElementById('tourEndDate').value;
|
||||
const payload = {
|
||||
name: document.getElementById('tourName').value,
|
||||
description: document.getElementById('tourDescription').value,
|
||||
...(startDate ? { start_date: startDate } : {}),
|
||||
...(endDate ? { end_date: endDate } : {})
|
||||
};
|
||||
|
||||
showLoader();
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tours`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
// FastAPI returns the full Tour JSON (with id, createdAt, ideas=[])
|
||||
const tourData = await response.json();
|
||||
currentTour = tourData;
|
||||
|
||||
// Push the new tour’s ID into the URL hash so loadTour() can pick it up
|
||||
window.location.hash = tourData.id;
|
||||
|
||||
showTourView();
|
||||
} catch (error) {
|
||||
console.error('Error creating tour:', error);
|
||||
alert('Failed to create tour. Please try again.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing tour
|
||||
async function loadTour(tourId) {
|
||||
showLoader();
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/tours/${tourId}`);
|
||||
if (response.status === 404) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
const tourData = await response.json();
|
||||
currentTour = tourData;
|
||||
showTourView();
|
||||
} catch (error) {
|
||||
console.error('Error loading tour:', error);
|
||||
alert('Tour not found or access denied.');
|
||||
window.location.hash = '';
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
// Add new idea
|
||||
async function addIdea(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Only send what FastAPI’s IdeaCreate expects:
|
||||
const name = document.getElementById('ideaName').value;
|
||||
const desc = document.getElementById('ideaDescription').value;
|
||||
const startTime = document.getElementById('ideaStartTime').value;
|
||||
const endTime = document.getElementById('ideaEndTime').value;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
description: desc,
|
||||
...(startTime ? { start_time: startTime } : {}),
|
||||
...(endTime ? { end_time: endTime } : {})
|
||||
};
|
||||
|
||||
showLoader();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/tours/${currentTour.id}/ideas`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
if (response.status === 404) {
|
||||
throw new Error('Tour not found');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
// FastAPI sends back the full Idea object, including id and empty voters
|
||||
const newIdea = await response.json();
|
||||
currentTour.ideas.push(newIdea);
|
||||
renderIdeas();
|
||||
document.getElementById('addIdeaForm').reset();
|
||||
} catch (error) {
|
||||
console.error('Error adding idea:', error);
|
||||
alert('Failed to add idea. Please try again.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
// Vote for idea
|
||||
async function voteForIdea(ideaId) {
|
||||
const voterName = prompt('Enter your name to vote:');
|
||||
if (!voterName) return;
|
||||
|
||||
showLoader();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/tours/${currentTour.id}/ideas/${ideaId}/vote`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voterName })
|
||||
}
|
||||
);
|
||||
if (response.status === 404) {
|
||||
throw new Error('Tour or Idea not found');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
// FastAPI returns the Idea object (with updated voters list)
|
||||
const updatedIdea = await response.json();
|
||||
// Replace the old idea in currentTour.ideas with the updated one
|
||||
currentTour.ideas = currentTour.ideas.map(i =>
|
||||
i.id === updatedIdea.id ? updatedIdea : i
|
||||
);
|
||||
renderIdeas();
|
||||
} catch (error) {
|
||||
console.error('Error voting:', error);
|
||||
alert('Failed to vote. Please try again.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
// Show tour view
|
||||
function showTourView() {
|
||||
document.getElementById('createTourSection').classList.add('hidden');
|
||||
document.getElementById('tourViewSection').classList.remove('hidden');
|
||||
|
||||
// Display tour info
|
||||
const tourInfo = document.getElementById('tourInfo');
|
||||
|
||||
// Build a friendly date‐range display:
|
||||
let dateHTML = "";
|
||||
if (currentTour.start_date && currentTour.end_date) {
|
||||
const s = new Date(currentTour.start_date).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
const e = new Date(currentTour.end_date).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
dateHTML = `<span class="date-badge">📅 ${s} – ${e}</span>`;
|
||||
} else if (currentTour.start_date) {
|
||||
const s = new Date(currentTour.start_date).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
dateHTML = `<span class="date-badge">📅 Starts ${s}</span>`;
|
||||
} else if (currentTour.end_date) {
|
||||
const e = new Date(currentTour.end_date).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
dateHTML = `<span class="date-badge">📅 Ends ${e}</span>`;
|
||||
} else {
|
||||
dateHTML = `<span class="date-badge">📅 TBD</span>`;
|
||||
}
|
||||
|
||||
tourInfo.innerHTML = `
|
||||
<h2 class="tour-title">${currentTour.name}</h2>
|
||||
${dateHTML}
|
||||
<p style="margin-top: 12px;">${currentTour.description}</p>
|
||||
`;
|
||||
|
||||
renderIdeas();
|
||||
}
|
||||
|
||||
// Render ideas
|
||||
function renderIdeas() {
|
||||
if (currentView === 'cards') {
|
||||
renderCardsView();
|
||||
} else {
|
||||
renderMatrixView();
|
||||
}
|
||||
}
|
||||
|
||||
// Render cards view
|
||||
function renderCardsView() {
|
||||
const container = document.getElementById('cardsView');
|
||||
container.innerHTML = '';
|
||||
|
||||
currentTour.ideas.forEach(idea => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'idea-card';
|
||||
|
||||
let timeBadge = "";
|
||||
if (idea.start_time && idea.end_time) {
|
||||
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
timeBadge = `<span class="date-badge">${s} → ${e}</span>`;
|
||||
} else if (idea.start_time) {
|
||||
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
timeBadge = `<span class="date-badge">🕒 Starts ${s}</span>`;
|
||||
} else if (idea.end_time) {
|
||||
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
timeBadge = `<span class="date-badge">🕒 Ends ${e}</span>`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<h3 class="idea-title">${idea.name}</h3>
|
||||
${timeBadge}
|
||||
<p class="idea-description" style="margin-top: 8px;">${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>
|
||||
</div>
|
||||
<div class="voters-list">
|
||||
${idea.voters.map(voter => `<span class="voter-chip">${voter}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Render matrix view
|
||||
function renderMatrixView() {
|
||||
const container = document.getElementById('matrixView');
|
||||
|
||||
const allVoters = [...new Set(currentTour.ideas.flatMap(idea => idea.voters))];
|
||||
|
||||
let html = '<table class="matrix-table"><thead><tr>'
|
||||
+ '<th>Idea</th><th>Time Range</th>';
|
||||
allVoters.forEach(voter => {
|
||||
html += `<th>${voter}</th>`;
|
||||
});
|
||||
html += '<th>Total</th></tr></thead><tbody>';
|
||||
|
||||
currentTour.ideas.forEach(idea => {
|
||||
// Format time only (HH:mm) in Norwegian:
|
||||
let timeCell = "";
|
||||
if (idea.start_time && idea.end_time) {
|
||||
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
timeCell = `<td>${s} → ${e}</td>`;
|
||||
} else if (idea.start_time) {
|
||||
const s = new Date(idea.start_time).toLocaleDateString('nb-NO', {
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
timeCell = `<td>Starts ${s}</td>`;
|
||||
} else if (idea.end_time) {
|
||||
const e = new Date(idea.end_time).toLocaleDateString('nb-NO', {
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
timeCell = `<td>Ends ${e}</td>`;
|
||||
} else {
|
||||
timeCell = `<td style="color: var(--text-secondary);">—</td>`;
|
||||
}
|
||||
|
||||
html += `<tr>`
|
||||
+ `<td><strong>${idea.name}</strong><br><small>${idea.description}</small></td>`
|
||||
+ timeCell;
|
||||
allVoters.forEach(voter => {
|
||||
html += `<td class="vote-cell">${idea.voters.includes(voter) ? '✓' : ''}</td>`;
|
||||
});
|
||||
html += `<td class="vote-cell">${idea.voters.length}</td></tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Switch view
|
||||
function showView(view) {
|
||||
currentView = view;
|
||||
if (view === 'cards') {
|
||||
document.getElementById('cardsView').classList.remove('hidden');
|
||||
document.getElementById('matrixView').classList.add('hidden');
|
||||
} else {
|
||||
document.getElementById('cardsView').classList.add('hidden');
|
||||
document.getElementById('matrixView').classList.remove('hidden');
|
||||
}
|
||||
renderIdeas();
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function showLoader() {
|
||||
document.getElementById('loader').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLoader() {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
189
server.py
Normal file
189
server.py
Normal file
@ -0,0 +1,189 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI, HTTPException, Path
|
||||
from pydantic import BaseModel, Field
|
||||
import argparse
|
||||
import uvicorn
|
||||
|
||||
app = FastAPI(title="Tour Voting API")
|
||||
|
||||
# ─── Pydantic MODELS ──────────────────────────────────────────────────────────
|
||||
|
||||
class Idea(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
description: str
|
||||
voters: list[str] = Field(default_factory=list)
|
||||
|
||||
class Tour(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
description: str
|
||||
ideas: list[Idea] = Field(default_factory=list)
|
||||
createdAt: datetime
|
||||
|
||||
class TourCreate(BaseModel):
|
||||
name: str
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
description: str
|
||||
|
||||
class IdeaCreate(BaseModel):
|
||||
name: str
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
description: str
|
||||
|
||||
class Vote(BaseModel):
|
||||
voterName: str
|
||||
|
||||
# ─── GLOBAL CONFIG ────────────────────────────────────────────────────────────
|
||||
|
||||
DATA_DIR = "" # will be overridden via command line
|
||||
|
||||
def get_tour_filepath(tour_id: str) -> str:
|
||||
return os.path.join(DATA_DIR, f"{tour_id}.json")
|
||||
|
||||
# ─── ENDPOINT: CREATE TOUR ─────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/tour/v1/tours", response_model=Tour)
|
||||
def create_tour(tour: TourCreate):
|
||||
"""
|
||||
Create a new tour. Generates its own UUID and timestamp, then writes to {tour_id}.json.
|
||||
"""
|
||||
tour_id = str(uuid.uuid4())
|
||||
created_at = datetime.utcnow()
|
||||
|
||||
new_tour = Tour(
|
||||
id=tour_id,
|
||||
name=tour.name,
|
||||
start_date=tour.start_date,
|
||||
end_date=tour.end_date,
|
||||
description=tour.description,
|
||||
ideas=[],
|
||||
createdAt=created_at,
|
||||
)
|
||||
|
||||
filepath = get_tour_filepath(tour_id)
|
||||
with open(filepath, "w") as f:
|
||||
# Using default=str so that datetime objects become ISO strings
|
||||
json.dump(new_tour.dict(), f, default=str)
|
||||
|
||||
return new_tour
|
||||
|
||||
# ─── ENDPOINT: GET TOUR ────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/tour/v1/tours/{tour_id}", response_model=Tour)
|
||||
def get_tour(tour_id: str = Path(...)):
|
||||
"""
|
||||
Retrieve an existing tour by ID. Loads {tour_id}.json and parses back into a Tour.
|
||||
"""
|
||||
filepath = get_tour_filepath(tour_id)
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Tour not found")
|
||||
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
|
||||
if data.get("start_date"):
|
||||
data["start_date"] = datetime.fromisoformat(data["start_date"])
|
||||
if data.get("end_date"):
|
||||
data["end_date"] = datetime.fromisoformat(data["end_date"])
|
||||
data["createdAt"] = datetime.fromisoformat(data["createdAt"])
|
||||
|
||||
# Convert nested idea timestamps as well
|
||||
for idea in data["ideas"]:
|
||||
if idea.get("start_time"):
|
||||
idea["start_time"] = datetime.fromisoformat(idea["start_time"])
|
||||
if idea.get("end_time"):
|
||||
idea["end_time"] = datetime.fromisoformat(idea["end_time"])
|
||||
return Tour(**data)
|
||||
|
||||
# ─── ENDPOINT: ADD IDEA ─────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/tour/v1/tours/{tour_id}/ideas", response_model=Idea)
|
||||
def add_idea(tour_id: str, idea: IdeaCreate):
|
||||
"""
|
||||
Add a new idea to the given tour. Generates a new UUID for the idea and appends it.
|
||||
"""
|
||||
filepath = get_tour_filepath(tour_id)
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Tour not found")
|
||||
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
|
||||
idea_id = str(uuid.uuid4())
|
||||
new_idea = Idea(
|
||||
id=idea_id,
|
||||
name=idea.name,
|
||||
description=idea.description,
|
||||
voters=[],
|
||||
start_time=idea.start_time,
|
||||
end_time=idea.end_time,
|
||||
)
|
||||
|
||||
data["ideas"].append(new_idea.dict())
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f, default=str)
|
||||
|
||||
return new_idea
|
||||
|
||||
# ─── ENDPOINT: VOTE FOR IDEA ───────────────────────────────────────────────────
|
||||
|
||||
@app.post("/tour/v1/tours/{tour_id}/ideas/{idea_id}/vote", response_model=Idea)
|
||||
def vote_idea(tour_id: str, idea_id: str, vote: Vote):
|
||||
"""
|
||||
Cast a vote on a specific idea. Adds voterName if not already present.
|
||||
"""
|
||||
filepath = get_tour_filepath(tour_id)
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="Tour not found")
|
||||
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
|
||||
for idea in data["ideas"]:
|
||||
if idea["id"] == idea_id:
|
||||
if vote.voterName not in idea["voters"]:
|
||||
idea["voters"].append(vote.voterName)
|
||||
# Persist the change
|
||||
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__":
|
||||
parser = argparse.ArgumentParser(description="Run Tour Voting API server")
|
||||
parser.add_argument(
|
||||
"--data-dir",
|
||||
required=True,
|
||||
help="Directory in which to store tour JSON files (one file per tour_id)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="0.0.0.0",
|
||||
help="Host to run the server on",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="Port to run the server on",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
DATA_DIR = args.data_dir
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port, reload=False)
|
||||
Loading…
x
Reference in New Issue
Block a user