<template> <QuickNav selected="Discover" /> <TopMessage /> <!-- CONTENT --> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <!-- Heading --> <h1 id="ViewHeading" class="text-4xl text-center font-light"> Discover Projects & People </h1> <OnboardingDialog ref="onboardingDialog" /> <!-- Quick Search --> <div id="QuickSearch" class="mt-8 mb-4 flex" :style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }" > <input type="text" v-model="searchTerms" placeholder="Search…" class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2" v-on:keyup.enter="searchSelected()" /> <button class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400" @click="searchSelected()" > <fa icon="magnifying-glass" class="fa-fw"></fa> </button> </div> <!-- Result Tabs --> <!-- Top Level Selection --> <div class="text-center text-slate-500 border-b border-slate-300 mb-4"> <ul class="flex flex-wrap justify-center gap-4 -mb-px"> <li> <a href="#" @click=" projects = []; userProfiles = []; isProjectsActive = true; isPeopleActive = false; searchSelected(); " v-bind:class="computedProjectsTabStyleClassNames()" > Projects </a> </li> <li> <a href="#" @click=" projects = []; userProfiles = []; isProjectsActive = false; isPeopleActive = true; searchSelected(); " v-bind:class="computedPeopleTabStyleClassNames()" > People </a> </li> </ul> </div> <!-- Secondary Tabs --> <div class="text-center text-slate-500 border-b border-slate-300"> <ul class="flex flex-wrap justify-center gap-4 -mb-px"> <li> <a href="#" @click=" projects = []; userProfiles = []; isLocalActive = true; isMappedActive = false; isAnywhereActive = false; isSearchVisible = true; tempSearchBox = null; searchLocal(); " v-bind:class="computedLocalTabStyleClassNames()" > Nearby <!-- restore when the links don't jump around for different numbers <span class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md" v-if="isLocalActive" > {{ localCount > -1 ? localCount : "?" }} </span> --> </a> </li> <li> <a href="#" @click=" projects = []; userProfiles = []; isLocalActive = false; isMappedActive = true; isAnywhereActive = false; isSearchVisible = false; searchTerms = ''; tempSearchBox = null; " v-bind:class="computedMappedTabStyleClassNames()" > <!-- search is triggered when map component gets to "ready" state --> Mapped </a> </li> <li> <a href="#" @click=" projects = []; userProfiles = []; isLocalActive = false; isMappedActive = false; isAnywhereActive = true; isSearchVisible = true; tempSearchBox = null; searchAll(); " v-bind:class="computedRemoteTabStyleClassNames()" > Anywhere <!-- restore when the links don't jump around for different numbers <span class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md" v-if="isAnywhereActive" > {{ remoteCount > -1 ? remoteCount : "?" }} </span> --> </a> </li> </ul> </div> <div v-if="isLocalActive"> <div class="text-center"> <button class="ml-2 mt-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500" @click="$router.push({ name: 'search-area' })" > <fa icon="location-dot" class="fa-fw" /> Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search </button> </div> </div> <div v-if="isMappedActive && !tempSearchBox"> <div class="mt-4 h-96 w-5/6 mx-auto"> <l-map ref="projectMap" @ready="onMapReady" @moveend="onMoveEnd" @movestart="onMoveStart" @zoomend="onZoomEnd" @zoomstart="onZoomStart" > <l-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" layer-type="base" name="OpenStreetMap" /> </l-map> </div> </div> <!-- Loading Animation --> <div class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full" v-if="isLoading" > <fa icon="spinner" class="fa-spin-pulse"></fa> </div> <div v-else-if="projects.length === 0 && userProfiles.length === 0" class="text-center mt-8" > <p class="text-lg text-slate-500"> <span v-if="isLocalActive"> <span v-if="searchBox"> None found in the selected area. </span> <!-- Otherwise there's no search area selected so we'll just leave the search box for them to click. --> </span> <span v-else-if="isAnywhereActive" >No projects were found with that search.</span > </p> </div> <!-- Results List --> <InfiniteScroll @reached-bottom="loadMoreData"> <ul id="listDiscoverResults"> <!-- Projects List --> <template v-if="isProjectsActive"> <li class="border-b border-slate-300" v-for="project in projects" :key="project.handleId" > <a @click="onClickLoadItem(project.handleId)" class="block py-4 flex gap-4 cursor-pointer" > <div> <ProjectIcon :entityId="project.handleId" :iconSize="48" :imageUrl="project.image" class="block border border-slate-300 rounded-md max-h-12 max-w-12" /> </div> <div class="grow"> <h2 class="text-base font-semibold">{{ project.name }}</h2> <div class="text-sm"> <fa icon="user" class="fa-fw text-slate-400"></fa> {{ didInfo( project.issuerDid, activeDid, allMyDids, allContacts, ) }} </div> </div> </a> </li> </template> <!-- Profiles List --> <template v-else> <li class="border-b border-slate-300" v-for="profile in userProfiles" :key="profile.issuerDid" > <a @click="onClickLoadItem(profile?.rowId || '')" class="block py-4 flex gap-4 cursor-pointer" > <div class="grow"> <div class="text-sm"> <fa icon="user" class="fa-fw text-slate-400"></fa> {{ didInfo( profile.issuerDid, activeDid, allMyDids, allContacts, ) }} </div> <p v-if="profile.description" class="mt-1 text-sm text-slate-600" > {{ profile.description }} </p> <div v-if="isAnywhereActive && profile.locLat && profile.locLon" class="mt-1 text-xs text-slate-500" > <fa icon="location-dot" class="fa-fw"></fa> {{ (profile.locLat > 0 ? "North" : "South") + " in " + (profile.locLon > 0 ? "Eastern" : "Western") + " Hemisphere" }} </div> </div> </a> </li> </template> </ul> </InfiniteScroll> </section> </template> <script lang="ts"> import "leaflet/dist/leaflet.css"; import * as L from "leaflet"; import { Component, Vue } from "vue-facing-decorator"; import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { Router, RouteLocationNormalizedLoaded } from "vue-router"; import QuickNav from "../components/QuickNav.vue"; import InfiniteScroll from "../components/InfiniteScroll.vue"; import ProjectIcon from "../components/ProjectIcon.vue"; import OnboardingDialog from "../components/OnboardingDialog.vue"; import TopMessage from "../components/TopMessage.vue"; import { NotificationIface, DEFAULT_PARTNER_API_SERVER, } from "../constants/app"; import { db, logConsoleAndDb, retrieveSettingsForActiveAccount, } from "../db/index"; import { Contact } from "../db/tables/contacts"; import { BoundingBox } from "../db/tables/settings"; import { didInfo, errorStringForLog, getHeaders, PlanData, } from "../libs/endorserServer"; import { OnboardPage, retrieveAccountDids } from "../libs/util"; interface Tile { indexLat: number; indexLon: number; minFoundLat: number; maxFoundLat: number; minFoundLon: number; maxFoundLon: number; recordCount: number; } @Component({ components: { InfiniteScroll, LMap, LTileLayer, OnboardingDialog, ProjectIcon, QuickNav, TopMessage, }, }) export default class DiscoverView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; $router!: Router; $route!: RouteLocationNormalizedLoaded; activeDid = ""; allContacts: Array<Contact> = []; allMyDids: Array<string> = []; apiServer = ""; isLoading = false; isLocalActive = false; isMappedActive = false; isAnywhereActive = true; isProjectsActive = true; isPeopleActive = false; isSearchVisible = true; localCenterLat = 0; localCenterLong = 0; localCount = -1; markers: { [key: string]: L.Marker } = {}; partnerApiServer = DEFAULT_PARTNER_API_SERVER; projects: PlanData[] = []; remoteCount = -1; searchBox: { name: string; bbox: BoundingBox } | null = null; searchTerms = ""; tempSearchBox: BoundingBox | null = null; userProfiles: UserProfile[] = []; zoomedSoDoNotMove = false; // make this function available to the Vue template didInfo = didInfo; async mounted() { this.searchTerms = this.$route.query["searchText"]?.toString() || ""; const searchPeople = !!this.$route.query["searchPeople"]; const settings = await retrieveSettingsForActiveAccount(); this.activeDid = (settings.activeDid as string) || ""; this.apiServer = (settings.apiServer as string) || ""; this.partnerApiServer = (settings.partnerApiServer as string) || this.partnerApiServer; this.searchBox = settings.searchBoxes?.[0] || null; this.allContacts = await db.contacts.toArray(); this.allMyDids = await retrieveAccountDids(); if (!settings.finishedOnboarding) { (this.$refs.onboardingDialog as OnboardingDialog).open( OnboardPage.Discover, ); } // Someday we'll have enough people that we can default to their local area. // if (this.searchBox) { // this.isLocalActive = true; // this.isAnywhereActive = false; // await this.searchLocal(); // // const bbox = this.searchBox.bbox; // this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2; // this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2; // } else { if (searchPeople) { this.isPeopleActive = true; this.isProjectsActive = false; this.isMappedActive = true; this.isAnywhereActive = false; } if (this.isMappedActive) { // The map will be loaded when it's ready // and if we try to do it here before the map is ready then we get errors. } else { await this.searchSelected(); } } public resetCounts() { this.localCount = -1; this.remoteCount = -1; } public async searchSelected() { if (this.isLocalActive) { await this.searchLocal(); } else if (this.isMappedActive) { const mapRef = this.$refs.projectMap as L.Map; this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation } else { await this.searchAll(); } } public async searchAll(beforeId?: string) { this.resetCounts(); if (!beforeId) { // this was an initial search so clear any previous results this.projects = []; this.userProfiles = []; } let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms); if (beforeId) { queryParams = queryParams + `&beforeId=${beforeId}`; } const endpoint = this.isProjectsActive ? this.apiServer + "/api/v2/report/plans" : this.partnerApiServer + "/api/partner/userProfile"; try { this.isLoading = true; const response = await fetch(endpoint + "?" + queryParams, { method: "GET", headers: await getHeaders(this.activeDid), }); if (response.status !== 200) { const details = await response.text(); throw details; } const results = await response.json(); if (this.isProjectsActive) { this.userProfiles = []; const plans: PlanData[] = results.data; if (plans) { this.projects.push(...plans); this.remoteCount = this.projects.length; } else { throw JSON.stringify(results); } } else { // people search must be active this.projects = []; const profiles: UserProfile[] = results.data; if (profiles) { this.userProfiles.push(...profiles); this.remoteCount = this.userProfiles.length; } else { throw JSON.stringify(results); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { console.error("Error with search all:", e); // this sometimes gives different information console.error("Error with search all (error added): " + e); this.$notify( { group: "alert", type: "danger", title: "Error Searching", text: e.userMessage || "There was a problem retrieving " + (this.isProjectsActive ? "projects" : "profiles") + ".", }, 5000, ); } finally { this.isLoading = false; } } public async searchLocal(beforeId?: string) { this.resetCounts(); const searchBox = (this.isMappedActive && this.tempSearchBox) || (this.isLocalActive && this.searchBox?.bbox); if (!searchBox) { this.projects = []; this.userProfiles = []; return; } if (!beforeId) { // this was an initial search so clear any previous results this.projects = []; this.userProfiles = []; } const claimContents = "claimContents=" + encodeURIComponent(this.searchTerms); let queryParams = [ claimContents, "minLocLat=" + searchBox.minLat, "maxLocLat=" + searchBox.maxLat, "minLocLon=" + searchBox.westLong, "maxLocLon=" + searchBox.eastLong, ].join("&"); if (beforeId) { queryParams = queryParams + `&beforeId=${beforeId}`; } const endpoint = this.isProjectsActive ? this.apiServer + "/api/v2/report/plansByLocation" : this.partnerApiServer + "/api/partner/userProfile"; try { this.isLoading = true; const response = await fetch(endpoint + "?" + queryParams, { method: "GET", headers: await getHeaders(this.activeDid), }); if (response.status !== 200) { const details = await response.text(); throw details; } const results = await response.json(); if (this.isProjectsActive) { this.userProfiles = []; const plans: PlanData[] = results.data; if (plans) { this.projects.push(...plans); this.localCount = this.projects.length; } else { throw JSON.stringify(results); } } else { this.projects = []; const profiles: UserProfile[] = results.data; if (profiles) { this.userProfiles.push(...profiles); this.localCount = this.userProfiles.length; } else { throw JSON.stringify(results); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { console.error("Error with search local:", e); this.$notify( { group: "alert", type: "danger", title: "Error", text: e.userMessage || "There was a problem retrieving " + (this.isProjectsActive ? "projects" : "profiles") + ".", }, 5000, ); } finally { this.isLoading = false; } } /** * Data loader used by infinite scroller * @param payload is the flag from the InfiniteScroll indicating if it should load **/ async loadMoreData(payload: boolean) { if (payload) { if (this.isProjectsActive && this.projects.length > 0) { const latestProject = this.projects[this.projects.length - 1]; if (this.isLocalActive || this.isMappedActive) { this.searchLocal(latestProject.rowId); } else if (this.isAnywhereActive) { this.searchAll(latestProject.rowId); } } else if (this.isPeopleActive && this.userProfiles.length > 0) { const latestProfile = this.userProfiles[this.userProfiles.length - 1]; if (this.isLocalActive || this.isMappedActive) { this.searchLocal(latestProfile.rowId || ""); } else if (this.isAnywhereActive) { this.searchAll(latestProfile.rowId || ""); } } } } clearMarkers() { Object.values(this.markers).forEach((marker) => marker.remove()); this.markers = {}; } async onMapReady(map: L.Map) { // doing this here instead of on the l-map element avoids a recentering after a drag then zoom at startup map.setView([this.localCenterLat, this.localCenterLong], 2); this.requestTiles(map); } // Tried but failed to use other vue-leaflet methods update:zoom and update:bounds // To access the from this.$refs, use this.$refs.projectMap.leafletObject (or maybe mapObject) onMoveStart(/* event: L.LocationEvent */) { // don't remove markers because they follow the map when moving (and the experience is jarring) } async onMoveEnd(event: L.LocationEvent) { if (this.zoomedSoDoNotMove) { // since a zoom triggers a moveend, too, don't duplicate a tile request this.zoomedSoDoNotMove = false; } else { // not part of a zoom so request tiles await this.requestTiles(event.target); } } onZoomStart(/* event: L.LocationEvent */) { // remove markers because otherwise they jump around at zoom end this.clearMarkers(); this.zoomedSoDoNotMove = true; } async onZoomEnd(event: L.LocationEvent) { await this.requestTiles(event.target); } async requestTiles(targetMap: L.Map) { try { const bounds = targetMap.getBounds(); const queryParams = [ "minLocLat=" + bounds?.getSouthWest().lat, "maxLocLat=" + bounds?.getNorthEast().lat, "westLocLon=" + bounds?.getSouthWest().lng, "eastLocLon=" + bounds?.getNorthEast().lng, ].join("&"); const endpoint = this.isProjectsActive ? this.apiServer + "/api/v2/report/planCountsByBBox" : this.partnerApiServer + "/api/partner/userProfileCountsByBBox"; const response = await fetch(endpoint + "?" + queryParams); if (response.status === 200) { this.clearMarkers(); const results = await response.json(); if (results.data?.tiles?.length > 0) { for (const tile: Tile of results.data.tiles) { const pinLat = (tile.minFoundLat + tile.maxFoundLat) / 2; const pinLon = (tile.minFoundLon + tile.maxFoundLon) / 2; const numberIcon = L.divIcon({ className: "numbered-marker", html: `<strong>${tile.recordCount}</strong>`, iconSize: [24, 24], // Why isn't this showing? iconAnchor: [12, 12], // coordinates of the tip of the icon relative to the top-left corner of the icon }); const marker = L.marker([pinLat, pinLon], { icon: numberIcon }); marker.addTo(targetMap); marker.on("click", () => { this.tempSearchBox = { minLat: tile.minFoundLat, maxLat: tile.maxFoundLat, westLong: tile.minFoundLon, eastLong: tile.maxFoundLon, }; this.searchLocal(); }); this.markers[ "" + tile.indexLat + "X" + tile.indexLon + "_" + tile.minFoundLat + "X" + tile.minFoundLon + "-" + tile.maxFoundLat + "X" + tile.maxFoundLon ] = marker; } } } else { throw { message: "Got an error loading projects on the map.", response: { status: response.status, statusText: response.statusText, url: response.url, }, }; } } catch (e) { logConsoleAndDb( "Error loading projects on the map: " + errorStringForLog(e), true, ); this.$notify( { group: "alert", type: "danger", title: "Map Error", text: "There was a problem loading projects on the map.", }, 3000, ); } } /** * Handle clicking on a project or profile entry found in the list * @param id of the project or profile **/ onClickLoadItem(id: string) { const route = { path: this.isProjectsActive ? "/project/" + encodeURIComponent(id) : "/userProfile/" + encodeURIComponent(id), }; this.$router.push(route); } public computedLocalTabStyleClassNames() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, active: this.isLocalActive, "text-black": this.isLocalActive, "border-black": this.isLocalActive, "font-semibold": this.isLocalActive, "text-blue-600": !this.isLocalActive, "border-transparent": !this.isLocalActive, "hover:border-slate-400": !this.isLocalActive, }; } public computedMappedTabStyleClassNames() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, active: this.isMappedActive, "text-black": this.isMappedActive, "border-black": this.isMappedActive, "font-semibold": this.isMappedActive, "text-blue-600": !this.isMappedActive, "border-transparent": !this.isMappedActive, "hover:border-slate-400": !this.isMappedActive, }; } public computedRemoteTabStyleClassNames() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, active: this.isAnywhereActive, "text-black": this.isAnywhereActive, "border-black": this.isAnywhereActive, "font-semibold": this.isAnywhereActive, "text-blue-600": !this.isAnywhereActive, "border-transparent": !this.isAnywhereActive, "hover:border-slate-400": !this.isAnywhereActive, }; } public computedProjectsTabStyleClassNames() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, active: this.isProjectsActive, "text-black": this.isProjectsActive, "border-black": this.isProjectsActive, "font-semibold": this.isProjectsActive, "text-blue-600": !this.isProjectsActive, "border-transparent": !this.isProjectsActive, "hover:border-slate-400": !this.isProjectsActive, }; } public computedPeopleTabStyleClassNames() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, active: this.isPeopleActive, "text-black": this.isPeopleActive, "border-black": this.isPeopleActive, "font-semibold": this.isPeopleActive, "text-blue-600": !this.isPeopleActive, "border-transparent": !this.isPeopleActive, "hover:border-slate-400": !this.isPeopleActive, }; } } </script> <style> .numbered-marker { display: flex; align-items: center; justify-content: center; text-align: center; font-size: 14px; font-weight: bold; color: white; background: blue; width: 24px; height: 24px; border-radius: 50%; border: 2px solid white; } </style>