rater/assets/controllers/map_controller.js

284 lines
9.9 KiB
JavaScript

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 = `
<p class="add-place-heading">Add a place</p>
<fieldset class="fieldset">
<legend class="fieldset-legend">Name</legend>
<input
type="text"
class="input input-bordered w-full add-place-input"
placeholder="e.g. Frituur De Leeuw"
autocomplete="off"
>
</fieldset>
<div class="add-place-actions">
<button type="button" class="btn btn-ghost btn-sm add-place-cancel">Cancel</button>
<button type="button" class="btn btn-primary btn-sm add-place-submit">Add</button>
</div>
`;
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 = `
<div class="rating-panel-header">
<span class="rating-panel-title">${subject.name}</span>
<button class="btn btn-ghost btn-sm rating-panel-close">✕</button>
</div>
<div class="rating-section">
<p class="rating-section-label">Overall</p>
${avg !== null
? `<div class="rating-stars">${this.renderStars(avg)}</div>
<p class="rating-meta">based on ${count} ${count === 1 ? 'rating' : 'ratings'}</p>`
: `<p class="rating-meta">No ratings yet.</p>`
}
</div>
<div class="rating-section">
<p class="rating-section-label">Your visits</p>
${myRatings.length > 0
? myRatings.map(r => `
<div class="visit-row">
<span class="rating-stars">${this.renderStars(r.score)}</span>
${r.note ? `<span class="visit-note">${r.note}</span>` : ''}
<span class="visit-date">${this.formatDate(r.createdAt)}</span>
</div>`).join('')
: `<p class="rating-meta">No visits logged yet.</p>`
}
</div>
<div class="rating-log-action"></div>
`;
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 += `
<fieldset class="fieldset mt-3">
<legend class="fieldset-legend">Note <span class="opacity-50">(optional)</span></legend>
<textarea class="textarea w-full log-note" rows="2" placeholder="How was it?"></textarea>
</fieldset>
<div class="log-visit-actions">
<button type="button" class="btn btn-ghost btn-sm log-cancel">Cancel</button>
<button type="button" class="btn btn-primary btn-sm log-save">Save</button>
</div>
`;
// 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 += '<span class="star-filled">★</span>';
} else if (score >= i - 0.5) {
html += '<span class="star-half">★</span>';
} else {
html += '<span class="star-empty">☆</span>';
}
}
return html;
}
formatDate(iso) {
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
.format(new Date(iso));
}
}