mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-03-23 02:34:23 +00:00
revert shitty layout, use cdn for vendors (new ones coming soon)
This commit is contained in:
@@ -13,7 +13,7 @@ const { showDialog, discordUrl, interceptDiscordLinks } = useDiscordIntercept();
|
||||
|
||||
function toggleTheme() {
|
||||
const newTheme = theme.global.name.value === 'dark' ? 'light' : 'dark';
|
||||
theme.global.name.value = newTheme;
|
||||
theme.change(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ function handleDiscordProceed(url: string) {
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
theme.global.name.value = savedTheme;
|
||||
theme.change(savedTheme);
|
||||
} else {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
theme.global.name.value = prefersDark ? 'dark' : 'light';
|
||||
theme.change(prefersDark ? 'dark' : 'light');
|
||||
localStorage.setItem('theme', theme.global.name.value);
|
||||
}
|
||||
interceptDiscordLinks();
|
||||
@@ -37,11 +37,19 @@ const items = [
|
||||
{ title: 'Home', icon: 'mdi-home', to: '/' },
|
||||
{ title: 'Map', icon: 'mdi-map', to: '/map' },
|
||||
{ title: 'Learn', icon: 'mdi-school', to: '/what-is-an-alpr' },
|
||||
{ title: 'Get Involved', icon: 'mdi-account-voice', to: '/get-involved' },
|
||||
{ title: 'News', icon: 'mdi-newspaper', to: '/blog' },
|
||||
]
|
||||
|
||||
const contributeItems = [
|
||||
{ title: 'Submit Cameras', icon: 'mdi-map-marker-plus', to: '/report' },
|
||||
{ title: 'Hang Signs', icon: 'mdi-sign-direction', to: '/store' },
|
||||
{ title: 'Public Records', icon: 'mdi-file-document', to: '/foia' },
|
||||
{ title: 'City Council', icon: 'mdi-account-voice', to: '/council' },
|
||||
]
|
||||
|
||||
const metaItems = [
|
||||
{ title: 'Discord', customIcon: '/icon-discord.svg', customIconDark: '/icon-discord-white.svg', customIconGrey: '/icon-discord-grey.svg', href: 'https://discord.gg/aV7v4R3sKT'},
|
||||
{ title: 'Local Groups', icon: 'mdi-account-group', to: '/groups' },
|
||||
{ title: 'Contact', icon: 'mdi-email-outline', to: '/contact' },
|
||||
{ title: 'GitHub', icon: 'mdi-github', href: 'https://github.com/frillweeman/deflock'},
|
||||
{ title: 'Donate', icon: 'mdi-heart', to: '/donate'},
|
||||
@@ -101,9 +109,71 @@ watch(() => theme.global.name.value, (newTheme) => {
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn icon to="/contact" aria-label="Toggle Theme">
|
||||
<v-icon>mdi-email-outline</v-icon>
|
||||
</v-btn>
|
||||
<!-- Contribute section -->
|
||||
<div class="d-flex align-center">
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
append-icon="mdi-chevron-down"
|
||||
class="mx-1"
|
||||
>
|
||||
Contribute
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="item in contributeItems"
|
||||
:key="item.title"
|
||||
:to="item.to"
|
||||
link
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- Get Involved section -->
|
||||
<v-menu offset-y>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
append-icon="mdi-chevron-down"
|
||||
class="mx-1"
|
||||
>
|
||||
Get Involved
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="item in metaItems"
|
||||
:key="item.title"
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
:target="item.href ? '_blank' : undefined"
|
||||
link
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="item.icon">{{ item.icon }}</v-icon>
|
||||
<v-img
|
||||
v-else-if="item.customIcon"
|
||||
class="mr-8"
|
||||
contain
|
||||
width="24"
|
||||
height="24"
|
||||
:src="isDark ? item.customIconDark : item.customIconGrey"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-spacer class="d-md-none" />
|
||||
@@ -132,10 +202,26 @@ watch(() => theme.global.name.value, (newTheme) => {
|
||||
{{ item.title }}
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="my-2" aria-hidden="true" role="presentation" />
|
||||
|
||||
<v-list-subheader class="px-4">Contribute</v-list-subheader>
|
||||
<v-list nav aria-label="Contribute Links">
|
||||
<v-list-item
|
||||
v-for="item in contributeItems"
|
||||
:key="item.title"
|
||||
link
|
||||
:to="item.to"
|
||||
role="option"
|
||||
>
|
||||
<v-icon v-if="item.icon" start>{{ item.icon }}</v-icon>
|
||||
<span style="vertical-align: middle;">{{ item.title }}</span>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="my-2" aria-hidden="true" role="presentation" />
|
||||
|
||||
<v-list-subheader class="px-4">More</v-list-subheader>
|
||||
<v-list-subheader class="px-4">Get Involved</v-list-subheader>
|
||||
<v-list nav aria-label="Meta Links">
|
||||
<v-list-item
|
||||
v-for="item in metaItems"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="position-relative">
|
||||
<v-img v-if="imageUrl" cover width="100%" height="150px" :src="imageUrl" class="rounded mt-5" />
|
||||
<div v-if="imageUrl" class="position-absolute bottom-0 left-0 right-0 text-center text-white text-caption" style="background: rgba(0, 0, 0, 0.5);">
|
||||
{{ manufacturer }} ALPR
|
||||
{{ manufacturer }} LPR
|
||||
</div>
|
||||
</div>
|
||||
<v-list density="compact" class="my-2">
|
||||
@@ -50,10 +50,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef, PropType } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { ALPR } from '@/types';
|
||||
import { VIcon, VList, VSheet, VListItem, VBtn, VImg, VListItemSubtitle, VDivider } from 'vuetify/components';
|
||||
import { useVendorStore } from '@/stores/vendorStore';
|
||||
|
||||
const props = defineProps({
|
||||
alpr: {
|
||||
@@ -70,16 +71,12 @@ const manufacturer = computed(() => (
|
||||
'Unknown'
|
||||
));
|
||||
|
||||
const imageUrl: ComputedRef<string|undefined> = computed(() => {
|
||||
const mft2Url: Record<string, string> = {
|
||||
'Flock Safety': '/alprs/flock-1.jpg',
|
||||
'Motorola Solutions': '/alprs/motorola-4.jpg',
|
||||
'Genetec': '/alprs/genetec-3.webp',
|
||||
'Leonardo': '/alprs/elsag-1.jpg',
|
||||
'Neology, Inc.': '/alprs/neology-2.jpg',
|
||||
}
|
||||
const store = useVendorStore();
|
||||
const imageUrl = ref<string | undefined | null>(undefined);
|
||||
|
||||
return mft2Url[manufacturer.value];
|
||||
onMounted(async () => {
|
||||
const url = await store.getFirstImageForManufacturer(manufacturer.value as string);
|
||||
if (url) imageUrl.value = url;
|
||||
});
|
||||
|
||||
const abbreviatedOperator = computed(() => {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<v-select
|
||||
color="rgb(18, 151, 195)"
|
||||
prepend-inner-icon="mdi-factory"
|
||||
v-model="selectedBrand"
|
||||
:items="alprBrands"
|
||||
item-title="nickname"
|
||||
v-model="selectedLprVendor"
|
||||
:items="lprVendors"
|
||||
item-title="shortName"
|
||||
return-object
|
||||
label="Choose a Manufacturer"
|
||||
variant="outlined"
|
||||
@@ -16,23 +16,25 @@
|
||||
<v-img
|
||||
:aspect-ratio="3/2"
|
||||
cover
|
||||
v-if="selectedBrand"
|
||||
:src="selectedBrand.exampleImage"
|
||||
:alt="selectedBrand.nickname"
|
||||
v-if="selectedLprVendor?.urls?.length"
|
||||
:src="selectedLprVendor.urls[0]?.url"
|
||||
:alt="selectedLprVendor.shortName + ' LPR'"
|
||||
max-width="100%"
|
||||
class="my-4"
|
||||
></v-img>
|
||||
<v-btn to="/what-is-an-alpr#photos" color="#1297C3" variant="tonal" size="small"><v-icon start>mdi-image-multiple</v-icon> See All Photos</v-btn>
|
||||
/>
|
||||
<v-responsive :aspect-ratio="3/2" class="my-4" v-else>
|
||||
<div class="d-flex align-center justify-center fill-height">
|
||||
<v-icon size="96" color="grey lighten-1" aria-hidden="true">mdi-image-off</v-icon>
|
||||
</div>
|
||||
</v-responsive>
|
||||
<v-btn to="/identify" color="#1297C3" variant="tonal" size="small"><v-icon start>mdi-image-multiple</v-icon> See All Photos</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<h3 class="text-center serif">Tags to Copy</h3>
|
||||
<DFCode>
|
||||
man_made=surveillance<br>
|
||||
surveillance:type=ALPR<br>
|
||||
camera:type=fixed<br>
|
||||
<span v-if="selectedBrand.name">manufacturer=<span :class="highlightClass(selectedBrand)">{{ selectedBrand.name }}</span><br></span>
|
||||
<span v-if="selectedBrand.wikidata">manufacturer:wikidata=<span :class="highlightClass(selectedBrand)">{{ selectedBrand.wikidata }}</span><br></span>
|
||||
<span v-for="(value, key) in lprBaseTags" :key="key">{{ key }}={{ value }}<br></span>
|
||||
<span v-for="(value, key) in selectedLprVendor?.osmTags" :key="key">{{ key }}=<span :class="highlightClass(selectedLprVendor)">{{ value }}</span><br></span>
|
||||
</DFCode>
|
||||
|
||||
<h5 class="text-center mt-4 serif">and if operator is known</h5>
|
||||
@@ -51,53 +53,34 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import DFCode from '@/components/DFCode.vue';
|
||||
import { ref, type Ref } from 'vue';
|
||||
import type { WikidataItem } from '@/types';
|
||||
import { ref, type Ref, onMounted } from 'vue';
|
||||
import { type LprVendor } from '@/types';
|
||||
import { lprBaseTags } from '@/constants';
|
||||
import { useVendorStore } from '@/stores/vendorStore';
|
||||
|
||||
const alprBrands: WikidataItem[] = [
|
||||
{
|
||||
name: 'Flock Safety',
|
||||
nickname: 'Flock',
|
||||
wikidata: 'Q108485435',
|
||||
exampleImage: '/alprs/flock-1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Motorola Solutions',
|
||||
nickname: 'Motorola/Vigilant',
|
||||
wikidata: 'Q634815',
|
||||
exampleImage: '/alprs/motorola-4.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Genetec',
|
||||
nickname: 'Genetec',
|
||||
wikidata: 'Q30295174',
|
||||
exampleImage: '/alprs/genetec-3.webp',
|
||||
},
|
||||
{
|
||||
name: 'Leonardo',
|
||||
nickname: 'Leonardo/ELSAG',
|
||||
wikidata: 'Q910379',
|
||||
exampleImage: '/alprs/elsag-1.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Neology, Inc.',
|
||||
nickname: 'Neology',
|
||||
wikidata: undefined,
|
||||
exampleImage: '/alprs/neology-2.jpg',
|
||||
},
|
||||
{
|
||||
name: undefined,
|
||||
nickname: 'Other',
|
||||
wikidata: undefined,
|
||||
exampleImage: '/other-1.jpeg',
|
||||
}
|
||||
];
|
||||
const selectedBrand: Ref<WikidataItem> = ref(alprBrands[0]);
|
||||
const lprVendors = ref<LprVendor[]>([]);
|
||||
const selectedLprVendor: Ref<LprVendor | null> = ref(null);
|
||||
const vendorStore = useVendorStore();
|
||||
|
||||
function highlightClass(item: WikidataItem): string {
|
||||
return item.nickname === 'Other' ? 'placeholder' : 'highlight';
|
||||
function highlightClass(item: LprVendor | null): string {
|
||||
if (!item) return 'placeholder';
|
||||
return item.shortName === 'Generic' ? 'placeholder' : 'highlight';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const genericLprVendor: LprVendor = {
|
||||
id: -1,
|
||||
fullName: 'Generic',
|
||||
shortName: 'Generic',
|
||||
osmTags: {},
|
||||
urls: [],
|
||||
}
|
||||
await vendorStore.loadAllVendors();
|
||||
lprVendors.value = [...vendorStore.lprVendors, genericLprVendor];
|
||||
|
||||
selectedLprVendor.value = lprVendors.value[0] ?? null;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -106,6 +106,12 @@ const similarProjects: SimilarProject[] = [
|
||||
url: "https://haveibeenflocked.com",
|
||||
imageUrl: "/similar-projects/hibf.webp"
|
||||
},
|
||||
{
|
||||
name: "Upcoming Meetings",
|
||||
description: "Find upcoming public meetings related to ALPR deployments.",
|
||||
url: "https://alpr.watch/",
|
||||
imageUrl: "/similar-projects/alprdotwatch.webp"
|
||||
},
|
||||
{
|
||||
name: "Eyes On Flock",
|
||||
description: "Dashboard for tracking Flock usage patterns, including total cameras & top search reasons.",
|
||||
@@ -113,13 +119,13 @@ const similarProjects: SimilarProject[] = [
|
||||
imageUrl: "/similar-projects/eof.webp"
|
||||
},
|
||||
{
|
||||
name: "ALPR Watch | Suspected Locations",
|
||||
name: "Suspected Locations",
|
||||
description: "Map of locations where ALPRs may be, based on 811 locate requests, FOIA, and other sources.",
|
||||
url: "https://alprwatch.org/flock/suspected-locations/",
|
||||
imageUrl: "/similar-projects/flockutil.webp"
|
||||
},
|
||||
{
|
||||
name: "ALPR Watch | Recents",
|
||||
name: "Recent Submissions",
|
||||
description: "Explore recently added Flock cameras in the US.",
|
||||
url: "https://alprwatch.org/flock/map",
|
||||
imageUrl: "/similar-projects/alprwatch.webp"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<v-card :to="props.to" :href="props.href" :target="props.href ? '_blank' : undefined" hover>
|
||||
<v-card-title class="font-weight-bold text-primary">
|
||||
<v-icon start>{{ props.icon }}</v-icon>
|
||||
<span>{{ props.title }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>{{ props.description }}</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
5
webapp/src/constants.ts
Normal file
5
webapp/src/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const lprBaseTags = {
|
||||
"man_made": "surveillance",
|
||||
"surveillance:type": "ALPR",
|
||||
"camera:type": "fixed"
|
||||
}
|
||||
@@ -31,14 +31,6 @@ const router = createRouter({
|
||||
title: 'ALPR Map | DeFlock'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/get-involved',
|
||||
name: 'get-involved',
|
||||
component: () => import('../views/WhatToDo.vue'),
|
||||
meta: {
|
||||
title: 'What Can I Do | DeFlock'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/groups',
|
||||
name: 'groups',
|
||||
@@ -154,7 +146,7 @@ const router = createRouter({
|
||||
{
|
||||
path: '/identify',
|
||||
name: 'identify',
|
||||
component: () => import('../views/Identification.vue'),
|
||||
component: () => import('../views/Identify.vue'),
|
||||
meta: {
|
||||
title: 'Identify ALPRs | DeFlock'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LprVendor } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
export interface Chapter {
|
||||
@@ -32,5 +33,14 @@ export const cmsService = {
|
||||
console.error("Error fetching chapters:", error);
|
||||
throw new Error("Failed to fetch chapters");
|
||||
}
|
||||
},
|
||||
async getLprVendors(): Promise<LprVendor[]> {
|
||||
try {
|
||||
const response = await cmsApiService.get("/items/lprVendors");
|
||||
return response.data.data as LprVendor[];
|
||||
} catch (error) {
|
||||
console.error("Error fetching LPR vendors:", error);
|
||||
throw new Error("Failed to fetch LPR vendors");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
7
webapp/src/services/deflockAppUrls.ts
Normal file
7
webapp/src/services/deflockAppUrls.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { lprBaseTags } from "@/constants";
|
||||
|
||||
export function createDeflockProfileUrl(obj: Record<string, string>): string {
|
||||
const tags = { ...lprBaseTags, ...obj };
|
||||
const payload = btoa(JSON.stringify(tags));
|
||||
return `deflockapp://profiles/add?p=${payload}`;
|
||||
}
|
||||
20
webapp/src/stores/vendorStore.ts
Normal file
20
webapp/src/stores/vendorStore.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { cmsService } from '@/services/cmsService';
|
||||
import type { LprVendor } from '@/types';
|
||||
|
||||
export const useVendorStore = defineStore('vendorStore', () => {
|
||||
const lprVendors = ref<LprVendor[]>([]);
|
||||
|
||||
async function loadAllVendors(): Promise<void> {
|
||||
if (lprVendors.value.length > 0) return;
|
||||
lprVendors.value = await cmsService.getLprVendors();
|
||||
}
|
||||
|
||||
async function getFirstImageForManufacturer(fullName: string): Promise<string | null> {
|
||||
const vendor = lprVendors.value.find(v => v.fullName === fullName);
|
||||
return vendor?.urls?.[0]?.url ?? null;
|
||||
}
|
||||
|
||||
return { lprVendors, loadAllVendors, getFirstImageForManufacturer };
|
||||
});
|
||||
@@ -6,9 +6,12 @@ export interface ALPR {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export interface WikidataItem {
|
||||
name?: string;
|
||||
nickname: string;
|
||||
wikidata?: string;
|
||||
exampleImage: string|undefined;
|
||||
export interface LprVendor {
|
||||
id: number;
|
||||
shortName: string;
|
||||
fullName: string;
|
||||
identificationHints?: string;
|
||||
urls: Array<{ url: string }>;
|
||||
logoUrl?: string;
|
||||
osmTags: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -9,66 +9,52 @@
|
||||
</template>
|
||||
|
||||
<v-container fluid>
|
||||
<!-- Flock Safety - Featured Section -->
|
||||
<v-container>
|
||||
<v-card class="featured-card" elevation="4">
|
||||
<v-card-title class="text-center bg-green text-h4 pt-6 px-6">
|
||||
<v-img
|
||||
contain
|
||||
src="/vendor-logos/Flock_Safety_Logo.svg"
|
||||
:alt="'Flock Logo'"
|
||||
style="height: 48px; filter: invert(1);"
|
||||
/>
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-center pa-4 text-h6" style="white-space: normal; word-break: break-word;">
|
||||
black housing • teardrop shape • usually with solar panels
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="pa-6">
|
||||
<v-row>
|
||||
<v-col v-for="(image, index) in flockImages" :key="index" cols="12" sm="6" md="3">
|
||||
<v-card class="image-card" elevation="2" @click="openImageInNewTab(image)">
|
||||
<v-img
|
||||
:src="image"
|
||||
:aspect-ratio="4/3"
|
||||
cover
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<template v-slot:placeholder>
|
||||
<v-row class="fill-height ma-0" align="center" justify="center">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-img>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<!-- Other ALPR Types -->
|
||||
<v-container class="mb-12">
|
||||
<h2 class="text-center mb-8">Other ALPR Types</h2>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" v-for="vendor in otherVendors" :key="vendor.vendor" class="mb-4">
|
||||
<!-- 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-img v-if="vendor.logoUrl" contain :src="vendor.logoUrl" :alt="`${vendor.vendor} Logo`" style="height: 48px;" />
|
||||
<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" 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.vendor }}
|
||||
{{ 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-row>
|
||||
<v-col v-for="(image, index) in vendor.imageUrls" :key="index" cols="6">
|
||||
<v-card class="image-card" elevation="1" @click="openImageInNewTab(image)">
|
||||
<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="image"
|
||||
:src="imageUrl"
|
||||
:aspect-ratio="4/3"
|
||||
cover
|
||||
class="cursor-pointer"
|
||||
@@ -159,46 +145,63 @@
|
||||
<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 { useVendorStore } from '@/stores/vendorStore';
|
||||
import { createDeflockProfileUrl } from '@/services/deflockAppUrls';
|
||||
import type { LprVendor } from '@/types';
|
||||
|
||||
function openImageInNewTab(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
// Flock Safety - Featured prominently
|
||||
const flockImages = [
|
||||
'/alprs/flock-5.webp',
|
||||
'/alprs/flock-1.jpg',
|
||||
'/alprs/flock-2.jpg',
|
||||
'/alprs/flock-3.jpg',
|
||||
'/alprs/flock-4.jpg'
|
||||
];
|
||||
const loading = ref(true);
|
||||
const vendorStore = useVendorStore();
|
||||
|
||||
// Other ALPR vendors
|
||||
const otherVendors = [
|
||||
{
|
||||
vendor: 'Motorola/Vigilant',
|
||||
logoUrl: '/vendor-logos/Motorola_Solutions_Logo.svg',
|
||||
description: 'Usually dark and rectangular, with visible IR illuminators. Often next to an ugly white box.',
|
||||
imageUrls: ['/alprs/motorola-1.jpg', '/alprs/motorola-2.jpg', '/alprs/motorola-3.jpg', '/alprs/motorola-4.jpg']
|
||||
},
|
||||
{
|
||||
vendor: 'Genetec',
|
||||
logoUrl: '/vendor-logos/logo_genetec_rgb_color_tm.svg',
|
||||
description: 'Usually white and rectangular, with visible IR illuminators',
|
||||
imageUrls: ['/alprs/genetec-1.webp', '/alprs/genetec-2.webp', '/alprs/genetec-3.webp']
|
||||
},
|
||||
{
|
||||
vendor: 'Leonardo/ELSAG',
|
||||
logoUrl: '/vendor-logos/Logo_Leonardo.svg',
|
||||
description: 'Large, highly visible array of IR lights',
|
||||
imageUrls: ['/alprs/elsag-3.jpg', '/alprs/elsag-4.jpg', '/alprs/elsag-1.jpg', '/alprs/elsag-2.jpg']
|
||||
},
|
||||
{
|
||||
vendor: 'Neology',
|
||||
description: 'Ugly with a white rounded rectangular shape and long hood',
|
||||
imageUrls: ['/alprs/neology-1.jpg', '/alprs/neology-2.jpg']
|
||||
// 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, []);
|
||||
}
|
||||
];
|
||||
}
|
||||
// END SECRET CLICKS
|
||||
|
||||
onMounted(async () => {
|
||||
await vendorStore.loadAllVendors();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function onAddToApp(vendor: LprVendor) {
|
||||
const url = createDeflockProfileUrl(vendor.osmTags);
|
||||
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
|
||||
router.push('/app');
|
||||
}
|
||||
}
|
||||
|
||||
const trafficCameraImages = [
|
||||
'/non-alprs/iteris.webp',
|
||||
@@ -213,7 +216,6 @@ const snowDetectionImages = [
|
||||
|
||||
<style scoped>
|
||||
.featured-card {
|
||||
/* border: 3px solid rgb(var(--v-theme-secondary)); */
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import { geocodeQuery } from '@/services/apiService';
|
||||
import { useDisplay, useTheme } from 'vuetify';
|
||||
import { useGlobalStore } from '@/stores/global';
|
||||
import { useTilesStore } from '@/stores/tiles';
|
||||
import { useVendorStore } from '@/stores/vendorStore';
|
||||
import L from 'leaflet';
|
||||
globalThis.L = L;
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
@@ -222,6 +223,10 @@ onMounted(() => {
|
||||
zoom.value = 5;
|
||||
center.value = { lat: 39.8283, lng: -98.5795 };
|
||||
}
|
||||
|
||||
// Cache vendors for displaying images on the map
|
||||
const vendorStore = useVendorStore();
|
||||
vendorStore.loadAllVendors();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -136,11 +136,16 @@ import Hero from '@/components/layout/Hero.vue';
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import OSMTagSelector from '@/components/OSMTagSelector.vue';
|
||||
import { VStepperVerticalItem, VStepperVertical } from 'vuetify/labs/components';
|
||||
import { useVendorStore } from '@/stores/vendorStore';
|
||||
|
||||
const step = ref(parseInt(localStorage.getItem('currentStep') || '1'));
|
||||
|
||||
onMounted(() => {
|
||||
step.value = parseInt(localStorage.getItem('currentStep') || '1');
|
||||
|
||||
// Cache vendors for tag selector component
|
||||
const vendorStore = useVendorStore();
|
||||
vendorStore.loadAllVendors();
|
||||
});
|
||||
|
||||
watch(step, (newStep) => {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<template #header>
|
||||
<Hero
|
||||
title="Get Involved"
|
||||
description="Steps you can take to make a difference"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-container>
|
||||
<h3 class="text-center">For Your Community</h3>
|
||||
<v-divider class="mb-4" />
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" v-for="{ title, description, icon, to, href } in localActions" :key="title">
|
||||
<ActionCard :title :description :icon :to :href />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<h3 class="text-center mb-5 mt-10">For the DeFlock Project</h3>
|
||||
<v-divider class="mb-4" />
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" v-for="{ title, description, icon, to, href } in deflockActions" :key="title">
|
||||
<ActionCard :title :description :icon :to :href />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
</DefaultLayout>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue';
|
||||
import Hero from '@/components/layout/Hero.vue';
|
||||
import ActionCard from '@/components/get-involved/ActionCard.vue';
|
||||
|
||||
interface Action {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
to?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const localActions: Action[] = [
|
||||
{
|
||||
title: 'Join a Local Group',
|
||||
description: 'Connect with local advocacy groups working to regulate LPR use in your community.',
|
||||
icon: 'mdi-account-group',
|
||||
to: '/groups'
|
||||
},
|
||||
{
|
||||
title: 'Contact Your Representatives',
|
||||
description: 'Reaching out to your local council members is surprisingly effective in influencing policy decisions.',
|
||||
icon: 'mdi-phone',
|
||||
to: '/council'
|
||||
},
|
||||
{
|
||||
title: 'Submit Cameras',
|
||||
description: 'Help us build a comprehensive map of LPR deployments by reporting cameras in your area.',
|
||||
icon: 'mdi-map-marker-plus',
|
||||
to: '/report'
|
||||
},
|
||||
{
|
||||
title: 'Join our Discord',
|
||||
description: 'Connect with other volunteers, share ideas, and stay updated on DeFlock\'s progress.',
|
||||
icon: 'mdi-chat',
|
||||
href: 'https://discord.gg/aV7v4R3sKT'
|
||||
},
|
||||
{
|
||||
title: 'Hang Signs',
|
||||
description: 'Use our printable signs to inform your community about LPR surveillance and your rights.',
|
||||
icon: 'mdi-sign-text',
|
||||
to: '/store'
|
||||
},
|
||||
{
|
||||
title: 'Request Public Records',
|
||||
description: 'File public records requests to obtain information about LPR deployments in your area.',
|
||||
icon: 'mdi-file-document',
|
||||
to: '/foia'
|
||||
},
|
||||
]
|
||||
|
||||
const deflockActions: Action[] = [
|
||||
{
|
||||
title: 'Become a GitHub Contributor',
|
||||
description: 'Contribute to our open-source projects by reporting bugs, fixing issues, and adding new features.',
|
||||
icon: 'mdi-github',
|
||||
href: 'https://github.com/foggedlens/deflock'
|
||||
},
|
||||
{
|
||||
title: 'Donate to DeFlock',
|
||||
description: `Don't have the time to volunteer? You can still support DeFlock's mission with a financial contribution.`,
|
||||
icon: 'mdi-cash-multiple',
|
||||
to: '/donate'
|
||||
},
|
||||
]
|
||||
</script>
|
||||
Reference in New Issue
Block a user