Add image upload fields, media edit/delete, blog seeds
This commit is contained in:
+116
-38
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { parseBlogContent } from '~/utils/blogContent'
|
||||
import { resolveCmsUrl } from '~/utils/cms'
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const { getBlogPostBySlug } = useNoroData()
|
||||
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
const post = computed(() => getBlogPostBySlug(slug.value))
|
||||
const apiBase = computed(() => String(config.public.cmsApiUrl || ''))
|
||||
|
||||
watchEffect(() => {
|
||||
if (!post.value) {
|
||||
@@ -11,32 +16,75 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const contentParagraphs = computed(() =>
|
||||
post.value?.content?.split('\n\n').filter(Boolean) ?? []
|
||||
const contentBlocks = computed(() =>
|
||||
post.value?.content
|
||||
? parseBlogContent(post.value.content, apiBase.value)
|
||||
: []
|
||||
)
|
||||
|
||||
const coverUrl = computed(() =>
|
||||
post.value?.coverUrl ? resolveCmsUrl(post.value.coverUrl, apiBase.value) : ''
|
||||
)
|
||||
|
||||
const pageTitle = computed(
|
||||
() => post.value?.seoTitle || (post.value ? `${post.value.title} — استودیو نورو` : 'وبلاگ')
|
||||
)
|
||||
|
||||
const pageDescription = computed(
|
||||
() => post.value?.seoDescription || post.value?.excerpt || 'مقالات استودیو نورو'
|
||||
)
|
||||
|
||||
const canonicalUrl = computed(() => `https://studionoro.ir/blog/${slug.value}`)
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
post.value?.seoTitle || (post.value ? `${post.value.title} — استودیو نورو` : 'وبلاگ')
|
||||
),
|
||||
title: pageTitle,
|
||||
meta: computed(() => [
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
post.value?.seoDescription ||
|
||||
post.value?.excerpt ||
|
||||
'مقالات استودیو نورو'
|
||||
}
|
||||
])
|
||||
{ name: 'description', content: pageDescription.value },
|
||||
{ property: 'og:type', content: 'article' },
|
||||
{ property: 'og:title', content: pageTitle.value },
|
||||
{ property: 'og:description', content: pageDescription.value },
|
||||
{ property: 'og:url', content: canonicalUrl.value },
|
||||
...(coverUrl.value ? [{ property: 'og:image', content: coverUrl.value }] : []),
|
||||
{ name: 'twitter:card', content: coverUrl.value ? 'summary_large_image' : 'summary' },
|
||||
{ name: 'twitter:title', content: pageTitle.value },
|
||||
{ name: 'twitter:description', content: pageDescription.value },
|
||||
...(coverUrl.value ? [{ name: 'twitter:image', content: coverUrl.value }] : []),
|
||||
]),
|
||||
link: [{ rel: 'canonical', href: canonicalUrl.value }],
|
||||
script: computed(() => {
|
||||
if (!post.value) return []
|
||||
return [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: post.value.title,
|
||||
description: pageDescription.value,
|
||||
image: coverUrl.value ? [coverUrl.value] : undefined,
|
||||
datePublished: post.value.publishedAt,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'استودیو نورو',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'استودیو نورو',
|
||||
},
|
||||
mainEntityOfPage: canonicalUrl.value,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -53,19 +101,13 @@ useHead({
|
||||
بازگشت به وبلاگ
|
||||
</NuxtLink>
|
||||
|
||||
<p
|
||||
v-if="post.publishedAt"
|
||||
class="text-sm text-noro-muted"
|
||||
>
|
||||
<p v-if="post.publishedAt" class="text-sm text-noro-muted">
|
||||
{{ formatDate(post.publishedAt) }}
|
||||
</p>
|
||||
<h1 class="mt-3 max-w-4xl text-3xl font-bold text-noro-text sm:text-4xl lg:text-5xl">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="post.excerpt"
|
||||
class="mt-5 max-w-3xl text-lg leading-8 text-noro-muted"
|
||||
>
|
||||
<p v-if="post.excerpt" class="mt-5 max-w-3xl text-lg leading-8 text-noro-muted">
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -73,13 +115,10 @@ useHead({
|
||||
|
||||
<section class="noro-section">
|
||||
<div class="noro-container">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div
|
||||
v-if="post.coverUrl"
|
||||
class="mb-10 overflow-hidden rounded-2xl border border-noro-border"
|
||||
>
|
||||
<article class="mx-auto max-w-3xl">
|
||||
<div v-if="coverUrl" class="mb-10 overflow-hidden rounded-2xl border border-noro-border">
|
||||
<img
|
||||
:src="post.coverUrl"
|
||||
:src="coverUrl"
|
||||
:alt="post.title"
|
||||
class="aspect-[16/10] w-full object-cover"
|
||||
decoding="async"
|
||||
@@ -87,25 +126,64 @@ useHead({
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 text-base leading-8 text-noro-muted">
|
||||
<p
|
||||
v-for="(paragraph, index) in contentParagraphs"
|
||||
:key="index"
|
||||
class="whitespace-pre-line"
|
||||
>
|
||||
{{ paragraph }}
|
||||
</p>
|
||||
<template v-for="(block, index) in contentBlocks" :key="index">
|
||||
<h2
|
||||
v-if="block.type === 'h2'"
|
||||
class="pt-4 text-2xl font-bold text-noro-text"
|
||||
>
|
||||
{{ block.text }}
|
||||
</h2>
|
||||
<h3
|
||||
v-else-if="block.type === 'h3'"
|
||||
class="pt-2 text-xl font-bold text-noro-text"
|
||||
>
|
||||
{{ block.text }}
|
||||
</h3>
|
||||
<h4
|
||||
v-else-if="block.type === 'h4'"
|
||||
class="pt-1 text-lg font-semibold text-noro-text"
|
||||
>
|
||||
{{ block.text }}
|
||||
</h4>
|
||||
<p v-else-if="block.type === 'p'" class="whitespace-pre-line">
|
||||
{{ block.text }}
|
||||
</p>
|
||||
<ul
|
||||
v-else-if="block.type === 'ul'"
|
||||
class="list-disc space-y-2 pr-6 marker:text-noro-brand"
|
||||
>
|
||||
<li v-for="(item, itemIndex) in block.items" :key="itemIndex">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<figure
|
||||
v-else-if="block.type === 'img'"
|
||||
class="overflow-hidden rounded-2xl border border-noro-border bg-noro-surface"
|
||||
>
|
||||
<img
|
||||
:src="block.src"
|
||||
:alt="block.alt"
|
||||
class="w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<figcaption v-if="block.alt" class="px-4 py-3 text-center text-sm text-noro-muted">
|
||||
{{ block.alt }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-noro-border bg-noro-surface/50 py-16">
|
||||
<div class="noro-container text-center">
|
||||
<h2 class="text-2xl font-bold text-noro-text">
|
||||
برای پروژه بعدی آمادهاید؟
|
||||
برای طراحی لوگوی حرفهای آمادهاید؟
|
||||
</h2>
|
||||
<p class="mt-3 text-noro-muted">
|
||||
با ما در تماس باشید تا درباره نیازهای برند و محتوای شما صحبت کنیم.
|
||||
با استودیو نورو در تماس باشید تا لوگویی متناسب با هویت برند شما طراحی کنیم.
|
||||
</p>
|
||||
<UiButton to="/contact" class="mt-6">
|
||||
تماس با ما
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export type BlogContentBlock =
|
||||
| { type: 'h2'; text: string }
|
||||
| { type: 'h3'; text: string }
|
||||
| { type: 'h4'; text: string }
|
||||
| { type: 'p'; text: string }
|
||||
| { type: 'ul'; items: string[] }
|
||||
| { type: 'img'; src: string; alt: string }
|
||||
|
||||
const imagePattern = /^!\[(.*?)\]\((.*?)\)$/
|
||||
const headingPattern = /^(#{2,4})\s+(.*)$/
|
||||
|
||||
export function parseBlogContent(content: string, apiBase = ''): BlogContentBlock[] {
|
||||
const blocks: BlogContentBlock[] = []
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n')
|
||||
let paragraph: string[] = []
|
||||
let listItems: string[] = []
|
||||
|
||||
const resolve = (url: string) => {
|
||||
if (!url) return ''
|
||||
if (/^https?:\/\//.test(url) || url.startsWith('data:')) return url
|
||||
const base = apiBase.replace(/\/$/, '')
|
||||
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`
|
||||
}
|
||||
|
||||
const flushParagraph = () => {
|
||||
const text = paragraph.join('\n').trim()
|
||||
if (text) blocks.push({ type: 'p', text })
|
||||
paragraph = []
|
||||
}
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length) blocks.push({ type: 'ul', items: [...listItems] })
|
||||
listItems = []
|
||||
}
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd()
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
continue
|
||||
}
|
||||
|
||||
const imageMatch = trimmed.match(imagePattern)
|
||||
if (imageMatch) {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
blocks.push({
|
||||
type: 'img',
|
||||
alt: imageMatch[1] || '',
|
||||
src: resolve(imageMatch[2] || ''),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(headingPattern)
|
||||
if (headingMatch) {
|
||||
flushParagraph()
|
||||
flushList()
|
||||
const level = headingMatch[1].length
|
||||
const text = headingMatch[2].trim()
|
||||
if (level === 2) blocks.push({ type: 'h2', text })
|
||||
else if (level === 3) blocks.push({ type: 'h3', text })
|
||||
else blocks.push({ type: 'h4', text })
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('* ')) {
|
||||
flushParagraph()
|
||||
listItems.push(trimmed.slice(2).trim())
|
||||
continue
|
||||
}
|
||||
|
||||
flushList()
|
||||
paragraph.push(trimmed)
|
||||
}
|
||||
|
||||
flushParagraph()
|
||||
flushList()
|
||||
return blocks
|
||||
}
|
||||
Reference in New Issue
Block a user