Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
99aeee51bd
|
|||
|
fa4115b714
|
|||
|
360e511849
|
|||
|
0fee9ad2dd
|
|||
|
c61c2c0e7a
|
|||
|
25f50f9091
|
|||
|
cf9139b9c1
|
|||
|
01c3b5a1ac
|
|||
|
3fcaa0bfa2
|
|||
|
0074b63ab2
|
|||
|
da3b5f2dd8
|
|||
|
26548cc97d
|
|||
|
babf2c4a89
|
|||
|
fbb90a330b
|
|||
|
15cbb3c9f3
|
|||
|
b6fd4aaea8
|
|||
|
696d4b0ae3
|
@@ -1,6 +1,6 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Mon Jan 19 2026
|
||||
**Last Updated:** Wed Jan 21 2026
|
||||
|
||||
## Project Context
|
||||
|
||||
@@ -25,6 +25,8 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
|
||||
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
|
||||
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
|
||||
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
|
||||
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
|
||||
|
||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||
|
||||
@@ -46,7 +48,10 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- `osm.js`: Fetches nearby POIs from Overpass API.
|
||||
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
|
||||
- **UI Components:**
|
||||
- `places-sidebar.gjs`: Displays a list of nearby POIs. Allows selecting a place to view details and saving it as a bookmark. Links to the OSM website via the node ID.
|
||||
- `places-sidebar.gjs`: Displays a list of nearby POIs.
|
||||
- `place-details.gjs`: Dedicated component for displaying rich place information.
|
||||
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
|
||||
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
|
||||
- **Geo Utils:**
|
||||
- `app/utils/geo.js`: Haversine distance calculations.
|
||||
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
||||
@@ -64,25 +69,23 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
||||
2. User clicks map -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
|
||||
3. **Navigation:** Selected place is passed to the route (`transitionTo` with model), updating the URL to `/place/<id>` or `/place/osm:<type>:<id>` without re-fetching data.
|
||||
4. Sidebar displays details (using normalized `osmTags`).
|
||||
4. Sidebar displays details via `<PlaceDetails>` component.
|
||||
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
|
||||
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||
|
||||
## Files Currently in Focus
|
||||
|
||||
- `app/routes/place.js`: Routing logic, ID parsing, and URL serialization.
|
||||
- `app/services/osm.js`: Data fetching and normalization.
|
||||
- `app/components/place-details.gjs`: UI logic for place info.
|
||||
- `app/routes/place.js`: Routing logic.
|
||||
- `app/components/map.gjs`: Map rendering and interaction.
|
||||
- `app/services/storage.js`: Data sync logic.
|
||||
|
||||
## Next Steps & Pending Tasks
|
||||
|
||||
1. **App Header:** Implement a transparent header bar with the App Logo (left) and Login/User Info (right).
|
||||
2. **Persist View:** Store the current map center and zoom level in `localStorage` to restore the view upon re-opening the app.
|
||||
3. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
|
||||
4. **Refine UI/UX:** Further polish sidebar interactions and mobile responsiveness.
|
||||
5. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
6. **Testing:** Add automated tests for the geohash coverage and retry logic.
|
||||
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
|
||||
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
4. **Testing:** Add automated tests for the geohash coverage and retry logic.
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
|
||||
54
app/components/icon.gjs
Normal file
54
app/components/icon.gjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
clock,
|
||||
globe,
|
||||
home,
|
||||
map,
|
||||
mapPin,
|
||||
navigation,
|
||||
phone,
|
||||
user,
|
||||
settings
|
||||
};
|
||||
|
||||
export default class IconComponent extends Component {
|
||||
get svg() {
|
||||
return ICONS[this.args.name];
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.args.size || 16;
|
||||
}
|
||||
|
||||
get color() {
|
||||
return this.args.color || '#888';
|
||||
}
|
||||
|
||||
get style() {
|
||||
return `width:${this.size}px;height:${this.size}px;color:${this.color}`;
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.args.title || '';
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.svg}}
|
||||
<span class="icon" style={{this.style}} title={{this.title}}>
|
||||
{{htmlSafe this.svg}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -16,15 +16,19 @@ 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';
|
||||
import Icon from '../components/icon';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
|
||||
mapInstance;
|
||||
bookmarkSource;
|
||||
searchOverlay;
|
||||
searchOverlayElement;
|
||||
selectedPinOverlay;
|
||||
selectedPinElement;
|
||||
|
||||
setupMap = modifier((element) => {
|
||||
if (this.mapInstance) return;
|
||||
@@ -57,9 +61,33 @@ export default class MapComponent extends Component {
|
||||
zIndex: 10, // Ensure it sits above the map tiles
|
||||
});
|
||||
|
||||
|
||||
// Default view settings
|
||||
let center = [99.05738, 7.55087];
|
||||
let zoom = 13.0;
|
||||
|
||||
// Try to restore from localStorage
|
||||
try {
|
||||
const storedView = localStorage.getItem('marco:map-view');
|
||||
if (storedView) {
|
||||
const parsed = JSON.parse(storedView);
|
||||
if (
|
||||
parsed.center &&
|
||||
Array.isArray(parsed.center) &&
|
||||
parsed.center.length === 2 &&
|
||||
typeof parsed.zoom === 'number'
|
||||
) {
|
||||
center = parsed.center;
|
||||
zoom = parsed.zoom;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore map view:', e);
|
||||
}
|
||||
|
||||
const view = new View({
|
||||
center: fromLonLat([99.05738, 7.55087]),
|
||||
zoom: 13.0,
|
||||
center: fromLonLat(center),
|
||||
zoom: zoom,
|
||||
projection: 'EPSG:3857',
|
||||
});
|
||||
|
||||
@@ -67,7 +95,7 @@ export default class MapComponent extends Component {
|
||||
target: element,
|
||||
layers: [openfreemap, bookmarkLayer],
|
||||
view: view,
|
||||
controls: defaultControls({ zoom: false, rotate: false, attribution: true }),
|
||||
controls: defaultControls({ zoom: false, rotate: true, attribution: true }),
|
||||
});
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
@@ -81,6 +109,34 @@ export default class MapComponent extends Component {
|
||||
});
|
||||
this.mapInstance.addOverlay(this.searchOverlay);
|
||||
|
||||
// Selected Pin Overlay (Red Marker)
|
||||
// We create the element in the template (or JS) and attach it.
|
||||
// Using JS creation to ensure it's cleanly managed by OpenLayers
|
||||
this.selectedPinElement = document.createElement('div');
|
||||
this.selectedPinElement.className = 'selected-pin-container';
|
||||
|
||||
// Create the icon structure inside
|
||||
const pinIcon = document.createElement('div');
|
||||
pinIcon.className = 'selected-pin';
|
||||
// We can't use the Glimmer <Icon> component easily inside a raw DOM element created here.
|
||||
// So we'll inject the SVG string directly or mount it.
|
||||
// Feather icons are globally available if we used the script, but we are using the module approach.
|
||||
// Simple SVG for Map Pin:
|
||||
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`;
|
||||
|
||||
const pinShadow = document.createElement('div');
|
||||
pinShadow.className = 'selected-pin-shadow';
|
||||
|
||||
this.selectedPinElement.appendChild(pinIcon);
|
||||
this.selectedPinElement.appendChild(pinShadow);
|
||||
|
||||
this.selectedPinOverlay = new Overlay({
|
||||
element: this.selectedPinElement,
|
||||
positioning: 'bottom-center', // Important: Pin tip is at the bottom
|
||||
stopEvent: false, // Let clicks pass through
|
||||
});
|
||||
this.mapInstance.addOverlay(this.selectedPinOverlay);
|
||||
|
||||
// Geolocation Pulse Overlay
|
||||
this.locationOverlayElement = document.createElement('div');
|
||||
this.locationOverlayElement.className = 'search-pulse blue';
|
||||
@@ -288,6 +344,65 @@ export default class MapComponent extends Component {
|
||||
// });
|
||||
});
|
||||
|
||||
// Track the selected place from the UI Service (Router -> Map)
|
||||
updateSelectedPin = modifier(() => {
|
||||
const selected = this.mapUi.selectedPlace;
|
||||
|
||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||
|
||||
if (selected && selected.lat && selected.lon) {
|
||||
const coords = fromLonLat([selected.lon, selected.lat]);
|
||||
this.selectedPinOverlay.setPosition(coords);
|
||||
|
||||
// Reset animation by removing/adding class
|
||||
this.selectedPinElement.classList.remove('active');
|
||||
// Force reflow
|
||||
void this.selectedPinElement.offsetWidth;
|
||||
this.selectedPinElement.classList.add('active');
|
||||
|
||||
this.panIfObscured(coords);
|
||||
} else {
|
||||
this.selectedPinElement.classList.remove('active');
|
||||
// Hide it effectively by moving it away or just relying on display:none in CSS
|
||||
this.selectedPinOverlay.setPosition(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
panIfObscured(coords) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const size = this.mapInstance.getSize();
|
||||
// Check if mobile (width <= 768px matches CSS)
|
||||
if (size[0] > 768) return;
|
||||
|
||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||
const height = size[1];
|
||||
|
||||
// Sidebar covers the bottom 50%
|
||||
const splitPoint = height / 2;
|
||||
|
||||
// If the pin is in the bottom half (y > splitPoint), it is obscured
|
||||
if (pixel[1] > splitPoint) {
|
||||
// Target position: Center of top half = height * 0.25
|
||||
const targetY = height * 0.25;
|
||||
const deltaY = pixel[1] - targetY;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = view.getCenter();
|
||||
const resolution = view.getResolution();
|
||||
|
||||
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
|
||||
const deltaMapUnits = deltaY * resolution;
|
||||
const newCenter = [center[0], center[1] - deltaMapUnits];
|
||||
|
||||
view.animate({
|
||||
center: newCenter,
|
||||
duration: 500,
|
||||
easing: (t) => t * (2 - t) // Ease-out
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
|
||||
updateBookmarks = modifier(() => {
|
||||
// Depend on the tracked storage.savedPlaces to automatically update when they change
|
||||
@@ -341,6 +456,22 @@ export default class MapComponent extends Component {
|
||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||
await this.storage.loadPlacesInBounds(bbox);
|
||||
this.loadBookmarks(this.storage.savedPlaces);
|
||||
|
||||
// Persist view to localStorage
|
||||
try {
|
||||
const view = this.mapInstance.getView();
|
||||
const currentCenter = toLonLat(view.getCenter());
|
||||
const currentZoom = view.getZoom();
|
||||
|
||||
const viewState = {
|
||||
center: currentCenter,
|
||||
zoom: currentZoom
|
||||
};
|
||||
|
||||
localStorage.setItem('marco:map-view', JSON.stringify(viewState));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save map view:', e);
|
||||
}
|
||||
};
|
||||
|
||||
handleMapClick = async (event) => {
|
||||
@@ -492,6 +623,7 @@ export default class MapComponent extends Component {
|
||||
class="map-container"
|
||||
{{this.setupMap}}
|
||||
{{this.updateBookmarks}}
|
||||
{{this.updateSelectedPin}}
|
||||
style="position: absolute; inset: 0;"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
198
app/components/place-details.gjs
Normal file
198
app/components/place-details.gjs
Normal file
@@ -0,0 +1,198 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import capitalize from '../helpers/capitalize';
|
||||
import Icon from '../components/icon';
|
||||
|
||||
export default class PlaceDetails extends Component {
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
}
|
||||
|
||||
get tags() {
|
||||
return this.place.osmTags || {};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return (
|
||||
this.place.title ||
|
||||
this.tags.name ||
|
||||
this.tags['name:en'] ||
|
||||
'Unnamed Place'
|
||||
);
|
||||
}
|
||||
|
||||
get type() {
|
||||
return (
|
||||
this.tags.amenity ||
|
||||
this.tags.shop ||
|
||||
this.tags.tourism ||
|
||||
this.tags.leisure ||
|
||||
this.tags.historic ||
|
||||
'Point of Interest'
|
||||
);
|
||||
}
|
||||
|
||||
get address() {
|
||||
const t = this.tags;
|
||||
const parts = [];
|
||||
|
||||
// Street + Number
|
||||
if (t['addr:street']) {
|
||||
let street = t['addr:street'];
|
||||
if (t['addr:housenumber']) {
|
||||
street += ` ${t['addr:housenumber']}`;
|
||||
}
|
||||
parts.push(street);
|
||||
}
|
||||
|
||||
// Postcode + City
|
||||
if (t['addr:city']) {
|
||||
let city = t['addr:city'];
|
||||
if (t['addr:postcode']) {
|
||||
city = `${t['addr:postcode']} ${city}`;
|
||||
}
|
||||
parts.push(city);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
get phone() {
|
||||
return this.tags.phone || this.tags['contact:phone'];
|
||||
}
|
||||
|
||||
get website() {
|
||||
return this.place.url || this.tags.website || this.tags['contact:website'];
|
||||
}
|
||||
|
||||
get openingHours() {
|
||||
return this.tags.opening_hours;
|
||||
}
|
||||
|
||||
get cuisine() {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
.split(';')
|
||||
.map(c => capitalize.compute([c]))
|
||||
.map(c => c.replace('_', ' '))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
get wikipedia() {
|
||||
return this.tags.wikipedia;
|
||||
}
|
||||
|
||||
get geoLink() {
|
||||
const lat = this.place.lat;
|
||||
const lon = this.place.lon;
|
||||
if (!lat || !lon) return '#';
|
||||
const label = encodeURIComponent(this.name);
|
||||
return `geo:${lat},${lon}?q=${lat},${lon}(${label})`;
|
||||
}
|
||||
|
||||
get visibleGeoLink() {
|
||||
const lat = this.place.lat;
|
||||
const lon = this.place.lon;
|
||||
if (!lat || !lon) return '';
|
||||
return `${lat}, ${lon}`;
|
||||
}
|
||||
|
||||
get osmUrl() {
|
||||
const id = this.place.osmId;
|
||||
if (!id) return null;
|
||||
const type = this.place.osmType || 'node';
|
||||
return `https://www.openstreetmap.org/${type}/${id}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-details">
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
{{this.type}}
|
||||
</p>
|
||||
{{#if this.place.description}}
|
||||
<p class="place-description">
|
||||
{{this.place.description}}
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class={{if this.place.createdAt "btn-secondary" "btn-primary"}}
|
||||
{{on "click" (fn @onToggleSave this.place)}}
|
||||
>
|
||||
{{if this.place.createdAt "Saved ✓" "Save"}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="meta-info">
|
||||
{{#if this.cuisine}}
|
||||
<p>
|
||||
<strong>Cuisine:</strong>
|
||||
{{this.cuisine}}
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.openingHours}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="clock" @title="Opening hours" />
|
||||
<span>{{this.openingHours}}</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.phone}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="phone" @title="Phone" />
|
||||
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.website}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="globe" @title="Website" />
|
||||
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">Website</a></span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.wikipedia}}
|
||||
<p>
|
||||
<strong>Wikipedia:</strong>
|
||||
<a href="https://wikipedia.org/wiki/{{this.wikipedia}}" target="_blank" rel="noopener noreferrer">Article</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<hr class="meta-divider">
|
||||
|
||||
{{#if this.address}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="home" @title="Address" />
|
||||
<span>{{this.address}}</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="mapPin" @title="Geo link" />
|
||||
<span>
|
||||
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
|
||||
{{this.visibleGeoLink}}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{{#if this.osmUrl}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" @title="OSM ID" />
|
||||
<span>
|
||||
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
|
||||
OpenStreetMap
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import PlaceDetails from './place-details';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@@ -160,77 +161,10 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
<div class="sidebar-content">
|
||||
{{#if @selectedPlace}}
|
||||
<div class="place-details">
|
||||
<h3>{{or
|
||||
@selectedPlace.title
|
||||
@selectedPlace.osmTags.name
|
||||
@selectedPlace.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</h3>
|
||||
<p class="place-meta">
|
||||
{{or
|
||||
@selectedPlace.osmTags.amenity
|
||||
@selectedPlace.osmTags.shop
|
||||
@selectedPlace.osmTags.tourism
|
||||
@selectedPlace.osmTags.leisure
|
||||
@selectedPlace.osmTags.historic
|
||||
}}
|
||||
{{#if @selectedPlace.description}}
|
||||
{{@selectedPlace.description}}
|
||||
{{/if}}
|
||||
</p>
|
||||
|
||||
{{#if (or @selectedPlace.url @selectedPlace.osmTags.website)}}
|
||||
<p><a
|
||||
href={{or @selectedPlace.url @selectedPlace.osmTags.website}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Website</a></p>
|
||||
{{/if}}
|
||||
|
||||
{{#if @selectedPlace.osmTags.opening_hours}}
|
||||
<p><strong>Open:</strong>
|
||||
{{@selectedPlace.osmTags.opening_hours}}</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class={{if
|
||||
@selectedPlace.createdAt
|
||||
"btn-secondary"
|
||||
"btn-primary"
|
||||
}}
|
||||
{{on "click" (fn this.toggleSave @selectedPlace)}}
|
||||
>
|
||||
{{if @selectedPlace.createdAt "Saved ✓" "Save"}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="meta-info">
|
||||
{{#if (or @selectedPlace.osmId @selectedPlace.id)}}
|
||||
<p>
|
||||
<a
|
||||
href={{this.geoLink}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{this.visibleGeoLink}}</a></p>
|
||||
<p><small>OSM ID:
|
||||
<a
|
||||
href="https://www.openstreetmap.org/{{if
|
||||
@selectedPlace.osmType
|
||||
@selectedPlace.osmType
|
||||
(if @selectedPlace.osmType @selectedPlace.osmType 'node')
|
||||
}}/{{or @selectedPlace.osmId @selectedPlace.id}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{or
|
||||
@selectedPlace.osmId
|
||||
@selectedPlace.id
|
||||
}}</a></small></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<PlaceDetails
|
||||
@place={{@selectedPlace}}
|
||||
@onToggleSave={{this.toggleSave}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if @places}}
|
||||
<ul class="places-list">
|
||||
|
||||
8
app/helpers/capitalize.js
Normal file
8
app/helpers/capitalize.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
export function capitalize([str]) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export default helper(capitalize);
|
||||
@@ -4,6 +4,7 @@ import { service } from '@ember/service';
|
||||
export default class PlaceRoute extends Route {
|
||||
@service storage;
|
||||
@service osm;
|
||||
@service mapUi;
|
||||
|
||||
async model(params) {
|
||||
const id = params.place_id;
|
||||
@@ -16,15 +17,8 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
|
||||
// 1. Try to find in local bookmarks
|
||||
// We rely on the service maintaining the list
|
||||
let bookmark = this.storage.findPlaceById(id);
|
||||
|
||||
// If not found instantly, maybe wait for storage ready?
|
||||
// For now assuming storage is reasonably fast or "ready" has fired.
|
||||
// If we land here directly on refresh, "savedPlaces" might be empty initially.
|
||||
// We could retry or wait, but simpler to fall back to OSM for now.
|
||||
// Ideally, we await `storage.loadAllPlaces()` promise if it's pending.
|
||||
|
||||
if (bookmark) {
|
||||
console.log('Found in bookmarks:', bookmark.title);
|
||||
return bookmark;
|
||||
@@ -35,6 +29,18 @@ export default class PlaceRoute extends Route {
|
||||
return this.loadOsmPlace(id);
|
||||
}
|
||||
|
||||
afterModel(model) {
|
||||
// Notify the Map UI to show the pin
|
||||
if (model) {
|
||||
this.mapUi.selectPlace(model);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
// Clear the pin when leaving the route
|
||||
this.mapUi.clearSelection();
|
||||
}
|
||||
|
||||
async loadOsmPlace(id, type = null) {
|
||||
try {
|
||||
const poi = await this.osm.getPoiById(id, type);
|
||||
|
||||
14
app/services/map-ui.js
Normal file
14
app/services/map-ui.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@tracked selectedPlace = null;
|
||||
|
||||
selectPlace(place) {
|
||||
this.selectedPlace = place;
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selectedPlace = null;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ body {
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
background: white;
|
||||
color: #333;
|
||||
z-index: 2000;
|
||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||
display: flex;
|
||||
@@ -56,6 +57,10 @@ body {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -73,18 +78,22 @@ body {
|
||||
}
|
||||
|
||||
.place-details {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.place-details h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.place-meta {
|
||||
.place-details .place-type {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
text-transform: capitalize;
|
||||
margin-bottom: 1rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.place-details .place-description {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -133,15 +142,42 @@ body {
|
||||
}
|
||||
|
||||
.place-type {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-top: 2rem;
|
||||
.meta-info {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.meta-info p {
|
||||
margin: 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
word-break: break-word; /* Prevent long URLs from breaking layout */
|
||||
}
|
||||
|
||||
.meta-info strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.meta-info a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.meta-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
border: 0;
|
||||
border-top: 1px dashed #ddd;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Map Search Pulse Animation */
|
||||
@@ -187,3 +223,112 @@ body {
|
||||
.ol-touch .ol-control.ol-locate {
|
||||
top: 5.5em; /* Adjust for touch devices where controls might be larger */
|
||||
}
|
||||
|
||||
span.icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.content-with-icon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Selected Pin Animation */
|
||||
.selected-pin-container {
|
||||
position: absolute;
|
||||
/* Center the bottom tip of the pin at the coordinate */
|
||||
transform: translate(-50%, -100%);
|
||||
pointer-events: none; /* Let clicks pass through to the map features below if needed */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selected-pin-container.active {
|
||||
display: block;
|
||||
animation: dropIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
|
||||
.selected-pin {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #ea4335; /* Google Red */
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.selected-pin svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: #ea4335;
|
||||
stroke: #b31412; /* Darker red stroke */
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Optional: Small dot at the bottom to ground it */
|
||||
.selected-pin-shadow {
|
||||
width: 10px;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
animation: shadowFade 0.5s 0.2s forwards;
|
||||
}
|
||||
|
||||
@keyframes dropIn {
|
||||
0% {
|
||||
transform: translate(-50%, -200%) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -100%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shadowFade {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
overflow-y: auto;
|
||||
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,15 +28,26 @@ export default class ApplicationComponent extends Component {
|
||||
|
||||
@action
|
||||
showPlaces(places, selectedPlace = null) {
|
||||
// Helper to resolve a place to its bookmark if it exists
|
||||
const resolvePlace = (p) => {
|
||||
if (!p) return null;
|
||||
// We use the OSM ID to check if we already have this place saved
|
||||
const saved = this.storage.findPlaceById(p.osmId);
|
||||
return saved || p;
|
||||
};
|
||||
|
||||
const resolvedSelected = resolvePlace(selectedPlace);
|
||||
const resolvedPlaces = places ? places.map(resolvePlace) : [];
|
||||
|
||||
// If we have a specific place, transition to the route
|
||||
if (selectedPlace) {
|
||||
if (resolvedSelected) {
|
||||
// Pass the FULL object model to avoid re-fetching!
|
||||
// The Route's serialize() hook handles URL generation.
|
||||
this.router.transitionTo('place', selectedPlace);
|
||||
this.router.transitionTo('place', resolvedSelected);
|
||||
this.nearbyPlaces = null; // Clear list when selecting specific
|
||||
} else if (places && places.length > 0) {
|
||||
} else if (resolvedPlaces && resolvedPlaces.length > 0) {
|
||||
// Show list case
|
||||
this.nearbyPlaces = places;
|
||||
this.nearbyPlaces = resolvedPlaces;
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
[
|
||||
'babel-plugin-ember-template-compilation',
|
||||
{
|
||||
compilerPath: 'ember-source/dist/ember-template-compiler.js',
|
||||
compilerPath: 'ember-source/ember-template-compiler/index.js',
|
||||
transforms: [...macros.templateMacros],
|
||||
},
|
||||
],
|
||||
|
||||
0
dist/@embroider/virtual/vendor.css
vendored
0
dist/@embroider/virtual/vendor.css
vendored
1
dist/@embroider/virtual/vendor.js
vendored
1
dist/@embroider/virtual/vendor.js
vendored
@@ -1 +0,0 @@
|
||||
var runningTests=false;
|
||||
15
dist/index.html
vendored
15
dist/index.html
vendored
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marco</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-91CAyURz.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/app-Bg0kM_Gw.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/app-Dxork-AG.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
3
dist/robots.txt
vendored
3
dist/robots.txt
vendored
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/
|
||||
User-agent: *
|
||||
Disallow:
|
||||
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "Small description for marco goes here",
|
||||
"repository": "",
|
||||
@@ -46,6 +46,7 @@
|
||||
"@embroider/vite": "^1.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.0",
|
||||
"@warp-drive/ember": "~5.8.0",
|
||||
@@ -62,6 +63,7 @@
|
||||
"ember-resolver": "^13.1.1",
|
||||
"ember-source": "~6.11.0-alpha.6",
|
||||
"ember-template-lint": "^7.9.3",
|
||||
"ember-truth-helpers": "^5.0.0",
|
||||
"ember-welcome-page": "^8.0.4",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -69,11 +71,17 @@
|
||||
"eslint-plugin-n": "^17.23.1",
|
||||
"eslint-plugin-qunit": "^8.2.5",
|
||||
"eslint-plugin-warp-drive": "^5.8.0",
|
||||
"feather-icons": "^4.29.2",
|
||||
"globals": "^16.5.0",
|
||||
"latlon-geohash": "^2.0.0",
|
||||
"ol": "^10.7.0",
|
||||
"ol-mapbox-style": "^13.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
||||
"qunit": "^2.25.0",
|
||||
"qunit-dom": "^3.5.0",
|
||||
"remotestorage-widget": "^1.8.0",
|
||||
"remotestoragejs": "2.0.0-beta.8",
|
||||
"sinon": "^21.0.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
@@ -85,14 +93,5 @@
|
||||
},
|
||||
"ember": {
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
|
||||
"ember-truth-helpers": "^5.0.0",
|
||||
"latlon-geohash": "^2.0.0",
|
||||
"ol": "^10.7.0",
|
||||
"ol-mapbox-style": "^13.2.0",
|
||||
"remotestorage-widget": "^1.8.0",
|
||||
"remotestoragejs": "2.0.0-beta.8"
|
||||
}
|
||||
}
|
||||
|
||||
64
pnpm-lock.yaml
generated
64
pnpm-lock.yaml
generated
@@ -7,28 +7,6 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@remotestorage/module-places':
|
||||
specifier: link:vendor/remotestorage-module-places
|
||||
version: link:vendor/remotestorage-module-places
|
||||
ember-truth-helpers:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
latlon-geohash:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
ol:
|
||||
specifier: ^10.7.0
|
||||
version: 10.7.0
|
||||
ol-mapbox-style:
|
||||
specifier: ^13.2.0
|
||||
version: 13.2.0(ol@10.7.0)
|
||||
remotestorage-widget:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
remotestoragejs:
|
||||
specifier: 2.0.0-beta.8
|
||||
version: 2.0.0-beta.8
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
specifier: ^7.28.5
|
||||
@@ -69,6 +47,9 @@ importers:
|
||||
'@glimmer/component':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@remotestorage/module-places':
|
||||
specifier: link:vendor/remotestorage-module-places
|
||||
version: link:vendor/remotestorage-module-places
|
||||
'@rollup/plugin-babel':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
||||
@@ -117,6 +98,9 @@ importers:
|
||||
ember-template-lint:
|
||||
specifier: ^7.9.3
|
||||
version: 7.9.3
|
||||
ember-truth-helpers:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
ember-welcome-page:
|
||||
specifier: ^8.0.4
|
||||
version: 8.0.5(@babel/core@7.28.6)
|
||||
@@ -138,9 +122,21 @@ importers:
|
||||
eslint-plugin-warp-drive:
|
||||
specifier: ^5.8.0
|
||||
version: 5.8.1(@babel/core@7.28.6)
|
||||
feather-icons:
|
||||
specifier: ^4.29.2
|
||||
version: 4.29.2
|
||||
globals:
|
||||
specifier: ^16.5.0
|
||||
version: 16.5.0
|
||||
latlon-geohash:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
ol:
|
||||
specifier: ^10.7.0
|
||||
version: 10.7.0
|
||||
ol-mapbox-style:
|
||||
specifier: ^13.2.0
|
||||
version: 13.2.0(ol@10.7.0)
|
||||
prettier:
|
||||
specifier: ^3.7.4
|
||||
version: 3.7.4
|
||||
@@ -153,6 +149,12 @@ importers:
|
||||
qunit-dom:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
remotestorage-widget:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
remotestoragejs:
|
||||
specifier: 2.0.0-beta.8
|
||||
version: 2.0.0-beta.8
|
||||
sinon:
|
||||
specifier: ^21.0.1
|
||||
version: 21.0.1
|
||||
@@ -1794,6 +1796,9 @@ packages:
|
||||
charm@1.0.2:
|
||||
resolution: {integrity: sha512-wqW3VdPnlSWT4eRiYX+hcs+C6ViBPUWk1qTCd+37qw9kEm/a5n2qcyQDMBWvSYKN/ctqZzeXNQaeBjOetJJUkw==}
|
||||
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
clean-up-path@1.0.0:
|
||||
resolution: {integrity: sha512-PHGlEF0Z6976qQyN6gM7kKH6EH0RdfZcc8V+QhFe36eRxV0SMH5OUBZG7Bxa9YcreNzyNbK63cGiZxdSZgosRw==}
|
||||
|
||||
@@ -2062,6 +2067,9 @@ packages:
|
||||
resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
|
||||
deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
|
||||
|
||||
core-js@3.47.0:
|
||||
resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -2544,6 +2552,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
feather-icons@4.29.2:
|
||||
resolution: {integrity: sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==}
|
||||
|
||||
file-entry-cache@11.1.1:
|
||||
resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==}
|
||||
|
||||
@@ -6397,6 +6408,8 @@ snapshots:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
clean-up-path@1.0.0: {}
|
||||
|
||||
cliui@8.0.1:
|
||||
@@ -6497,6 +6510,8 @@ snapshots:
|
||||
|
||||
core-js@2.6.12: {}
|
||||
|
||||
core-js@3.47.0: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.5:
|
||||
@@ -7206,6 +7221,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
feather-icons@4.29.2:
|
||||
dependencies:
|
||||
classnames: 2.5.1
|
||||
core-js: 3.47.0
|
||||
|
||||
file-entry-cache@11.1.1:
|
||||
dependencies:
|
||||
flat-cache: 6.1.19
|
||||
|
||||
1
release/assets/main-B6U20YwQ.css
Normal file
1
release/assets/main-B6U20YwQ.css
Normal file
File diff suppressed because one or more lines are too long
2
release/assets/main-DQ03OXTu.js
Normal file
2
release/assets/main-DQ03OXTu.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,8 +6,8 @@
|
||||
<meta name="description" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-PEcndiCZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Dxork-AG.css">
|
||||
<script type="module" crossorigin src="/assets/main-DQ03OXTu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-B6U20YwQ.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user