Compare commits
10 Commits
70d2fe1c6c
...
v1.24.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
59bc5ca046
|
|||
|
ef4bb8f51a
|
|||
|
f82a797720
|
|||
|
f9cb22ee0e
|
|||
|
a77ea0c97d
|
|||
|
208b77a294
|
|||
|
ea3e4dd0dc
|
|||
|
2c2a3e2a4c
|
|||
|
d266bb92bd
|
|||
|
200100686d
|
@@ -21,7 +21,10 @@ const GalleryContent = <template>
|
|||||||
{{@bindKeyboard @handleKeydown}}
|
{{@bindKeyboard @handleKeydown}}
|
||||||
>
|
>
|
||||||
{{! template-lint-disable no-invalid-interactive }}
|
{{! template-lint-disable no-invalid-interactive }}
|
||||||
<div class="photo-gallery-content">
|
<div
|
||||||
|
class="photo-gallery-content"
|
||||||
|
data-current-event-id={{@currentPhoto.eventId}}
|
||||||
|
>
|
||||||
<div class="actions-btn-container">
|
<div class="actions-btn-container">
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@iconSize={{24}}
|
@iconSize={{24}}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { task } from 'ember-concurrency';
|
|||||||
import Geohash from 'latlon-geohash';
|
import Geohash from 'latlon-geohash';
|
||||||
import PlacePhotoUploadItem from './place-photo-upload-item';
|
import PlacePhotoUploadItem from './place-photo-upload-item';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
|
import { getSuggestedPhotoTags } from '../utils/photo-tag-suggestions';
|
||||||
|
import capitalize from '../helpers/capitalize';
|
||||||
|
import includes from '../helpers/includes';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
import { or, not } from 'ember-truth-helpers';
|
import { or, not } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class PlacePhotoUpload extends Component {
|
export default class PlacePhotoUpload extends Component {
|
||||||
@@ -22,6 +26,7 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
@tracked error = '';
|
@tracked error = '';
|
||||||
@tracked isPublishing = false;
|
@tracked isPublishing = false;
|
||||||
@tracked isDragging = false;
|
@tracked isDragging = false;
|
||||||
|
@tracked selectedTags = [];
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
@@ -37,6 +42,10 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get suggestedTags() {
|
||||||
|
return getSuggestedPhotoTags(this.place);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleFileSelect(event) {
|
handleFileSelect(event) {
|
||||||
this.addFile(event.target.files[0]);
|
this.addFile(event.target.files[0]);
|
||||||
@@ -93,11 +102,22 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
}
|
}
|
||||||
this.file = null;
|
this.file = null;
|
||||||
this.uploadedPhoto = null;
|
this.uploadedPhoto = null;
|
||||||
|
this.selectedTags = [];
|
||||||
if (this.args.onUploadStateChange) {
|
if (this.args.onUploadStateChange) {
|
||||||
this.args.onUploadStateChange(false);
|
this.args.onUploadStateChange(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleTag(tag) {
|
||||||
|
if (this.selectedTags.includes(tag)) {
|
||||||
|
this.selectedTags = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedTags = [tag];
|
||||||
|
}
|
||||||
|
|
||||||
deletePhotoTask = task(async (photoData) => {
|
deletePhotoTask = task(async (photoData) => {
|
||||||
try {
|
try {
|
||||||
if (photoData.hash) {
|
if (photoData.hash) {
|
||||||
@@ -139,6 +159,10 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
|
|
||||||
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
const tags = [['i', `osm:${osmType}:${osmId}`]];
|
||||||
|
|
||||||
|
for (const tag of this.selectedTags) {
|
||||||
|
tags.push(['t', tag]);
|
||||||
|
}
|
||||||
|
|
||||||
if (lat && lon) {
|
if (lat && lon) {
|
||||||
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
tags.push(['g', Geohash.encode(lat, lon, 4)]);
|
||||||
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
tags.push(['g', Geohash.encode(lat, lon, 6)]);
|
||||||
@@ -227,6 +251,26 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.suggestedTags.length}}
|
||||||
|
<div class="photo-tag-suggestions">
|
||||||
|
<p class="photo-tag-suggestions-title">
|
||||||
|
Choose a tag/category (optional):
|
||||||
|
</p>
|
||||||
|
<div class="photo-tag-suggestions-list">
|
||||||
|
{{#each this.suggestedTags as |tag|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="photo-tag-chip
|
||||||
|
{{if (includes this.selectedTags tag) 'is-selected'}}"
|
||||||
|
{{on "click" (fn this.toggleTag tag)}}
|
||||||
|
>
|
||||||
|
{{capitalize tag}}
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-publish"
|
class="btn btn-primary btn-publish"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
|
|||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@service photon;
|
@service photon;
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service map; // Assuming we might need map context, but mostly we use router
|
@service map; // Assuming we might need map context, but mostly we use router
|
||||||
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
|
|||||||
this.searchTask.perform(value);
|
this.searchTask.perform(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatSavedPlace(place) {
|
||||||
|
const listNames = (place._listIds || [])
|
||||||
|
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const description = listNames
|
||||||
|
? `Saved place (${listNames})`
|
||||||
|
: 'Saved place';
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'saved',
|
||||||
|
id: place.id,
|
||||||
|
title: place.title,
|
||||||
|
icon: 'bookmark',
|
||||||
|
description,
|
||||||
|
osmId: place.osmId,
|
||||||
|
osmType: place.osmType,
|
||||||
|
lat: place.lat,
|
||||||
|
lon: place.lon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
searchTask = task({ restartable: true }, async (term) => {
|
searchTask = task({ restartable: true }, async (term) => {
|
||||||
await timeout(300);
|
await timeout(300);
|
||||||
|
|
||||||
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
|
|||||||
icon: 'search',
|
icon: 'search',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Filter saved places (minimum 3 characters)
|
||||||
|
let savedMatches = [];
|
||||||
|
if (q.length >= 3) {
|
||||||
|
savedMatches = this.storage.savedPlaces
|
||||||
|
.filter((p) => p.title && p.title.toLowerCase().includes(q))
|
||||||
|
.map((p) => this.formatSavedPlace(p));
|
||||||
|
}
|
||||||
|
|
||||||
const results = await this.photon.search(query, lat, lon);
|
const results = await this.photon.search(query, lat, lon);
|
||||||
this.results = [...categoryMatches, ...results];
|
|
||||||
|
// Deduplicate Photon results that are already in saved matches
|
||||||
|
const savedOsmIds = new Set(
|
||||||
|
savedMatches.map((s) => s.osmId).filter(Boolean)
|
||||||
|
);
|
||||||
|
const filteredPhotonResults = results.filter(
|
||||||
|
(r) => !savedOsmIds.has(r.osmId)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.results = [
|
||||||
|
...categoryMatches,
|
||||||
|
...savedMatches,
|
||||||
|
...filteredPhotonResults,
|
||||||
|
];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Search failed', e);
|
console.error('Search failed', e);
|
||||||
this.results = [];
|
this.results = [];
|
||||||
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
|
|||||||
}
|
}
|
||||||
this.results = []; // Hide popover
|
this.results = []; // Hide popover
|
||||||
|
|
||||||
// If it has an OSM ID, go to place details
|
// If it's a custom saved place without an OSM ID, go to place details via internal ID
|
||||||
if (place.osmId) {
|
if (place.source === 'saved' && place.id && !place.osmId) {
|
||||||
|
this.router.transitionTo('place', place.id);
|
||||||
|
}
|
||||||
|
// If it has an OSM ID, go to place details via OSM ID
|
||||||
|
else if (place.osmId) {
|
||||||
// Format: osm:node:123
|
// Format: osm:node:123
|
||||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||||
|
|||||||
@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
|
|||||||
// Search with Photon (using lat/lon for bias if available)
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
// Search local bookmarks by name
|
// Search local bookmarks by name (minimum 3 characters)
|
||||||
const queryLower = params.q.toLowerCase();
|
const queryLower = params.q.toLowerCase();
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
let localMatches = [];
|
||||||
|
if (queryLower.length >= 3) {
|
||||||
|
localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
return (
|
return (
|
||||||
p.title?.toLowerCase().includes(queryLower) ||
|
p.title?.toLowerCase().includes(queryLower) ||
|
||||||
p.description?.toLowerCase().includes(queryLower)
|
p.description?.toLowerCase().includes(queryLower)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Merge local matches
|
// Merge local matches
|
||||||
localMatches.forEach((local) => {
|
localMatches.forEach((local) => {
|
||||||
|
|||||||
6
app/helpers/capitalize.js
Normal file
6
app/helpers/capitalize.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
import { capitalize as format } from '../utils/format-text';
|
||||||
|
|
||||||
|
export default helper(function capitalize([text]) {
|
||||||
|
return format(text);
|
||||||
|
});
|
||||||
6
app/helpers/includes.js
Normal file
6
app/helpers/includes.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
|
||||||
|
export default helper(function includes([collection, value]) {
|
||||||
|
if (!Array.isArray(collection)) return false;
|
||||||
|
return collection.includes(value);
|
||||||
|
});
|
||||||
@@ -1843,6 +1843,42 @@ button.create-place {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions {
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions-title {
|
||||||
|
color: #898989;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-suggestions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip:hover {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-tag-chip.is-selected {
|
||||||
|
background: rgb(255 204 51 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ export function humanizeOsmTag(text) {
|
|||||||
w.replace(/^\w/, (c) => c.toUpperCase())
|
w.replace(/^\w/, (c) => c.toUpperCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function capitalize(text) {
|
||||||
|
if (typeof text !== 'string' || !text) return '';
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export function parsePlacePhotos(events) {
|
|||||||
const allPhotos = [];
|
const allPhotos = [];
|
||||||
|
|
||||||
for (const event of sortedEvents) {
|
for (const event of sortedEvents) {
|
||||||
|
const eventTagValues = event.tags
|
||||||
|
.filter((t) => t[0] === 't')
|
||||||
|
.map((t) => t[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
// Find all imeta tags
|
// Find all imeta tags
|
||||||
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
const imetas = event.tags.filter((t) => t[0] === 'imeta');
|
||||||
for (const imeta of imetas) {
|
for (const imeta of imetas) {
|
||||||
@@ -70,6 +75,7 @@ export function parsePlacePhotos(events) {
|
|||||||
isLandscape,
|
isLandscape,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
placeIdentifier,
|
placeIdentifier,
|
||||||
|
tags: eventTagValues,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/utils/photo-tag-suggestions.js
Normal file
32
app/utils/photo-tag-suggestions.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { POI_CATEGORIES } from './poi-categories';
|
||||||
|
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
||||||
|
|
||||||
|
export const CATEGORY_TAGS = {
|
||||||
|
restaurants: ['food', 'menu', 'vibe', 'front'],
|
||||||
|
coffee: ['food', 'menu', 'vibe', 'front'],
|
||||||
|
groceries: ['front', 'food'],
|
||||||
|
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
|
||||||
|
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSuggestedPhotoTags(place) {
|
||||||
|
const osmTags = place?.osmTags || place?.tags || {};
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(osmTags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
const suggested = [];
|
||||||
|
for (const categoryId of categoryIds) {
|
||||||
|
const tags = CATEGORY_TAGS[categoryId];
|
||||||
|
if (!Array.isArray(tags)) continue;
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!suggested.includes(tag)) {
|
||||||
|
suggested.push(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggested.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggested;
|
||||||
|
}
|
||||||
95
app/utils/poi-category-matcher.js
Normal file
95
app/utils/poi-category-matcher.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export function getMatchingPoiCategories(osmTags, categories) {
|
||||||
|
if (!Array.isArray(categories) || !osmTags) return [];
|
||||||
|
|
||||||
|
return categories.filter((category) => {
|
||||||
|
if (!Array.isArray(category.filter)) return false;
|
||||||
|
return category.filter.some((filterStr) =>
|
||||||
|
matchesFilter(osmTags, filterStr)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMatchingPoiCategoryIds(osmTags, categories) {
|
||||||
|
return getMatchingPoiCategories(osmTags, categories).map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(osmTags, filterStr) {
|
||||||
|
const clauses = parseOverpassClauses(filterStr);
|
||||||
|
if (clauses.length === 0) return false;
|
||||||
|
return clauses.every((clause) => matchesClause(osmTags, clause));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOverpassClauses(filterStr) {
|
||||||
|
if (!filterStr) return [];
|
||||||
|
const matches = filterStr.match(/\[[^\]]+\]/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.map((raw) => parseClause(raw.slice(1, -1).trim()))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClause(content) {
|
||||||
|
const presenceMatch = content.match(/^"([^"]+)"$/);
|
||||||
|
if (presenceMatch) {
|
||||||
|
return { type: 'presence', key: presenceMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const equalsMatch = content.match(/^"([^"]+)"\s*=\s*"([^"]*)"$/);
|
||||||
|
if (equalsMatch) {
|
||||||
|
return { type: 'equals', key: equalsMatch[1], value: equalsMatch[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexMatch = content.match(/^"([^"]+)"\s*~\s*"([^"]*)"$/);
|
||||||
|
if (regexMatch) {
|
||||||
|
return {
|
||||||
|
type: 'regex',
|
||||||
|
key: regexMatch[1],
|
||||||
|
pattern: regexMatch[2],
|
||||||
|
regex: new RegExp(regexMatch[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notRegexMatch = content.match(/^"([^"]+)"\s*!~\s*"([^"]*)"$/);
|
||||||
|
if (notRegexMatch) {
|
||||||
|
return {
|
||||||
|
type: 'not-regex',
|
||||||
|
key: notRegexMatch[1],
|
||||||
|
pattern: notRegexMatch[2],
|
||||||
|
regex: new RegExp(notRegexMatch[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesClause(osmTags, clause) {
|
||||||
|
const tagValues = getTagValues(osmTags, clause.key);
|
||||||
|
|
||||||
|
switch (clause.type) {
|
||||||
|
case 'presence':
|
||||||
|
return tagValues.length > 0;
|
||||||
|
case 'equals':
|
||||||
|
return tagValues.some((value) => value === clause.value);
|
||||||
|
case 'regex':
|
||||||
|
return tagValues.some((value) => clause.regex.test(value));
|
||||||
|
case 'not-regex':
|
||||||
|
return (
|
||||||
|
tagValues.length === 0 ||
|
||||||
|
!tagValues.some((value) => clause.regex.test(value))
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagValues(osmTags, key) {
|
||||||
|
if (!osmTags || !key) return [];
|
||||||
|
const rawValue = osmTags[key];
|
||||||
|
if (rawValue === undefined || rawValue === null) return [];
|
||||||
|
|
||||||
|
return String(rawValue)
|
||||||
|
.split(';')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"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
@@ -39,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-CjSZtg4Y.js"></script>
|
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BmLeTC2Y.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="modal-portal"></div>
|
<div id="modal-portal"></div>
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/testem.js" integrity="" data-embroider-ignore></script>
|
<script
|
||||||
|
src="/testem.js"
|
||||||
|
integrity=""
|
||||||
|
data-embroider-ignore
|
||||||
|
vite-ignore
|
||||||
|
></script>
|
||||||
|
|
||||||
<script type="module">import "ember-testing";</script>
|
<script type="module">import "ember-testing";</script>
|
||||||
|
|
||||||
|
|||||||
@@ -287,35 +287,30 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Let carousel settle
|
assert
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
.dom('.photo-gallery-content')
|
||||||
|
.hasAttribute('data-current-event-id', 'event1');
|
||||||
|
|
||||||
// Right Arrow
|
// Right Arrow
|
||||||
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
// Let's just assert that currentPhoto was updated internally, which trickles down.
|
|
||||||
// The actual DOM update for the main image might be tricky if the carousel relies on scroll events.
|
|
||||||
// We can at least check if the thumbnail selection changed, as that is directly driven by currentPhoto
|
|
||||||
assert
|
assert
|
||||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
.dom('.photo-gallery-content')
|
||||||
.hasAttribute('data-src', 'photo2.jpg');
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
|
|
||||||
// Right Arrow again
|
// Right Arrow again
|
||||||
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
assert
|
assert
|
||||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
.dom('.photo-gallery-content')
|
||||||
.hasAttribute('data-src', 'photo3.jpg');
|
.hasAttribute('data-current-event-id', 'event3');
|
||||||
|
|
||||||
// Left Arrow
|
// Left Arrow
|
||||||
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
|
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
|
|
||||||
assert
|
assert
|
||||||
.dom('.thumbnail-strip-container .carousel-slide.active img')
|
.dom('.photo-gallery-content')
|
||||||
.hasAttribute('data-src', 'photo2.jpg');
|
.hasAttribute('data-current-event-id', 'event2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('escape key closes gallery', async function (assert) {
|
test('escape key closes gallery', async function (assert) {
|
||||||
|
|||||||
100
tests/integration/components/place-photo-upload-test.gjs
Normal file
100
tests/integration/components/place-photo-upload-test.gjs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click, triggerEvent } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import PlacePhotoUpload from 'marco/components/place-photo-upload';
|
||||||
|
|
||||||
|
module('Integration | Component | place-photo-upload', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
class MockNostrAuthService extends Service {
|
||||||
|
get isConnected() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signer() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectFile(element, file) {
|
||||||
|
const input = element.querySelector('#photo-upload-input');
|
||||||
|
Object.defineProperty(input, 'files', {
|
||||||
|
value: [file],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(input, 'change');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('it shows tag suggestions when they exist after upload selection', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Cafe Alpha',
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { amenity: 'cafe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').exists();
|
||||||
|
assert.dom('.photo-tag-chip').exists();
|
||||||
|
assert.dom('.photo-tag-chip').includesText('Food');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it only allows one selected tag at a time', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Cafe Alpha',
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { amenity: 'cafe' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
const chips = this.element.querySelectorAll('.photo-tag-chip');
|
||||||
|
assert.ok(chips.length > 1, 'multiple tag chips are rendered');
|
||||||
|
|
||||||
|
await click(chips[0]);
|
||||||
|
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
|
||||||
|
assert.dom(chips[0]).hasClass('is-selected');
|
||||||
|
|
||||||
|
await click(chips[1]);
|
||||||
|
assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 });
|
||||||
|
assert.dom(chips[1]).hasClass('is-selected');
|
||||||
|
assert.dom(chips[0]).doesNotHaveClass('is-selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it hides tag suggestions when no tags are suggested', async function (assert) {
|
||||||
|
this.place = {
|
||||||
|
title: 'Office Beta',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
osmTags: { office: 'lawyer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><PlacePhotoUpload @place={{this.place}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
|
await selectFile(this.element, file);
|
||||||
|
|
||||||
|
assert.dom('.photo-tag-suggestions').doesNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render, fillIn, click, waitFor } from '@ember/test-helpers';
|
import { render, fillIn, click, waitFor, focus } from '@ember/test-helpers';
|
||||||
import SearchBox from 'marco/components/search-box';
|
import SearchBox from 'marco/components/search-box';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
|
|
||||||
@@ -208,22 +208,301 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Type "Resta" to trigger "Restaurants" category match
|
// Type "Resta" to trigger "Restaurants" category match
|
||||||
|
await focus('.search-input');
|
||||||
await fillIn('.search-input', 'Resta');
|
await fillIn('.search-input', 'Resta');
|
||||||
|
|
||||||
// Wait for debounce (300ms) + execution
|
await waitFor('.search-result-item');
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
await delay(400);
|
|
||||||
|
|
||||||
// The first result should be the category match
|
const resultItems = Array.from(
|
||||||
assert.dom('.search-result-item').exists({ count: 1 });
|
this.element.querySelectorAll('.search-result-item')
|
||||||
assert.dom('.result-title').hasText('Restaurants');
|
);
|
||||||
|
const categoryResult = resultItems.find((item) =>
|
||||||
|
item.textContent.includes('Restaurants')
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(categoryResult, 'Restaurants category result is shown');
|
||||||
|
|
||||||
// Click the result
|
// Click the result
|
||||||
await click('.search-result-item');
|
await click(categoryResult);
|
||||||
|
|
||||||
// Assert transition with lat/lon from map center
|
// Assert transition with lat/lon from map center
|
||||||
assert.verifySteps([
|
assert.verifySteps([
|
||||||
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, id) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
_listIds: ['favs'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'coffee') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
description: 'Duplicate to be removed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Other Coffee',
|
||||||
|
osmId: '888',
|
||||||
|
osmType: 'node',
|
||||||
|
description: 'A different coffee shop',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "coffee" to trigger matches in Category, Saved, and Photon
|
||||||
|
await fillIn('.search-input', 'coffee');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
const resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should be exactly 3 items:
|
||||||
|
// 1. Category (Coffee)
|
||||||
|
// 2. Saved (Awesome Coffee)
|
||||||
|
// 3. Photon (Other Coffee)
|
||||||
|
// (The Photon duplicate of "Awesome Coffee" is removed)
|
||||||
|
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
|
||||||
|
|
||||||
|
// 1. Category
|
||||||
|
assert.ok(
|
||||||
|
resultItems[0].textContent.includes('Coffee'),
|
||||||
|
'First item is the category match'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom(resultItems[0].querySelector('.result-icon svg'))
|
||||||
|
.hasClass('feather-search', 'Category uses search icon');
|
||||||
|
|
||||||
|
// 2. Saved Place
|
||||||
|
assert.ok(
|
||||||
|
resultItems[1].textContent.includes('Awesome Coffee'),
|
||||||
|
'Second item is the saved place match'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems[1].textContent.includes('Saved place'),
|
||||||
|
'Saved place has correct description text'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom(resultItems[1].querySelector('.result-icon svg'))
|
||||||
|
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
|
||||||
|
|
||||||
|
// 3. Photon Match
|
||||||
|
assert.ok(
|
||||||
|
resultItems[2].textContent.includes('Other Coffee'),
|
||||||
|
'Third item is the unique photon result'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the Saved Place
|
||||||
|
await click(resultItems[1]);
|
||||||
|
|
||||||
|
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it requires 3 or more characters to match saved places', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites' }];
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
_listIds: ['favs'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'aw' || query === 'awe') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Aww Some Place',
|
||||||
|
osmId: '111',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "aw" (2 characters)
|
||||||
|
await fillIn('.search-input', 'aw');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
let resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only show Photon match since 'aw' is < 3 characters
|
||||||
|
assert.strictEqual(
|
||||||
|
resultItems.length,
|
||||||
|
1,
|
||||||
|
'Renders exactly 1 item for 2 chars'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems[0].textContent.includes('Aww Some Place'),
|
||||||
|
'Shows photon match'
|
||||||
|
);
|
||||||
|
assert.notOk(
|
||||||
|
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||||
|
'Saved place is NOT shown for 2 char query'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "awe" (3 characters)
|
||||||
|
await fillIn('.search-input', 'awe');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should now show Saved Place and Photon match
|
||||||
|
assert.strictEqual(
|
||||||
|
resultItems.length,
|
||||||
|
2,
|
||||||
|
'Renders exactly 2 items for 3 chars'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
|
||||||
|
'Saved place is now shown'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
resultItems.some((item) =>
|
||||||
|
item.textContent.includes('Saved place (Favorites)')
|
||||||
|
),
|
||||||
|
'List names are appended to the description'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, id) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ["${id}"]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
// Mock Storage Service (Custom Place)
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
id: 'custom-1234',
|
||||||
|
title: 'My Custom Home',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
// Notice NO osmId or osmType
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type 3 chars to trigger saved place match
|
||||||
|
await fillIn('.search-input', 'cus');
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
const resultItems = Array.from(
|
||||||
|
this.element.querySelectorAll('.search-result-item')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure our custom place is rendered
|
||||||
|
const customResult = resultItems.find((item) =>
|
||||||
|
item.textContent.includes('My Custom Home')
|
||||||
|
);
|
||||||
|
assert.ok(customResult, 'Custom place is rendered');
|
||||||
|
|
||||||
|
// Click it
|
||||||
|
await click(customResult);
|
||||||
|
|
||||||
|
// Verify it navigated using the internal ID, NOT a search query
|
||||||
|
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
144
tests/unit/utils/nostr-test.js
Normal file
144
tests/unit/utils/nostr-test.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr';
|
||||||
|
|
||||||
|
module('Unit | Utility | nostr', function () {
|
||||||
|
test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) {
|
||||||
|
assert.strictEqual(normalizeRelayUrl(null), '');
|
||||||
|
assert.strictEqual(normalizeRelayUrl(''), '');
|
||||||
|
assert.strictEqual(normalizeRelayUrl(' '), '');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('Relay.example.com'),
|
||||||
|
'wss://relay.example.com'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('ws://Relay.example.com/'),
|
||||||
|
'ws://relay.example.com'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
normalizeRelayUrl('wss://relay.example.com///'),
|
||||||
|
'wss://relay.example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos includes event t tags on photo objects', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 123,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['t', 'food'],
|
||||||
|
['t', 'vibe'],
|
||||||
|
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 1);
|
||||||
|
assert.deepEqual(photos[0].tags, ['food', 'vibe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos sorts by created_at', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:456'],
|
||||||
|
['imeta', 'url https://example.com/late.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['imeta', 'url https://example.com/early.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].url, 'https://example.com/early.jpg');
|
||||||
|
assert.strictEqual(photos[1].url, 'https://example.com/late.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos promotes first landscape photo to index 0', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['imeta', 'url https://example.com/portrait.jpg', 'dim 600x900'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['imeta', 'url https://example.com/landscape.jpg', 'dim 1200x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].url, 'https://example.com/landscape.jpg');
|
||||||
|
assert.strictEqual(photos[1].url, 'https://example.com/portrait.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos skips imeta entries without urls', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [['imeta', 'dim 800x600']],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.deepEqual(photos, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePlacePhotos returns one photo per event imeta tag', function (assert) {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
pubkey: 'pubkey-1',
|
||||||
|
created_at: 100,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:123'],
|
||||||
|
['imeta', 'url https://example.com/photo-1.jpg', 'dim 800x600'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
pubkey: 'pubkey-2',
|
||||||
|
created_at: 200,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:456'],
|
||||||
|
['imeta', 'url https://example.com/photo-2.jpg', 'dim 600x800'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const photos = parsePlacePhotos(events);
|
||||||
|
|
||||||
|
assert.strictEqual(photos.length, 2);
|
||||||
|
assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123');
|
||||||
|
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
|
||||||
|
});
|
||||||
|
});
|
||||||
30
tests/unit/utils/photo-tag-suggestions-test.js
Normal file
30
tests/unit/utils/photo-tag-suggestions-test.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
import { getMatchingPoiCategoryIds } from 'marco/utils/poi-category-matcher';
|
||||||
|
import {
|
||||||
|
getSuggestedPhotoTags,
|
||||||
|
CATEGORY_TAGS,
|
||||||
|
} from 'marco/utils/photo-tag-suggestions';
|
||||||
|
|
||||||
|
module('Unit | Utility | photo-tag-suggestions', function () {
|
||||||
|
test('returns tags for all matching categories with de-duplication', function (assert) {
|
||||||
|
const place = { osmTags: { amenity: 'cafe' } };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(
|
||||||
|
place.osmTags,
|
||||||
|
POI_CATEGORIES
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('restaurants'));
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
|
||||||
|
const result = getSuggestedPhotoTags(place);
|
||||||
|
assert.deepEqual(result, CATEGORY_TAGS.restaurants);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns no tags when no category matches', function (assert) {
|
||||||
|
const place = { osmTags: { office: 'lawyer' } };
|
||||||
|
const result = getSuggestedPhotoTags(place);
|
||||||
|
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
38
tests/unit/utils/poi-category-matcher-test.js
Normal file
38
tests/unit/utils/poi-category-matcher-test.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
import {
|
||||||
|
getMatchingPoiCategories,
|
||||||
|
getMatchingPoiCategoryIds,
|
||||||
|
} from 'marco/utils/poi-category-matcher';
|
||||||
|
|
||||||
|
module('Unit | Utility | poi-category-matcher', function () {
|
||||||
|
test('matches multiple categories from OSM tags', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('restaurants'));
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports semicolon-separated values', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe;bar' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('coffee'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative regex clause fails if any value matches', function (assert) {
|
||||||
|
const tags = { amenity: 'cafe', cuisine: 'coffee;irish' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.notOk(categoryIds.includes('restaurants'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presence clause matches when tag exists', function (assert) {
|
||||||
|
const tags = { historic: 'castle' };
|
||||||
|
const categories = getMatchingPoiCategories(tags, POI_CATEGORIES);
|
||||||
|
const categoryIds = categories.map((category) => category.id);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('things-to-do'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user