Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions composables/useBlog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { BlogPost } from '~/utils/blog'

interface BlogState {
posts: BlogPost[]
loading: boolean
error: string | null
}

export const useBlog = () => {
const state = reactive<BlogState>({
posts: [],
loading: false,
error: null
})

const loadAllPosts = async () => {
state.loading = true
state.error = null

try {
const response = await $fetch<{ success: boolean, data: BlogPost[], total: number }>('/api/blog')
state.posts = response.data
} catch (error) {
state.error = error instanceof Error ? error.message : 'Failed to load blog posts'
console.error('Error loading blog posts:', error)
} finally {
state.loading = false
}
}

const getPostBySlug = async (slug: string): Promise<BlogPost | null> => {
try {
const response = await $fetch<{ success: boolean, data: BlogPost }>(`/api/blog/${slug}`)
return response.data
} catch (error) {
console.error(`Error loading blog post ${slug}:`, error)
return null
}
}

const getPostsByLanguage = (language: string): BlogPost[] => {
return state.posts.filter(post => post.language === language)
}

const getPostsByTag = (tag: string): BlogPost[] => {
return state.posts.filter(post => post.tags?.includes(tag))
}

const getAllTags = (): string[] => {
const tags = new Set<string>()
state.posts.forEach(post => {
post.tags?.forEach(tag => tags.add(tag))
})
return Array.from(tags).sort()
}

const getRelatedPosts = (currentPost: BlogPost, limit: number = 3): BlogPost[] => {
const relatedPosts = state.posts
.filter(post => post.slug !== currentPost.slug)
.filter(post => post.language === currentPost.language)
.filter(post => {
const commonTags = post.tags?.filter(tag => currentPost.tags?.includes(tag)) || []
return commonTags.length > 0
})
.slice(0, limit)

if (relatedPosts.length < limit) {
const additionalPosts = state.posts
.filter(post => post.slug !== currentPost.slug)
.filter(post => post.language === currentPost.language)
.filter(post => !relatedPosts.includes(post))
.slice(0, limit - relatedPosts.length)

relatedPosts.push(...additionalPosts)
}

return relatedPosts
}

return {
...toRefs(state),
loadAllPosts,
getPostBySlug,
getPostsByLanguage,
getPostsByTag,
getAllTags,
getRelatedPosts
}
}

export const useBlogSEO = (post: BlogPost) => {
const { $i18n } = useNuxtApp()
const route = useRoute()

const generateSEOMeta = () => {
const title = `${post.title} | ChatOllama Blog`
const description = post.description || post.excerpt || `Read ${post.title} on ChatOllama Blog`
const url = `${useRuntimeConfig().public.baseUrl || 'https://chatollama.com'}${route.path}`
const imageUrl = `${useRuntimeConfig().public.baseUrl || 'https://chatollama.com'}/og-blog.png`

return {
title,
meta: [
{ name: 'description', content: description },
{ name: 'keywords', content: post.tags?.join(', ') || '' },
{ name: 'author', content: post.author || 'ChatOllama Team' },
{ name: 'robots', content: 'index, follow' },

// Open Graph
{ property: 'og:type', content: 'article' },
{ property: 'og:title', content: post.title },
{ property: 'og:description', content: description },
{ property: 'og:url', content: url },
{ property: 'og:image', content: imageUrl },
{ property: 'og:site_name', content: 'ChatOllama Blog' },
{ property: 'article:published_time', content: new Date(post.date).toISOString() },
{ property: 'article:author', content: post.author || 'ChatOllama Team' },
{ property: 'article:tag', content: post.tags?.join(', ') || '' },

// Twitter Card
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: post.title },
{ name: 'twitter:description', content: description },
{ name: 'twitter:image', content: imageUrl },

// Language
{ 'http-equiv': 'content-language', content: post.language }
],
link: [
{ rel: 'canonical', href: url },
{ rel: 'alternate', hreflang: 'en', href: url.replace('/zh/', '/') },
{ rel: 'alternate', hreflang: 'zh', href: url.includes('/zh/') ? url : url.replace('/blog/', '/zh/blog/') }
]
}
}

const generateStructuredData = () => {
const baseUrl = useRuntimeConfig().public.baseUrl || 'https://chatollama.com'

return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description || post.excerpt,
image: `${baseUrl}/og-blog.png`,
author: {
'@type': 'Organization',
name: post.author || 'ChatOllama Team',
url: baseUrl
},
publisher: {
'@type': 'Organization',
name: 'ChatOllama',
url: baseUrl,
logo: {
'@type': 'ImageObject',
url: `${baseUrl}/logo.png`
}
},
datePublished: new Date(post.date).toISOString(),
dateModified: new Date(post.date).toISOString(),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${baseUrl}${route.path}`
},
articleSection: 'Technology',
keywords: post.tags?.join(', ') || '',
wordCount: post.content.split(/\s+/).length,
timeRequired: `PT${post.readingTime || 1}M`,
inLanguage: post.language === 'zh' ? 'zh-CN' : 'en-US'
}
}

return {
generateSEOMeta,
generateStructuredData
}
}
1 change: 1 addition & 0 deletions composables/useMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function useMenus() {
menus.push({ label: t('menu.realtime'), icon: 'i-iconoir-microphone', to: '/realtime' })
}

menus.push({ label: t('menu.blog'), icon: 'i-heroicons-document-text', to: '/blog' })
menus.push({ label: t('menu.settings'), icon: 'i-heroicons-cog-6-tooth', to: '/settings' })
menus.push({ label: 'GitHub', icon: 'i-mdi-github', to: 'https://github.com/sugarforever/chat-ollama', external: true })

Expand Down
116 changes: 116 additions & 0 deletions layouts/blog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Header -->
<header class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo and Brand -->
<div class="flex items-center">
<NuxtLink to="/" class="flex items-center space-x-3 hover:opacity-80 transition-opacity">
<TheLogo class="w-8 h-8" />
<span class="text-xl font-bold text-gray-900 dark:text-white">
{{ $config.public.appName }}
</span>
</NuxtLink>
</div>

<!-- Navigation -->
<div class="flex-1 flex justify-center">
<nav class="hidden md:flex items-center space-x-8">
<NuxtLink
to="/blog"
class="text-primary-600 dark:text-primary-400 font-medium"
active-class="!text-primary-600 dark:!text-primary-400">
{{ $t('menu.blog', 'Blog') }}
</NuxtLink>
<NuxtLink
to="/chat"
class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
{{ $t('menu.chat', 'Chat') }}
</NuxtLink>
</nav>
</div>

<!-- Mobile menu button -->
<div class="md:hidden">
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
<Icon :name="mobileMenuOpen ? 'heroicons:x-mark' : 'heroicons:bars-3'" class="w-6 h-6" />
</button>
</div>

<!-- Theme toggle -->
<div class="hidden md:flex items-center">
<ColorMode />
</div>
</div>

<!-- Mobile menu -->
<div v-if="mobileMenuOpen" class="md:hidden border-t border-gray-200 dark:border-gray-700 py-4">
<div class="flex flex-col space-y-3">
<NuxtLink
to="/"
@click="mobileMenuOpen = false"
class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
{{ $t('menu.home', 'Home') }}
</NuxtLink>
<NuxtLink
to="/blog"
@click="mobileMenuOpen = false"
class="text-primary-600 dark:text-primary-400 font-medium">
{{ $t('menu.blog', 'Blog') }}
</NuxtLink>
<NuxtLink
to="/chat"
@click="mobileMenuOpen = false"
class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
{{ $t('menu.chat', 'Chat') }}
</NuxtLink>
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
<ColorMode />
</div>
</div>
</div>
</div>
</header>

<!-- Main content -->
<main class="flex-1">
<slot />
</main>

<!-- Footer -->
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-col md:flex-row items-center justify-between">
<div class="flex items-center space-x-3 mb-4 md:mb-0">
<TheLogo class="w-6 h-6" />
<span class="text-gray-600 dark:text-gray-400">
© {{ new Date().getFullYear() }} {{ $config.public.appName }}. All rights reserved.
</span>
</div>

<div class="flex items-center space-x-6">
<NuxtLink
to="https://github.com/sugarforever/chat-ollama"
target="_blank"
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<Icon name="mdi:github" class="w-5 h-5" />
</NuxtLink>
</div>
</div>
</div>
</footer>
</div>
</template>

<script setup lang="ts">
const mobileMenuOpen = ref(false)

// Close mobile menu when route changes
const route = useRoute()
watch(() => route.path, () => {
mobileMenuOpen.value = false
})
</script>
22 changes: 22 additions & 0 deletions locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"chat": "Chat",
"realtime": "Realtime Chat",
"playground": "Playground",
"blog": "Blog",
"settings": "Settings"
},
"colorMode": {
Expand Down Expand Up @@ -272,5 +273,26 @@
"confirmTitle": "Confirm action",
"add": "Add",
"invalidUrl": "Invalid URL"
},
"blog": {
"title": "ChatOllama Blog",
"description": "Insights, tutorials, and updates about AI, LLMs, and ChatOllama development",
"loading": "Loading blog posts...",
"noPosts": "No blog posts found",
"postNotFound": "Blog post not found",
"backToBlog": "Back to Blog",
"allPosts": "All Posts",
"readMore": "Read more",
"readingTime": "{time} min read",
"relatedPosts": "Related Posts",
"by": "By",
"share": "Share",
"filter": {
"tags": "Filter by tags:"
}
},
"common": {
"previous": "Previous",
"next": "Next"
}
}
24 changes: 23 additions & 1 deletion locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"chat": "对话",
"settings": "设置",
"realtime": "实时语音",
"playground": "实验场"
"playground": "实验场",
"blog": "博客"
},
"colorMode": {
"system": "自动",
Expand Down Expand Up @@ -271,5 +272,26 @@
"selectModel": "选择模型",
"selectModels": "选择模型",
"invalidUrl": "无效的URL"
},
"blog": {
"title": "ChatOllama 博客",
"description": "关于AI、大语言模型和ChatOllama开发的见解、教程和更新",
"loading": "正在加载博客文章...",
"noPosts": "未找到博客文章",
"postNotFound": "博客文章未找到",
"backToBlog": "返回博客",
"allPosts": "所有文章",
"readMore": "阅读更多",
"readingTime": "{time} 分钟阅读",
"relatedPosts": "相关文章",
"by": "作者",
"share": "分享",
"filter": {
"tags": "按标签筛选:"
}
},
"common": {
"previous": "上一页",
"next": "下一页"
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"dexie": "^4.0.7",
"eventemitter3": "^5.0.1",
"googleapis": "^155.0.1",
"gray-matter": "^4.0.3",
"highlight.js": "^11.9.0",
"html-to-text": "^9.0.5",
"http-proxy-agent": "^7.0.2",
Expand All @@ -83,6 +84,7 @@
"markdown-it-anchor": "^8.6.7",
"markdown-it-diagram": "^1.0.3",
"markdown-it-footnote": "^4.0.0",
"markdown-it-highlightjs": "^4.2.0",
"markdown-it-katex": "^2.0.3",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
Expand Down
Loading