Compare commits

..

11 Commits

Author SHA1 Message Date
1ba4afdf08 1.20.3
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
2026-04-24 13:55:07 +01:00
d764134513 Remove superfluous publishing status alert 2026-04-24 13:53:40 +01:00
e38f540c79 1.20.2
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-24 12:28:08 +01:00
73ad5b4eb1 Disable closing modal during photo upload 2026-04-24 12:24:19 +01:00
b4a70233cf Show detailed photo upload status
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 56s
2026-04-24 11:56:37 +01:00
cb4b9c6b40 Render portrait thumbnails as squares on mobile
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
A bit too small otherwise
2026-04-24 11:01:58 +01:00
98dcb4f25b 1.20.1
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 57s
2026-04-23 09:23:41 +01:00
7709634a9a Merge pull request 'Clear Nostr event cache from Settings' (#47) from feature/clear_nostr_cache into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 57s
Reviewed-on: #47
2026-04-23 08:22:02 +00:00
3ddc85669f Clear nostr event cache from Settings
All checks were successful
CI / Lint (pull_request) Successful in 32s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-04-23 09:19:25 +01:00
95961e680f Add rationale for kind numbers
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
2026-04-22 15:37:45 +04:00
9468a6a0cc Revise photos NIP draft
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-22 15:31:24 +04:00
19 changed files with 159 additions and 48 deletions

View File

@@ -12,6 +12,7 @@ const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
export default class AppMenuSettingsNostr extends Component {
@service settings;
@service nostrData;
@service toast;
@tracked newReadRelay = '';
@tracked newWriteRelay = '';
@@ -90,6 +91,16 @@ export default class AppMenuSettingsNostr extends Component {
this.settings.update('nostrWriteRelays', null);
}
@action
async clearCache() {
try {
await this.nostrData.clearCache();
this.toast.show('Nostr cache cleared');
} catch (e) {
this.toast.show(`Failed to clear Nostr cache: ${e.message}`);
}
}
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
@@ -213,6 +224,18 @@ export default class AppMenuSettingsNostr extends Component {
</button>
{{/if}}
</div>
<div class="form-group">
<label>Cached data</label>
<button
type="button"
class="btn btn-outline btn-full"
{{on "click" this.clearCache}}
>
<Icon @name="database" @size={{18}} @color="var(--danger-color)" />
Clear profiles, photos, and reviews
</button>
</div>
</div>
</details>
</template>

View File

@@ -11,6 +11,7 @@ export default class Modal extends Component {
@action
close() {
if (this.args.disableClose) return;
if (this.args.onClose) {
this.args.onClose();
}
@@ -31,10 +32,11 @@ export default class Modal extends Component {
>
<button
type="button"
class="close-modal-btn btn-text"
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
disabled={{@disableClose}}
{{on "click" this.close}}
>
<Icon @name="x" @size={{24}} />
<Icon @name="x" @size={{24}} @color="currentColor" />
</button>
{{yield}}
</div>

View File

@@ -27,6 +27,12 @@ export default class PlaceDetails extends Component {
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@tracked isPhotoUploadActive = false;
@action
handleUploadStateChange(isActive) {
this.isPhotoUploadActive = isActive;
}
@action
openPhotoUploadModal(e) {
@@ -42,6 +48,7 @@ export default class PlaceDetails extends Component {
@action
closePhotoUploadModal(eventId) {
if (this.isPhotoUploadActive) return;
this.isPhotoUploadModalOpen = false;
if (typeof eventId === 'string') {
this.newlyUploadedPhotoId = eventId;
@@ -585,10 +592,14 @@ export default class PlaceDetails extends Component {
</div>
{{#if this.isPhotoUploadModalOpen}}
<Modal @onClose={{this.closePhotoUploadModal}}>
<Modal
@onClose={{this.closePhotoUploadModal}}
@disableClose={{this.isPhotoUploadActive}}
>
<PlacePhotoUpload
@place={{this.saveablePlace}}
@onClose={{this.closePhotoUploadModal}}
@onUploadStateChange={{this.handleUploadStateChange}}
/>
</Modal>
{{/if}}

View File

@@ -22,6 +22,7 @@ export default class PlacePhotoUploadItem extends Component {
@tracked thumbnailUrl = '';
@tracked blurhash = '';
@tracked error = '';
@tracked statusText = '';
constructor() {
super(...arguments);
@@ -47,6 +48,7 @@ export default class PlacePhotoUploadItem extends Component {
uploadTask = task(async (file) => {
this.error = '';
this.statusText = 'Processing';
try {
// 1. Process main image and generate blurhash in worker
const mainData = await this.imageProcessor.process(
@@ -71,18 +73,34 @@ export default class PlacePhotoUploadItem extends Component {
let mainResult, thumbResult;
const isMobileDevice = isMobile();
const mainProgress = (status) => {
if (status === 'signing') this.statusText = 'Signing photo upload';
if (status === 'uploading') this.statusText = 'Uploading photo';
};
const thumbProgress = (status) => {
if (status === 'signing') this.statusText = 'Signing thumbnail upload';
if (status === 'uploading') this.statusText = 'Uploading thumbnail';
};
if (isMobileDevice) {
// Mobile: sequential uploads to preserve bandwidth and memory
mainResult = await this.blossom.upload(mainData.blob, {
sequential: true,
onProgress: mainProgress,
});
thumbResult = await this.blossom.upload(thumbData.blob, {
sequential: true,
onProgress: thumbProgress,
});
} else {
// Desktop: concurrent uploads
const mainUploadPromise = this.blossom.upload(mainData.blob);
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
const mainUploadPromise = this.blossom.upload(mainData.blob, {
onProgress: mainProgress,
});
const thumbUploadPromise = this.blossom.upload(thumbData.blob, {
onProgress: thumbProgress,
});
[mainResult, thumbResult] = await Promise.all([
mainUploadPromise,
@@ -127,6 +145,9 @@ export default class PlacePhotoUploadItem extends Component {
@color="white"
class="spin-animation"
/>
{{#if this.statusText}}
<span class="upload-status-text">{{this.statusText}}</span>
{{/if}}
</div>
{{/if}}

View File

@@ -19,7 +19,6 @@ export default class PlacePhotoUpload extends Component {
@tracked file = null;
@tracked uploadedPhoto = null;
@tracked status = '';
@tracked error = '';
@tracked isPublishing = false;
@tracked isDragging = false;
@@ -77,6 +76,9 @@ export default class PlacePhotoUpload extends Component {
}
this.file = file;
this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(true);
}
}
@action
@@ -91,6 +93,9 @@ export default class PlacePhotoUpload extends Component {
}
this.file = null;
this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
}
deletePhotoTask = task(async (photoData) => {
@@ -126,7 +131,6 @@ export default class PlacePhotoUpload extends Component {
return;
}
this.status = 'Publishing event...';
this.error = '';
this.isPublishing = true;
@@ -185,18 +189,20 @@ export default class PlacePhotoUpload extends Component {
this.nostrData.store.add(event);
this.toast.show('Photo published successfully');
this.status = '';
// Clear out the file so user can upload more or be done
this.file = null;
this.uploadedPhoto = null;
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
if (this.args.onClose) {
this.args.onClose(event.id);
}
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
} finally {
this.isPublishing = false;
}
@@ -212,12 +218,6 @@ export default class PlacePhotoUpload extends Component {
</div>
{{/if}}
{{#if this.status}}
<div class="alert alert-info">
{{this.status}}
</div>
{{/if}}
{{#if this.file}}
<div class="photo-grid">
<PlacePhotoUploadItem

View File

@@ -116,7 +116,8 @@ export default class PlacePhotosCarousel extends Component {
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
<div
class="carousel-slide"
class="carousel-slide
{{if photo.isLandscape 'landscape' 'portrait'}}"
style={{photo.style}}
data-event-id={{photo.eventId}}
>

View File

@@ -60,10 +60,13 @@ export default class BlossomService extends Service {
return `Nostr ${base64url}`;
}
async _uploadToServer(file, hash, serverUrl) {
async _uploadToServer(file, hash, serverUrl, onProgress) {
const uploadUrl = getBlossomUrl(serverUrl, 'upload');
if (onProgress) onProgress('signing');
const authHeader = await this._getAuthHeader('upload', hash, serverUrl);
if (onProgress) onProgress('uploading');
// eslint-disable-next-line warp-drive/no-external-request-patterns
const response = await fetch(uploadUrl, {
method: 'PUT',
@@ -109,14 +112,20 @@ export default class BlossomService extends Service {
if (options.sequential) {
// Sequential upload logic
mainResult = await this._uploadToServer(file, payloadHash, mainServer);
mainResult = await this._uploadToServer(
file,
payloadHash,
mainServer,
options.onProgress
);
for (const serverUrl of fallbackServers) {
try {
const result = await this._uploadToServer(
file,
payloadHash,
serverUrl
serverUrl,
options.onProgress
);
fallbackUrls.push(result.url);
} catch (error) {
@@ -125,9 +134,14 @@ export default class BlossomService extends Service {
}
} else {
// Concurrent upload logic
const mainPromise = this._uploadToServer(file, payloadHash, mainServer);
const mainPromise = this._uploadToServer(
file,
payloadHash,
mainServer,
options.onProgress
);
const fallbackPromises = fallbackServers.map((serverUrl) =>
this._uploadToServer(file, payloadHash, serverUrl)
this._uploadToServer(file, payloadHash, serverUrl, options.onProgress)
);
// Main server MUST succeed

View File

@@ -356,6 +356,13 @@ export default class NostrDataService extends Service {
return 'Not connected';
}
async clearCache() {
await this._cachePromise;
if (this.cache) {
await this.cache.deleteAllEvents();
}
}
_cleanupSubscriptions() {
if (this._requestSub) {
this._requestSub.unsubscribe();

View File

@@ -285,10 +285,20 @@ body {
inset: 0;
background: rgb(0 0 0 / 60%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-status-text {
color: white;
margin-top: 1rem;
font-size: 0.9rem;
text-shadow: 0 1px 3px rgb(0 0 0 / 80%);
text-align: center;
padding: 0 1rem;
}
.photo-upload-item .error-overlay {
background: rgb(224 108 117 / 80%);
cursor: pointer;
@@ -1006,10 +1016,17 @@ abbr[title] {
flex: 0 0 auto;
height: 100px;
width: auto;
aspect-ratio: var(--slide-ratio, 16 / 9);
scroll-snap-align: none;
}
.carousel-slide.landscape {
aspect-ratio: var(--slide-ratio, 16 / 9);
}
.carousel-slide.portrait {
aspect-ratio: 1 / 1;
}
.carousel-placeholder {
display: block;
background-color: var(--hover-bg);
@@ -1798,6 +1815,12 @@ button.create-place {
top: 1rem;
right: 1rem;
cursor: pointer;
color: #898989;
}
.close-modal-btn.disabled {
color: #ccc;
cursor: not-allowed;
}
.place-photo-upload h2 {
@@ -1816,11 +1839,6 @@ button.create-place {
color: #c00;
}
.alert-info {
background: #eef;
color: #00c;
}
.preview-group {
margin-bottom: 1rem;
}

View File

@@ -7,6 +7,7 @@ import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import database from 'feather-icons/dist/icons/database.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import gift from 'feather-icons/dist/icons/gift.svg?raw';
@@ -153,6 +154,7 @@ const ICONS = {
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
croissant,
'cup-and-saucer': cupAndSaucer,
database,
donut,
edit,
eyeglasses,

View File

@@ -14,7 +14,7 @@ While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP spec
## Content
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead.
## Tags
@@ -45,17 +45,19 @@ Used for spatial indexing and discovery. Events MUST include at least one high-p
#### 3. `imeta` — Inline Media Metadata
An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` MAY also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags.
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible.
Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), `thumb` (URL to a smaller thumbnail image), and `blurhash` where possible. Clients MAY also include `fallback` URLs if the media is hosted on multiple servers.
```json
[
"imeta",
"url https://example.com/photo.jpg",
"url https://blossom.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
"m image/jpeg",
"dim 3024x4032",
"dim 1440x1920",
"alt A steaming bowl of ramen on a wooden table at the restaurant.",
"fallback https://mirror.example.com/8e2e28a503fa37482de5b0959ee38b2bb4de4e0a752db24c568981c2ab410260.jpg",
"thumb https://example.com/7a1f592f6ea8e932b1de9568285b01851e4cf708466b0a03010b91e92c6c8135.jpg",
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$"
]
```
@@ -83,10 +85,12 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
[
"imeta",
"url https://example.com/ramen.jpg",
"url https://blossom.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
"m image/jpeg",
"dim 1080x1080",
"dim 1920x1920",
"alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.",
"fallback https://mirror.example.com/a9c84e183789a74288b8e05d04cc61230e74f386925a953e6b29f957e8cc3a61.jpg",
"thumb https://example.com/c5a528e20235e16cc1c18090b8f04179de76288ea4e410b0fcb8d1487e416a2d.jpg",
"blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB"
],
@@ -98,6 +102,10 @@ Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `
## Rationale
### Kind 360
Easy to remember as a 360-degree view of places.
### Why not use NIP-68 (Picture-first feeds)?
NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus).

View File

@@ -276,6 +276,10 @@ Content payloads SHOULD NOT include place identifiers.
## Rationale
### Kind 30360
Pairs with kind 360 (Place Photos). Easy to remember as a 360-degree review of all aspects of a place.
### No Place Field in Content
Avoids duplication and inconsistency with tags.

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.20.0",
"version": "1.20.3",
"private": true,
"description": "Unhosted maps app",
"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

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-AsE4IKjj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BA3LWr76.css">
<script type="module" crossorigin src="/assets/main-CfJ9up1Y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHuW_yI-.css">
</head>
<body>
</body>