TourPlanner/index.html
Oleksandr Kozachuk 0331bdb9a9 First version.
2025-06-04 00:00:44 +02:00

862 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 formgroup down */
}
.glass-card h3 {
margin-bottom: 16px; /* pushes the next formgroup 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 formgroup 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 tours 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 FastAPIs 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 daterange 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>