import { Controller } from '@hotwired/stimulus'; import L from 'leaflet'; import 'leaflet/dist/leaflet.min.css'; L.Icon.Default.mergeOptions({ iconUrl: '/leaflet/marker-icon.png', iconRetinaUrl: '/leaflet/marker-icon-2x.png', shadowUrl: '/leaflet/marker-shadow.png', }); export default class extends Controller { connect() { this.map = L.map(this.element).setView([51.0543, 3.7174], 13); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(this.map); this.currentUserId = parseInt(this.element.dataset.userId); this.panel = document.getElementById('rating-panel'); this.popup = null; this.markerLayer = L.layerGroup().addTo(this.map); this.loadSubjects(); this.map.on('click', (e) => this.openPopup(e.latlng)); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') this.closePanel(); }); } loadSubjects() { fetch('/api/subjects') .then(r => r.json()) .then(data => { this.markerLayer.clearLayers(); data.payload.subjects.forEach(s => { if (s.latitude && s.longitude) { this.addMarker(s); } }); }); } openPopup(latlng) { this.closePanel(); if (this.popup) { this.popup.remove(); } const content = this.buildPopupContent(latlng); this.popup = L.popup({ closeOnClick: false, autoClose: false, minWidth: 280 }) .setLatLng(latlng) .setContent(content) .openOn(this.map); this.popup.once('add', () => { const input = this.popup.getElement()?.querySelector('.add-place-input'); if (input) input.focus(); }); } buildPopupContent(latlng) { const container = document.createElement('div'); container.className = 'add-place-popup'; container.innerHTML = `

Add a place

Name
`; const input = container.querySelector('.add-place-input'); const submitBtn = container.querySelector('.add-place-submit'); const cancelBtn = container.querySelector('.add-place-cancel'); const submit = () => { const name = input.value.trim(); if (!name) { input.focus(); return; } this.createSubject(name, latlng); }; submitBtn.addEventListener('click', submit); cancelBtn.addEventListener('click', () => this.popup.remove()); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') this.popup.remove(); }); return container; } createSubject(name, latlng) { fetch('/api/subjects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, latitude: latlng.lat, longitude: latlng.lng }), }) .then(r => r.json()) .then(() => { // Reload subjects to get the new one with its ID this.popup.remove(); this.loadSubjects(); }); } addMarker(subject) { const marker = L.marker([subject.latitude, subject.longitude]).addTo(this.markerLayer); marker.on('click', (e) => { L.DomEvent.stopPropagation(e); if (this.popup) this.popup.remove(); this.openPanel(subject); }); } openPanel(subject) { fetch(`/api/subjects/${subject.id}`) .then(r => r.json()) .then(data => this.renderPanel(data.payload.subject)); } closePanel() { this.panel.classList.remove('open'); this.panel.innerHTML = ''; } renderPanel(subject) { this.panel.innerHTML = ''; const myRatings = subject.ratings.filter(r => r.user.id === this.currentUserId); const avg = subject.averageScore; const count = subject.ratingCount; const el = document.createElement('div'); el.innerHTML = `
${subject.name}

Overall

${avg !== null ? `
${this.renderStars(avg)}

based on ${count} ${count === 1 ? 'rating' : 'ratings'}

` : `

No ratings yet.

` }

Your visits

${myRatings.length > 0 ? myRatings.map(r => `
${this.renderStars(r.score)} ${r.note ? `${r.note}` : ''} ${this.formatDate(r.createdAt)}
`).join('') : `

No visits logged yet.

` }
`; el.querySelector('.rating-panel-close').addEventListener('click', () => this.closePanel()); const actionArea = el.querySelector('.rating-log-action'); this.renderLogButton(actionArea, subject); this.panel.appendChild(el); this.panel.classList.add('open'); } renderLogButton(container, subject) { container.innerHTML = ''; const btn = document.createElement('button'); btn.className = 'btn btn-primary w-full mt-2'; btn.textContent = 'Log a visit'; btn.addEventListener('click', () => this.renderLogForm(container, subject)); container.appendChild(btn); } renderLogForm(container, subject) { container.innerHTML = ''; const form = document.createElement('div'); form.className = 'log-visit-form'; const picker = this.buildStarPicker(); form.appendChild(picker.el); form.innerHTML += `
Note (optional)
`; // Re-insert picker before the fieldset (innerHTML nuked it) form.insertBefore(picker.el, form.firstChild); form.querySelector('.log-cancel').addEventListener('click', () => this.renderLogButton(container, subject)); form.querySelector('.log-save').addEventListener('click', () => { const score = picker.getScore(); if (!score) { picker.el.classList.add('shake'); return; } const note = form.querySelector('.log-note').value.trim(); this.saveVisit(subject, score, note, container); }); container.appendChild(form); } saveVisit(subject, score, note, container) { fetch('/api/ratings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject_id: subject.id, score, note }), }) .then(r => { if (!r.ok) throw new Error(); return r; }) .then(() => fetch(`/api/subjects/${subject.id}`)) .then(r => r.json()) .then(data => this.renderPanel(data.payload.subject)); } buildStarPicker() { const el = document.createElement('div'); el.className = 'star-picker'; let selected = 0; const buttons = Array.from({ length: 5 }, (_, i) => { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = '★'; btn.dataset.score = i + 1; btn.addEventListener('click', () => { selected = i + 1; buttons.forEach((b, j) => b.classList.toggle('active', j < selected)); }); el.appendChild(btn); return btn; }); return { el, getScore: () => selected }; } renderStars(score, max = 5) { let html = ''; for (let i = 1; i <= max; i++) { if (score >= i) { html += ''; } else if (score >= i - 0.5) { html += ''; } else { html += ''; } } return html; } formatDate(iso) { return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) .format(new Date(iso)); } }