diff --git a/server/index.js b/server/index.js index 839b695..dc0e24f 100644 --- a/server/index.js +++ b/server/index.js @@ -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 ] ) diff --git a/server/migrations.js b/server/migrations.js index 56538eb..1317ded 100644 --- a/server/migrations.js +++ b/server/migrations.js @@ -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 $$ diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f2cfc40..b834e27 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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('') const [mainImage, setMainImage] = useState({ 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 = { + '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() { /> + +