mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-29 19:30:37 +01:00
up
This commit is contained in:
@@ -11,6 +11,7 @@ const [{ data: page }, { data: surround }] = await Promise.all([
|
|||||||
}).order('date', 'DESC')
|
}).order('date', 'DESC')
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!page.value) {
|
if (!page.value) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||||
}
|
}
|
||||||
@@ -40,59 +41,143 @@ const formatDate = (dateString: string) => {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
})
|
}).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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UMain class="mt-20 px-2">
|
<div v-if="page" class="min-h-screen">
|
||||||
<UContainer class="relative min-h-screen">
|
<div class="border-b border-default">
|
||||||
<ULink
|
<UContainer class="py-4">
|
||||||
to="/blog"
|
<ULink to="/blog" class="flex items-center gap-2 text-sm">
|
||||||
class="text-sm flex items-center gap-1 w-fit"
|
<UIcon name="i-lucide-chevron-left" class="size-4" />
|
||||||
>
|
Back to Blog
|
||||||
<UIcon name="lucide:chevron-left" />
|
</ULink>
|
||||||
Blog
|
</UContainer>
|
||||||
</ULink>
|
</div>
|
||||||
<UPage v-if="page">
|
|
||||||
<div class="flex flex-col gap-3 mt-8">
|
<div class="py-16 sm:pt-20 pb-10">
|
||||||
<div class="flex text-xs text-muted items-center justify-center gap-2">
|
<UContainer class="max-w-4xl">
|
||||||
<span v-if="page.date">
|
<div class="text-center space-y-6">
|
||||||
|
<div class="flex items-center justify-center gap-4 text-sm">
|
||||||
|
<UBadge
|
||||||
|
v-if="page.category"
|
||||||
|
:variant="getCategoryVariant(page.category)"
|
||||||
|
size="sm"
|
||||||
|
class="font-mono text-xs gap-2"
|
||||||
|
>
|
||||||
|
<UIcon :name="getCategoryIcon(page.category)" class="size-3" />
|
||||||
|
{{ page.category?.toUpperCase() }}
|
||||||
|
</UBadge>
|
||||||
|
|
||||||
|
<span class="text-muted font-mono text-xs">
|
||||||
{{ formatDate(page.date) }}
|
{{ formatDate(page.date) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="page.date && page.minRead">
|
|
||||||
-
|
<span v-if="page.minRead" class="text-muted font-mono text-xs">
|
||||||
</span>
|
{{ page.minRead }} MIN READ
|
||||||
<span v-if="page.minRead">
|
|
||||||
{{ page.minRead }} min read
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Motion
|
||||||
|
:initial="{ opacity: 0, y: 20 }"
|
||||||
|
:animate="{ opacity: 1, y: 0 }"
|
||||||
|
:transition="{ duration: 0.6 }"
|
||||||
|
>
|
||||||
|
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-highlighted leading-tight">
|
||||||
|
{{ page.title }}
|
||||||
|
</h1>
|
||||||
|
</Motion>
|
||||||
|
|
||||||
|
<Motion
|
||||||
|
:initial="{ opacity: 0, y: 20 }"
|
||||||
|
:animate="{ opacity: 1, y: 0 }"
|
||||||
|
:transition="{ delay: 0.1, duration: 0.6 }"
|
||||||
|
>
|
||||||
|
<p class="text-lg text-muted max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ page.description }}
|
||||||
|
</p>
|
||||||
|
</Motion>
|
||||||
|
|
||||||
|
<Motion
|
||||||
|
v-if="page.authors?.length"
|
||||||
|
:initial="{ opacity: 0, y: 20 }"
|
||||||
|
:animate="{ opacity: 1, y: 0 }"
|
||||||
|
:transition="{ delay: 0.2, duration: 0.6 }"
|
||||||
|
class="flex justify-center"
|
||||||
|
>
|
||||||
|
<UAvatarGroup>
|
||||||
|
<ULink
|
||||||
|
v-for="(author, index) in page.authors"
|
||||||
|
:key="index"
|
||||||
|
:to="author.to"
|
||||||
|
raw
|
||||||
|
>
|
||||||
|
<UAvatar v-bind="author.avatar" />
|
||||||
|
</ULink>
|
||||||
|
</UAvatarGroup>
|
||||||
|
</Motion>
|
||||||
|
</div>
|
||||||
|
</UContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="page.image" class="py-4">
|
||||||
|
<UContainer class="max-w-6xl">
|
||||||
|
<Motion
|
||||||
|
:initial="{ opacity: 0, y: 30 }"
|
||||||
|
:animate="{ opacity: 1, y: 0 }"
|
||||||
|
:transition="{ delay: 0.3, duration: 0.8 }"
|
||||||
|
>
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
:src="page.image"
|
:src="page.image"
|
||||||
:alt="page.title"
|
:alt="page.title"
|
||||||
class="rounded-lg w-full h-[400px] object-cover object-center max-w-5xl mx-auto"
|
class="w-full max-h-[400px] object-cover object-center max-w-5xl mx-auto"
|
||||||
/>
|
/>
|
||||||
<h1 class="text-4xl text-center font-medium max-w-3xl mx-auto mt-4">
|
</Motion>
|
||||||
{{ page.title }}
|
</UContainer>
|
||||||
</h1>
|
</div>
|
||||||
<p class="text-muted text-center max-w-2xl mx-auto">
|
|
||||||
{{ page.description }}
|
<div class="py-12 sm:py-16">
|
||||||
</p>
|
<UContainer class="max-w-3xl">
|
||||||
<div class="mt-4 flex justify-center flex-wrap items-center gap-6">
|
<Motion
|
||||||
<UUser v-for="(author, index) in page.authors" :key="index" v-bind="author" :description="author.to ? `@${author.to.split('/').pop()}` : undefined" />
|
:initial="{ opacity: 0, y: 20 }"
|
||||||
</div>
|
:animate="{ opacity: 1, y: 0 }"
|
||||||
</div>
|
:transition="{ delay: 0.4, duration: 0.6 }"
|
||||||
<UPageBody class="max-w-3xl mx-auto">
|
>
|
||||||
<ContentRenderer
|
<ContentRenderer
|
||||||
v-if="page.body"
|
v-if="page.body"
|
||||||
:value="page"
|
:value="page"
|
||||||
/>
|
/>
|
||||||
|
</Motion>
|
||||||
|
|
||||||
<USeparator v-if="surround?.length" />
|
<div v-if="surround?.length" class="mt-16 pt-8 border-t border-default">
|
||||||
|
<Motion
|
||||||
<UContentSurround :surround="surround" />
|
:initial="{ opacity: 0, y: 20 }"
|
||||||
</UPageBody>
|
:animate="{ opacity: 1, y: 0 }"
|
||||||
</UPage>
|
:transition="{ delay: 0.6, duration: 0.6 }"
|
||||||
</UContainer>
|
>
|
||||||
</UMain>
|
<UContentSurround :surround="surround" />
|
||||||
|
</Motion>
|
||||||
|
</div>
|
||||||
|
</UContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -20,12 +20,31 @@ const selectedFilter = ref('all')
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
const availableFilters = computed(() => {
|
const availableFilters = computed(() => {
|
||||||
return [
|
if (!posts.value?.length) return [{ key: 'all', label: 'ALL', count: 0 }]
|
||||||
{ key: 'all', label: 'ALL', count: posts.value?.length || 0 },
|
|
||||||
{ key: 'release', label: 'NEW RELEASES', count: posts.value?.filter(p => p.category?.toLowerCase() === 'release').length || 0 },
|
const postsData = posts.value
|
||||||
{ key: 'tutorial', label: 'TUTORIALS', count: posts.value?.filter(p => p.category?.toLowerCase() === 'tutorial').length || 0 },
|
const categories = new Set(postsData.map(post => post.category?.toLowerCase()).filter(Boolean))
|
||||||
{ key: 'improvement', label: 'IMPROVEMENTS', count: posts.value?.filter(p => p.category?.toLowerCase() === 'improvement').length || 0 }
|
|
||||||
|
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(() => {
|
const filteredPosts = computed(() => {
|
||||||
@@ -128,22 +147,17 @@ const getCategoryIcon = (category: string) => {
|
|||||||
<UInput
|
<UInput
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="Search posts..."
|
placeholder="Search posts..."
|
||||||
|
icon="i-lucide-search"
|
||||||
class="w-full sm:w-64"
|
class="w-full sm:w-64"
|
||||||
size="sm"
|
|
||||||
:ui="{
|
:ui="{
|
||||||
base: 'rounded-none'
|
base: 'rounded-none'
|
||||||
}"
|
}"
|
||||||
>
|
/>
|
||||||
<template #leading>
|
|
||||||
<UIcon name="i-lucide-search" class="w-4 h-4 text-muted" />
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
class="rounded-none whitespace-nowrap"
|
||||||
class="rounded-none text-xs sm:text-sm whitespace-nowrap"
|
|
||||||
icon="i-lucide-external-link"
|
icon="i-lucide-external-link"
|
||||||
label="Follow @nuxt_js on X"
|
label="Follow @nuxt_js on X"
|
||||||
to="https://x.com/nuxt_js"
|
to="https://x.com/nuxt_js"
|
||||||
|
|||||||
Reference in New Issue
Block a user