init
This commit is contained in:
131
frontend/src/views/ArticleView.vue
Normal file
131
frontend/src/views/ArticleView.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div v-if="article" class="mx-auto max-w-3xl">
|
||||
<router-link to="/blog" class="mb-6 inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
|
||||
<svg class="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Все статьи
|
||||
</router-link>
|
||||
|
||||
<article>
|
||||
<header class="mb-8">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<span class="rounded-full bg-primary-100 px-3 py-1 text-xs font-medium text-primary-700">
|
||||
{{ article.category }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-400">{{ formatDate(article.date) }}</span>
|
||||
<span class="text-sm text-gray-400">{{ article.readTime }} мин чтения</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 leading-tight">{{ article.title }}</h1>
|
||||
<p class="mt-3 text-lg text-gray-500">{{ article.description }}</p>
|
||||
</header>
|
||||
|
||||
<div class="prose prose-gray max-w-none" v-html="renderedContent"></div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-12 rounded-xl bg-primary-50 border border-primary-100 p-6 text-center">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Нужна 3D-печать?</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Загрузите модель и получите точный расчёт стоимости за секунды</p>
|
||||
<router-link to="/" class="btn-primary">Рассчитать стоимость</router-link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Related articles -->
|
||||
<div v-if="relatedArticles.length" class="mt-12">
|
||||
<h3 class="mb-4 text-lg font-bold text-gray-900">Читайте также</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<router-link
|
||||
v-for="related in relatedArticles"
|
||||
:key="related.slug"
|
||||
:to="`/blog/${related.slug}`"
|
||||
class="card group transition-shadow hover:shadow-md"
|
||||
>
|
||||
<span class="text-xs text-primary-600 font-medium">{{ related.category }}</span>
|
||||
<h4 class="mt-1 text-sm font-bold text-gray-900 group-hover:text-primary-600 transition-colors">
|
||||
{{ related.title }}
|
||||
</h4>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-20">
|
||||
<p class="text-gray-500">Статья не найдена</p>
|
||||
<router-link to="/blog" class="btn-primary mt-4 inline-block">Все статьи</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getArticleBySlug, articles } from '../data/articles'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const article = computed(() => getArticleBySlug(route.params.slug))
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!article.value) return ''
|
||||
return simpleMarkdown(article.value.content)
|
||||
})
|
||||
|
||||
const relatedArticles = computed(() => {
|
||||
if (!article.value) return []
|
||||
return articles
|
||||
.filter((a) => a.slug !== article.value.slug)
|
||||
.filter((a) => a.category === article.value.category)
|
||||
.slice(0, 2)
|
||||
})
|
||||
|
||||
watch(() => route.params.slug, () => {
|
||||
window.scrollTo(0, 0)
|
||||
if (article.value) {
|
||||
document.title = `${article.value.title} — Filam3D`
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
function simpleMarkdown(md) {
|
||||
let html = md
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-bold text-gray-900 mt-6 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold text-gray-900 mt-8 mb-3">$1</h2>')
|
||||
.replace(/^\- \[ \] (.+)$/gm, '<div class="flex items-center gap-2 my-1"><input type="checkbox" disabled class="rounded"><span class="text-sm text-gray-700">$1</span></div>')
|
||||
.replace(/^\- (.+)$/gm, '<li class="ml-4 text-gray-700">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-gray-700 list-decimal">$2</li>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code class="rounded bg-gray-100 px-1.5 py-0.5 text-sm font-mono text-primary-700">$1</code>')
|
||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" class="text-primary-600 hover:text-primary-700 underline">$1</a>')
|
||||
.replace(/^---$/gm, '<hr class="my-6 border-gray-200">')
|
||||
.replace(/^\|(.+)$/gm, (match) => {
|
||||
const cells = match.split('|').filter(c => c.trim())
|
||||
if (cells.every(c => /^[\s-:]+$/.test(c))) return ''
|
||||
const isHeader = match.includes('---')
|
||||
const tag = isHeader ? 'th' : 'td'
|
||||
const cls = isHeader
|
||||
? 'px-3 py-2 text-left text-xs font-semibold text-gray-600 bg-gray-50'
|
||||
: 'px-3 py-2 text-sm text-gray-700 border-t border-gray-100'
|
||||
const row = cells.map(c => `<${tag} class="${cls}">${c.trim()}</${tag}>`).join('')
|
||||
return `<tr>${row}</tr>`
|
||||
})
|
||||
|
||||
// Wrap table rows
|
||||
html = html.replace(/((<tr>.*<\/tr>\s*)+)/g, '<div class="overflow-x-auto my-4"><table class="w-full border border-gray-200 rounded-lg overflow-hidden">$1</table></div>')
|
||||
|
||||
// Wrap list items
|
||||
html = html.replace(/((<li class="ml-4 text-gray-700">.*<\/li>\s*)+)/g, '<ul class="my-3 space-y-1">$1</ul>')
|
||||
|
||||
// Paragraphs for remaining text
|
||||
html = html.split('\n').map(line => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('<')) return line
|
||||
return `<p class="my-3 text-gray-700 leading-relaxed">${trimmed}</p>`
|
||||
}).join('\n')
|
||||
|
||||
return html
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user