feat: allow custom layouts when creating tours
Courses without scraped layouts can now be used in tours by entering a layout name and par manually. The layout is saved to the database for reuse. All courses are shown in the dropdown, not just those with existing layouts.
This commit is contained in:
+16
-1
@@ -44,6 +44,16 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.manual-layout {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.manual-layout input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
@@ -178,10 +188,15 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.course-entry select {
|
||||
.course-entry select,
|
||||
.manual-layout {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manual-layout {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.tour-meta {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
+105
-19
@@ -35,12 +35,17 @@ function updateCourseDropdowns() {
|
||||
|
||||
function onCourseChange(courseSelect) {
|
||||
var index = courseSelect.dataset.index;
|
||||
var layoutSelect = document.querySelector('.layout-select[data-index="' + index + '"]');
|
||||
var layoutSelect = courseSelect.parentElement.querySelector('.layout-select');
|
||||
var manualFields = courseSelect.parentElement.querySelector('.manual-layout');
|
||||
var courseId = parseInt(courseSelect.value);
|
||||
|
||||
layoutSelect.textContent = '';
|
||||
layoutSelect.disabled = true;
|
||||
|
||||
if (manualFields) {
|
||||
manualFields.style.display = 'none';
|
||||
}
|
||||
|
||||
var defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = 'Select layout...';
|
||||
@@ -51,36 +56,95 @@ function onCourseChange(courseSelect) {
|
||||
var course = coursesData.find(function(c) { return c.id === courseId; });
|
||||
if (!course) return;
|
||||
|
||||
course.layouts.forEach(function(layout) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = layout.id;
|
||||
opt.textContent = layout.name + ' (Par ' + layout.par + ')';
|
||||
layoutSelect.appendChild(opt);
|
||||
});
|
||||
if (course.layouts.length > 0) {
|
||||
course.layouts.forEach(function(layout) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = layout.id;
|
||||
opt.textContent = layout.name + ' (Par ' + layout.par + ')';
|
||||
layoutSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Add "custom" option at the end
|
||||
var customOpt = document.createElement('option');
|
||||
customOpt.value = 'custom';
|
||||
customOpt.textContent = '+ Add custom layout...';
|
||||
layoutSelect.appendChild(customOpt);
|
||||
} else {
|
||||
// No layouts — show "custom" as the only real option
|
||||
var customOpt = document.createElement('option');
|
||||
customOpt.value = 'custom';
|
||||
customOpt.textContent = '+ Add custom layout...';
|
||||
layoutSelect.appendChild(customOpt);
|
||||
}
|
||||
|
||||
layoutSelect.disabled = false;
|
||||
}
|
||||
|
||||
function onLayoutChange(layoutSelect) {
|
||||
var manualFields = layoutSelect.parentElement.querySelector('.manual-layout');
|
||||
if (layoutSelect.value === 'custom') {
|
||||
if (manualFields) manualFields.style.display = '';
|
||||
} else {
|
||||
if (manualFields) manualFields.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function createManualLayoutFields() {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'manual-layout';
|
||||
div.style.display = 'none';
|
||||
|
||||
var nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.className = 'input layout-name-input';
|
||||
nameInput.placeholder = 'Layout name';
|
||||
|
||||
var parInput = document.createElement('input');
|
||||
parInput.type = 'number';
|
||||
parInput.className = 'input layout-par-input';
|
||||
parInput.placeholder = 'Par';
|
||||
parInput.min = '1';
|
||||
parInput.style.width = '80px';
|
||||
|
||||
div.appendChild(nameInput);
|
||||
div.appendChild(parInput);
|
||||
return div;
|
||||
}
|
||||
|
||||
var courseIndex = 1;
|
||||
|
||||
function addCourseEntry() {
|
||||
var container = document.getElementById('course-selector');
|
||||
function createCourseEntry(index) {
|
||||
var entry = document.createElement('div');
|
||||
entry.className = 'course-entry';
|
||||
|
||||
var courseSelect = document.createElement('select');
|
||||
courseSelect.className = 'input course-select';
|
||||
courseSelect.dataset.index = courseIndex;
|
||||
courseSelect.dataset.index = index;
|
||||
populateSelectWithCourses(courseSelect);
|
||||
courseSelect.addEventListener('change', function() { onCourseChange(courseSelect); });
|
||||
|
||||
var layoutSelect = document.createElement('select');
|
||||
layoutSelect.className = 'input layout-select';
|
||||
layoutSelect.dataset.index = courseIndex;
|
||||
layoutSelect.dataset.index = index;
|
||||
layoutSelect.disabled = true;
|
||||
var defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = 'Select course first';
|
||||
layoutSelect.appendChild(defaultOpt);
|
||||
layoutSelect.addEventListener('change', function() { onLayoutChange(layoutSelect); });
|
||||
|
||||
var manualFields = createManualLayoutFields();
|
||||
|
||||
entry.appendChild(courseSelect);
|
||||
entry.appendChild(layoutSelect);
|
||||
entry.appendChild(manualFields);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function addCourseEntry() {
|
||||
var container = document.getElementById('course-selector');
|
||||
var entry = createCourseEntry(courseIndex);
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-remove';
|
||||
@@ -90,8 +154,6 @@ function addCourseEntry() {
|
||||
removeBtn.appendChild(icon);
|
||||
removeBtn.addEventListener('click', function() { entry.remove(); });
|
||||
|
||||
entry.appendChild(courseSelect);
|
||||
entry.appendChild(layoutSelect);
|
||||
entry.appendChild(removeBtn);
|
||||
container.appendChild(entry);
|
||||
|
||||
@@ -109,14 +171,30 @@ async function createTour() {
|
||||
}
|
||||
|
||||
var courses = [];
|
||||
var valid = true;
|
||||
document.querySelectorAll('.course-entry').forEach(function(entry) {
|
||||
var courseId = entry.querySelector('.course-select').value;
|
||||
var layoutId = entry.querySelector('.layout-select').value;
|
||||
if (courseId && layoutId) {
|
||||
courses.push({ courseId: parseInt(courseId), layoutId: parseInt(layoutId) });
|
||||
var layoutSelect = entry.querySelector('.layout-select');
|
||||
var layoutValue = layoutSelect.value;
|
||||
|
||||
if (!courseId) return;
|
||||
|
||||
if (layoutValue === 'custom') {
|
||||
var layoutName = entry.querySelector('.layout-name-input').value.trim();
|
||||
var par = entry.querySelector('.layout-par-input').value;
|
||||
if (!layoutName || !par) {
|
||||
alert('Please fill in layout name and par for custom layouts');
|
||||
valid = false;
|
||||
return;
|
||||
}
|
||||
courses.push({ courseId: parseInt(courseId), layoutName: layoutName, par: parseInt(par) });
|
||||
} else if (layoutValue) {
|
||||
courses.push({ courseId: parseInt(courseId), layoutId: parseInt(layoutValue) });
|
||||
}
|
||||
});
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
if (courses.length === 0) {
|
||||
alert('Please select at least one course with a layout');
|
||||
return;
|
||||
@@ -160,10 +238,18 @@ function goToTour() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCourses();
|
||||
|
||||
// Delegate change events for course selects
|
||||
document.getElementById('course-selector').addEventListener('change', function(e) {
|
||||
// Replace the initial static course entry with a dynamic one
|
||||
var container = document.getElementById('course-selector');
|
||||
container.textContent = '';
|
||||
container.appendChild(createCourseEntry(0));
|
||||
|
||||
// Delegate change events
|
||||
container.addEventListener('change', function(e) {
|
||||
if (e.target.classList.contains('course-select')) {
|
||||
onCourseChange(e.target);
|
||||
}
|
||||
if (e.target.classList.contains('layout-select')) {
|
||||
onLayoutChange(e.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,10 +69,33 @@ function updateLayoutRating(courseId, layoutName, par, meanRating, ratingCount,
|
||||
});
|
||||
}
|
||||
|
||||
function getOrCreateLayout(courseId, name, par) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT id FROM layouts WHERE course_id = ? AND name = ? AND par = ?',
|
||||
[courseId, name, par],
|
||||
(err, row) => {
|
||||
if (err) return reject(err);
|
||||
if (row) return resolve(row.id);
|
||||
|
||||
db.run(
|
||||
'INSERT INTO layouts (course_id, name, par) VALUES (?, ?, ?)',
|
||||
[courseId, name, par],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this.lastID);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveCourseToDB,
|
||||
getAllCoursesFromDB,
|
||||
saveLayoutToDB,
|
||||
getLayoutsForCourse,
|
||||
getOrCreateLayout,
|
||||
updateLayoutRating
|
||||
};
|
||||
|
||||
+17
-8
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllCoursesFromDB, getLayoutsForCourse } = require('../models/course');
|
||||
const { getAllCoursesFromDB, getLayoutsForCourse, getOrCreateLayout } = require('../models/course');
|
||||
const {
|
||||
createTour, getTourByCode, addCourseToTour, getTourCourses,
|
||||
getTourCourseById, joinTour, getPlayerInTour, recordResult
|
||||
@@ -25,7 +25,18 @@ router.post('/api/tours', async (req, res) => {
|
||||
const tourId = await createTour(name, code, startDate, endDate);
|
||||
|
||||
for (const course of courses) {
|
||||
await addCourseToTour(tourId, course.courseId, course.layoutId);
|
||||
let layoutId = course.layoutId;
|
||||
|
||||
// Create new layout if name and par provided instead of layoutId
|
||||
if (!layoutId && course.layoutName && course.par) {
|
||||
layoutId = await getOrCreateLayout(course.courseId, course.layoutName, parseInt(course.par));
|
||||
}
|
||||
|
||||
if (!layoutId) {
|
||||
return res.status(400).json({ error: 'Each course needs a layout (existing or new with name and par)' });
|
||||
}
|
||||
|
||||
await addCourseToTour(tourId, course.courseId, layoutId);
|
||||
}
|
||||
|
||||
logger.info(`Tour created: "${name}" (${code}) with ${courses.length} courses`);
|
||||
@@ -121,20 +132,18 @@ router.get('/partials/tour-leaderboard/:code', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get courses and layouts for tour creation form
|
||||
// Get all courses with their layouts (if any) for tour creation form
|
||||
router.get('/api/tours/courses-with-layouts', async (req, res) => {
|
||||
try {
|
||||
const courses = await getAllCoursesFromDB();
|
||||
const coursesWithLayouts = [];
|
||||
const result = [];
|
||||
|
||||
for (const course of courses) {
|
||||
const layouts = await getLayoutsForCourse(course.id);
|
||||
if (layouts.length > 0) {
|
||||
coursesWithLayouts.push({ ...course, layouts });
|
||||
}
|
||||
result.push({ ...course, layouts });
|
||||
}
|
||||
|
||||
res.json(coursesWithLayouts);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching courses with layouts:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch courses' });
|
||||
|
||||
Reference in New Issue
Block a user