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

<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>