Create new places
And find them in search
This commit is contained in:
parent
a10f87290a
commit
8c58a76030
@ -15,7 +15,6 @@ import Point from 'ol/geom/Point.js';
|
||||
import Geolocation from 'ol/Geolocation.js';
|
||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
||||
import { apply } from 'ol-mapbox-style';
|
||||
import { getDistance } from '../utils/geo';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@ -29,6 +28,8 @@ export default class MapComponent extends Component {
|
||||
searchOverlayElement;
|
||||
selectedPinOverlay;
|
||||
selectedPinElement;
|
||||
crosshairElement; // New crosshair
|
||||
crosshairOverlay; // New crosshair overlay
|
||||
|
||||
setupMap = modifier((element) => {
|
||||
if (this.mapInstance) return;
|
||||
@ -140,6 +141,29 @@ export default class MapComponent extends Component {
|
||||
});
|
||||
this.mapInstance.addOverlay(this.selectedPinOverlay);
|
||||
|
||||
// Crosshair Overlay (for Creating New Place)
|
||||
this.crosshairElement = document.createElement('div');
|
||||
this.crosshairElement.className = 'map-crosshair';
|
||||
// Use an SVG or simple CSS cross
|
||||
this.crosshairElement.innerHTML = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
`;
|
||||
// We attach it to the map control container OR keep it as an overlay centered on map center?
|
||||
// Actually, a fixed center overlay is trickier in OpenLayers because Overlays move with the map.
|
||||
// If we want it FIXED in the center of the VIEWPORT, it should be a Control or just an absolute HTML element on top of the map div.
|
||||
// Adding it as a Control is cleaner.
|
||||
|
||||
// HOWEVER, the request says "cross hair drawn on the map... which should be removed when saving".
|
||||
// A fixed element in the center of the screen is best for "choose location by dragging map".
|
||||
// So let's append it to the map container directly via Glimmer template or JS.
|
||||
|
||||
// We'll append it to the map target element (this.element is the target).
|
||||
element.appendChild(this.crosshairElement);
|
||||
|
||||
|
||||
// Geolocation Pulse Overlay
|
||||
this.locationOverlayElement = document.createElement('div');
|
||||
this.locationOverlayElement.className = 'search-pulse blue';
|
||||
@ -522,9 +546,128 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
// Sync the creation mode (Crosshair)
|
||||
syncCreationMode = modifier(() => {
|
||||
if (!this.crosshairElement || !this.mapInstance) return;
|
||||
|
||||
if (this.mapUi.isCreating) {
|
||||
this.crosshairElement.classList.add('visible');
|
||||
|
||||
// If we have initial coordinates from the route (e.g. reload or link),
|
||||
// we need to pan the map so those coordinates are UNDER the crosshair.
|
||||
const coords = this.mapUi.creationCoordinates;
|
||||
if (coords && coords.lat && coords.lon) {
|
||||
// We only animate if the map center isn't already "roughly" correct
|
||||
// But actually, updateCreationCoordinates is called by handleMapMove too.
|
||||
// We need to distinguish "initial set" vs "drag update".
|
||||
// The Service doesn't distinguish, but if we are just entering mode,
|
||||
// we can check if the current map center aligns.
|
||||
|
||||
// Better approach:
|
||||
// We calculate where the map center *should* be to put the target coords
|
||||
// under the crosshair.
|
||||
const targetCoords = fromLonLat([coords.lon, coords.lat]);
|
||||
this.animateToCrosshair(targetCoords);
|
||||
}
|
||||
} else {
|
||||
this.crosshairElement.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
animateToCrosshair(targetCoords) {
|
||||
if (!this.mapInstance || !this.crosshairElement) return;
|
||||
|
||||
// 1. Get current visual position of the crosshair
|
||||
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||
|
||||
const crosshairPixelX =
|
||||
crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||
const crosshairPixelY =
|
||||
crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||
|
||||
// 2. Get the center pixel of the map viewport
|
||||
const size = this.mapInstance.getSize();
|
||||
const mapCenterX = size[0] / 2;
|
||||
const mapCenterY = size[1] / 2;
|
||||
|
||||
// 3. Calculate the offset (how far the crosshair is from the geometric center)
|
||||
const offsetX = crosshairPixelX - mapCenterX;
|
||||
const offsetY = crosshairPixelY - mapCenterY;
|
||||
|
||||
// 4. Calculate the new map center
|
||||
// We want 'targetCoords' to be at [crosshairPixelX, crosshairPixelY].
|
||||
// If we center the map on 'targetCoords', it will be at [mapCenterX, mapCenterY].
|
||||
// So we need to shift the map center by the OPPOSITE of the offset.
|
||||
// Wait.
|
||||
// If crosshair is to the right (+X), we need to move the camera LEFT (-X) to bring the point there?
|
||||
// Let's think in map units.
|
||||
const view = this.mapInstance.getView();
|
||||
const resolution = view.getResolution();
|
||||
|
||||
const offsetMapUnitsX = offsetX * resolution;
|
||||
const offsetMapUnitsY = -offsetY * resolution; // Y is inverted in pixel vs map coords usually?
|
||||
// In Web Mercator: Y increases North (Up).
|
||||
// In Pixels: Y increases South (Down).
|
||||
// So +PixelY (Down) = -MapY (South). Correct.
|
||||
|
||||
// If crosshair is at +100px (Right), we want the target to be there.
|
||||
// If we center on target, it is at 0px.
|
||||
// To make it appear at +100px, we must shift the camera center by -100px (Left).
|
||||
// So CenterX_new = TargetX - offsetMapUnitsX.
|
||||
|
||||
const targetX = targetCoords[0];
|
||||
const targetY = targetCoords[1];
|
||||
|
||||
const newCenterX = targetX - offsetMapUnitsX;
|
||||
const newCenterY = targetY - offsetMapUnitsY;
|
||||
|
||||
// Only animate if the difference is significant (avoid micro-jitters/loops)
|
||||
const currentCenter = view.getCenter();
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(currentCenter[0] - newCenterX, 2) +
|
||||
Math.pow(currentCenter[1] - newCenterY, 2)
|
||||
);
|
||||
|
||||
// 1 meter is approx 1 unit in Mercator near equator, varies by latitude.
|
||||
// Resolution at zoom 18 is approx 0.6m/pixel.
|
||||
// Let's use a small pixel threshold.
|
||||
if (dist > resolution * 5) {
|
||||
view.animate({
|
||||
center: [newCenterX, newCenterY],
|
||||
duration: 800,
|
||||
easing: (t) => t * (2 - t), // Ease-out
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMapMove = async () => {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
// If in creation mode, update the coordinates in the service AND the URL
|
||||
if (this.mapUi.isCreating) {
|
||||
// Calculate coordinates under the crosshair element
|
||||
// We need the pixel position of the crosshair relative to the map viewport
|
||||
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
|
||||
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||
|
||||
const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||
|
||||
const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]);
|
||||
const center = toLonLat(coordinate);
|
||||
|
||||
const lat = parseFloat(center[1].toFixed(6));
|
||||
const lon = parseFloat(center[0].toFixed(6));
|
||||
|
||||
this.mapUi.updateCreationCoordinates(lat, lon);
|
||||
|
||||
// Update URL without triggering a full refresh
|
||||
// We use replaceWith to avoid cluttering history
|
||||
this.router.replaceWith('place.new', { queryParams: { lat, lon } });
|
||||
}
|
||||
|
||||
const size = this.mapInstance.getSize();
|
||||
const extent = this.mapInstance.getView().calculateExtent(size);
|
||||
const [minLon, minLat] = toLonLat([extent[0], extent[1]]);
|
||||
@ -640,11 +783,12 @@ export default class MapComponent extends Component {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="map-container"
|
||||
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
|
||||
{{this.setupMap}}
|
||||
{{this.updateBookmarks}}
|
||||
{{this.updateSelectedPin}}
|
||||
{{this.syncPulse}}
|
||||
{{this.syncCreationMode}}
|
||||
></div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@ -3,19 +3,13 @@ import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import { humanizeOsmTag } from '../utils/format-text';
|
||||
import Icon from '../components/icon';
|
||||
import PlaceEditForm from './place-edit-form';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceDetails extends Component {
|
||||
@tracked isEditing = false;
|
||||
@tracked editTitle = '';
|
||||
@tracked editDescription = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.resetEditFields();
|
||||
}
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
@ -34,16 +28,9 @@ export default class PlaceDetails extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
resetEditFields() {
|
||||
this.editTitle = this.name;
|
||||
this.editDescription = this.place.description || '';
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
||||
this.resetEditFields();
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
@ -53,28 +40,16 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
async saveChanges(event) {
|
||||
event.preventDefault();
|
||||
async saveChanges(changes) {
|
||||
if (this.args.onSave) {
|
||||
await this.args.onSave({
|
||||
...this.place,
|
||||
title: this.editTitle,
|
||||
description: this.editDescription,
|
||||
...changes,
|
||||
});
|
||||
}
|
||||
this.isEditing = false;
|
||||
}
|
||||
|
||||
@action
|
||||
updateTitle(e) {
|
||||
this.editTitle = e.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDescription(e) {
|
||||
this.editDescription = e.target.value;
|
||||
}
|
||||
|
||||
get type() {
|
||||
const rawType =
|
||||
this.tags.amenity ||
|
||||
@ -171,32 +146,11 @@ export default class PlaceDetails extends Component {
|
||||
<template>
|
||||
<div class="place-details">
|
||||
{{#if this.isEditing}}
|
||||
<form class="edit-form" {{on "submit" this.saveChanges}}>
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
value={{this.editTitle}}
|
||||
{{on "input" this.updateTitle}}
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-desc">Description</label>
|
||||
<textarea
|
||||
id="edit-desc"
|
||||
value={{this.editDescription}}
|
||||
{{on "input" this.updateDescription}}
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button type="submit" class="btn btn-blue">Save</button>
|
||||
<button type="button" class="btn btn-outline" {{on "click" this.cancelEditing}}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<PlaceEditForm
|
||||
@place={{this.place}}
|
||||
@onSave={{this.saveChanges}}
|
||||
@onCancel={{this.cancelEditing}}
|
||||
/>
|
||||
{{else}}
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
|
||||
72
app/components/place-edit-form.gjs
Normal file
72
app/components/place-edit-form.gjs
Normal file
@ -0,0 +1,72 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceEditForm extends Component {
|
||||
@tracked title = '';
|
||||
@tracked description = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.title = this.args.place?.title || '';
|
||||
this.description = this.args.place?.description || '';
|
||||
}
|
||||
|
||||
@action
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (this.args.onSave) {
|
||||
this.args.onSave({
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
updateTitle(e) {
|
||||
this.title = e.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDescription(e) {
|
||||
this.description = e.target.value;
|
||||
}
|
||||
|
||||
<template>
|
||||
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
value={{this.title}}
|
||||
{{on "input" this.updateTitle}}
|
||||
class="form-control"
|
||||
placeholder="Name of the place"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-desc">Description</label>
|
||||
<textarea
|
||||
id="edit-desc"
|
||||
value={{this.description}}
|
||||
{{on "input" this.updateDescription}}
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Add some details..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button type="submit" class="btn btn-blue">Save</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
{{on "click" @onCancel}}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
@ -10,6 +10,22 @@ import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
|
||||
@action
|
||||
createNewPlace() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
const lat = qp.lat;
|
||||
const lon = qp.lon;
|
||||
|
||||
if (lat && lon) {
|
||||
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
|
||||
} else {
|
||||
// Fallback (shouldn't happen in search context)
|
||||
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
selectPlace(place) {
|
||||
@ -180,6 +196,7 @@ export default class PlacesSidebar extends Component {
|
||||
place.osmTags.tourism
|
||||
place.osmTags.leisure
|
||||
place.osmTags.historic
|
||||
"Point of Interest"
|
||||
)}}</div>
|
||||
</button>
|
||||
</li>
|
||||
@ -188,6 +205,15 @@ export default class PlacesSidebar extends Component {
|
||||
{{else}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="create-place-btn"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} />
|
||||
Create new place
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,5 +8,6 @@ export default class Router extends EmberRouter {
|
||||
|
||||
Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
});
|
||||
|
||||
30
app/routes/place/new.js
Normal file
30
app/routes/place/new.js
Normal file
@ -0,0 +1,30 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class PlaceNewRoute extends Route {
|
||||
@service mapUi;
|
||||
|
||||
queryParams = {
|
||||
lat: { refreshModel: true },
|
||||
lon: { refreshModel: true },
|
||||
};
|
||||
|
||||
model(params) {
|
||||
return {
|
||||
lat: parseFloat(params.lat),
|
||||
lon: parseFloat(params.lon),
|
||||
};
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
if (model.lat && model.lon) {
|
||||
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
||||
}
|
||||
this.mapUi.startCreating();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.mapUi.stopCreating();
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,26 @@ export default class SearchRoute extends Route {
|
||||
// Fetch POIs
|
||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Add local matches to the list if they aren't already there
|
||||
// We use osmId to deduplicate if possible
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
|
||||
@ -4,6 +4,8 @@ import { tracked } from '@glimmer/tracking';
|
||||
export default class MapUiService extends Service {
|
||||
@tracked selectedPlace = null;
|
||||
@tracked isSearching = false;
|
||||
@tracked isCreating = false;
|
||||
@tracked creationCoordinates = null;
|
||||
|
||||
selectPlace(place) {
|
||||
this.selectedPlace = place;
|
||||
@ -15,9 +17,24 @@ export default class MapUiService extends Service {
|
||||
|
||||
startSearch() {
|
||||
this.isSearching = true;
|
||||
this.isCreating = false;
|
||||
}
|
||||
|
||||
stopSearch() {
|
||||
this.isSearching = false;
|
||||
}
|
||||
|
||||
startCreating() {
|
||||
this.isCreating = true;
|
||||
this.isSearching = false;
|
||||
}
|
||||
|
||||
stopCreating() {
|
||||
this.isCreating = false;
|
||||
this.creationCoordinates = null;
|
||||
}
|
||||
|
||||
updateCreationCoordinates(lat, lon) {
|
||||
this.creationCoordinates = { lat, lon };
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,29 +216,54 @@ export default class StorageService extends Service {
|
||||
|
||||
async storePlace(placeData) {
|
||||
const savedPlace = await this.places.store(placeData);
|
||||
// Only append if not already there (handlePlaceChange might also fire)
|
||||
|
||||
// Optimistic Update: Global List
|
||||
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
|
||||
this.savedPlaces = [...this.savedPlaces, savedPlace];
|
||||
} else {
|
||||
// Update if exists
|
||||
this.savedPlaces = this.savedPlaces.map((p) =>
|
||||
p.id === savedPlace.id ? savedPlace : p
|
||||
);
|
||||
}
|
||||
|
||||
// Optimistic Update: Map View (same logic as Global List)
|
||||
if (!this.placesInView.some((p) => p.id === savedPlace.id)) {
|
||||
this.placesInView = [...this.placesInView, savedPlace];
|
||||
} else {
|
||||
this.placesInView = this.placesInView.map((p) =>
|
||||
p.id === savedPlace.id ? savedPlace : p
|
||||
);
|
||||
}
|
||||
|
||||
return savedPlace;
|
||||
}
|
||||
|
||||
async updatePlace(placeData) {
|
||||
const savedPlace = await this.places.store(placeData);
|
||||
|
||||
// Update local list
|
||||
// Optimistic Update: Global List
|
||||
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
|
||||
if (index !== -1) {
|
||||
const newPlaces = [...this.savedPlaces];
|
||||
newPlaces[index] = savedPlace;
|
||||
this.savedPlaces = newPlaces;
|
||||
}
|
||||
|
||||
// Update Map View
|
||||
this.placesInView = this.placesInView.map((p) =>
|
||||
p.id === savedPlace.id ? savedPlace : p
|
||||
);
|
||||
|
||||
return savedPlace;
|
||||
}
|
||||
|
||||
async removePlace(place) {
|
||||
await this.places.remove(place.id, place.geohash);
|
||||
|
||||
// Update both lists
|
||||
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
|
||||
this.placesInView = this.placesInView.filter((p) => p.id !== place.id);
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@ -649,6 +649,82 @@ span.icon {
|
||||
}
|
||||
}
|
||||
|
||||
/* Map Crosshair for "Create Place" mode */
|
||||
.map-crosshair {
|
||||
position: absolute;
|
||||
/* Default Center */
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #333;
|
||||
pointer-events: none;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
transition:
|
||||
top 0.3s ease,
|
||||
left 0.3s ease;
|
||||
}
|
||||
|
||||
.map-crosshair.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sidebar is open (Desktop: Left 300px) */
|
||||
/* We want to center in the remaining space (width - 300px) */
|
||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
||||
/* So shift left by 150px from center */
|
||||
.map-container.sidebar-open .map-crosshair {
|
||||
left: calc(50% + 150px);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
||||
.map-container.sidebar-open .map-crosshair {
|
||||
left: 50%; /* Reset desktop shift */
|
||||
top: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Helper Text */
|
||||
.helper-text {
|
||||
background: #eef4fc;
|
||||
color: #1a5c9b;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Create Place Button */
|
||||
.create-place-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
background: white;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.create-place-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #999;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
|
||||
@ -21,6 +21,7 @@ export default class ApplicationComponent extends Component {
|
||||
// This helps the map know if it should shift the center or adjust view.
|
||||
return (
|
||||
this.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'place.new' ||
|
||||
this.router.currentRouteName === 'search'
|
||||
);
|
||||
}
|
||||
|
||||
83
app/templates/place/new.gjs
Normal file
83
app/templates/place/new.gjs
Normal file
@ -0,0 +1,83 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import PlaceEditForm from '#components/place-edit-form';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class PlaceNewTemplate extends Component {
|
||||
@service router;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
|
||||
get initialPlace() {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
async savePlace(changes) {
|
||||
try {
|
||||
// Use coordinates from Map UI (which tracks the crosshair center)
|
||||
// Fallback to URL params if map state isn't ready
|
||||
const center = this.mapUi.creationCoordinates || {
|
||||
lat: this.args.model.lat,
|
||||
lon: this.args.model.lon,
|
||||
};
|
||||
|
||||
const lat = parseFloat(center.lat.toFixed(6));
|
||||
const lon = parseFloat(center.lon.toFixed(6));
|
||||
|
||||
const placeData = {
|
||||
title: changes.title || 'Untitled Place',
|
||||
description: changes.description,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
tags: [],
|
||||
osmTags: {},
|
||||
};
|
||||
|
||||
const savedPlace = await this.storage.storePlace(placeData);
|
||||
console.log('Created private place:', savedPlace.title);
|
||||
|
||||
// Transition to the new place
|
||||
this.router.replaceWith('place', savedPlace);
|
||||
} catch (e) {
|
||||
console.error('Failed to create place:', e);
|
||||
alert('Failed to create place: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||
New Place</h2>
|
||||
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
|
||||
@name="x"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<p class="helper-text">
|
||||
Drag the map to position the crosshair.
|
||||
</p>
|
||||
|
||||
<PlaceEditForm
|
||||
@place={{this.initialPlace}}
|
||||
@onSave={{this.savePlace}}
|
||||
@onCancel={{this.close}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user