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