Kategorien
This commit is contained in:
parent
70991a4345
commit
fd0542f2a4
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 $$
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue