Allow Profile Imports to App, Map Sharing, iframe improvements (#102)

* update identiyf page

* support non-ALPRs on Idenfity page

* show pending devices, even w/o tags

* update lprBaseTags

* detect iframe instead of passing query string

* implement share dialog

* clean up identify

* finishing touches for identify page
This commit is contained in:
Will Freeman
2026-02-16 20:09:02 -07:00
committed by GitHub
parent c71898e1a0
commit 5cca87208d
14 changed files with 550 additions and 273 deletions
+293 -193
View File
@@ -2,162 +2,248 @@
<DefaultLayout>
<template #header>
<Hero
title="How to Identify LPRs"
description="Visual guide to identifying license plate readers"
title="How to Identify Surveillance Equipment"
description="Learn to identify LPRs and other devices"
gradient="linear-gradient(135deg, rgb(var(--v-theme-primary)) 0%, rgb(var(--v-theme-secondary)) 100%)"
/>
</template>
<v-container fluid>
<v-container>
<v-expansion-panels>
<v-expansion-panel elevation="2">
<v-expansion-panel-title class="text-h5 font-weight-bold">
Common Features
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list lines="two">
<v-list-item prepend-icon="mdi-eye">
<v-list-item-title class="text-h6">Rear-Facing Cameras</v-list-item-title>
<v-list-item-subtitle>
LPRs almost always face the <b>rear of vehicles</b>.
</v-list-item-subtitle>
</v-list-item>
<v-list-item prepend-icon="mdi-lightbulb-on">
<v-list-item-title class="text-h6">Infrared Lights</v-list-item-title>
<v-list-item-subtitle>
Look for infrared lights that emit a <b>faint red glow</b> at night.
</v-list-item-subtitle>
</v-list-item>
<v-list-item prepend-icon="mdi-solar-panel">
<v-list-item-title class="text-h6">Solar Panels</v-list-item-title>
<v-list-item-subtitle>
Many LPRs are powered by nearby <b>solar panels</b>.
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<v-tabs
v-model="activeTab"
align-tabs="center"
color="primary"
class="mb-6"
>
<v-tab value="alprs">License Plate Readers</v-tab>
<v-tab value="other">Other Devices</v-tab>
</v-tabs>
</v-container>
<v-container class="mb-12">
<h2 class="text-h4 text-center mt-3">Common LPR Vendors</h2>
<p class="text-center text-medium-emphasis mb-8">
Most LPRs are easy to recognize.
</p>
<v-window v-model="activeTab">
<!-- ALPR Tab -->
<v-window-item value="alprs">
<v-container>
<v-expansion-panels>
<v-expansion-panel elevation="2">
<v-expansion-panel-title class="text-h5 font-weight-bold">
Common Features
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list lines="two">
<v-list-item prepend-icon="mdi-eye">
<v-list-item-title class="text-h6">Rear-Facing Cameras</v-list-item-title>
<v-list-item-subtitle>
LPRs almost always face the <b>rear of vehicles</b>.
</v-list-item-subtitle>
</v-list-item>
<!-- Skeleton Loader -->
<v-row v-if="loading">
<v-col cols="12" md="6" v-for="n in 4" :key="`skeleton-${n}`" class="mb-4">
<v-card class="vendor-card h-100" elevation="2">
<v-card-title class="text-center" style="background-color: #f5f5f5;">
<v-skeleton-loader type="image" style="height:48px; width:150px; margin:0 auto;" />
</v-card-title>
<v-card-subtitle class="text-center pa-4">
<v-skeleton-loader type="text" width="80%" />
</v-card-subtitle>
<v-card-text class="pa-4">
<v-row>
<v-col cols="6" v-for="i in 4" :key="`skeleton-img-${i}`">
<v-skeleton-loader type="image" :height="120" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-list-item prepend-icon="mdi-lightbulb-on">
<v-list-item-title class="text-h6">Infrared Lights</v-list-item-title>
<v-list-item-subtitle>
Look for infrared lights that emit a <b>faint red glow</b> at night.
</v-list-item-subtitle>
</v-list-item>
<v-row v-else>
<v-col cols="12" md="6" v-for="vendor in vendorStore.lprVendors" :key="vendor.id" class="mb-4">
<v-card class="vendor-card h-100" elevation="2">
<v-card-title class="text-center" style="background-color: #f5f5f5;"
@click="onVendorTitleClick(vendor.id)"
>
<v-img v-if="vendor.logoUrl" contain :src="vendor.logoUrl" :alt="`${vendor.shortName} Logo`" style="height: 48px;" />
<div
style="height: 48px; display: flex; align-items: center; justify-content: center;"
class="font-weight-bold text-black"
v-else
>
{{ vendor.shortName }}
</div>
</v-card-title>
<v-card-subtitle class="text-center pa-4 text-h6" style="white-space: normal; word-break: break-word;">
{{ vendor.identificationHints }}
</v-card-subtitle>
<v-card-text class="pa-4">
<v-list-item prepend-icon="mdi-solar-panel">
<v-list-item-title class="text-h6">Solar Panels</v-list-item-title>
<v-list-item-subtitle>
Many LPRs are powered by nearby <b>solar panels</b>.
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-container>
<v-container class="mb-12">
<h2 class="text-h4 text-center mt-3">Common LPR Vendors</h2>
<p class="text-center text-medium-emphasis mb-8">
Most LPRs are easy to recognize.
</p>
<!-- Skeleton Loader -->
<v-row v-if="loading">
<v-col cols="12" md="6" v-for="n in 4" :key="`skeleton-${n}`" class="mb-4">
<v-card class="vendor-card h-100" elevation="2">
<v-card-title class="text-center" style="background-color: #f5f5f5;">
<v-skeleton-loader type="image" style="height:48px; width:150px; margin:0 auto;" />
</v-card-title>
<v-card-subtitle class="text-center pa-4">
<v-skeleton-loader type="text" width="80%" />
</v-card-subtitle>
<v-card-text class="pa-4">
<v-row>
<v-col cols="6" v-for="i in 4" :key="`skeleton-img-${i}`">
<v-skeleton-loader type="image" :height="120" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" md="6" v-for="vendor in vendorStore.lprVendors" :key="vendor.id" class="mb-4">
<v-card class="vendor-card h-100 d-flex flex-column justify-space-between" elevation="2">
<v-card-title class="text-center" style="background-color: #f5f5f5;">
<v-img v-if="vendor.logoUrl" contain :src="vendor.logoUrl" :alt="`${vendor.shortName} Logo`" style="height: 48px;" />
<div
style="height: 48px; display: flex; align-items: center; justify-content: center;"
class="font-weight-bold text-black"
v-else
>
{{ vendor.shortName }}
</div>
</v-card-title>
<v-card-subtitle class="text-center pa-4 text-h6" style="white-space: normal; word-break: break-word;">
{{ vendor.identificationHints }}
</v-card-subtitle>
<v-card-text class="pa-4 d-flex flex-column justify-space-between">
<v-row>
<v-col v-for="{ url: imageUrl } in vendor.urls" :key="imageUrl" cols="6">
<v-card class="image-card" elevation="1" @click="openImageInNewTab(imageUrl)">
<v-img
:src="imageUrl"
:aspect-ratio="4/3"
cover
class="cursor-pointer"
>
</v-img>
</v-card>
</v-col>
</v-row>
<v-divider class="my-4" />
<div class="mt-4">
<h4 class="text-center mb-2">OSM Tags</h4>
<DFCode
v-if="hasOsmTags(vendor.osmTags)"
:osm-tags="getMergedTags(vendor)"
:highlight-values-for-keys="getVendorTagKeys(vendor)"
/>
<div v-else class="text-center pa-4 text-medium-emphasis">
Coming soon
</div>
<div class="text-center mt-3">
<v-btn
color="var(--df-blue)"
class="text-white mt-3"
variant="elevated"
size="large"
:disabled="!hasOsmTags(vendor.osmTags)"
@click="onAddToApp(vendor.shortName, vendor.osmTags, true, true, null)"
prepend-icon="mdi-application-import"
>
Import to App
</v-btn>
</div>
<div class="text-center text-caption mt-1">Requires app v2.7.1+</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-window-item>
<!-- Other Surveillance Tab -->
<v-window-item value="other">
<v-container class="mb-12">
<!-- Skeleton Loader -->
<div v-if="loadingOther">
<v-row>
<v-col cols="12" md="6" v-for="n in 4" :key="`skeleton-other-${n}`" class="mb-4">
<v-card class="vendor-card h-100" elevation="2">
<v-card-title class="text-center" style="background-color: #f5f5f5;">
<v-skeleton-loader type="text" style="height:48px; width:150px; margin:0 auto;" />
</v-card-title>
<v-card-text class="pa-4">
<v-row>
<v-col cols="6" v-for="i in 4" :key="`skeleton-other-img-${i}`">
<v-skeleton-loader type="image" :height="120" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<div v-else>
<div v-for="(devices, category) in devicesByCategory" :key="category" class="mb-8">
<h2 class="text-h4 text-center mt-6 mb-6">{{ category }}</h2>
<v-row>
<v-col v-for="{ url: imageUrl } in vendor.urls" :key="imageUrl" cols="6">
<v-card class="image-card" elevation="1" @click="openImageInNewTab(imageUrl)">
<v-img
:src="imageUrl"
:aspect-ratio="4/3"
cover
class="cursor-pointer"
>
</v-img>
<v-col cols="12" md="6" v-for="device in devices" :key="device.id" class="mb-4">
<v-card class="vendor-card h-100 d-flex flex-column justify-space-between" elevation="2">
<v-card-title class="text-center" style="background-color: #f5f5f5;">
<div
style="height: 48px; display: flex; align-items: center; justify-content: center;"
class="font-weight-bold text-black"
>
{{ device.name }}
</div>
</v-card-title>
<v-card-subtitle v-if="device.capabilities" class="text-center pa-4 text-h6" style="white-space: normal; word-break: break-word;">
{{ device.capabilities }}
</v-card-subtitle>
<v-card-text class="pa-4 d-flex flex-column justify-space-between">
<v-row>
<v-col v-for="{ url: imageUrl } in device.urls" :key="imageUrl" cols="6">
<v-card class="image-card" elevation="1" @click="openImageInNewTab(imageUrl)">
<v-img
:src="imageUrl"
:aspect-ratio="4/3"
cover
class="cursor-pointer"
>
</v-img>
</v-card>
</v-col>
</v-row>
<v-divider class="my-4" />
<div class="mt-4">
<h4 class="text-center mb-2">OSM Tags</h4>
<DFCode
v-if="hasOsmTags(device.osmTags)"
:osm-tags="device.osmTags"
/>
<div v-else class="text-center pa-4 text-medium-emphasis">
Coming soon
</div>
<div class="text-center mt-3">
<v-btn
color="var(--df-blue)"
class="text-white mt-3"
variant="elevated"
size="large"
:disabled="!hasOsmTags(device.osmTags)"
@click="onAddToApp(device.name, device.osmTags, false, device.requiresDirection, device.fov ?? null)"
prepend-icon="mdi-application-import"
>
Import to App
</v-btn>
</div>
<div class="text-center text-caption text-grey-darken-1 mt-1">Requires app v2.7.1+</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- What ALPRs are NOT -->
<v-container class="mb-12">
<v-card class="not-alpr-card" elevation="3">
<v-card-title
class="text-center px-6 bg-warning font-weight-bold"
:class="[$vuetify.display.smAndDown ? 'text-h6' : 'text-h4']"
style="white-space: normal; word-break: break-word;"
>
NOT ALPRs
</v-card-title>
<v-card-text class="px-6 pt-6">
<v-row>
<!-- Traffic Detection Cameras -->
<v-col cols="12" md="9" class="mb-6">
<h3 class="text-center mb-4">Traffic Detection Cameras</h3>
<v-row>
<v-col v-for="(image, index) in trafficCameraImages" :key="index" cols="12" md="4">
<v-card class="image-card" elevation="1" @click="openImageInNewTab(image)">
<v-img
:src="image"
:aspect-ratio="4/3"
cover
class="cursor-pointer"
/>
</v-card>
</v-col>
</v-row>
</v-col>
<!-- Snow Detection Cameras -->
<v-col cols="12" md="3" class="mb-6">
<h3 class="text-center mb-4">Snow/Ice Cameras</h3>
<v-row>
<v-col v-for="(image, index) in snowDetectionImages" :key="index" cols="12">
<v-card class="image-card" elevation="1" @click="openImageInNewTab(image)">
<v-img
:src="image"
:aspect-ratio="4/3"
cover
class="cursor-pointer"
/>
</v-card>
</v-col>
</v-row>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-container>
</div>
<div class="text-center text-body-1">
Don't see a device you know about? We're always adding more, so check back later!
</div>
</div>
</v-container>
</v-window-item>
</v-window>
<!-- Action Section -->
<v-container class="text-center mb-12">
@@ -166,11 +252,11 @@
<v-card-text>
<v-btn
size="x-large"
color="primary"
color="var(--df-blue)"
to="/report"
prepend-icon="mdi-map-marker-plus"
variant="elevated"
class="mr-4"
class="text-white"
>
Add to Map
</v-btn>
@@ -184,74 +270,92 @@
<script setup lang="ts">
import DefaultLayout from '@/layouts/DefaultLayout.vue';
import Hero from '@/components/layout/Hero.vue';
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import DFCode from '@/components/DFCode.vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useVendorStore } from '@/stores/vendorStore';
import { createDeflockProfileUrl } from '@/services/deflockAppUrls';
import type { LprVendor } from '@/types';
import { lprBaseTags } from '@/constants';
import type { LprVendor, OtherSurveillanceDevice } from '@/types';
function openImageInNewTab(url: string) {
window.open(url, '_blank');
}
const loading = ref(true);
const vendorStore = useVendorStore();
const router = useRouter();
const route = useRoute();
// BEGIN SECRET CLICKS
const secretClicks = new Map<string | number, number[]>();
function onVendorTitleClick(vendorId: string | number) {
const now = Date.now();
const windowMs = 2500; // 2.5 seconds
const required = 3;
const arr = secretClicks.get(vendorId) || [];
// keep clicks within window
const filtered = arr.filter(t => now - t <= windowMs);
filtered.push(now);
secretClicks.set(vendorId, filtered);
if (filtered.length >= required) {
// find vendor object from store and call onAddToApp
const vendor = vendorStore.lprVendors.find(v => v.id === vendorId);
if (vendor) onAddToApp(vendor as any);
secretClicks.set(vendorId, []);
const activeTab = computed({
get: () => {
const tab = route.query.tab as string;
return tab === 'other' ? 'other' : 'alprs';
},
set: (tab: string) => {
router.push({
path: route.path,
query: { ...route.query, tab: tab === 'alprs' ? undefined : tab }
});
}
}
// END SECRET CLICKS
onMounted(async () => {
await vendorStore.loadAllVendors();
loading.value = false;
});
const router = useRouter();
const loading = ref(true);
const loadingOther = ref(true);
const vendorStore = useVendorStore();
async function onAddToApp(vendor: LprVendor) {
const url = createDeflockProfileUrl(vendor.shortName, vendor.osmTags);
function getMergedTags(vendor: LprVendor): Record<string, string> {
return { ...lprBaseTags, ...vendor.osmTags };
}
function getVendorTagKeys(vendor: LprVendor): string[] {
return Object.keys(vendor.osmTags ?? {});
}
function hasOsmTags(osmTags: Record<string, string> | undefined): boolean {
return osmTags !== undefined && Object.keys(osmTags).length > 0;
}
// Group other surveillance devices by category, preserving CMS order
const devicesByCategory = computed(() => {
const grouped: Record<string, OtherSurveillanceDevice[]> = {};
for (const device of vendorStore.otherDevices) {
if (!grouped[device.category]) {
grouped[device.category] = [];
}
grouped[device.category].push(device);
}
return grouped;
});
onMounted(async () => {
await Promise.all([
vendorStore.loadAllVendors().then(() => { loading.value = false; }),
vendorStore.loadAllOtherDevices().then(() => { loadingOther.value = false; })
]);
});
async function onAddToApp(
name: string,
osmTags: Record<string, string>,
isAlpr: boolean,
requiresDirection: boolean,
fov: number | null
) {
const tags = isAlpr ? { ...lprBaseTags, ...osmTags } : osmTags;
const url = createDeflockProfileUrl(name, tags, { requiresDirection, fov });
const ua = typeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent : '';
const isMobile = /iphone|ipod|ipad|android|blackberry|bb|playbook|windows phone|iemobile|opera mini|mobile/i.test(ua);
if (isMobile) {
// attempt to open the app via custom scheme on mobile
try {
window.location.href = url;
} catch (e) {
window.open(url, '_blank');
}
} else {
// on Desktop
console.log(`Deflock profile URL: ${url}`);
// router.push('/app');
router.push('/app');
}
}
const trafficCameraImages = [
'/non-alprs/iteris.webp',
'/non-alprs/traffic-cam.webp',
'/non-alprs/flir.webp',
];
const snowDetectionImages = [
'/non-alprs/frost-cam.jpeg'
];
</script>
<style scoped>
@@ -263,10 +367,6 @@ const snowDetectionImages = [
transition: transform 0.2s ease-in-out;
}
.vendor-card:hover {
transform: translateY(-2px);
}
.image-card {
transition: transform 0.2s ease-in-out;
cursor: pointer;
+16 -5
View File
@@ -1,5 +1,7 @@
<template>
<NewVisitor />
<NewVisitor v-if="!isIframe" />
<ShareDialog v-model="shareDialogOpen" />
<div class="map-container" @keyup="handleKeyUp">
<leaflet-map
v-if="center"
@@ -36,8 +38,13 @@
</form>
</template>
<!-- CURRENT LOCATION -->
<template v-slot:bottomright>
<v-btn icon @click="shareDialogOpen = true" v-if="!isIframe">
<v-icon>mdi-share-variant</v-icon>
</v-btn>
<v-btn icon to="/report" style="color: unset" v-if="!isIframe">
<v-icon size="large">mdi-map-marker-plus</v-icon>
</v-btn>
<v-btn icon @click="goToUserLocation">
<v-icon>mdi-crosshairs-gps</v-icon>
</v-btn>
@@ -52,12 +59,12 @@
<script setup lang="ts">
import 'leaflet/dist/leaflet.css';
import { ref, onMounted, computed, watch } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router'
import type { Ref } from 'vue';
import { BoundingBox } from '@/services/apiService';
import { geocodeQuery } from '@/services/apiService';
import { useDisplay, useTheme } from 'vuetify';
import { useDisplay } from 'vuetify';
import { useGlobalStore } from '@/stores/global';
import { useTilesStore } from '@/stores/tiles';
import { useVendorStore } from '@/stores/vendorStore';
@@ -66,6 +73,7 @@ globalThis.L = L;
import 'leaflet/dist/leaflet.css'
import LeafletMap from '@/components/LeafletMap.vue';
import NewVisitor from '@/components/NewVisitor.vue';
import ShareDialog from '@/components/ShareDialog.vue';
const DEFAULT_ZOOM = 12;
@@ -76,8 +84,11 @@ const searchField: Ref<any|null> = ref(null);
const searchInput: Ref<string> = ref(''); // For the text input field
const searchQuery: Ref<string> = ref(''); // For URL and boundaries (persistent)
const geojson: Ref<GeoJSON.GeoJsonObject | null> = ref(null);
const shareDialogOpen = ref(false);
const tilesStore = useTilesStore();
const isIframe = computed(() => window.self !== window.top);
const { fetchVisibleTiles } = tilesStore;
const alprs = computed(() => tilesStore.allNodes);
@@ -101,7 +112,7 @@ function onSearch() {
if (!searchInput.value) {
return;
}
geocodeQuery(searchInput.value, center.value)
geocodeQuery(searchInput.value)
.then((result: any) => {
if (!result) {
alert('No results found');
-7
View File
@@ -41,13 +41,6 @@
If you would like to <b>localize the URL</b> to a specific region, please zoom to the area at <router-link to="/map">https://deflock.org/map</router-link> and copy the URL from your browser's address bar.
</p>
<p>
To <b>remove the header bar</b>, add the following query parameter to the URL: <code>?fullscreen=true</code>. For example:
<DFCode>
&lt;iframe src=&quot;https://deflock.org/map?fullscreen=true#map=14/40.014863/-105.266275&quot; width=&quot;100%&quot; height=&quot;600&quot; style=&quot;border: none;&quot;&gt;&lt;/iframe&gt;
</DFCode>
</p>
<h2>Contact Us</h2>
<p>
For media inquiries and interview requests, send us an email at <a href="mailto:media@deflock.me">media@deflock.me</a>.