8 Commits

Author SHA1 Message Date
f60dacac80 1.5.0 2026-01-24 14:16:40 +07:00
9a02363515 Move all map controls to bottom right corner 2026-01-24 14:16:08 +07:00
f28be0c994 Add user/accounts menu, RS connect 2026-01-24 13:51:29 +07:00
721fe5f01d Fix linting/formatting 2026-01-24 12:52:19 +07:00
518685b7dc Improve secondary btn style 2026-01-24 11:17:07 +07:00
262e5b61a8 1.4.3 2026-01-23 16:52:21 +07:00
f87d8bdda9 Improve save button styles 2026-01-23 16:51:53 +07:00
f17f8ca17b Use feather icons in sidebar header 2026-01-23 15:49:59 +07:00
18 changed files with 545 additions and 93 deletions

View File

@@ -0,0 +1,59 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
export default class AppHeaderComponent extends Component {
@service storage;
@tracked isUserMenuOpen = false;
@action
toggleUserMenu() {
this.isUserMenuOpen = !this.isUserMenuOpen;
}
@action
closeUserMenu() {
this.isUserMenuOpen = false;
}
<template>
<header class="app-header">
<div class="header-left">
<button class="icon-btn" type="button" aria-label="Menu">
<Icon @name="menu" @size={{24}} @color="#333" />
</button>
</div>
<div class="header-right">
<div class="user-menu-container">
<button
class="user-btn"
type="button"
aria-label="User Menu"
{{on "click" this.toggleUserMenu}}
>
<div class="user-avatar-placeholder">
<Icon @name="user" @size={{20}} @color="white" />
</div>
</button>
{{#if this.isUserMenuOpen}}
<UserMenu
@storage={{this.storage}}
@onClose={{this.closeUserMenu}}
/>
<div
class="menu-backdrop"
{{on "click" this.closeUserMenu}}
role="button"
></div>
{{/if}}
</div>
</div>
</header>
</template>
}

View File

@@ -1,26 +1,44 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
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 logIn from 'feather-icons/dist/icons/log-in.svg?raw';
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.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 server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
const ICONS = {
'arrow-left': arrowLeft,
activity,
bookmark,
clock,
globe,
home,
'log-in': logIn,
'log-out': logOut,
map,
mapPin,
'map-pin': mapPin,
menu,
navigation,
phone,
server,
settings,
user,
settings
x,
zap,
};
export default class IconComponent extends Component {

View File

@@ -61,7 +61,6 @@ 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;
@@ -95,7 +94,11 @@ export default class MapComponent extends Component {
target: element,
layers: [openfreemap, bookmarkLayer],
view: view,
controls: defaultControls({ zoom: false, rotate: true, attribution: true }),
controls: defaultControls({
zoom: false,
rotate: true,
attribution: true,
}),
});
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
@@ -210,7 +213,9 @@ export default class MapComponent extends Component {
geolocation.un('change:position', zoomToLocation);
locateListenerKey = null;
}
} catch (e) { /* ignore */ }
} catch (e) {
/* ignore */
}
// Hide pulse
if (this.locationOverlayElement) {
@@ -261,7 +266,8 @@ export default class MapComponent extends Component {
else if (accuracy) {
const viewportWidthMeters = 6.325 * accuracy;
const minDimensionPixels = Math.min(size[0], size[1]);
const requiredResolutionMeters = viewportWidthMeters / minDimensionPixels;
const requiredResolutionMeters =
viewportWidthMeters / minDimensionPixels;
const metersPerMapUnit = getPointResolution(
view.getProjection(),
1,
@@ -443,23 +449,23 @@ export default class MapComponent extends Component {
// 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;
// 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();
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];
// 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
});
view.animate({
center: newCenter,
duration: 500,
easing: (t) => t * (2 - t), // Ease-out
});
}
}
@@ -525,7 +531,7 @@ export default class MapComponent extends Component {
const viewState = {
center: currentCenter,
zoom: currentZoom
zoom: currentZoom,
};
localStorage.setItem('marco:map-view', JSON.stringify(viewState));
@@ -545,7 +551,9 @@ export default class MapComponent extends Component {
if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`);
for (const f of features) { console.debug(f) }
for (const f of features) {
console.debug(f);
}
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
if (bookmarkFeature) {
clickedBookmark = bookmarkFeature.get('originalPlace');

View File

@@ -79,10 +79,10 @@ export default class PlaceDetails extends Component {
get cuisine() {
if (!this.tags.cuisine) return null;
return this.tags.cuisine
.split(';')
.map(c => capitalize.compute([c]))
.map(c => c.replace('_', ' '))
.join(', ');
.split(';')
.map((c) => capitalize.compute([c]))
.map((c) => c.replace('_', ' '))
.join(', ');
}
get wikipedia() {
@@ -123,17 +123,25 @@ export default class PlaceDetails extends Component {
</p>
{{#if this.place.description}}
<p class="place-description">
{{this.place.description}}
{{this.place.description}}
</p>
{{/if}}
<div class="actions">
<button
type="button"
class={{if this.place.createdAt "btn-secondary" "btn-primary"}}
class={{if
this.place.createdAt
"btn btn-secondary"
"btn btn-outline"
}}
{{on "click" (fn @onToggleSave this.place)}}
>
{{if this.place.createdAt "Saved ✓" "Save"}}
<Icon
@name="bookmark"
@color={{if this.place.createdAt "currentColor" "#007bff"}}
/>
{{if this.place.createdAt "Saved" "Save"}}
</button>
</div>
@@ -163,14 +171,22 @@ export default class PlaceDetails extends Component {
{{#if this.website}}
<p class="content-with-icon">
<Icon @name="globe" @title="Website" />
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">{{this.websiteDomain}}</a></span>
<span><a
href={{this.website}}
target="_blank"
rel="noopener noreferrer"
>{{this.websiteDomain}}</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>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
target="_blank"
rel="noopener noreferrer"
>Article</a>
</p>
{{/if}}
@@ -185,7 +201,7 @@ export default class PlaceDetails extends Component {
{{/if}}
<p class="content-with-icon">
<Icon @name="mapPin" @title="Geo link" />
<Icon @name="map-pin" @title="Geo link" />
<span>
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
{{this.visibleGeoLink}}

View File

@@ -5,6 +5,7 @@ import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import PlaceDetails from './place-details';
import Icon from './icon';
export default class PlacesSidebar extends Component {
@service storage;
@@ -49,23 +50,23 @@ export default class PlacesSidebar extends Component {
// Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined
};
this.args.onUpdate(freshPlace);
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined,
};
this.args.onUpdate(freshPlace);
}
// Also fire onSelect if it exists (for list view)
// Also fire onSelect if it exists (for list view)
if (this.args.onSelect) {
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null);
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null);
}
// Close sidebar after delete
@@ -80,7 +81,8 @@ export default class PlacesSidebar extends Component {
} else {
// It's a fresh POI -> Save it
const placeData = {
title: place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
title:
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
lat: place.lat,
lon: place.lon,
tags: [],
@@ -123,15 +125,15 @@ export default class PlacesSidebar extends Component {
type="button"
class="back-btn"
{{on "click" this.clearSelection}}
></button>
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}}
<h2>Nearby Places</h2>
{{/if}}
<button
type="button"
class="close-btn"
{{on "click" @onClose}}
>×</button>
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@name="x"
@size={{20}}
@color="#333"
/></button>
</div>
<div class="sidebar-content">

View File

@@ -0,0 +1,66 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
export default class UserMenuComponent extends Component {
@action
connectRS() {
this.args.onClose();
this.args.storage.connect();
}
@action
disconnectRS() {
this.args.storage.disconnect();
}
<template>
<div class="user-menu-popover">
<div class="user-status">
{{#if @storage.connected}}
Connected as
<strong>{{@storage.userAddress}}</strong>
{{else}}
Not connected
{{/if}}
</div>
<ul class="account-list">
<li class="account-item">
<div class="account-info">
<Icon @name="server" @size={{18}} />
<span>RemoteStorage</span>
</div>
{{#if @storage.connected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectRS}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectRS}}
>Connect</button>
{{/if}}
</li>
<li class="account-item disabled">
<div class="account-info">
<Icon @name="globe" @size={{18}} />
<span>OpenStreetMap</span>
</div>
</li>
<li class="account-item disabled">
<div class="account-info">
<Icon @name="zap" @size={{18}} />
<span>Nostr</span>
</div>
</li>
</ul>
</div>
</template>
}

View File

@@ -24,7 +24,7 @@ out center;
`.trim();
const url = `https://overpass.bke.ro/api/interpreter?data=${encodeURIComponent(
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
query
)}`;
@@ -100,7 +100,8 @@ out center;
`.trim();
}
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
const url = `https://overpass.bke.ro/api/interpreter?data=${encodeURIComponent(
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
query
)}`;
const res = await this.fetchWithRetry(url);

View File

@@ -1,19 +1,25 @@
import Service from '@ember/service';
import RemoteStorage from 'remotestoragejs';
import Places from '@remotestorage/module-places';
import Widget from 'remotestorage-widget';
import { tracked } from '@glimmer/tracking';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
import { action } from '@ember/object';
import { debounce } from '@ember/runloop';
import Geohash from 'latlon-geohash';
export default class StorageService extends Service {
rs;
widget;
@tracked placesInView = [];
@tracked savedPlaces = [];
@tracked loadedPrefixes = [];
@tracked currentBbox = null;
@tracked version = 0; // Shared version tracker for bookmarks
@tracked initialSyncDone = false;
@tracked connected = false;
@tracked userAddress = null;
@tracked isWidgetOpen = false;
constructor() {
super(...arguments);
@@ -29,14 +35,38 @@ export default class StorageService extends Service {
window.remoteStorage = this.rs;
// const widget = new Widget(this.rs);
// widget.attach();
this.widget = new Widget(this.rs, {
leaveOpen: true,
skipInitial: true,
});
// We don't attach immediately; we'll attach when the user clicks Connect
this.rs.on('ready', () => {
// console.debug('[rs] client ready');
});
this.rs.on('sync-done', (result) => {
this.rs.on('connected', () => {
console.debug('Remote storage connected');
this.connected = true;
this.userAddress = this.rs.remote.userAddress;
// Close widget after successful connection (respecting autoCloseAfter)
setTimeout(() => {
this.isWidgetOpen = false;
}, 1500);
});
this.rs.on('disconnected', () => {
console.debug('Remote storage disconnected');
this.connected = false;
this.userAddress = null;
this.placesInView = [];
this.savedPlaces = [];
this.loadedPrefixes = [];
this.initialSyncDone = false;
});
this.rs.on('sync-done', () => {
// console.debug('[rs] sync done:', result);
if (!this.initialSyncDone) {
this.initialSyncDone = true;
@@ -157,7 +187,7 @@ export default class StorageService extends Service {
// If the hash is in the set of reloaded prefixes, we discard the old version
// (because the 'places' array contains the authoritative new state for this prefix)
return !prefixSet.has(hash);
} catch (e) {
} catch {
return true; // Keep malformed/unknown places safe
}
});
@@ -198,6 +228,28 @@ export default class StorageService extends Service {
async removePlace(place) {
await this.places.remove(place.id, place.geohash);
this.savedPlaces = this.savedPlaces.filter(p => p.id !== place.id);
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
}
@action
connect() {
this.isWidgetOpen = true;
// Check if widget is already attached
if (!document.querySelector('.rs-widget')) {
// Attach to our specific container
this.widget.attach('rs-widget-container');
}
}
@action
closeWidget() {
this.isWidgetOpen = false;
}
@action
disconnect() {
this.rs.disconnect();
this.isWidgetOpen = false;
}
}

View File

@@ -22,12 +22,161 @@ body {
outline: none; /* Prevent focus outline on click */
}
/* Ensure RS widget is above the map */
#remotestorage-widget {
/* Ensure RS widget is above the map but potentially hidden initially if needed */
#rs-widget-container {
position: absolute;
top: 10px;
top: 60px; /* Below header */
right: 10px;
z-index: 1000;
z-index: 4000;
display: none; /* Hidden by default */
}
#rs-widget-container.visible {
display: block;
}
.rs-backdrop {
position: fixed;
inset: 0;
z-index: 3999; /* Below widget container but above everything else */
/* background: rgba(0,0,0,0.2); Optional: dim background */
}
/* App Header */
.app-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 3000; /* Above sidebar (2000) and map */
pointer-events: none; /* Let clicks pass through to map where transparent */
}
.header-left,
.header-right {
pointer-events: auto; /* Re-enable clicks for buttons */
}
.icon-btn {
background: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: transform 0.1s;
}
.icon-btn:active {
transform: scale(0.95);
}
.user-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.user-avatar-placeholder {
width: 40px;
height: 40px;
background: #333;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
/* User Menu Popover */
.user-menu-container {
position: relative;
}
.user-menu-popover {
position: absolute;
top: 100%;
right: 0;
margin-top: 10px;
width: 280px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem;
z-index: 3001;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3000; /* Below popover but above everything else */
/* background: rgba(0,0,0,0.1); Optional dimming */
}
.user-status {
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #eee;
color: #666;
font-size: 0.9rem;
}
.account-list {
list-style: none;
padding: 0;
margin: 0;
}
.account-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
}
.account-item.disabled {
opacity: 0.5;
pointer-events: none;
}
.account-info {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500;
}
.btn-text {
background: none;
border: none;
padding: 0;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
}
.text-primary {
color: #007bff;
}
.text-danger {
color: #dc3545;
}
.btn-text:hover {
text-decoration: underline;
}
/* Sidebar Styles */
@@ -62,20 +211,26 @@ body {
padding: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.5rem;
}
.back-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
margin-right: 0.5rem;
padding: 0 0.5rem;
margin-left: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0 0.5rem;
margin-right: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.place-details {
@@ -99,23 +254,63 @@ body {
.place-details .actions {
padding-bottom: 0.3rem;
/* display: flex; */
/* flex-direction: row; */
/* gap: 1rem; */
}
.btn-primary {
background: #007bff;
color: white;
border: none;
.btn {
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
width: 100%;
/* width: 50%; */
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary:hover {
.btn-outline {
background: transparent;
color: #333;
border: 1px solid #ccc;
}
.btn-outline:hover {
border: 1px solid #898989;
}
.btn-secondary {
color: #333;
border: 1px solid rgba(255, 204, 51, 0.2);
background: rgba(255, 204, 51, 0.3);
}
.btn-secondary:hover {
background: rgba(255, 204, 51, 0.4);
}
.btn-blue {
background: #007bff;
color: white;
border: none;
}
.btn-blue:hover {
background: #0056b3;
}
.btn-green {
background: #198754;
color: white;
border: none;
}
.btn-green:hover {
background: #157347;
}
.places-list {
list-style: none;
padding: 0;
@@ -218,13 +413,28 @@ body {
/* Locate Control */
.ol-control.ol-locate {
top: 5em; /* Position below zoom controls (usually at .5em or similar) */
top: auto;
bottom: 2.5em;
right: 0.5em;
left: auto;
}
.ol-touch .ol-control.ol-locate {
top: 5.5em; /* Adjust for touch devices where controls might be larger */
top: auto;
bottom: 3.5em;
}
/* Rotate Control */
.ol-rotate {
top: auto;
bottom: 5em;
right: 0.5em;
left: auto;
}
.ol-touch .ol-rotate {
top: auto;
bottom: 6em;
}
span.icon {

View File

@@ -2,11 +2,13 @@ import Component from '@glimmer/component';
import { pageTitle } from 'ember-page-title';
import Map from '#components/map';
import PlacesSidebar from '#components/places-sidebar';
import AppHeader from '#components/app-header';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { eq } from 'ember-truth-helpers';
import { and } from 'ember-truth-helpers';
import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component {
@service storage;
@@ -74,6 +76,21 @@ export default class ApplicationComponent extends Component {
<template>
{{pageTitle "M/\RCO"}}
<AppHeader />
<div
id="rs-widget-container"
class={{if this.storage.isWidgetOpen "visible"}}
></div>
{{#if this.storage.isWidgetOpen}}
<div
class="rs-backdrop"
role="button"
{{on "click" this.storage.closeWidget}}
></div>
{{/if}}
<Map
@onPlacesFound={{this.showPlaces}}
@isSidebarOpen={{this.isSidebarOpen}}

View File

@@ -38,8 +38,11 @@ export default class PlaceTemplate extends Component {
// 3. If not saved, check our local "optimistic" state (from handleUpdate)
// This handles the "unsaved" state immediately after deletion before any other sync
if (this.localPlace && (this.localPlace.osmId === id || this.localPlace.id === id)) {
return this.localPlace;
if (
this.localPlace &&
(this.localPlace.osmId === id || this.localPlace.id === id)
) {
return this.localPlace;
}
// 4. Fallback to the route model (which might be the stale "saved" object from when the route loaded)
@@ -50,7 +53,7 @@ export default class PlaceTemplate extends Component {
...model,
id: undefined,
createdAt: undefined,
geohash: undefined
geohash: undefined,
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"description": "Small description for marco goes here",
"repository": "",

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

File diff suppressed because one or more lines are too long

View File

@@ -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-C6x36ClG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-_X0dk-zm.css">
<script type="module" crossorigin src="/assets/main-DPNrocGB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-h3_9Qqi3.css">
</head>
<body>
</body>