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() .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) { async function generateUniqueSlug(title, excludeId) {
const base = slugify(title) const base = slugify(title)
let slug = base let slug = base
@ -138,6 +150,7 @@ function mapPostRow(row) {
sections: row.sections || [], sections: row.sections || [],
footer: row.footer, footer: row.footer,
isEditorsPick: row.is_editors_pick, isEditorsPick: row.is_editors_pick,
category: row.category,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at updatedAt: row.updated_at
} }
@ -192,11 +205,21 @@ function getUploadFields() {
return fields return fields
} }
app.get('/posts', async (_req, res) => { app.get('/posts', async (req, res) => {
try { try {
const result = await query( const { category } = req.query
'SELECT * FROM blog_posts ORDER BY created_at DESC' 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 => ({ const posts = result.rows.map(mapPostRow).map(post => ({
...post, ...post,
excerpt: createExcerpt(post.sections) excerpt: createExcerpt(post.sections)
@ -251,8 +274,8 @@ app.post('/posts', upload.fields(getUploadFields()), async (req, res) => {
await ensureEditorsPickLimit(null, isEditorsPick) await ensureEditorsPickLimit(null, isEditorsPick)
const result = await query( const result = await query(
`INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick) `INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick, category)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`, RETURNING *`,
[ [
payload.title.trim(), payload.title.trim(),
@ -261,7 +284,8 @@ app.post('/posts', upload.fields(getUploadFields()), async (req, res) => {
payload.linkUrl || null, payload.linkUrl || null,
JSON.stringify(sections), JSON.stringify(sections),
payload.footer || null, 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, link_url = $4,
sections = $5, sections = $5,
footer = $6, footer = $6,
is_editors_pick = $7 is_editors_pick = $7,
WHERE id = $8 category = $8
WHERE id = $9
RETURNING *`, RETURNING *`,
[ [
payload.title.trim(), payload.title.trim(),
@ -328,6 +353,7 @@ app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => {
JSON.stringify(sections), JSON.stringify(sections),
payload.footer || null, payload.footer || null,
isEditorsPick, isEditorsPick,
payload.category || null,
id id
] ]
) )

View File

@ -11,11 +11,26 @@ async function runMigrations() {
sections JSONB NOT NULL DEFAULT '[]'::jsonb, sections JSONB NOT NULL DEFAULT '[]'::jsonb,
footer TEXT, footer TEXT,
is_editors_pick BOOLEAN NOT NULL DEFAULT FALSE, is_editors_pick BOOLEAN NOT NULL DEFAULT FALSE,
category TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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(` await query(`
CREATE OR REPLACE FUNCTION set_updated_at() CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$

View File

@ -1,9 +1,21 @@
'use client' 'use client'
import { FormEvent, useEffect, useMemo, useState } from 'react' 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' 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 MAX_SECTIONS = 5
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4005' 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 [linkUrl, setLinkUrl] = useState('')
const [footer, setFooter] = useState('') const [footer, setFooter] = useState('')
const [isEditorsPick, setIsEditorsPick] = useState(false) const [isEditorsPick, setIsEditorsPick] = useState(false)
const [category, setCategory] = useState<string>('')
const [mainImage, setMainImage] = useState<MainImageState>({ const [mainImage, setMainImage] = useState<MainImageState>({
file: null, file: null,
existing: null, existing: null,
@ -103,6 +116,7 @@ export default function AdminPage() {
setLinkUrl('') setLinkUrl('')
setFooter('') setFooter('')
setIsEditorsPick(false) setIsEditorsPick(false)
setCategory('')
setMainImage({ file: null, existing: null, previewUrl: null, removed: false }) setMainImage({ file: null, existing: null, previewUrl: null, removed: false })
setSections(ensureSectionSlots([])) setSections(ensureSectionSlots([]))
setEditingPost(null) setEditingPost(null)
@ -114,6 +128,16 @@ export default function AdminPage() {
setLinkUrl(post.linkUrl || '') setLinkUrl(post.linkUrl || '')
setFooter(post.footer || '') setFooter(post.footer || '')
setIsEditorsPick(post.isEditorsPick) 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({ setMainImage({
file: null, file: null,
existing: post.previewImage || null, existing: post.previewImage || null,
@ -252,6 +276,7 @@ export default function AdminPage() {
linkUrl: normaliseText(linkUrl) || undefined, linkUrl: normaliseText(linkUrl) || undefined,
footer: normaliseText(footer) || undefined, footer: normaliseText(footer) || undefined,
isEditorsPick, isEditorsPick,
category: category || undefined,
existingMainImage: mainImage.file || mainImage.removed ? undefined : mainImage.existing, existingMainImage: mainImage.file || mainImage.removed ? undefined : mainImage.existing,
removeMainImage: mainImage.removed, removeMainImage: mainImage.removed,
sections: sections.map(section => ({ sections: sections.map(section => ({
@ -635,6 +660,23 @@ export default function AdminPage() {
/> />
</label> </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' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input <input
type="checkbox" type="checkbox"

View File

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

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import { BlogPost } from '@/types/blog' import { BlogPost, CATEGORY_LABELS, BlogCategory } from '@/types/blog'
import { ScrollEffects } from '@/components/ScrollEffects' import { ScrollEffects } from '@/components/ScrollEffects'
import { FloatingElements } from '@/components/FloatingElements' import { FloatingElements } from '@/components/FloatingElements'
import { ClientOnly } from '@/components/ClientOnly' import { ClientOnly } from '@/components/ClientOnly'
@ -9,9 +9,10 @@ import { BlogPostCard } from '@/components/BlogPost'
type HomePageClientProps = { type HomePageClientProps = {
posts: BlogPost[] posts: BlogPost[]
selectedCategory?: string
} }
export function HomePageClient({ posts }: HomePageClientProps) { export function HomePageClient({ posts, selectedCategory }: HomePageClientProps) {
const { editorsPicks, regularPosts, latestPostId } = useMemo(() => { const { editorsPicks, regularPosts, latestPostId } = useMemo(() => {
if (!posts.length) { if (!posts.length) {
return { editorsPicks: [], regularPosts: [], latestPostId: null } return { editorsPicks: [], regularPosts: [], latestPostId: null }
@ -53,6 +54,48 @@ export function HomePageClient({ posts }: HomePageClientProps) {
<p className="tagline"> <p className="tagline">
Handpicked Treasures from the eBay Universe - Est. 2024 Handpicked Treasures from the eBay Universe - Est. 2024
</p> </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>
</div> </div>
</header> </header>
@ -211,6 +254,17 @@ export function HomePageClient({ posts }: HomePageClientProps) {
> >
Handpicked treasures. Honest descriptions. Careful packaging. Handpicked treasures. Honest descriptions. Careful packaging.
</p> </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 <div
className="newspaper-rule" className="newspaper-rule"
style={{ margin: '16px auto', width: '120px' }} style={{ margin: '16px auto', width: '120px' }}

View File

@ -4,6 +4,19 @@
image?: string | null 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 { export interface BlogPost {
id: number id: number
title: string title: string
@ -13,6 +26,7 @@ export interface BlogPost {
sections: BlogPostSection[] sections: BlogPostSection[]
footer?: string | null footer?: string | null
isEditorsPick: boolean isEditorsPick: boolean
category?: BlogCategory | null
excerpt?: string excerpt?: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string