diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 1e4ecd6..10ae5f5 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -24,9 +24,9 @@ @@ -950,9 +1060,11 @@ export default { isDrawing: false, drawingTools: [ { type: "Point", icon: "map-marker-plus" }, + { type: "Note", icon: "note-text-outline" }, { type: "LineString", icon: "vector-line" }, { type: "Polygon", icon: "vector-polygon" }, { type: "Circle", icon: "circle-outline" }, + { type: "Export", icon: "crop-free" }, ], // measurement @@ -965,6 +1077,13 @@ export default { // drawing storage savedDrawings: [], + + // note editing + editingFeature: null, + noteText: "", + hoveredNote: null, + noteOverlay: null, + showNoteModal: false, showSaveDrawingModal: false, newDrawingName: "", isLoadingDrawings: false, @@ -1219,25 +1338,54 @@ export default { this.drawSource = new VectorSource(); this.drawLayer = new VectorLayer({ source: this.drawSource, - style: new Style({ - fill: new Fill({ - color: "rgba(59, 130, 246, 0.2)", - }), - stroke: new Stroke({ - color: "#3b82f6", - width: 3, - }), - image: new CircleStyle({ - radius: 7, + style: (feature) => { + const type = feature.get("type"); + if (type === "note") { + return new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ + color: "#f59e0b", + }), + stroke: new Stroke({ + color: "#ffffff", + width: 2, + }), + }), + // Use a simple circle for now as custom fonts in canvas can be tricky + // or use the built-in Text style if we are sure it works + }); + } + return new Style({ fill: new Fill({ - color: "#3b82f6", + color: "rgba(59, 130, 246, 0.2)", }), - }), - }), + stroke: new Stroke({ + color: "#3b82f6", + width: 3, + }), + image: new CircleStyle({ + radius: 7, + fill: new Fill({ + color: "#3b82f6", + }), + }), + }); + }, zIndex: 50, }); this.map.addLayer(this.drawLayer); + this.noteOverlay = new Overlay({ + element: this.$refs.noteOverlayElement, + autoPan: { + animation: { + duration: 250, + }, + }, + }); + this.map.addOverlay(this.noteOverlay); + this.modify = new Modify({ source: this.drawSource }); this.modify.on("modifyend", () => this.saveMapState()); this.map.addInteraction(this.modify); @@ -1253,7 +1401,9 @@ export default { }); this.map.addLayer(this.markerLayer); + this.map.on("pointermove", this.handleMapPointerMove); this.map.on("click", (evt) => { + this.handleMapClick(evt); const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f); if (feature && feature.get("telemetry")) { this.onMarkerClick(feature); @@ -1434,12 +1584,27 @@ export default { updateMapSource() { if (!this.map) return; const layers = this.map.getLayers(); + + // Find and replace the tile layer (first layer usually) + // or just clear and re-add everything correctly layers.clear(); + + // 1. Tile layer layers.push( new TileLayer({ source: this.getTileSource(), }) ); + + // 2. Draw layer + if (this.drawLayer) { + layers.push(this.drawLayer); + } + + // 3. Marker layer + if (this.markerLayer) { + layers.push(this.markerLayer); + } }, async toggleOffline(enabled) { if (enabled && !this.hasOfflineMap) { @@ -1594,8 +1759,9 @@ export default { this.metadata = response.data.metadata; this.hasOfflineMap = true; this.offlineEnabled = true; + await this.loadMBTilesList(); + await this.checkOfflineMap(); this.updateMapSource(); - this.loadMBTilesList(); ToastUtils.success(this.$t("map.upload_success")); // If the map has bounds, we might want to fit to them @@ -1895,15 +2061,24 @@ export default { this.draw = new Draw({ source: this.drawSource, - type: type, + type: type === "Note" ? "Point" : type, }); this.draw.on("drawstart", () => { this.isDrawing = true; }); - this.draw.on("drawend", () => { + this.draw.on("drawend", (evt) => { this.isDrawing = false; + const feature = evt.feature; + if (type === "Note") { + feature.set("type", "note"); + feature.set("note", ""); + // Open edit box after a short delay to let the feature settle + setTimeout(() => { + this.startEditingNote(feature); + }, 200); + } // Use setTimeout to ensure the feature is actually in the source before saving setTimeout(() => this.saveMapState(), 100); }); @@ -1911,6 +2086,86 @@ export default { this.map.addInteraction(this.draw); }, + startEditingNote(feature) { + this.editingFeature = feature; + this.noteText = feature.get("note") || ""; + if (this.isMobileScreen) { + this.showNoteModal = true; + } else { + this.updateNoteOverlay(); + } + }, + + updateNoteOverlay() { + if (!this.editingFeature || !this.map) return; + const geometry = this.editingFeature.getGeometry(); + const coord = geometry.getCoordinates(); + this.noteOverlay.setPosition(coord); + }, + + saveNote() { + if (this.editingFeature) { + this.editingFeature.set("note", this.noteText); + this.saveMapState(); + } + this.closeNoteEditor(); + }, + + cancelNote() { + // If it's a new note (no text and just added), we might want to remove it + // but for now just close + this.closeNoteEditor(); + }, + + closeNoteEditor() { + this.editingFeature = null; + this.noteText = ""; + this.showNoteModal = false; + if (this.noteOverlay) { + this.noteOverlay.setPosition(undefined); + } + }, + + deleteNote() { + if (this.editingFeature) { + this.drawSource.removeFeature(this.editingFeature); + this.saveMapState(); + } + this.closeNoteEditor(); + }, + + handleMapPointerMove(evt) { + if (evt.dragging || this.isDrawing || this.isMeasuring) return; + + const pixel = this.map.getEventPixel(evt.originalEvent); + const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, { + layerFilter: (l) => l === this.drawLayer, + }); + + if (feature && feature.get("type") === "note") { + this.hoveredNote = feature; + this.map.getTargetElement().style.cursor = "pointer"; + } else { + this.hoveredNote = null; + this.map.getTargetElement().style.cursor = ""; + } + }, + + handleMapClick(evt) { + if (this.isDrawing || this.isMeasuring) return; + + const pixel = this.map.getEventPixel(evt.originalEvent); + const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, { + layerFilter: (l) => l === this.drawLayer, + }); + + if (feature && feature.get("type") === "note") { + this.startEditingNote(feature); + } else { + this.closeNoteEditor(); + } + }, + stopDrawing() { if (this.draw) { this.map.removeInteraction(this.draw); @@ -2351,4 +2606,40 @@ export default { :deep(.ol-tooltip-static:before) { border-top-color: #3b82f6; } + +@keyframes slide-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animate-slide-up { + animation: slide-up 0.3s ease-out; +} + +.animate-fade-in { + animation: fade-in 0.3s ease-out; +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 2ed99ec..d6b94d3 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -892,12 +892,50 @@ /> -

- {{ $t("messages.no_active_chat") }} -

-

- {{ $t("messages.select_peer_or_enter_address") }} -

+ +

+ {{ $t("messages.no_active_chat") }} +

+

+ {{ $t("messages.select_peer_or_enter_address") }} +

+ + +
+
+

+ Latest Chats +

+
+
+
+ +
+
+ {{ chat.custom_display_name ?? chat.display_name }} +
+
+ {{ chat.latest_message_preview || chat.latest_message_title || "No messages yet" }} +
+
+ +
+
@@ -1088,6 +1126,9 @@ export default { isMobile() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); }, + latestConversations() { + return this.conversations.slice(0, 4); + }, canSendMessage() { // can send if message text is present const messageText = this.newMessageText.trim(); diff --git a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue index ecb7af3..7165ba3 100644 --- a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue +++ b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue @@ -91,8 +91,23 @@
- {{ identity.hash }} + ID: {{ identity.hash }} +
+
+ LXMF: {{ identity.lxmf_address }} +
+
+ LXST: {{ identity.lxst_address }}