diff --git a/public/css/courses.css b/public/css/courses.css index 672364c..8ff8ed7 100644 --- a/public/css/courses.css +++ b/public/css/courses.css @@ -131,3 +131,37 @@ opacity: 0.6; border-style: dashed; } + +/* ── Tjing Import ────────────────────────────── */ + +.tjing-result { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + margin: 6px 0; + background: var(--surface-2); + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +.tjing-result-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.tjing-result-name { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); +} + +.tjing-result-address { + font-size: 13px; + color: var(--text-secondary); +} + +#tjing-results { + margin-top: 12px; +} diff --git a/public/js/courses.js b/public/js/courses.js index ef557eb..7ee7990 100644 --- a/public/js/courses.js +++ b/public/js/courses.js @@ -54,6 +54,88 @@ async function scrapeCourses() { } } +async function searchTjing() { + var input = document.getElementById('tjing-search'); + var query = input.value.trim(); + if (query.length < 2) return; + + var btn = document.getElementById('tjing-search-btn'); + btn.disabled = true; + + try { + var response = await fetch('/api/tjing/search?q=' + encodeURIComponent(query)); + var courses = await response.json(); + var container = document.getElementById('tjing-results'); + container.textContent = ''; + + if (courses.length === 0) { + var p = document.createElement('p'); + p.className = 'no-layouts'; + p.textContent = 'No courses found on Tjing'; + container.appendChild(p); + return; + } + + courses.forEach(function(course) { + var item = document.createElement('div'); + item.className = 'tjing-result'; + + var info = document.createElement('div'); + info.className = 'tjing-result-info'; + + var name = document.createElement('span'); + name.className = 'tjing-result-name'; + name.textContent = course.name; + + var addr = document.createElement('span'); + addr.className = 'tjing-result-address'; + addr.textContent = course.address || ''; + + info.appendChild(name); + info.appendChild(addr); + + var importBtn = document.createElement('button'); + importBtn.className = 'btn'; + importBtn.textContent = 'Import'; + importBtn.addEventListener('click', function() { importFromTjing(course.id, importBtn); }); + + item.appendChild(info); + item.appendChild(importBtn); + container.appendChild(item); + }); + } catch (error) { + console.error('Error searching Tjing:', error); + alert('Failed to search Tjing'); + } finally { + btn.disabled = false; + } +} + +async function importFromTjing(tjingId, btn) { + btn.disabled = true; + btn.textContent = 'Importing...'; + + try { + var response = await fetch('/api/tjing/import/' + tjingId, { method: 'POST' }); + var data = await response.json(); + + if (data.success) { + btn.textContent = 'Imported!'; + btn.style.background = 'var(--green)'; + htmx.ajax('GET', '/partials/course-table', '#courses-table'); + } else { + alert(data.error || 'Failed to import'); + btn.disabled = false; + btn.textContent = 'Import'; + } + } catch (error) { + console.error('Error importing from Tjing:', error); + alert('Failed to import course'); + btn.disabled = false; + btn.textContent = 'Import'; + } +} + async function scrapeLayouts(courseId, courseName) { const icon = document.querySelector(`#row-${courseId} .refresh-icon`); icon.classList.add('spinning'); diff --git a/src/routes/courses.js b/src/routes/courses.js index fa0db78..0f532c8 100644 --- a/src/routes/courses.js +++ b/src/routes/courses.js @@ -1,7 +1,8 @@ const express = require('express'); const router = express.Router(); const { db } = require('../db'); -const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating } = require('../models/course'); +const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating, saveCourseToDB, getOrCreateLayout } = require('../models/course'); +const { searchTjingCourses, getTjingCourse } = require('../scrapers/tjing'); const { launchBrowser } = require('../scrapers/browser'); const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer'); const logger = require('../logger'); @@ -369,4 +370,55 @@ router.post('/api/scrape-event-results/:courseId', async (req, res) => { } }); +// Search Tjing for courses +router.get('/api/tjing/search', async (req, res) => { + try { + const { q } = req.query; + if (!q || q.trim().length < 2) { + return res.json([]); + } + + const courses = await searchTjingCourses(q.trim()); + res.json(courses); + } catch (error) { + logger.error({ err: error }, 'Error searching Tjing'); + res.status(500).json({ error: 'Failed to search Tjing' }); + } +}); + +// Import a course from Tjing +router.post('/api/tjing/import/:tjingId', async (req, res) => { + try { + const { tjingId } = req.params; + const courseData = await getTjingCourse(tjingId); + + if (courseData.layouts.length === 0) { + return res.status(400).json({ error: 'Course has no published layouts on Tjing' }); + } + + const courseId = await saveCourseToDB({ + name: courseData.name, + link: 'https://tjing.se/courses/' + tjingId, + city: courseData.address || '' + }); + + let layoutCount = 0; + for (const layout of courseData.layouts) { + await getOrCreateLayout(courseId, layout.name, layout.par); + layoutCount++; + } + + logger.info(`Imported from Tjing: "${courseData.name}" with ${layoutCount} layouts`); + res.json({ + success: true, + message: `Imported "${courseData.name}" with ${layoutCount} layouts`, + courseName: courseData.name, + layoutCount + }); + } catch (error) { + logger.error({ err: error }, 'Error importing from Tjing'); + res.status(500).json({ error: 'Failed to import course from Tjing' }); + } +}); + module.exports = router; diff --git a/src/scrapers/tjing.js b/src/scrapers/tjing.js new file mode 100644 index 0000000..a9d5bdc --- /dev/null +++ b/src/scrapers/tjing.js @@ -0,0 +1,95 @@ +const logger = require('../logger'); + +const TJING_API = 'https://api.tjing.se/graphql'; + +async function searchTjingCourses(searchTerm) { + const query = `{ + courses(first: 10, filter: { search: "${searchTerm.replace(/"/g, '\\"')}" }) { + id + name + address + type + } + }`; + + const response = await fetch(TJING_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + + if (!response.ok) { + throw new Error(`Tjing API returned ${response.status}`); + } + + const data = await response.json(); + + if (data.errors) { + throw new Error(data.errors[0].message); + } + + return data.data.courses || []; +} + +async function getTjingCourse(courseId) { + const query = `{ + course(courseId: "${courseId.replace(/"/g, '\\"')}") { + id + name + address + layouts { + id + name + published + latestVersion { + holes { + number + par + } + } + } + } + }`; + + const response = await fetch(TJING_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + + if (!response.ok) { + throw new Error(`Tjing API returned ${response.status}`); + } + + const data = await response.json(); + + if (data.errors) { + throw new Error(data.errors[0].message); + } + + const course = data.data.course; + if (!course) { + throw new Error('Course not found'); + } + + // Calculate total par per layout from holes + const layouts = (course.layouts || []) + .filter(l => l.published && l.latestVersion && l.latestVersion.holes.length > 0) + .map(l => ({ + name: l.name, + par: l.latestVersion.holes.reduce((sum, h) => sum + h.par, 0), + holes: l.latestVersion.holes.length + })); + + return { + name: course.name, + address: course.address, + tjingId: course.id, + layouts + }; +} + +module.exports = { + searchTjingCourses, + getTjingCourse +}; diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index 0fe9cc3..8a33e53 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -19,6 +19,23 @@ +