feat(App, ConfirmDialog, AboutPage, MapPage, ConversationViewer, IdentitiesPage): enhance UI components with improved styles, new features for note editing, and better user interactions

This commit is contained in:
2026-01-04 00:04:32 -06:00
parent d0db79e4e4
commit 2b6cef04d0
6 changed files with 1055 additions and 771 deletions

View File

@@ -24,9 +24,9 @@
<template v-else>
<!-- header -->
<div
class="relative z-[60] flex bg-white/80 dark:bg-zinc-900/70 backdrop-blur border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors"
class="relative z-[60] flex bg-white/60 dark:bg-zinc-900/50 backdrop-blur-lg border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors overflow-x-hidden"
>
<div class="flex w-full px-4">
<div class="flex w-full px-2 sm:px-4 overflow-x-auto no-scrollbar">
<button
type="button"
class="sm:hidden my-auto mr-4 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"

View File

@@ -1,45 +1,38 @@
<template>
<Transition name="confirm-dialog">
<div v-if="pendingConfirm" class="fixed inset-0 z-[200] flex items-center justify-center p-4">
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm sm:bg-transparent sm:backdrop-blur-none"
@click="cancel"
></div>
<div v-if="pendingConfirm" class="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm shadow-2xl" @click="cancel"></div>
<div
class="relative w-full sm:w-auto sm:min-w-[360px] sm:max-w-md bg-white dark:bg-zinc-900 sm:rounded-lg rounded-2xl sm:shadow-lg shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transform transition-all"
class="relative w-full sm:w-auto sm:min-w-[400px] sm:max-w-md bg-white dark:bg-zinc-900 sm:rounded-3xl rounded-3xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transform transition-all"
@click.stop
>
<div class="p-4 sm:p-4">
<div class="flex items-start mb-4 sm:mb-3">
<div class="p-8">
<div class="flex items-start mb-6">
<div
class="flex-shrink-0 flex items-center justify-center w-9 h-9 sm:w-7 sm:h-7 rounded-full bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 mr-3 sm:mr-2.5"
class="flex-shrink-0 flex items-center justify-center w-12 h-12 rounded-2xl bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 mr-4"
>
<MaterialDesignIcon icon-name="alert-circle" class="w-5 h-5 sm:w-4 sm:h-4" />
<MaterialDesignIcon icon-name="alert-circle" class="w-6 h-6" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base sm:text-sm font-semibold text-gray-900 dark:text-white mb-1.5 sm:mb-1">
Confirm
</h3>
<p
class="text-sm sm:text-xs text-gray-600 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed"
>
<h3 class="text-xl font-black text-gray-900 dark:text-white mb-2">Confirm Action</h3>
<p class="text-gray-600 dark:text-zinc-300 whitespace-pre-wrap leading-relaxed">
{{ pendingConfirm.message }}
</p>
</div>
</div>
<div class="flex flex-col-reverse sm:flex-row gap-2 sm:gap-2 sm:justify-end mt-5 sm:mt-3">
<div class="flex flex-col sm:flex-row gap-3 sm:justify-end mt-8">
<button
type="button"
class="px-4 py-2 sm:px-3 sm:py-1.5 text-sm sm:text-xs font-medium text-gray-700 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg hover:bg-gray-50 dark:hover:bg-zinc-700 transition-colors"
class="px-6 py-3 text-sm font-bold text-gray-700 dark:text-zinc-300 bg-gray-100 dark:bg-zinc-800 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="cancel"
>
Cancel
</button>
<button
type="button"
class="px-4 py-2 sm:px-3 sm:py-1.5 text-sm sm:text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
class="px-6 py-3 text-sm font-bold text-white bg-red-600 hover:bg-red-700 rounded-xl shadow-lg shadow-red-600/20 transition-all active:scale-95"
@click="confirm"
>
Confirm

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,8 @@
class="flex items-center px-4 py-2 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur z-10 relative"
>
<div class="flex items-center space-x-2">
<MaterialDesignIcon icon-name="map" class="size-6 text-blue-500" />
<h1 class="text-lg font-semibold text-gray-900 dark:text-zinc-100">{{ $t("map.title") }}</h1>
<v-icon icon="mdi-map" class="text-blue-500 dark:text-blue-400" size="24"></v-icon>
<h1 class="text-xl font-black text-gray-900 dark:text-white">{{ $t("map.title") }}</h1>
</div>
<div class="ml-auto flex items-center space-x-2">
@@ -78,25 +78,25 @@
<!-- drawing toolbar -->
<div class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu">
<div
class="bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-xl shadow-xl overflow-hidden flex flex-row p-1 gap-1"
class="bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
>
<button
v-for="tool in drawingTools"
:key="tool.type"
class="p-2 rounded-lg transition-all"
class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
drawType === tool.type && !isMeasuring
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
: 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400',
]"
:title="$t(`map.tool_${tool.type.toLowerCase()}`)"
@click="toggleDraw(tool.type)"
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
>
<MaterialDesignIcon :icon-name="tool.icon" class="size-6" />
<v-icon :icon="'mdi-' + tool.icon" size="22"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
class="p-2 rounded-lg transition-all"
class="p-2.5 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
isMeasuring
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
@@ -105,37 +105,37 @@
:title="$t('map.tool_measure')"
@click="toggleMeasure"
>
<MaterialDesignIcon icon-name="ruler" class="size-6" />
<v-icon icon="mdi-ruler" size="22"></v-icon>
</button>
<button
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500 transition-all"
class="p-2.5 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.tool_clear')"
@click="clearDrawings"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-6" />
<v-icon icon="mdi-trash-can-outline" size="22"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all"
class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.save_drawing')"
@click="showSaveDrawingModal = true"
>
<MaterialDesignIcon icon-name="content-save-outline" class="size-6" />
<v-icon icon="mdi-content-save-outline" size="22"></v-icon>
</button>
<button
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all"
class="p-2.5 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.load_drawing')"
@click="openLoadDrawingModal"
>
<MaterialDesignIcon icon-name="folder-open-outline" class="size-6" />
<v-icon icon="mdi-folder-open-outline" size="22"></v-icon>
</button>
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
<button
class="p-2 rounded-lg hover:bg-emerald-100 dark:hover:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 transition-all"
class="p-2.5 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.go_to_my_location')"
@click="goToMyLocation"
>
<MaterialDesignIcon icon-name="crosshairs-gps" class="size-6" />
<v-icon icon="mdi-crosshairs-gps" size="22"></v-icon>
</button>
</div>
</div>
@@ -148,12 +148,12 @@
>
<div class="relative">
<div
class="flex items-center bg-white/90 dark:bg-zinc-900/90 backdrop-blur rounded-lg shadow-lg border border-gray-200/50 dark:border-zinc-800/50"
class="flex items-center bg-white/70 dark:bg-zinc-900/60 backdrop-blur-md rounded-xl shadow-2xl border-0 ring-0"
>
<input
v-model="searchQuery"
type="text"
class="flex-1 px-3 py-2 bg-transparent text-gray-900 dark:text-zinc-100 placeholder-gray-400 focus:outline-none focus:ring-0 text-sm"
class="flex-1 px-4 py-2.5 bg-transparent text-gray-900 dark:text-zinc-100 placeholder-gray-400 focus:outline-none focus:ring-0 border-0 text-sm"
:placeholder="$t('map.search_placeholder')"
@input="onSearchInput"
@keydown.enter="performSearch"
@@ -161,41 +161,45 @@
/>
<button
v-if="searchQuery"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 transition-colors"
@click="clearSearch"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
<v-icon icon="mdi-close" size="18"></v-icon>
</button>
<button
class="p-2 text-blue-500 hover:text-blue-600 disabled:text-gray-300"
class="p-2 mr-1 text-blue-500 hover:text-blue-600 disabled:text-gray-300 transition-colors"
:disabled="!searchQuery || isSearching"
@click="performSearch"
>
<MaterialDesignIcon
:icon-name="isSearching ? 'loading' : 'magnify'"
:class="['size-5', { 'animate-spin': isSearching }]"
/>
<v-icon
:icon="isSearching ? 'mdi-loading' : 'mdi-magnify'"
:class="{ 'animate-spin': isSearching }"
size="20"
></v-icon>
</button>
</div>
<!-- search results dropdown -->
<div
v-if="isSearchFocused && (searchResults.length > 0 || searchError)"
class="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-zinc-900 rounded-lg shadow-xl border border-gray-200 dark:border-zinc-800 max-h-64 overflow-y-auto z-40"
class="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border-0 overflow-y-auto z-40 max-h-64"
>
<div v-if="searchError" class="p-4 text-sm text-red-500">
<div v-if="searchError" class="p-4 text-sm text-red-500 flex items-center gap-2">
<v-icon icon="mdi-alert-circle" size="16"></v-icon>
{{ searchError }}
</div>
<button
v-for="(result, index) in searchResults"
:key="index"
class="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-zinc-800 border-b border-gray-100 dark:border-zinc-800 last:border-b-0 transition-colors"
class="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-zinc-800/50 border-b border-gray-100/50 dark:border-zinc-800/50 last:border-b-0 transition-all"
@click="selectSearchResult(result)"
>
<div class="font-medium text-gray-900 dark:text-zinc-100 text-sm">
<div class="font-bold text-gray-900 dark:text-zinc-100 text-sm">
{{ result.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-1">
<div
class="text-[10px] text-gray-400 dark:text-zinc-500 mt-0.5 font-bold uppercase tracking-wider"
>
{{ result.type }}
</div>
</button>
@@ -205,6 +209,63 @@
<div ref="mapContainer" class="absolute inset-0" :class="{ 'cursor-crosshair': isExportMode }"></div>
<!-- note hover tooltip -->
<div
v-if="hoveredNote && !editingFeature"
class="absolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4"
:style="{
left: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[0] + 'px',
top: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[1] + 'px',
}"
>
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
<MaterialDesignIcon icon-name="note-text" class="size-4" />
<span>Note</span>
</div>
<div class="whitespace-pre-wrap break-words">{{ hoveredNote.get("note") || "Empty note" }}</div>
</div>
<!-- inline note editor (overlay) -->
<div ref="noteOverlayElement" class="absolute z-40">
<div
v-if="editingFeature && !isMobileScreen"
class="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-700 p-4 w-64 transform -translate-x-1/2 -translate-y-full mb-6"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-bold text-gray-900 dark:text-white flex items-center gap-1">
<MaterialDesignIcon icon-name="note-edit" class="size-4 text-amber-500" />
Edit Note
</span>
<button
@click="closeNoteEditor"
class="text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
>
<MaterialDesignIcon icon-name="close" class="size-4" />
</button>
</div>
<textarea
v-model="noteText"
class="w-full h-24 p-2 text-sm bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none text-gray-900 dark:text-zinc-100"
placeholder="Type your note here..."
></textarea>
<div class="flex justify-between mt-3">
<button
@click="deleteNote"
class="px-3 py-1.5 text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors flex items-center gap-1"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
Delete
</button>
<button
@click="saveNote"
class="px-3 py-1.5 text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 rounded-lg shadow-sm transition-colors"
>
Save
</button>
</div>
</div>
</div>
<!-- loading skeleton for map -->
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
<div class="grid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20">
@@ -227,10 +288,10 @@
backgroundColor: selectedMarker.peer?.lxmf_user_icon?.background_colour || '#ffffff',
}"
>
<MaterialDesignIcon
:icon-name="selectedMarker.peer?.lxmf_user_icon?.icon_name || 'account'"
class="size-5"
/>
<v-icon
:icon="'mdi-' + (selectedMarker.peer?.lxmf_user_icon?.icon_name || 'account')"
size="18"
></v-icon>
</div>
<div>
<h3 class="font-bold text-gray-900 dark:text-zinc-100 truncate w-40">
@@ -248,7 +309,7 @@
class="text-gray-500 hover:text-gray-700 dark:hover:text-zinc-300"
@click="selectedMarker = null"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
<v-icon icon="mdi-close" size="20"></v-icon>
</button>
</div>
<div class="p-4 space-y-3">
@@ -294,7 +355,7 @@
</div>
<div class="pt-2 text-[10px] text-gray-400 flex items-center gap-1">
<MaterialDesignIcon icon-name="clock-outline" class="size-3" />
<v-icon icon="mdi-clock-outline" size="12"></v-icon>
Updated: {{ formatTimestamp(selectedMarker.telemetry.timestamp) }}
</div>
@@ -302,7 +363,7 @@
class="w-full py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-bold transition-colors text-sm flex items-center justify-center gap-2"
@click="openChat(selectedMarker.telemetry.destination_hash)"
>
<MaterialDesignIcon icon-name="message-text" class="size-4" />
<v-icon icon="mdi-message-text" size="16"></v-icon>
Open Chat
</button>
</div>
@@ -844,6 +905,55 @@
</div>
</div>
</div>
<!-- mobile note modal -->
<transition name="fade">
<div
v-if="showNoteModal"
class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="closeNoteEditor"
>
<div
class="bg-white dark:bg-zinc-900 w-full max-w-lg rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden animate-slide-up sm:animate-fade-in"
>
<div class="p-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<MaterialDesignIcon icon-name="note-edit" class="size-5 text-amber-500" />
Edit Note
</h3>
<button
@click="closeNoteEditor"
class="p-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
>
<MaterialDesignIcon icon-name="close" class="size-5" />
</button>
</div>
<div class="p-4">
<textarea
v-model="noteText"
class="w-full h-40 p-4 text-base bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none text-gray-900 dark:text-zinc-100"
placeholder="Type your note here..."
autofocus
></textarea>
</div>
<div class="p-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3">
<button
@click="deleteNote"
class="flex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-5" />
Delete
</button>
<button
@click="saveNote"
class="flex-[2] px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colors"
>
Save Note
</button>
</div>
</div>
</div>
</transition>
</div>
</template>
@@ -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;
}
</style>

View File

@@ -892,12 +892,50 @@
/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">
{{ $t("messages.no_active_chat") }}
</h3>
<p class="text-sm text-gray-500 dark:text-zinc-400">
{{ $t("messages.select_peer_or_enter_address") }}
</p>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">
{{ $t("messages.no_active_chat") }}
</h3>
<p class="text-sm text-gray-500 dark:text-zinc-400 mb-8">
{{ $t("messages.select_peer_or_enter_address") }}
</p>
<!-- latest chats grid (desktop only) -->
<div v-if="!isMobile && latestConversations.length > 0" class="w-full max-w-2xl mb-8">
<div class="flex items-center justify-between mb-4">
<h4 class="text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest">
Latest Chats
</h4>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
v-for="chat in latestConversations"
:key="chat.destination_hash"
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900/50 border border-gray-100 dark:border-zinc-800 rounded-2xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 flex items-center gap-4"
@click="$emit('update:selectedPeer', chat)"
>
<LxmfUserIcon
:custom-image="chat.contact_image"
:icon-name="chat.lxmf_user_icon ? chat.lxmf_user_icon.icon_name : ''"
:icon-foreground-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''"
:icon-background-colour="chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''"
icon-class="size-10"
/>
<div class="flex-1 min-w-0">
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
{{ chat.custom_display_name ?? chat.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-zinc-500 truncate mt-0.5">
{{ chat.latest_message_preview || chat.latest_message_title || "No messages yet" }}
</div>
</div>
<v-icon
icon="mdi-chevron-right"
size="18"
class="text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
></v-icon>
</div>
</div>
</div>
<!-- compose message input -->
@@ -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();

View File

@@ -91,8 +91,23 @@
</div>
<div
class="text-xs font-mono text-gray-500 dark:text-zinc-500 truncate mt-0.5 tracking-tight"
:title="'RNS: ' + identity.hash"
>
{{ identity.hash }}
ID: {{ identity.hash }}
</div>
<div
v-if="identity.lxmf_address"
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 truncate mt-0.5 tracking-tighter"
:title="'LXMF: ' + identity.lxmf_address"
>
LXMF: {{ identity.lxmf_address }}
</div>
<div
v-if="identity.lxst_address"
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 truncate mt-0.5 tracking-tighter"
:title="'LXST: ' + identity.lxst_address"
>
LXST: {{ identity.lxst_address }}
</div>
</div>