This commit is contained in:
Timo Knuth 2025-09-30 22:10:00 +02:00
parent 5856eda62b
commit f36fe6276c
5 changed files with 348 additions and 236 deletions

View File

@ -12,18 +12,21 @@
API_PORT: 4005
API_BASE_URL: http://localhost:4005
NEXT_PUBLIC_API_BASE_URL: http://localhost:4005
DATABASE_URL: postgres://postgres:postgres@localhost:5433/claudia_blog
CORS_ORIGINS: http://localhost:3005
DATABASE_URL: postgres://postgres:postgres@postgres:5432/claudia_blog
CORS_ORIGINS: http://localhost:3005,http://172.25.182.67:3005
WATCHPACK_POLLING: "true"
volumes:
- .:/app
- node_modules:/app/node_modules
- uploads:/app/public/uploads
depends_on:
- postgres
postgres:
condition: service_healthy
working_dir: /app
tty: true
stdin_open: true
networks:
- app-network
postgres:
image: postgres:latest
@ -31,14 +34,25 @@
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: claudia_blog
PGDATA: /tmp/pgdata
command:
- "postgres"
- "-c"
- "listen_addresses=*"
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- "0.0.0.0:5433:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
node_modules:
postgres_data:
uploads:

View File

@ -211,7 +211,18 @@ app.get('/posts', async (_req, res) => {
app.get('/posts/:id', async (req, res) => {
const { id } = req.params
try {
const result = await query('SELECT * FROM blog_posts WHERE id = $1', [id])
// Try to parse as integer ID first, otherwise treat as slug
const numericId = parseInt(id, 10)
let result
if (!isNaN(numericId) && numericId.toString() === id) {
// It's a numeric ID
result = await query('SELECT * FROM blog_posts WHERE id = $1', [numericId])
} else {
// It's a slug
result = await query('SELECT * FROM blog_posts WHERE slug = $1', [id])
}
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Post not found' })
}

View 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'
}}
>
&larr; 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}