diff --git a/assets/controllers/map_controller.js b/assets/controllers/map_controller.js
index ca36d82..aff9211 100644
--- a/assets/controllers/map_controller.js
+++ b/assets/controllers/map_controller.js
@@ -17,26 +17,36 @@ export default class extends Controller {
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.latitude, s.longitude, s.name);
+ this.addMarker(s);
}
});
});
}
openPopup(latlng) {
+ this.closePanel();
+
if (this.popup) {
this.popup.remove();
}
@@ -48,7 +58,6 @@ export default class extends Controller {
.setContent(content)
.openOn(this.map);
- // Autofocus after Leaflet inserts the DOM
this.popup.once('add', () => {
const input = this.popup.getElement()?.querySelector('.add-place-input');
if (input) input.focus();
@@ -81,10 +90,7 @@ export default class extends Controller {
const submit = () => {
const name = input.value.trim();
- if (!name) {
- input.focus();
- return;
- }
+ if (!name) { input.focus(); return; }
this.createSubject(name, latlng);
};
@@ -102,20 +108,177 @@ export default class extends Controller {
fetch('/api/subjects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- name: name,
- latitude: latlng.lat,
- longitude: latlng.lng,
- }),
+ body: JSON.stringify({ name, latitude: latlng.lat, longitude: latlng.lng }),
})
.then(r => r.json())
.then(() => {
- this.addMarker(latlng.lat, latlng.lng, name);
+ // Reload subjects to get the new one with its ID
this.popup.remove();
+ this.loadSubjects();
});
}
- addMarker(lat, lng, name) {
- L.marker([lat, lng]).addTo(this.map).bindPopup(name);
+ 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 = `
+
+
+
+
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 += `
+
+
+
+
+
+ `;
+
+ // 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));
}
}
diff --git a/assets/styles/app.css b/assets/styles/app.css
index a91872c..d142bf3 100644
--- a/assets/styles/app.css
+++ b/assets/styles/app.css
@@ -54,3 +54,105 @@ html, body { height: 100%; margin: 0; }
gap: 0.5rem;
margin-top: 1rem;
}
+
+/* Rating panel — bottom sheet on mobile, side panel on desktop */
+.rating-panel {
+ display: none;
+ position: fixed;
+ bottom: 0; left: 0; right: 0;
+ height: 55vh;
+ z-index: 1000;
+ background: var(--color-base-100);
+ color: var(--color-base-content);
+ border-radius: 1rem 1rem 0 0;
+ overflow-y: auto;
+ padding: 1.25rem;
+ box-shadow: 0 -4px 24px oklch(0% 0 0 / 0.4);
+}
+.rating-panel.open { display: block; }
+
+@media (min-width: 768px) {
+ .rating-panel {
+ top: 4rem;
+ right: 0; bottom: 0; left: auto;
+ width: 320px; height: auto;
+ border-radius: 0;
+ box-shadow: -4px 0 24px oklch(0% 0 0 / 0.3);
+ }
+}
+
+.rating-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+.rating-panel-title {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--color-primary);
+}
+.rating-section {
+ margin-bottom: 1.25rem;
+}
+.rating-section-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ opacity: 0.5;
+ margin-bottom: 0.4rem;
+}
+.rating-meta {
+ font-size: 0.8rem;
+ opacity: 0.5;
+ margin-top: 0.2rem;
+}
+.visit-row {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ margin-bottom: 0.4rem;
+ font-size: 0.9rem;
+}
+.visit-note {
+ flex: 1;
+ opacity: 0.8;
+ font-style: italic;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.visit-date {
+ font-size: 0.75rem;
+ opacity: 0.5;
+ white-space: nowrap;
+}
+
+/* Stars */
+.rating-stars { display: inline-flex; gap: 1px; }
+.star-filled { color: var(--color-primary); }
+.star-half { color: var(--color-primary); opacity: 0.45; }
+.star-empty { opacity: 0.2; }
+
+/* Star picker */
+.star-picker { display: flex; gap: 2px; margin-bottom: 0.25rem; }
+.star-picker button {
+ font-size: 1.6rem;
+ background: none; border: none;
+ cursor: pointer; padding: 0 1px;
+ color: var(--color-base-content);
+ opacity: 0.2;
+ transition: opacity 0.1s;
+ line-height: 1;
+}
+.star-picker button.active {
+ opacity: 1;
+ color: var(--color-primary);
+}
+
+.log-visit-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
diff --git a/src/Factory/RatingFactory.php b/src/Factory/RatingFactory.php
index 499c687..390fb4d 100644
--- a/src/Factory/RatingFactory.php
+++ b/src/Factory/RatingFactory.php
@@ -33,7 +33,7 @@ final class RatingFactory extends PersistentObjectFactory
{
return [
'score' => self::faker()->numberBetween(1, 5),
- 'note' => self::faker()->optional()->sentence() ?? '',
+ 'note' => self::faker()->optional()->sentence(),
'subject' => RestaurantFactory::new(),
'user' => UserFactory::new(),
];