Add image upload fields, media edit/delete, blog seeds

This commit is contained in:
Noro Studio
2026-06-30 20:58:09 +03:30
parent 7af8fbca2d
commit 7f8eece848
2 changed files with 199 additions and 38 deletions
+116 -38
View File
@@ -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">
تماس با ما
+83
View File
@@ -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
}