mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 20:19:34 +01:00
256 lines
8.8 KiB
Vue
256 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|
// @ts-expect-error - yaml import not typed
|
|
import page from '.blog.yml'
|
|
|
|
const { data: posts } = await useAsyncData('blogs', () =>
|
|
queryCollection('blog').order('date', 'DESC').all()
|
|
)
|
|
|
|
const title = page.seo?.title || page.title
|
|
const description = page.seo?.description || page.description
|
|
|
|
useSeoMeta({
|
|
title,
|
|
description,
|
|
ogTitle: title,
|
|
ogDescription: description
|
|
})
|
|
|
|
const selectedFilter = ref('all')
|
|
const searchQuery = ref('')
|
|
|
|
const availableFilters = computed(() => {
|
|
if (!posts.value?.length) return [{ key: 'all', label: 'ALL', count: 0 }]
|
|
|
|
const postsData = posts.value
|
|
const categories = new Set(postsData.map(post => post.category?.toLowerCase()).filter(Boolean))
|
|
|
|
const filters = [
|
|
{ key: 'all', label: 'ALL', count: postsData.length }
|
|
]
|
|
|
|
categories.forEach((category) => {
|
|
const count = postsData.filter(p => p.category?.toLowerCase() === category).length
|
|
const label = category.replace(/\b\w/g, l => l.toUpperCase()).replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
|
|
filters.push({
|
|
key: category,
|
|
label: label,
|
|
count
|
|
})
|
|
})
|
|
|
|
return filters.sort((a, b) => {
|
|
if (a.key === 'all') return -1
|
|
if (b.key === 'all') return 1
|
|
return b.count - a.count
|
|
})
|
|
})
|
|
|
|
const filteredPosts = computed(() => {
|
|
if (!posts.value) return []
|
|
|
|
let filtered = posts.value
|
|
|
|
if (selectedFilter.value !== 'all') {
|
|
filtered = filtered.filter(post => post.category?.toLowerCase() === selectedFilter.value)
|
|
}
|
|
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
filtered = filtered.filter(post =>
|
|
post.title?.toLowerCase().includes(query)
|
|
|| post.description?.toLowerCase().includes(query)
|
|
)
|
|
}
|
|
|
|
return filtered
|
|
})
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: '2-digit'
|
|
}).toUpperCase()
|
|
}
|
|
|
|
const getCategoryVariant = (category: string) => {
|
|
switch (category?.toLowerCase()) {
|
|
case 'release': return 'solid'
|
|
case 'tutorial': return 'soft'
|
|
case 'improvement': return 'soft'
|
|
default: return 'soft'
|
|
}
|
|
}
|
|
|
|
const getCategoryIcon = (category: string) => {
|
|
switch (category?.toLowerCase()) {
|
|
case 'release': return 'i-lucide-rocket'
|
|
case 'tutorial': return 'i-lucide-book-open'
|
|
case 'improvement': return 'i-lucide-trending-up'
|
|
default: return 'i-lucide-file-text'
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="page" class="relative grid grid-rows-[auto_auto_1fr] min-h-[calc(100vh-150px)]">
|
|
<UPageHero :links="page.links" :ui="{ container: 'relative py-10 sm:py-16 lg:py-24' }">
|
|
<LazyStarsBg />
|
|
|
|
<div aria-hidden="true" class="absolute z-[-1] border-x border-default inset-0 mx-4 sm:mx-6 lg:mx-8" />
|
|
|
|
<template #title>
|
|
<MDC :value="page.title" unwrap="p" cache-key="pro-templates-hero-title" />
|
|
</template>
|
|
|
|
<template #description>
|
|
<MDC :value="page.description" unwrap="p" cache-key="pro-templates-hero-description" />
|
|
</template>
|
|
</UPageHero>
|
|
|
|
<UPageBody class="!my-0 !py-0 border-y border-default">
|
|
<UContainer>
|
|
<div class="border-x border-default px-4 sm:px-6 py-6 sm:py-8">
|
|
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6 lg:gap-8 sm:mb-6">
|
|
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
|
<Motion
|
|
v-for="(filter, index) in availableFilters"
|
|
:key="filter.key"
|
|
:initial="{ opacity: 0, y: 10 }"
|
|
:animate="{ opacity: 1, y: 0 }"
|
|
:transition="{ delay: index * 0.1 }"
|
|
>
|
|
<UButton
|
|
:variant="selectedFilter === filter.key ? 'solid' : 'ghost'"
|
|
:color="selectedFilter === filter.key ? 'primary' : 'neutral'"
|
|
size="sm"
|
|
class="font-medium transition-all duration-200 hover:scale-105 focus:scale-100 rounded-none text-xs sm:text-sm"
|
|
:leading-icon="selectedFilter === filter.key ? 'i-lucide-check' : 'i-lucide-circle'"
|
|
:label="filter.label"
|
|
@click="selectedFilter = filter.key"
|
|
>
|
|
<template #trailing>
|
|
<UBadge
|
|
:variant="selectedFilter === filter.key ? 'solid' : 'soft'"
|
|
size="xs"
|
|
>
|
|
{{ filter.count }}
|
|
</UBadge>
|
|
</template>
|
|
</UButton>
|
|
</Motion>
|
|
</div>
|
|
|
|
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
|
|
<div class="relative">
|
|
<UInput
|
|
v-model="searchQuery"
|
|
placeholder="Search posts..."
|
|
icon="i-lucide-search"
|
|
class="w-full sm:w-64"
|
|
:ui="{
|
|
base: 'rounded-none'
|
|
}"
|
|
/>
|
|
</div>
|
|
|
|
<UButton
|
|
variant="ghost"
|
|
class="rounded-none whitespace-nowrap"
|
|
icon="i-lucide-external-link"
|
|
label="Follow @nuxt_js on X"
|
|
to="https://x.com/nuxt_js"
|
|
target="_blank"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-x border-t border-default !gap-0">
|
|
<Motion
|
|
v-for="(post, index) in filteredPosts"
|
|
:key="post.path"
|
|
:initial="{ opacity: 0, x: -20 }"
|
|
:animate="{ opacity: 1, x: 0 }"
|
|
:transition="{ delay: index * 0.05, type: 'spring', stiffness: 300, damping: 30 }"
|
|
class="group border-b border-default last:border-b-0"
|
|
>
|
|
<ULink
|
|
:to="post.path"
|
|
class="flex flex-col sm:flex-row sm:items-center justify-between p-4 sm:p-6 hover:bg-muted/30 transition-all duration-200 gap-4 sm:gap-6"
|
|
>
|
|
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6 flex-1 min-w-0">
|
|
<div class="text-xs text-muted font-mono shrink-0 sm:min-w-[60px]">
|
|
{{ formatDate(post.date) }}
|
|
</div>
|
|
|
|
<UBadge
|
|
:variant="getCategoryVariant(post.category)"
|
|
size="sm"
|
|
class="font-mono text-xs justify-center gap-2 shrink-0 self-start sm:self-center"
|
|
>
|
|
<UIcon :name="getCategoryIcon(post.category)" class="size-3" />
|
|
{{ post.category?.toUpperCase() || 'POST' }}
|
|
</UBadge>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-medium text-highlighted group-hover:text-primary transition-colors duration-200 truncate sm:text-base">
|
|
{{ post.title }}
|
|
</h3>
|
|
<p class="text-sm text-muted mt-1 line-clamp-2 sm:line-clamp-1">
|
|
{{ post.description }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between sm:justify-end gap-3 sm:gap-2 shrink-0">
|
|
<UAvatarGroup v-if="post.authors?.length" size="sm" class="sm:size-sm">
|
|
<UAvatar
|
|
v-for="author in post.authors.slice(0, 3)"
|
|
:key="author.name"
|
|
:src="author.avatar?.src"
|
|
:alt="author.name"
|
|
size="sm"
|
|
/>
|
|
</UAvatarGroup>
|
|
|
|
<UIcon
|
|
name="i-lucide-chevron-right"
|
|
class="size-4 text-muted group-hover:text-highlighted transition-colors duration-200 shrink-0"
|
|
/>
|
|
</div>
|
|
</ULink>
|
|
</Motion>
|
|
|
|
<Motion
|
|
v-if="filteredPosts.length === 0"
|
|
:initial="{ opacity: 0, y: 20 }"
|
|
:animate="{ opacity: 1, y: 0 }"
|
|
class="text-center py-12 sm:py-16 px-4 sm:px-6"
|
|
>
|
|
<UIcon name="i-lucide-search-x" class="size-10 sm:size-12 text-muted mx-auto mb-4" />
|
|
<h3 class="text-lg font-medium mb-2">
|
|
No posts found
|
|
</h3>
|
|
<p class="text-muted mb-4 text-sm sm:text-base">
|
|
{{ searchQuery ? `No posts match "${searchQuery}"` : 'No posts in this category yet' }}
|
|
</p>
|
|
<UButton
|
|
v-if="selectedFilter !== 'all' || searchQuery"
|
|
variant="outline"
|
|
label="Clear filters"
|
|
class="rounded-none"
|
|
@click="selectedFilter = 'all'; searchQuery = ''"
|
|
/>
|
|
</Motion>
|
|
</div>
|
|
</UContainer>
|
|
</UPageBody>
|
|
|
|
<UContainer class="relative min-h-24">
|
|
<div aria-hidden="true" class="absolute z-[-1] border-x border-default inset-0 mx-4 sm:mx-6 lg:mx-8" />
|
|
</UContainer>
|
|
</div>
|
|
</template>
|