Add Blog (#83)

* add rss ingestor

* it works

* blog working

* fix the theme switcher

* finalize blog, re-add store

* fix terraform for blog_scraper

* update sitemap

* update readme
This commit is contained in:
Will Freeman
2025-12-14 18:52:53 -07:00
committed by GitHub
parent 3fdc5142de
commit 1c241d17c9
19 changed files with 1339 additions and 5 deletions

View File

@@ -72,5 +72,9 @@
<loc>https://deflock.me/store</loc>
<changefreq>daily</changefreq>
</url>
<url>
<loc>https://deflock.me/blog</loc>
<changefreq>daily</changefreq>
</url>
</urlset>

View File

@@ -37,11 +37,12 @@ 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: 'Store', icon: 'mdi-shopping', to: '/store' },
{ 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' },
]
@@ -176,8 +177,8 @@ watch(() => theme.global.name.value, (newTheme) => {
<v-spacer class="d-md-none" />
<v-btn icon>
<v-icon @click="toggleTheme" aria-label="Toggle Theme">mdi-theme-light-dark</v-icon>
<v-btn icon @click="toggleTheme" aria-label="Toggle Theme">
<v-icon>mdi-theme-light-dark</v-icon>
</v-btn>
</v-app-bar>

View File

@@ -8,7 +8,7 @@
>
<v-col cols="12" md="8">
<h1 class="mb-4">{{ title }}</h1>
<p class="mb-4">
<p class="mb-4 px-8">
{{ description }}
</p>
<v-btn

View File

@@ -159,6 +159,22 @@ const router = createRouter({
title: 'Store | DeFlock'
}
},
{
path: '/blog',
name: 'blog',
component: () => import('../views/Blog.vue'),
meta: {
title: 'News | DeFlock'
}
},
{
path: '/blog/:id',
name: 'blog-post',
component: () => import('../views/BlogPost.vue'),
meta: {
title: 'Blog Post | DeFlock'
}
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',

View File

@@ -0,0 +1,80 @@
import axios from "axios";
export interface BlogPost {
id: number;
published: string;
description: string;
content: string | null;
title: string;
externalUrl?: string;
}
export interface BlogResponse {
data: BlogPost[];
meta?: {
total_count: number;
filter_count: number;
};
}
export interface BlogQueryParams {
limit?: number;
offset?: number;
page?: number;
sort?: string;
fields?: string[];
}
const CMS_BASE_URL = "https://cms.deflock.me";
const blogApiService = axios.create({
baseURL: CMS_BASE_URL,
timeout: 10000,
});
export const blogService = {
async getBlogPosts(params: BlogQueryParams = {}): Promise<BlogResponse> {
const queryParams = new URLSearchParams();
// Set default sorting by newest first
const sort = params.sort || "-date_created";
queryParams.append("sort", sort);
// Set pagination parameters
if (params.limit) {
queryParams.append("limit", params.limit.toString());
}
if (params.offset) {
queryParams.append("offset", params.offset.toString());
}
if (params.page) {
queryParams.append("page", params.page.toString());
}
// Set fields if specified
if (params.fields && params.fields.length > 0) {
queryParams.append("fields", params.fields.join(","));
}
// Request metadata for pagination
queryParams.append("meta", "total_count,filter_count");
try {
const response = await blogApiService.get(`/items/blog?${queryParams.toString()}`);
return response.data;
} catch (error) {
console.error("Error fetching blog posts:", error);
throw new Error("Failed to fetch blog posts");
}
},
async getBlogPost(id: number): Promise<BlogPost> {
try {
const response = await blogApiService.get(`/items/blog/${id}?t=${Date.now()}`);
return response.data.data;
} catch (error) {
console.error(`Error fetching blog post ${id}:`, error);
throw new Error(`Failed to fetch blog post ${id}`);
}
}
};

192
webapp/src/views/Blog.vue Normal file
View File

@@ -0,0 +1,192 @@
<template>
<DefaultLayout>
<template #header>
<Hero
title="DeFlock News"
description="The latest news on LPRs and surveillance from us and our partners."
gradient="linear-gradient(135deg, rgb(var(--v-theme-primary)) 0%, rgb(var(--v-theme-secondary)) 100%)"
/>
</template>
<v-container class="py-8">
<!-- Loading State -->
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 200px;">
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
</div>
<!-- Error State -->
<v-alert v-else-if="error" type="error" class="mb-6">
{{ error }}
<template #append>
<v-btn variant="outlined" size="small" @click="fetchBlogPosts">
Retry
</v-btn>
</template>
</v-alert>
<!-- Blog Posts List -->
<div v-else>
<div v-if="blogPosts.length > 0" class="mx-auto" style="max-width: 900px;">
<article
v-for="post in blogPosts"
:key="post.id"
class="mb-8"
>
<v-card
class="rounded-xl transition-all cursor-pointer mx-4 mx-sm-0"
:href="post.externalUrl || `/blog/${post.id}`"
:target="post.externalUrl ? '_blank' : undefined"
:to="post.externalUrl ? undefined : `/blog/${post.id}`"
flat
>
<v-card-text class="pa-8">
<div class="mb-3">
<h2 class="font-weight-medium mb-0">{{ post.title }}</h2>
</div>
<p class="text-caption text-uppercase font-weight-medium text-medium-emphasis mb-4">
{{ formatDate(post.published) }}
</p>
<p class="text-body-1 mb-6" style="line-height: 1.6;">
{{ post.description }}
</p>
<div class="d-flex align-center justify-space-between">
<span class="text-body-2 font-weight-medium text-primary">
{{ post.externalUrl ? `Read on ${getExternalOrigin(post.externalUrl)}` : 'Read full article' }}
</span>
<v-icon
size="20"
color="primary"
:icon="post.externalUrl ? 'mdi-open-in-new' : 'mdi-arrow-right'"
></v-icon>
</div>
</v-card-text>
</v-card>
</article>
</div>
<!-- Empty State -->
<div v-else class="text-center py-12">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-post-outline</v-icon>
<h3 class="text-h5 text-grey-darken-1 mb-2">No blog posts yet</h3>
<p class="text-body-1 text-grey-darken-2">Check back later for updates!</p>
</div>
<!-- Pagination -->
<div v-if="blogPosts.length > 0" class="d-flex justify-center mt-8">
<v-pagination
class="pl-0"
v-model="currentPage"
:length="totalPages > 0 ? totalPages : 1"
:total-visible="3"
:disabled="totalPages <= 1"
@update:model-value="onPageChange"
></v-pagination>
</div>
</div>
</v-container>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Hero from '@/components/layout/Hero.vue';
import DefaultLayout from '@/layouts/DefaultLayout.vue';
import { blogService, type BlogPost } from '@/services/blogService';
// Router
const route = useRoute();
const router = useRouter();
// Reactive state
const blogPosts = ref<BlogPost[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const totalCount = ref(0);
const postsPerPage = 5; // Fewer posts per page for larger cards
// Current page from route query parameter
const currentPage = computed({
get: () => {
const page = parseInt(route.query.page as string) || 1;
return page > 0 ? page : 1;
},
set: (page: number) => {
router.push({
path: route.path,
query: { ...route.query, page: page > 1 ? page.toString() : undefined }
});
}
});
// Computed properties
const totalPages = computed(() => Math.ceil(totalCount.value / postsPerPage));
// Methods
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const getExternalOrigin = (url: string) => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return 'external site';
}
};
const fetchBlogPosts = async (page = 1) => {
loading.value = true;
error.value = null;
try {
const response = await blogService.getBlogPosts({
limit: postsPerPage,
page: page,
sort: '-published',
fields: ['id', 'title', 'description', 'published', 'externalUrl']
});
blogPosts.value = response.data;
totalCount.value = response.meta?.total_count || response.data.length;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load blog posts';
console.error('Error fetching blog posts:', err);
} finally {
loading.value = false;
}
};
const onPageChange = (page: number) => {
currentPage.value = page;
// Scroll to top of the page
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Watch route query changes to fetch posts when page parameter changes
watch(() => route.query.page, (newPage) => {
const page = parseInt(newPage as string) || 1;
fetchBlogPosts(page);
}, { immediate: false });
// Lifecycle
onMounted(() => {
fetchBlogPosts(currentPage.value);
});
</script>
<style scoped>
/* Fix for pagination padding issue */
:deep(.v-pagination__list) {
padding-left: 0 !important;
}
</style>

View File

@@ -0,0 +1,232 @@
<template>
<DefaultLayout>
<!-- Loading State -->
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 50vh;">
<v-progress-circular indeterminate size="64" color="primary"></v-progress-circular>
</div>
<!-- Error State -->
<v-container v-else-if="error" class="py-8">
<v-btn
:to="{ name: 'blog' }"
variant="text"
prepend-icon="mdi-arrow-left"
>
Back to News
</v-btn>
<v-alert type="error" class="mt-6" variant="tonal">
{{ error }}
</v-alert>
</v-container>
<!-- Blog Post Content -->
<div v-else-if="blogPost">
<!-- Header -->
<v-container class="py-8">
<div class="mb-6">
<v-btn
:to="{ name: 'blog' }"
variant="text"
size="small"
prepend-icon="mdi-arrow-left"
class="mb-4"
>
Back to News
</v-btn>
<h1 class="text-h3 text-md-h2 font-weight-bold mb-4 mt-0">
{{ blogPost.title }}
</h1>
<v-card flat class="mb-6" color="transparent">
<div class="d-flex flex-column flex-sm-row">
<v-chip
prepend-icon="mdi-account"
color="grey-darken-1"
variant="text"
size="default"
>
by Will Freeman
</v-chip>
<v-chip
prepend-icon="mdi-calendar"
color="grey-darken-1"
variant="text"
size="default"
>
{{ formatDate(blogPost.published) }}
</v-chip>
</div>
</v-card>
</div>
<!-- Blog Content -->
<v-card v-if="blogPost.content"elevation="0" class="bg-transparent">
<v-card-text class="pa-0">
<div
class="blog-content"
v-html="blogPost.content"
></div>
</v-card-text>
</v-card>
</v-container>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import DefaultLayout from '@/layouts/DefaultLayout.vue';
import { blogService, type BlogPost } from '@/services/blogService';
const route = useRoute();
// Reactive state
const blogPost = ref<BlogPost | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Methods
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const fetchBlogPost = async () => {
const postId = route.params.id;
if (!postId || Array.isArray(postId)) {
error.value = 'Invalid blog post ID';
return;
}
const id = parseInt(postId, 10);
if (isNaN(id)) {
error.value = 'Invalid blog post ID';
return;
}
loading.value = true;
error.value = null;
try {
blogPost.value = await blogService.getBlogPost(id);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load blog post';
console.error('Error fetching blog post:', err);
} finally {
loading.value = false;
}
};
// Lifecycle
onMounted(() => {
fetchBlogPost();
});
</script>
<style scoped>
.blog-content {
font-size: 1.1rem;
line-height: 1.7;
}
.blog-content :deep(h1),
.blog-content :deep(h2),
.blog-content :deep(h3),
.blog-content :deep(h4),
.blog-content :deep(h5),
.blog-content :deep(h6) {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.blog-content :deep(h1) { font-size: 2.5rem; }
.blog-content :deep(h2) { font-size: 2rem; }
.blog-content :deep(h3) { font-size: 1.75rem; }
.blog-content :deep(h4) { font-size: 1.5rem; }
.blog-content :deep(h5) { font-size: 1.25rem; }
.blog-content :deep(h6) { font-size: 1.1rem; }
.blog-content :deep(p) {
margin-bottom: 1.5rem;
}
.blog-content :deep(ul),
.blog-content :deep(ol) {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.blog-content :deep(li) {
margin-bottom: 0.5rem;
}
.blog-content :deep(blockquote) {
margin: 2rem 0;
padding: 1rem 1.5rem;
border-left: 4px solid rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-surface-variant), 0.1);
font-style: italic;
}
.blog-content :deep(code) {
background-color: rgba(var(--v-theme-surface-variant), 0.2);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.blog-content :deep(pre) {
background-color: rgba(var(--v-theme-surface-variant), 0.1);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
}
.blog-content :deep(pre code) {
background-color: transparent;
padding: 0;
}
.blog-content :deep(a) {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
.blog-content :deep(a:hover) {
text-decoration: underline;
}
.blog-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1.5rem 0;
}
.blog-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
.blog-content :deep(th),
.blog-content :deep(td) {
border: 1px solid rgba(var(--v-theme-outline), 0.2);
padding: 0.75rem;
text-align: left;
}
.blog-content :deep(th) {
background-color: rgba(var(--v-theme-surface-variant), 0.1);
font-weight: 600;
}
</style>

View File

@@ -2,7 +2,7 @@
<DefaultLayout>
<template #header>
<Hero
title="DeFlock Store"
title="DeFlock Store (Coming Soon)"
description="Full store coming soon! In the meantime, check out our free Downloads."
gradient="linear-gradient(135deg, rgb(var(--v-theme-primary)) 0%, rgb(var(--v-theme-secondary)) 100%)"
/>