You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
382 lines
12 KiB
382 lines
12 KiB
<template>
|
|
<QuickNav />
|
|
|
|
<!-- CONTENT -->
|
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
|
<!-- Breadcrumb -->
|
|
<div class="mb-8">
|
|
<!-- Back -->
|
|
<div class="text-lg text-center font-light relative px-7">
|
|
<h1
|
|
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
|
@click="goBack"
|
|
>
|
|
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Heading -->
|
|
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
|
Area for Nearby Search
|
|
</h1>
|
|
</div>
|
|
|
|
<div class="px-2 py-4">
|
|
This location is only stored on your device. It is sometimes sent from
|
|
your device to run searches but it is not stored on our servers.
|
|
</div>
|
|
|
|
<div class="text-center">
|
|
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
|
|
Click to Choose a Location for Nearby Search
|
|
</button>
|
|
<button
|
|
v-if="isNewMarkerSet"
|
|
:class="actionButtonClass"
|
|
@click="storeSearchBox"
|
|
>
|
|
<font-awesome icon="save" class="fa-fw" />
|
|
Store This Location for Nearby Search
|
|
</button>
|
|
<button
|
|
v-if="searchBox"
|
|
:class="actionButtonClass"
|
|
@click="forgetSearchBox"
|
|
>
|
|
<font-awesome icon="trash-can" class="fa-fw" />
|
|
Delete Stored Location
|
|
</button>
|
|
<button v-if="searchBox" :class="actionButtonClass" @click="resetLatLong">
|
|
<font-awesome icon="rotate" class="fa-fw" />
|
|
Reset To Original
|
|
</button>
|
|
<button
|
|
v-if="isNewMarkerSet"
|
|
:class="actionButtonClass"
|
|
@click="isNewMarkerSet = false"
|
|
>
|
|
<font-awesome icon="eraser" class="fa-fw" />
|
|
Erase Marker
|
|
</button>
|
|
<div v-if="isNewMarkerSet">
|
|
Click on the pin to erase it. Click anywhere else to set a different
|
|
different corner.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="aspect-video">
|
|
<l-map
|
|
ref="map"
|
|
v-model:zoom="localZoom"
|
|
:center="[localCenterLat, localCenterLong]"
|
|
class="!z-40 rounded-md"
|
|
@click="setMapPoint"
|
|
>
|
|
<l-tile-layer
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
layer-type="base"
|
|
name="OpenStreetMap"
|
|
/>
|
|
<l-marker
|
|
v-if="isNewMarkerSet"
|
|
:lat-lng="[localCenterLat, localCenterLong]"
|
|
@click="isNewMarkerSet = false"
|
|
/>
|
|
<l-rectangle
|
|
v-if="isNewMarkerSet"
|
|
:bounds="[
|
|
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
|
|
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
|
|
]"
|
|
:weight="1"
|
|
/>
|
|
</l-map>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
/**
|
|
* @fileoverview SearchAreaView - Geographic Search Area Management Component
|
|
*
|
|
* This component provides an interactive map interface for users to set and manage
|
|
* geographic search areas for location-based content filtering. It uses Leaflet maps
|
|
* to allow users to select, store, and delete geographic bounding boxes that define
|
|
* their preferred search areas.
|
|
*
|
|
* Key Features:
|
|
* - Interactive Leaflet map with marker and rectangle overlays
|
|
* - Geographic bounding box calculation and storage
|
|
* - Privacy-preserving local storage (no server transmission)
|
|
* - Real-time map interaction with visual feedback
|
|
* - Automatic bounding box size estimation based on map zoom
|
|
*
|
|
* Enhanced Triple Migration Pattern Status:
|
|
* ✅ Phase 1: Database Migration - PlatformServiceMixin integration
|
|
* ⏳ Phase 2: SQL Abstraction - No raw SQL queries to migrate
|
|
* ⏳ Phase 3: Notification Migration - Helper system integration
|
|
* ⏳ Phase 4: Template Streamlining - Method extraction and styling
|
|
*
|
|
* Security: All location data is stored locally on the user's device and is never
|
|
* transmitted to external servers except for explicit search operations.
|
|
*
|
|
* @component SearchAreaView
|
|
* @requires PlatformServiceMixin - Database operations
|
|
* @requires Leaflet - Map rendering and interaction
|
|
* @requires Vue-Leaflet - Vue.js Leaflet integration
|
|
* @author TimeSafari Development Team
|
|
* @since 2024-01-01
|
|
* @version 1.0.0
|
|
* @migrated 2025-07-09 (Enhanced Triple Migration Pattern)
|
|
*/
|
|
|
|
import { LeafletMouseEvent } from "leaflet";
|
|
import "leaflet/dist/leaflet.css";
|
|
import { Component, Vue } from "vue-facing-decorator";
|
|
import {
|
|
LMap,
|
|
LMarker,
|
|
LRectangle,
|
|
LTileLayer,
|
|
} from "@vue-leaflet/vue-leaflet";
|
|
import { Router } from "vue-router";
|
|
|
|
import QuickNav from "../components/QuickNav.vue";
|
|
import { NotificationIface } from "../constants/app";
|
|
import { BoundingBox } from "../db/tables/settings";
|
|
import { logger } from "../utils/logger";
|
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
|
import {
|
|
NOTIFY_SEARCH_AREA_SAVED,
|
|
NOTIFY_SEARCH_AREA_ERROR,
|
|
NOTIFY_SEARCH_AREA_NO_LOCATION,
|
|
NOTIFY_SEARCH_AREA_DELETED,
|
|
} from "../constants/notifications";
|
|
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify";
|
|
|
|
// Geographic constants for map rendering and bounding box calculations
|
|
const DEFAULT_LAT_LONG_DIFF = 0.01;
|
|
const WORLD_ZOOM = 2;
|
|
const DEFAULT_ZOOM = 2;
|
|
|
|
@Component({
|
|
components: {
|
|
QuickNav,
|
|
LRectangle,
|
|
LMap,
|
|
LMarker,
|
|
LTileLayer,
|
|
},
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class SearchAreaView extends Vue {
|
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
$router!: Router;
|
|
|
|
// Notification helper system
|
|
private notify = createNotifyHelpers(this.$notify);
|
|
|
|
// User interface state management
|
|
isChoosingSearchBox = false;
|
|
isNewMarkerSet = false;
|
|
|
|
// Local geographic coordinates for current map selection
|
|
// These represent the currently selected area before storage
|
|
localCenterLat = 0;
|
|
localCenterLong = 0;
|
|
localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
|
localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
|
localZoom = DEFAULT_ZOOM;
|
|
|
|
// Stored search box configuration loaded from database
|
|
// This represents the persisted geographic search area
|
|
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
|
|
|
/**
|
|
* Component lifecycle hook - Initialize geographic search area
|
|
* Loads existing search box configuration from local database
|
|
* and initializes map display with stored or default coordinates
|
|
*/
|
|
async mounted() {
|
|
try {
|
|
const settings = await this.$accountSettings();
|
|
this.searchBox = settings.searchBoxes?.[0] || null;
|
|
this.resetLatLong();
|
|
|
|
logger.info("[SearchAreaView] Component mounted", {
|
|
hasStoredSearchBox: !!this.searchBox,
|
|
searchBoxName: this.searchBox?.name,
|
|
coordinates: this.searchBox?.bbox,
|
|
});
|
|
} catch (error) {
|
|
logger.error(
|
|
"[SearchAreaView] Failed to load search area settings",
|
|
error,
|
|
);
|
|
// Continue with default behavior if settings load fails
|
|
this.resetLatLong();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle map click events for geographic area selection
|
|
* Supports two modes: initial marker placement and bounding box sizing
|
|
*
|
|
* @param event - Leaflet mouse event containing geographic coordinates
|
|
*/
|
|
setMapPoint(event: LeafletMouseEvent) {
|
|
if (this.isNewMarkerSet) {
|
|
// Existing marker - calculate bounding box size based on click distance
|
|
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
|
|
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
|
|
} else {
|
|
// New marker - set center point and estimate initial bounding box size
|
|
this.localCenterLat = event.latlng.lat;
|
|
this.localCenterLong = event.latlng.lng;
|
|
|
|
let latDiff = DEFAULT_LAT_LONG_DIFF;
|
|
let longDiff = DEFAULT_LAT_LONG_DIFF;
|
|
|
|
// Estimate reasonable bounding box size based on current map bounds
|
|
const bounds = event.target.boxZoom?._map?.getBounds();
|
|
if (bounds) {
|
|
latDiff =
|
|
Math.abs(bounds.getNorthEast().lat - bounds.getSouthWest().lat) / 8;
|
|
longDiff =
|
|
Math.abs(bounds.getNorthEast().lng - bounds.getSouthWest().lng) / 8;
|
|
}
|
|
|
|
this.localLatDiff = latDiff;
|
|
this.localLongDiff = longDiff;
|
|
this.isNewMarkerSet = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset map coordinates to stored search box or default values
|
|
* Used when component loads or when user wants to restore original area
|
|
*/
|
|
public resetLatLong() {
|
|
if (this.searchBox?.bbox) {
|
|
// Restore coordinates from stored search box
|
|
const bbox = this.searchBox.bbox;
|
|
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
|
|
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
|
|
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
|
|
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
|
|
this.localZoom = WORLD_ZOOM;
|
|
this.isNewMarkerSet = true;
|
|
} else {
|
|
// No stored search box - reset to default state
|
|
this.isNewMarkerSet = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computed property for consistent action button styling
|
|
* Provides standardized classes for all geographic action buttons
|
|
*/
|
|
get actionButtonClass() {
|
|
return "m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500";
|
|
}
|
|
|
|
/**
|
|
* Navigate back to previous page
|
|
* Extracted from template for better maintainability
|
|
*/
|
|
goBack() {
|
|
this.$router.back();
|
|
}
|
|
|
|
/**
|
|
* Store the currently selected geographic area as search box preference
|
|
* Validates coordinates and persists bounding box to local database
|
|
* Provides user feedback and navigates back on success
|
|
*/
|
|
public async storeSearchBox() {
|
|
if (this.localCenterLong || this.localCenterLat) {
|
|
try {
|
|
// Create search box configuration with calculated bounding box
|
|
const newSearchBox = {
|
|
name: "Local",
|
|
bbox: {
|
|
eastLong: this.localCenterLong + this.localLongDiff,
|
|
maxLat: this.localCenterLat + this.localLatDiff,
|
|
minLat: this.localCenterLat - this.localLatDiff,
|
|
westLong: this.localCenterLong - this.localLongDiff,
|
|
},
|
|
};
|
|
|
|
const searchBoxes = JSON.stringify([newSearchBox]);
|
|
|
|
// Store search box configuration using platform service
|
|
await this.$updateSettings({ searchBoxes: searchBoxes as any });
|
|
|
|
this.searchBox = newSearchBox;
|
|
this.isChoosingSearchBox = false;
|
|
|
|
logger.info("[SearchAreaView] Search box stored successfully", {
|
|
searchBox: newSearchBox,
|
|
coordinates: newSearchBox.bbox,
|
|
});
|
|
|
|
// Enhanced notification system with proper timeout
|
|
this.notify.success(NOTIFY_SEARCH_AREA_SAVED.text, TIMEOUTS.VERY_LONG);
|
|
this.$router.back();
|
|
} catch (err) {
|
|
logger.error("[SearchAreaView] Failed to store search box", err);
|
|
|
|
// Enhanced notification system with proper timeout
|
|
this.notify.error(NOTIFY_SEARCH_AREA_ERROR.text, TIMEOUTS.LONG);
|
|
}
|
|
} else {
|
|
// Invalid coordinates - show validation warning
|
|
this.notify.warning(NOTIFY_SEARCH_AREA_NO_LOCATION.text, TIMEOUTS.LONG);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete stored search box preference and reset component state
|
|
* Removes geographic search area from database and UI
|
|
* Provides success feedback on completion
|
|
*/
|
|
public async forgetSearchBox() {
|
|
try {
|
|
// Clear search box settings and disable nearby filtering
|
|
await this.$updateSettings({
|
|
searchBoxes: "[]" as any,
|
|
filterFeedByNearby: false,
|
|
});
|
|
|
|
// Reset component state to default values
|
|
this.searchBox = null;
|
|
this.localCenterLat = 0;
|
|
this.localCenterLong = 0;
|
|
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
|
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
|
this.localZoom = DEFAULT_ZOOM;
|
|
this.isChoosingSearchBox = false;
|
|
this.isNewMarkerSet = false;
|
|
|
|
logger.info("[SearchAreaView] Search box deleted successfully");
|
|
|
|
// Enhanced notification system with proper timeout
|
|
this.notify.success(NOTIFY_SEARCH_AREA_DELETED.text, TIMEOUTS.STANDARD);
|
|
} catch (err) {
|
|
logger.error("[SearchAreaView] Failed to delete search box", err);
|
|
|
|
// Enhanced notification system with proper timeout
|
|
this.notify.error(NOTIFY_SEARCH_AREA_ERROR.text, TIMEOUTS.LONG);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel search box selection and reset zoom level
|
|
* Used when user wants to abort the selection process
|
|
*/
|
|
public cancelSearchBoxSelect() {
|
|
this.isChoosingSearchBox = false;
|
|
this.localZoom = WORLD_ZOOM;
|
|
}
|
|
}
|
|
</script>
|
|
|