feat: Implement Phase 2 features including blog, collections, saved listings, manifest, and api cron sync
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Calendar, Tag, ArrowLeft } from 'lucide-react'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string; slug: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props) {
|
||||
const { slug } = await params
|
||||
const post = await mockDb.getBlogPostBySlug(slug)
|
||||
if (!post) return {}
|
||||
|
||||
return {
|
||||
title: `${post.titleTr} — Marmaris Local`,
|
||||
description: post.contentTr.substring(0, 150)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight safe Markdown to HTML parsing function
|
||||
function renderMarkdownToHtml(md: string): string {
|
||||
if (!md) return ''
|
||||
let html = md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>')
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
html = html.replace(/_(.*?)_/g, '<em>$1</em>')
|
||||
|
||||
// Headings
|
||||
html = html.replace(/^### (.*?)$/gm, '<h4 class="text-base font-heading font-bold text-pine mt-6 mb-2 lowercase">$1</h4>')
|
||||
html = html.replace(/^## (.*?)$/gm, '<h3 class="text-lg font-heading font-extrabold text-pine mt-8 mb-3 lowercase">$1</h3>')
|
||||
html = html.replace(/^# (.*?)$/gm, '<h2 class="text-xl font-heading font-extrabold text-pine mt-10 mb-4 lowercase">$1</h2>')
|
||||
|
||||
// Bullet Lists
|
||||
html = html.replace(/^\* (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
|
||||
html = html.replace(/^- (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-turquoise hover:underline" target="_blank" rel="noopener">$1</a>')
|
||||
|
||||
// Paragraphs
|
||||
const blocks = html.split(/\n\n+/)
|
||||
html = blocks.map(block => {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('<h') || trimmed.startsWith('<li') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol')) {
|
||||
return trimmed
|
||||
}
|
||||
return `<p class="leading-relaxed mb-4 text-sm sm:text-base text-ink/80 font-medium">${trimmed.replace(/\n/g, '<br/>')}</p>`
|
||||
}).join('\n')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
export default async function BlogPostDetailPage({ params }: Props) {
|
||||
const { locale, slug } = await params
|
||||
const post = await mockDb.getBlogPostBySlug(slug)
|
||||
|
||||
if (!post || post.deletedAt) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const title = locale === 'en' ? post.titleEn : locale === 'ru' ? post.titleRu : post.titleTr
|
||||
const content = locale === 'en' ? post.contentEn : locale === 'ru' ? post.contentRu : post.contentTr
|
||||
const htmlContent = renderMarkdownToHtml(content)
|
||||
|
||||
// Fetch associated listings
|
||||
const relatedListings: any[] = []
|
||||
if (post.relatedListingIds && post.relatedListingIds.length > 0) {
|
||||
for (const listingId of post.relatedListingIds) {
|
||||
const listing = await mockDb.getListingById(listingId)
|
||||
if (listing) {
|
||||
relatedListings.push(listing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// schema.org Structured Data
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'headline': title,
|
||||
'image': post.coverImage || 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=1200&auto=format&fit=crop&q=80',
|
||||
'datePublished': post.publishedAt || post.createdAt,
|
||||
'dateModified': post.updatedAt,
|
||||
'author': {
|
||||
'@type': 'Organization',
|
||||
'name': 'Marmaris Local',
|
||||
'url': 'https://marmarislocal.com'
|
||||
},
|
||||
'publisher': {
|
||||
'@type': 'Organization',
|
||||
'name': 'Marmaris Local',
|
||||
'logo': {
|
||||
'@type': 'ImageObject',
|
||||
'url': 'https://marmarislocal.com/logo.png'
|
||||
}
|
||||
},
|
||||
'description': content.substring(0, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Schema.org Article Structured Data */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 border border-pine/10 rounded-xl text-xs font-mono font-bold text-pine hover:bg-paper transition"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
GERİ DÖN
|
||||
</Link>
|
||||
|
||||
{/* Article Box */}
|
||||
<article className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm">
|
||||
{post.coverImage && (
|
||||
<div className="h-[350px] relative overflow-hidden bg-stone border-b border-pine/5">
|
||||
<img
|
||||
src={post.coverImage}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 sm:p-10 space-y-6">
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-shutter font-mono border-b border-dashed border-pine/8 pb-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString(locale === 'tr' ? 'tr-TR' : locale === 'ru' ? 'ru-RU' : 'en-US') : ''}
|
||||
</span>
|
||||
|
||||
{post.tags.map((tag) => (
|
||||
<span key={tag} className="flex items-center gap-1 bg-stone/50 px-2 py-0.5 rounded-md border border-pine/5">
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase leading-tight">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Markdown Content */}
|
||||
<div
|
||||
className="markdown-content space-y-4"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Related Listings Section (Internal Linking) */}
|
||||
{relatedListings.length > 0 && (
|
||||
<div className="space-y-6 pt-6">
|
||||
<h3 className="text-xl font-heading font-extrabold text-pine lowercase border-b border-dashed border-pine/8 pb-3">
|
||||
yazıda geçen mekanlar
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{relatedListings.map((listing) => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Calendar, Tag } from 'lucide-react'
|
||||
|
||||
export async function generateMetadata() {
|
||||
return {
|
||||
title: 'Marmaris Local Blog — Yerel Lezzet ve Gezi Rehberi',
|
||||
description: 'Marmaris\'i bir yerel gibi keşfetmeniz için rehberler, nerede ne yenir tavsiyeleri ve gizli yerler.'
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogIndexPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations('nav')
|
||||
const posts = await mockDb.getBlogPosts(true)
|
||||
|
||||
return (
|
||||
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
|
||||
<div className="max-w-5xl mx-auto space-y-10">
|
||||
|
||||
{/* Title */}
|
||||
<div className="text-center space-y-3">
|
||||
<span className="text-[10px] font-mono tracking-widest text-shutter uppercase bg-paper px-3 py-1.5 rounded-full border border-pine/5">
|
||||
küratörlü rehberler
|
||||
</span>
|
||||
<h1 className="text-4xl font-heading font-extrabold text-pine lowercase">
|
||||
marmaris local <span className="text-turquoise">blog</span>
|
||||
</h1>
|
||||
<p className="max-w-xl mx-auto text-ink/70 text-xs sm:text-sm font-medium">
|
||||
Turistlerin gözünden kaçan yerel detaylar, en iyi lezzet durakları ve gizli gezi noktaları.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Blog Posts Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{posts.length === 0 ? (
|
||||
<div className="col-span-2 bg-paper border border-pine/8 p-12 text-center text-shutter text-sm rounded-3xl">
|
||||
Henüz yayınlanmış bir yazı bulunmuyor.
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post) => {
|
||||
const title = locale === 'en' ? post.titleEn : locale === 'ru' ? post.titleRu : post.titleTr
|
||||
const content = locale === 'en' ? post.contentEn : locale === 'ru' ? post.contentRu : post.contentTr
|
||||
const snippet = content.substring(0, 140) + '...'
|
||||
|
||||
return (
|
||||
<article
|
||||
key={post.id}
|
||||
className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm flex flex-col justify-between hover:border-turquoise/35 transition-all duration-300 hover:shadow-md"
|
||||
>
|
||||
<div>
|
||||
{/* Cover image */}
|
||||
{post.coverImage && (
|
||||
<div className="h-56 relative overflow-hidden bg-stone border-b border-pine/5">
|
||||
<img
|
||||
src={post.coverImage}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover hover:scale-105 transition duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post content preview */}
|
||||
<div className="p-6 sm:p-8 space-y-4">
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-4 text-[10px] text-shutter font-mono">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString(locale === 'tr' ? 'tr-TR' : locale === 'ru' ? 'ru-RU' : 'en-US') : ''}
|
||||
</span>
|
||||
|
||||
{post.tags.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
{post.tags[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="font-heading font-extrabold text-lg text-pine hover:text-turquoise transition duration-150 lowercase leading-snug">
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
{title}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<p className="text-ink/75 text-xs sm:text-sm leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 sm:px-8 pb-6 sm:pb-8 pt-2">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="inline-flex items-center text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wider"
|
||||
>
|
||||
okumaya devam et →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user