284 lines
9.9 KiB
JavaScript
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));
|
|
}
|
|
}
|