Kategorien

This commit is contained in:
Timo Knuth 2025-10-01 21:46:15 +02:00
parent 70991a4345
commit fd0542f2a4
6 changed files with 173 additions and 17 deletions

View File

@ -46,6 +46,18 @@ function slugify(value) {
.replace(/(^-|-$)+/g, '') || crypto.randomUUID()
}
function normalizeCategoryForSearch(category) {
if (!category) return null
// Convert slug format to display format for backward compatibility
const categoryMap = {
'books-magazine': 'Books & Magazine',
'clothing-shoes': 'Clothing & Shoes',
'collectibles-art': 'Collectibles & Art',
'toys-hobbies': 'Toys & Hobbies'
}
return categoryMap[category.toLowerCase()] || category
}
async function generateUniqueSlug(title, excludeId) {
const base = slugify(title)
let slug = base
@ -138,6 +150,7 @@ function mapPostRow(row) {
sections: row.sections || [],
footer: row.footer,
isEditorsPick: row.is_editors_pick,
category: row.category,
createdAt: row.created_at,
updatedAt: row.updated_at
}
@ -192,11 +205,21 @@ function getUploadFields() {
return fields
}
app.get('/posts', async (_req, res) => {
app.get('/posts', async (req, res) => {
try {
const result = await query(
'SELECT * FROM blog_posts ORDER BY created_at DESC'
)
const { category } = req.query
let queryText = 'SELECT * FROM blog_posts'
const queryParams = []
if (category) {
const normalizedCategory = normalizeCategoryForSearch(category)
queryText += ' WHERE category = $1'
queryParams.push(normalizedCategory)
}
queryText += ' ORDER BY created_at DESC'
const result = await query(queryText, queryParams)
const posts = result.rows.map(mapPostRow).map(post => ({
...post,
excerpt: createExcerpt(post.sections)
@ -251,8 +274,8 @@ app.post('/posts', upload.fields(getUploadFields()), async (req, res) => {
await ensureEditorsPickLimit(null, isEditorsPick)
const result = await query(
`INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
payload.title.trim(),
@ -261,7 +284,8 @@ app.post('/posts', upload.fields(getUploadFields()), async (req, res) => {
payload.linkUrl || null,
JSON.stringify(sections),
payload.footer || null,
isEditorsPick
isEditorsPick,
payload.category || null
]
)
@ -317,8 +341,9 @@ app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => {
link_url = $4,
sections = $5,
footer = $6,
is_editors_pick = $7
WHERE id = $8
is_editors_pick = $7,
category = $8
WHERE id = $9
RETURNING *`,
[
payload.title.trim(),
@ -328,6 +353,7 @@ app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => {
JSON.stringify(sections),
payload.footer || null,
isEditorsPick,
payload.category || null,
id
]
)

View File

@ -11,11 +11,26 @@ async function runMigrations() {
sections JSONB NOT NULL DEFAULT '[]'::jsonb,
footer TEXT,
is_editors_pick BOOLEAN NOT NULL DEFAULT FALSE,
category TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
await query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'blog_posts'
AND column_name = 'category'
) THEN
ALTER TABLE blog_posts ADD COLUMN category TEXT;
END IF;
END $$;
`)
await query(`
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$

View File

@ -1,9 +1,21 @@
'use client'
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { BlogPost, BlogPostSection } from '@/types/blog'
import { BlogPost, BlogPostSection, CATEGORY_LABELS, BlogCategory } from '@/types/blog'
import { resolveMediaUrl } from '@/lib/media'
function getCategoryLabel(category: string | null | undefined): string {
if (!category) return 'None'
// Try as slug first
if (category in CATEGORY_LABELS) {
return CATEGORY_LABELS[category as BlogCategory]
}
// Return as-is if it's already a display name
return category
}
const MAX_SECTIONS = 5
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4005'
@ -63,6 +75,7 @@ export default function AdminPage() {
const [linkUrl, setLinkUrl] = useState('')
const [footer, setFooter] = useState('')
const [isEditorsPick, setIsEditorsPick] = useState(false)
const [category, setCategory] = useState<string>('')
const [mainImage, setMainImage] = useState<MainImageState>({
file: null,
existing: null,
@ -103,6 +116,7 @@ export default function AdminPage() {
setLinkUrl('')
setFooter('')
setIsEditorsPick(false)
setCategory('')
setMainImage({ file: null, existing: null, previewUrl: null, removed: false })
setSections(ensureSectionSlots([]))
setEditingPost(null)
@ -114,6 +128,16 @@ export default function AdminPage() {
setLinkUrl(post.linkUrl || '')
setFooter(post.footer || '')
setIsEditorsPick(post.isEditorsPick)
// Convert old format to new format
const categoryMap: Record<string, string> = {
'Books & Magazine': 'books-magazine',
'Clothing & Shoes': 'clothing-shoes',
'Collectibles & Art': 'collectibles-art',
'Toys & Hobbies': 'toys-hobbies'
}
const normalizedCategory = post.category ? (categoryMap[post.category] || post.category) : ''
setCategory(normalizedCategory)
setMainImage({
file: null,
existing: post.previewImage || null,
@ -252,6 +276,7 @@ export default function AdminPage() {
linkUrl: normaliseText(linkUrl) || undefined,
footer: normaliseText(footer) || undefined,
isEditorsPick,
category: category || undefined,
existingMainImage: mainImage.file || mainImage.removed ? undefined : mainImage.existing,
removeMainImage: mainImage.removed,
sections: sections.map(section => ({
@ -635,6 +660,23 @@ export default function AdminPage() {
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px', letterSpacing: '0.15em' }}>
Category (optional)
</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
style={{ padding: '12px', border: '1px solid #8B7D6B', backgroundColor: 'white' }}
>
<option value="">-- Select Category --</option>
<option value="books-magazine">Books & Magazine</option>
<option value="clothing-shoes">Clothing & Shoes</option>
<option value="collectibles-art">Collectibles & Art</option>
<option value="toys-hobbies">Toys & Hobbies</option>
</select>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input
type="checkbox"

View File

@ -1,11 +1,16 @@
import { HomePageClient } from '@/components/HomePageClient'
import { BlogPost } from '@/types/blog'
async function getBlogPosts(): Promise<BlogPost[]> {
async function getBlogPosts(category?: string): Promise<BlogPost[]> {
const baseUrl = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4005'
try {
const response = await fetch(`${baseUrl}/posts`, {
let url = `${baseUrl}/posts`
if (category) {
url += `?category=${encodeURIComponent(category)}`
}
const response = await fetch(url, {
cache: 'no-store',
next: { revalidate: 0 }
})
@ -23,7 +28,7 @@ async function getBlogPosts(): Promise<BlogPost[]> {
}
}
export default async function HomePage() {
const posts = await getBlogPosts()
return <HomePageClient posts={posts} />
export default async function HomePage({ searchParams }: { searchParams: { category?: string } }) {
const posts = await getBlogPosts(searchParams.category)
return <HomePageClient posts={posts} selectedCategory={searchParams.category} />
}

View File

@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
import { BlogPost } from '@/types/blog'
import { BlogPost, CATEGORY_LABELS, BlogCategory } from '@/types/blog'
import { ScrollEffects } from '@/components/ScrollEffects'
import { FloatingElements } from '@/components/FloatingElements'
import { ClientOnly } from '@/components/ClientOnly'
@ -9,9 +9,10 @@ import { BlogPostCard } from '@/components/BlogPost'
type HomePageClientProps = {
posts: BlogPost[]
selectedCategory?: string
}
export function HomePageClient({ posts }: HomePageClientProps) {
export function HomePageClient({ posts, selectedCategory }: HomePageClientProps) {
const { editorsPicks, regularPosts, latestPostId } = useMemo(() => {
if (!posts.length) {
return { editorsPicks: [], regularPosts: [], latestPostId: null }
@ -53,6 +54,48 @@ export function HomePageClient({ posts }: HomePageClientProps) {
<p className="tagline">
Handpicked Treasures from the eBay Universe - Est. 2024
</p>
<div style={{ marginTop: '24px' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '24px',
flexWrap: 'wrap'
}}
>
<a
href="/"
style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: !selectedCategory ? '#1E1A17' : '#8B7D6B',
textDecoration: 'none',
fontWeight: !selectedCategory ? 'bold' : 'normal'
}}
>
All
</a>
{(Object.keys(CATEGORY_LABELS) as BlogCategory[]).map((categoryKey) => (
<a
key={categoryKey}
href={`/?category=${categoryKey}`}
style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: selectedCategory === categoryKey ? '#1E1A17' : '#8B7D6B',
textDecoration: 'none',
fontWeight: selectedCategory === categoryKey ? 'bold' : 'normal'
}}
>
{CATEGORY_LABELS[categoryKey]}
</a>
))}
</div>
</div>
</div>
</div>
</header>
@ -211,6 +254,17 @@ export function HomePageClient({ posts }: HomePageClientProps) {
>
Handpicked treasures. Honest descriptions. Careful packaging.
</p>
<p
style={{
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
color: '#8B7D6B',
marginTop: '12px',
letterSpacing: '0.05em'
}}
>
Handpicked Treasures from the eBay Universe - Est. 2024
</p>
<div
className="newspaper-rule"
style={{ margin: '16px auto', width: '120px' }}

View File

@ -4,6 +4,19 @@
image?: string | null
}
export type BlogCategory =
| 'books-magazine'
| 'clothing-shoes'
| 'collectibles-art'
| 'toys-hobbies'
export const CATEGORY_LABELS: Record<BlogCategory, string> = {
'books-magazine': 'Books & Magazine',
'clothing-shoes': 'Clothing & Shoes',
'collectibles-art': 'Collectibles & Art',
'toys-hobbies': 'Toys & Hobbies'
}
export interface BlogPost {
id: number
title: string
@ -13,6 +26,7 @@ export interface BlogPost {
sections: BlogPostSection[]
footer?: string | null
isEditorsPick: boolean
category?: BlogCategory | null
excerpt?: string
createdAt: string
updatedAt: string