DB ready
This commit is contained in:
236
src/app/blog/[slug]/page.tsx
Normal file
236
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { resolveMediaUrl } from '@/lib/media'
|
||||
import { BlogPost, BlogPostSection } from '@/types/blog'
|
||||
|
||||
async function getBlogPost(slug: string): Promise<BlogPost | null> {
|
||||
const baseUrl = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4005'
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/posts/${slug}`, {
|
||||
cache: 'no-store',
|
||||
next: { revalidate: 0 }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
console.error(`[frontend] Failed to load post: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
return payload.data || null
|
||||
} catch (error) {
|
||||
console.error('[frontend] Failed to load post', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: string) {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function renderSection(section: BlogPostSection) {
|
||||
const imageUrl = resolveMediaUrl(section.image)
|
||||
|
||||
return (
|
||||
<div key={section.id} style={{ marginBottom: '32px' }}>
|
||||
{section.text && (
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'Spectral, serif',
|
||||
fontSize: '16px',
|
||||
color: '#4A4A4A',
|
||||
lineHeight: 1.8,
|
||||
marginBottom: imageUrl ? '18px' : 0
|
||||
}}
|
||||
>
|
||||
{section.text}
|
||||
</p>
|
||||
)}
|
||||
{imageUrl && (
|
||||
<div
|
||||
style={{
|
||||
border: '2px solid #8B7D6B',
|
||||
backgroundColor: 'white',
|
||||
padding: '6px',
|
||||
maxWidth: '420px',
|
||||
width: '100%',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Blog section"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
backgroundColor: '#F7F1E1'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
|
||||
const post = await getBlogPost(params.slug)
|
||||
|
||||
if (!post) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const heroImage = resolveMediaUrl(post.previewImage)
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', backgroundColor: '#F7F1E1' }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '820px',
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
padding: '48px 20px',
|
||||
backgroundColor: '#F7F1E1'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
backgroundColor: '#F7F1E1',
|
||||
border: '2px solid #8B7D6B',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.35)'
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '16px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #8B7D6B',
|
||||
backgroundColor: 'transparent',
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
textDecoration: 'none',
|
||||
color: '#1E1A17'
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</a>
|
||||
|
||||
{heroImage && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: '-32px',
|
||||
marginRight: '-32px',
|
||||
marginTop: '-32px',
|
||||
borderBottom: '2px solid #8B7D6B'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={heroImage}
|
||||
alt={post.title}
|
||||
style={{ display: 'block', width: '100%', height: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header style={{ marginTop: '24px', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{post.isEditorsPick && (
|
||||
<span style={{
|
||||
backgroundColor: '#C89C2B',
|
||||
color: '#F7F1E1',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Editor's Pick
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.15em',
|
||||
color: '#8B7D6B',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{formatDate(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 style={{
|
||||
fontFamily: 'Abril Fatface, serif',
|
||||
fontSize: '42px',
|
||||
color: '#1E1A17',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{post.linkUrl && (
|
||||
<a
|
||||
href={post.linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#1E1A17',
|
||||
color: '#F7F1E1',
|
||||
padding: '12px 22px',
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase',
|
||||
border: '2px solid #8B7D6B'
|
||||
}}
|
||||
>
|
||||
To Produkt
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div>
|
||||
{post.sections.map(section => renderSection(section))}
|
||||
</div>
|
||||
|
||||
{post.footer && (
|
||||
<footer style={{
|
||||
borderTop: '2px solid #8B7D6B',
|
||||
marginTop: '32px',
|
||||
paddingTop: '24px'
|
||||
}}>
|
||||
<p style={{
|
||||
fontFamily: 'Spectral, serif',
|
||||
fontSize: '15px',
|
||||
color: '#4A4A4A',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{post.footer}
|
||||
</p>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { BlogPost, BlogPostSection } from '@/types/blog'
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPost
|
||||
onReadMore: (post: BlogPost) => void
|
||||
isLatest: boolean
|
||||
}
|
||||
|
||||
@@ -48,14 +47,14 @@ const cardStyles = {
|
||||
|
||||
function formatDate(timestamp: string) {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
export function BlogPostCard({ post, onReadMore, isLatest }: BlogPostCardProps) {
|
||||
export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
|
||||
const previewUrl = resolveMediaUrl(post.previewImage)
|
||||
|
||||
return (
|
||||
@@ -153,9 +152,8 @@ export function BlogPostCard({ post, onReadMore, isLatest }: BlogPostCardProps)
|
||||
|
||||
{post.excerpt && <p style={cardStyles.excerpt}>{post.excerpt}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReadMore(post)}
|
||||
<a
|
||||
href={`/blog/${post.slug}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -167,212 +165,14 @@ export function BlogPostCard({ post, onReadMore, isLatest }: BlogPostCardProps)
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase',
|
||||
textDecoration: 'none',
|
||||
color: '#1E1A17',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Read More
|
||||
</button>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
interface BlogPostModalProps {
|
||||
post: BlogPost
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function renderSection(section: BlogPostSection) {
|
||||
const imageUrl = resolveMediaUrl(section.image)
|
||||
|
||||
return (
|
||||
<div key={section.id} style={{ marginBottom: '32px' }}>
|
||||
{section.text && (
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'Spectral, serif',
|
||||
fontSize: '16px',
|
||||
color: '#4A4A4A',
|
||||
lineHeight: 1.8,
|
||||
marginBottom: imageUrl ? '18px' : 0
|
||||
}}
|
||||
>
|
||||
{section.text}
|
||||
</p>
|
||||
)}
|
||||
{imageUrl && (
|
||||
<div
|
||||
style={{
|
||||
border: '2px solid #8B7D6B',
|
||||
backgroundColor: 'white',
|
||||
padding: '6px',
|
||||
maxWidth: '420px',
|
||||
width: '100%',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Blog section"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
backgroundColor: '#F7F1E1'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BlogPostModal({ post, onClose }: BlogPostModalProps) {
|
||||
const heroImage = resolveMediaUrl(post.previewImage)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
overflowY: 'auto',
|
||||
padding: '48px 20px'
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxWidth: '820px',
|
||||
width: '100%',
|
||||
backgroundColor: '#F7F1E1',
|
||||
border: '2px solid #8B7D6B',
|
||||
padding: '32px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.35)'
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
|
||||
{heroImage && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: '-32px',
|
||||
marginRight: '-32px',
|
||||
marginTop: '-32px',
|
||||
borderBottom: '2px solid #8B7D6B'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={heroImage}
|
||||
alt={post.title}
|
||||
style={{ display: 'block', width: '100%', height: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header style={{ marginTop: '24px', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{post.isEditorsPick && (
|
||||
<span style={{
|
||||
backgroundColor: '#C89C2B',
|
||||
color: '#F7F1E1',
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
Editor's Pick
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.15em',
|
||||
color: '#8B7D6B',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{formatDate(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 style={{
|
||||
fontFamily: 'Abril Fatface, serif',
|
||||
fontSize: '42px',
|
||||
color: '#1E1A17',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
{post.linkUrl && (
|
||||
<a
|
||||
href={post.linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
backgroundColor: '#1E1A17',
|
||||
color: '#F7F1E1',
|
||||
padding: '12px 22px',
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase',
|
||||
border: '2px solid #8B7D6B'
|
||||
}}
|
||||
>
|
||||
To Produkt
|
||||
</a>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div>
|
||||
{post.sections.map(section => renderSection(section))}
|
||||
</div>
|
||||
|
||||
{post.footer && (
|
||||
<footer style={{
|
||||
borderTop: '2px solid #8B7D6B',
|
||||
marginTop: '32px',
|
||||
paddingTop: '24px'
|
||||
}}>
|
||||
<p style={{
|
||||
fontFamily: 'Spectral, serif',
|
||||
fontSize: '15px',
|
||||
color: '#4A4A4A',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{post.footer}
|
||||
</p>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { BlogPost } from '@/types/blog'
|
||||
import { ScrollEffects } from '@/components/ScrollEffects'
|
||||
import { FloatingElements } from '@/components/FloatingElements'
|
||||
import { ClientOnly } from '@/components/ClientOnly'
|
||||
import { BlogPostCard, BlogPostModal } from '@/components/BlogPost'
|
||||
import { BlogPostCard } from '@/components/BlogPost'
|
||||
|
||||
type HomePageClientProps = {
|
||||
posts: BlogPost[]
|
||||
}
|
||||
|
||||
export function HomePageClient({ posts }: HomePageClientProps) {
|
||||
const [selectedBlogPost, setSelectedBlogPost] = useState<BlogPost | null>(null)
|
||||
|
||||
const latestPostId = useMemo(() => {
|
||||
const { editorsPicks, regularPosts, latestPostId } = useMemo(() => {
|
||||
if (!posts.length) {
|
||||
return null
|
||||
return { editorsPicks: [], regularPosts: [], latestPostId: null }
|
||||
}
|
||||
return posts.reduce((latestId, current) => {
|
||||
|
||||
const editorsPicks = posts.filter(post => post.isEditorsPick)
|
||||
const regularPosts = posts.filter(post => !post.isEditorsPick)
|
||||
|
||||
const latestPostId = posts.reduce((latestId, current) => {
|
||||
if (!latestId) {
|
||||
return current.id
|
||||
}
|
||||
@@ -28,6 +30,8 @@ export function HomePageClient({ posts }: HomePageClientProps) {
|
||||
}
|
||||
return new Date(current.createdAt) > new Date(latest.createdAt) ? current.id : latestId
|
||||
}, posts[0].id)
|
||||
|
||||
return { editorsPicks, regularPosts, latestPostId }
|
||||
}, [posts])
|
||||
|
||||
return (
|
||||
@@ -125,7 +129,7 @@ export function HomePageClient({ posts }: HomePageClientProps) {
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '800px',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
@@ -142,14 +146,38 @@ export function HomePageClient({ posts }: HomePageClientProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{posts.map(post => (
|
||||
<BlogPostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLatest={latestPostId === post.id}
|
||||
onReadMore={setSelectedBlogPost}
|
||||
/>
|
||||
))}
|
||||
{editorsPicks.length > 0 && (
|
||||
<div style={{ marginBottom: '48px' }}>
|
||||
{editorsPicks.map(post => (
|
||||
<div key={post.id} style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<BlogPostCard
|
||||
post={post}
|
||||
isLatest={latestPostId === post.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regularPosts.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))',
|
||||
gap: '32px',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{regularPosts.map(post => (
|
||||
<BlogPostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
isLatest={latestPostId === post.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -196,13 +224,36 @@ export function HomePageClient({ posts }: HomePageClientProps) {
|
||||
>
|
||||
(c) {new Date().getFullYear()} The Curated Finds - All rights reserved.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const password = prompt('Admin-Passwort eingeben:')
|
||||
if (password === 'timo') {
|
||||
window.location.href = '/admin'
|
||||
} else if (password !== null) {
|
||||
alert('Falsches Passwort')
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
border: '1px solid #8B7D6B',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#8B7D6B',
|
||||
fontFamily: 'Space Mono, monospace',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.5
|
||||
}}
|
||||
>
|
||||
admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{selectedBlogPost && (
|
||||
<BlogPostModal post={selectedBlogPost} onClose={() => setSelectedBlogPost(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user