This commit is contained in:
Timo Knuth 2025-09-30 01:54:58 +02:00
parent 148cb6d283
commit 5856eda62b
16 changed files with 3340 additions and 1271 deletions

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000 4005
CMD ["npm", "run", "dev"]

44
docker-compose.yml Normal file
View File

@ -0,0 +1,44 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
command: sh -c "npm install && npm run dev"
ports:
- "3005:3000"
- "4005:4005"
environment:
NODE_ENV: development
API_PORT: 4005
API_BASE_URL: http://localhost:4005
NEXT_PUBLIC_API_BASE_URL: http://localhost:4005
DATABASE_URL: postgres://postgres:postgres@localhost:5433/claudia_blog
CORS_ORIGINS: http://localhost:3005
WATCHPACK_POLLING: "true"
volumes:
- .:/app
- node_modules:/app/node_modules
- uploads:/app/public/uploads
depends_on:
- postgres
working_dir: /app
tty: true
stdin_open: true
postgres:
image: postgres:latest
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: claudia_blog
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
node_modules:
postgres_data:
uploads:

1369
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,22 +3,31 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "concurrently -k \"next dev\" \"nodemon --watch server --ext js,json --exec node server/index.js\"",
"dev:web": "next dev",
"dev:api": "nodemon --watch server --ext js,json --exec node server/index.js",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.0",
"next": "14.2.5",
"pg": "^8.11.5",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"concurrently": "^8.2.2",
"eslint": "^8",
"eslint-config-next": "14.2.5"
"eslint-config-next": "14.2.5",
"nodemon": "^3.1.4",
"typescript": "^5"
}
}

2
public/uploads/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

164
server/db.js Normal file
View File

@ -0,0 +1,164 @@
const { Pool, Client } = require('pg')
const fs = require('fs')
const path = require('path')
const dotenvPath = path.resolve(__dirname, '..', '.env')
if (fs.existsSync(dotenvPath)) {
require('dotenv').config({ path: dotenvPath })
} else {
require('dotenv').config()
}
const hasConnectionString =
typeof process.env.DATABASE_URL === 'string' && process.env.DATABASE_URL.trim().length > 0
if (!hasConnectionString) {
console.warn('[db] DATABASE_URL is not set. Falling back to discrete Postgres environment variables.')
}
const sslConfig =
process.env.DATABASE_SSL === 'true' || process.env.PGSSLMODE === 'require'
? { rejectUnauthorized: false }
: undefined
const defaultConfig = {
host: process.env.PGHOST || 'localhost',
port: Number(process.env.PGPORT || 5432),
user: process.env.PGUSER || 'postgres',
password: process.env.PGPASSWORD || 'postgres',
database: process.env.PGDATABASE || 'claudia_blog'
}
const poolConfig = hasConnectionString
? { connectionString: process.env.DATABASE_URL.trim() }
: { ...defaultConfig }
if (sslConfig) {
poolConfig.ssl = sslConfig
}
let pool
let ensuringDatabasePromise
function quoteIdentifier(identifier) {
return `"${identifier.replace(/"/g, '""')}"`
}
function resolveTargetDatabase() {
if (hasConnectionString) {
try {
const url = new URL(process.env.DATABASE_URL.trim())
const dbName = url.pathname ? url.pathname.replace(/^\//, '') : null
return dbName || null
} catch (error) {
console.warn(`[db] Unable to parse DATABASE_URL: ${error.message}`)
return null
}
}
return poolConfig.database || null
}
async function ensureDatabaseExists(force = false) {
if (ensuringDatabasePromise && !force) {
return ensuringDatabasePromise
}
ensuringDatabasePromise = (async () => {
const targetDb = resolveTargetDatabase()
if (!targetDb) {
return
}
let adminConfig
if (hasConnectionString) {
try {
const adminUrl = new URL(process.env.DATABASE_URL.trim())
adminUrl.pathname = '/postgres'
adminConfig = { connectionString: adminUrl.toString() }
if (sslConfig) {
adminConfig.ssl = sslConfig
}
} catch (error) {
console.warn(`[db] Unable to prepare admin connection to create database: ${error.message}`)
return
}
} else {
const adminDatabase = process.env.PGDEFAULTDB || 'postgres'
adminConfig = {
...poolConfig,
database: adminDatabase
}
}
const client = new Client(adminConfig)
try {
await client.connect()
const exists = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [targetDb])
if (exists.rowCount === 0) {
await client.query(`CREATE DATABASE ${quoteIdentifier(targetDb)}`)
console.log(`[db] Created database ${targetDb}`)
}
} catch (error) {
if (error.code === '42P04') {
return
}
console.warn(`[db] Could not ensure database ${targetDb}: ${error.message}`)
} finally {
await client.end().catch(() => {})
}
})()
return ensuringDatabasePromise
}
async function initialisePool() {
const activePool = new Pool(poolConfig)
activePool.on('error', (err) => {
console.error('[db] Unexpected error on idle client', err)
})
// Force a connection so we surface errors immediately
await activePool.query('SELECT 1')
return activePool
}
async function getPool() {
if (pool) {
return pool
}
try {
await ensureDatabaseExists()
pool = await initialisePool()
return pool
} catch (error) {
if (error.code === '3D000') {
// Database missing, retry once after forcing ensure
await ensureDatabaseExists(true)
pool = await initialisePool()
return pool
}
throw error
}
}
async function query(text, params) {
const activePool = await getPool()
return activePool.query(text, params)
}
async function closePool() {
if (pool) {
await pool.end()
pool = null
}
}
module.exports = {
query,
getPool,
closePool,
ensureDatabaseExists
}

396
server/index.js Normal file
View File

@ -0,0 +1,396 @@
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const express = require('express')
const cors = require('cors')
const { upload } = require('./storage')
const { query, closePool } = require('./db')
const { runMigrations } = require('./migrations')
const PORT = Number(process.env.API_PORT) || 4005
const MAX_SECTIONS = Number(process.env.BLOG_MAX_SECTIONS || 5)
const app = express()
const allowedOrigins = (process.env.CORS_ORIGINS || 'http://localhost:3000')
.split(',')
.map(origin => origin.trim())
.filter(Boolean)
app.use(cors({
origin: allowedOrigins,
credentials: true
}))
app.use(express.json({ limit: '2mb' }))
app.use(express.urlencoded({ extended: true }))
const uploadsPath = path.join(__dirname, '..', 'public', 'uploads')
if (!fs.existsSync(uploadsPath)) {
fs.mkdirSync(uploadsPath, { recursive: true })
}
app.use('/uploads', express.static(uploadsPath))
app.get('/health', (_req, res) => {
res.json({ status: 'ok' })
})
function slugify(value) {
return value
.toString()
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '') || crypto.randomUUID()
}
async function generateUniqueSlug(title, excludeId) {
const base = slugify(title)
let slug = base
let suffix = 1
// eslint-disable-next-line no-constant-condition
while (true) {
const values = [slug]
let condition = ''
const excludeNumeric = Number(excludeId)
if (Number.isInteger(excludeNumeric) && excludeNumeric > 0) {
condition = 'AND id <> $2'
values.push(excludeNumeric)
}
const existing = await query(
`SELECT id FROM blog_posts WHERE slug = $1 ${condition} LIMIT 1`,
values
)
if (existing.rows.length === 0) {
return slug
}
suffix += 1
slug = `${base}-${suffix}`
}
}
function parsePayload(body) {
if (body.payload) {
try {
return JSON.parse(body.payload)
} catch (error) {
throw new Error('Invalid payload JSON')
}
}
return body
}
function buildMainImage(payload, files) {
const uploadForMain = files.mainImage && files.mainImage[0]
if (payload.removeMainImage === true || payload.removeMainImage === 'true') {
return null
}
if (uploadForMain) {
return `/uploads/${uploadForMain.filename}`
}
if (payload.existingMainImage) {
return payload.existingMainImage
}
return null
}
function buildSections(payload, files) {
const sections = []
const inputSections = Array.isArray(payload.sections) ? payload.sections : []
for (let index = 0; index < MAX_SECTIONS; index += 1) {
const sectionInput = inputSections[index] || {}
const fileKey = `section${index}Image`
const uploadForSection = files[fileKey] && files[fileKey][0]
const rawText = typeof sectionInput.text === 'string' ? sectionInput.text : ''
const text = rawText.trim()
const image = uploadForSection
? `/uploads/${uploadForSection.filename}`
: sectionInput.existingImage || null
if (text || image) {
sections.push({
id: sectionInput.id || crypto.randomUUID(),
text: text || null,
image
})
}
}
return sections
}
function mapPostRow(row) {
return {
id: row.id,
title: row.title,
slug: row.slug,
previewImage: row.preview_image,
linkUrl: row.link_url,
sections: row.sections || [],
footer: row.footer,
isEditorsPick: row.is_editors_pick,
createdAt: row.created_at,
updatedAt: row.updated_at
}
}
function createExcerpt(sections) {
const firstText = sections
.map(section => section.text || '')
.find(text => text && text.trim().length > 0)
if (!firstText) {
return ''
}
const trimmed = firstText.trim()
if (trimmed.length <= 220) {
return trimmed
}
return `${trimmed.slice(0, 217)}...`
}
async function ensureEditorsPickLimit(targetId, makePick) {
if (!makePick) {
return
}
let condition = ''
const params = []
if (Number.isInteger(targetId) && targetId > 0) {
condition = 'AND id <> $1'
params.push(targetId)
}
const result = await query(
`SELECT id FROM blog_posts WHERE is_editors_pick = true ${condition}`,
params
)
if (result.rows.length >= 3) {
const ids = result.rows.map(r => r.id)
throw new Error(`Only three editor's picks allowed. Currently set: ${ids.join(', ')}`)
}
}
function getUploadFields() {
const fields = [{ name: 'mainImage', maxCount: 1 }]
for (let index = 0; index < MAX_SECTIONS; index += 1) {
fields.push({ name: `section${index}Image`, maxCount: 1 })
}
return fields
}
app.get('/posts', async (_req, res) => {
try {
const result = await query(
'SELECT * FROM blog_posts ORDER BY created_at DESC'
)
const posts = result.rows.map(mapPostRow).map(post => ({
...post,
excerpt: createExcerpt(post.sections)
}))
res.json({ data: posts })
} catch (error) {
console.error('[GET /posts] error', error)
res.status(500).json({ error: 'Failed to fetch posts' })
}
})
app.get('/posts/:id', async (req, res) => {
const { id } = req.params
try {
const result = await query('SELECT * FROM blog_posts WHERE id = $1', [id])
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Post not found' })
}
const post = mapPostRow(result.rows[0])
post.excerpt = createExcerpt(post.sections)
return res.json({ data: post })
} catch (error) {
console.error('[GET /posts/:id] error', error)
return res.status(500).json({ error: 'Failed to fetch post' })
}
})
app.post('/posts', upload.fields(getUploadFields()), async (req, res) => {
try {
const payload = parsePayload(req.body)
if (!payload.title || !payload.title.trim()) {
return res.status(400).json({ error: 'Title is required' })
}
const mainImage = buildMainImage(payload, req.files)
const sections = buildSections(payload, req.files)
const slug = await generateUniqueSlug(payload.title.trim())
const isEditorsPick = Boolean(payload.isEditorsPick)
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)
RETURNING *`,
[
payload.title.trim(),
slug,
mainImage,
payload.linkUrl || null,
JSON.stringify(sections),
payload.footer || null,
isEditorsPick
]
)
if (isEditorsPick) {
try {
await ensureEditorsPickLimit(result.rows[0].id, true)
} catch (limitError) {
await query('UPDATE blog_posts SET is_editors_pick = false WHERE id = $1', [result.rows[0].id])
throw limitError
}
}
const post = mapPostRow(result.rows[0])
post.excerpt = createExcerpt(post.sections)
res.status(201).json({ data: post })
} catch (error) {
console.error('[POST /posts] error', error)
const message = error.message || 'Failed to create post'
res.status(400).json({ error: message })
}
})
app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => {
const { id } = req.params
try {
const payload = parsePayload(req.body)
if (!payload.title || !payload.title.trim()) {
return res.status(400).json({ error: 'Title is required' })
}
const existingResult = await query('SELECT * FROM blog_posts WHERE id = $1', [id])
if (existingResult.rows.length === 0) {
return res.status(404).json({ error: 'Post not found' })
}
const existingPost = mapPostRow(existingResult.rows[0])
const mainImage = buildMainImage(payload, req.files)
const sections = buildSections(payload, req.files)
const nextSlug = await generateUniqueSlug(payload.title.trim(), Number(id))
const isEditorsPick = Boolean(payload.isEditorsPick)
if (isEditorsPick && !existingPost.isEditorsPick) {
await ensureEditorsPickLimit(Number(id), true)
}
const result = await query(
`UPDATE blog_posts
SET title = $1,
slug = $2,
preview_image = $3,
link_url = $4,
sections = $5,
footer = $6,
is_editors_pick = $7
WHERE id = $8
RETURNING *`,
[
payload.title.trim(),
nextSlug,
mainImage,
payload.linkUrl || null,
JSON.stringify(sections),
payload.footer || null,
isEditorsPick,
id
]
)
const post = mapPostRow(result.rows[0])
post.excerpt = createExcerpt(post.sections)
res.json({ data: post })
} catch (error) {
console.error('[PUT /posts/:id] error', error)
const status = error.message && error.message.includes('editor') ? 400 : 500
res.status(status).json({ error: error.message || 'Failed to update post' })
}
})
app.delete('/posts/:id', async (req, res) => {
const { id } = req.params
try {
const existing = await query('SELECT * FROM blog_posts WHERE id = $1', [id])
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Post not found' })
}
await query('DELETE FROM blog_posts WHERE id = $1', [id])
return res.json({ success: true })
} catch (error) {
console.error('[DELETE /posts/:id] error', error)
return res.status(500).json({ error: 'Failed to delete post' })
}
})
app.patch('/posts/:id/editors-pick', async (req, res) => {
const { id } = req.params
const makePick = Boolean(req.body?.isEditorsPick)
try {
const existing = await query('SELECT * FROM blog_posts WHERE id = $1', [id])
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Post not found' })
}
if (makePick && !existing.rows[0].is_editors_pick) {
await ensureEditorsPickLimit(Number(id), true)
}
const result = await query(
'UPDATE blog_posts SET is_editors_pick = $1 WHERE id = $2 RETURNING *',
[makePick, id]
)
const post = mapPostRow(result.rows[0])
post.excerpt = createExcerpt(post.sections)
return res.json({ data: post })
} catch (error) {
console.error('[PATCH /posts/:id/editors-pick] error', error)
const status = error.message && error.message.includes('Only three') ? 400 : 500
return res.status(status).json({ error: error.message || 'Failed to update editor pick' })
}
})
async function start() {
try {
await runMigrations()
app.listen(PORT, '0.0.0.0', () => {
console.log(`[api] listening on port ${PORT}`)
})
} catch (error) {
console.error('[api] failed to start', error)
await closePool()
process.exit(1)
}
}
start()

46
server/migrations.js Normal file
View File

@ -0,0 +1,46 @@
const { query } = require('./db')
async function runMigrations() {
await query(`
CREATE TABLE IF NOT EXISTS blog_posts (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
preview_image TEXT,
link_url TEXT,
sections JSONB NOT NULL DEFAULT '[]'::jsonb,
footer TEXT,
is_editors_pick BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
await query(`
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`)
await query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_trigger
WHERE tgname = 'trg_blog_posts_updated_at'
) THEN
CREATE TRIGGER trg_blog_posts_updated_at
BEFORE UPDATE ON blog_posts
FOR EACH ROW
EXECUTE PROCEDURE set_updated_at();
END IF;
END; $$;
`)
}
module.exports = { runMigrations }

42
server/storage.js Normal file
View File

@ -0,0 +1,42 @@
const multer = require('multer')
const path = require('path')
const fs = require('fs')
const uploadsDir = path.join(__dirname, '..', 'public', 'uploads')
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, uploadsDir)
},
filename: (_req, file, cb) => {
const timestamp = Date.now()
const random = Math.round(Math.random() * 1e9)
const ext = path.extname(file.originalname) || '.bin'
cb(null, `${timestamp}-${random}${ext}`)
}
})
function fileFilter(_req, file, cb) {
if (file.mimetype.startsWith('image/')) {
cb(null, true)
} else {
cb(new Error('Only image uploads are allowed'))
}
}
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB per file
}
})
module.exports = {
upload,
uploadsDir
}

698
src/app/admin/page.tsx Normal file
View File

@ -0,0 +1,698 @@
'use client'
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { BlogPost, BlogPostSection } from '@/types/blog'
import { resolveMediaUrl } from '@/lib/media'
const MAX_SECTIONS = 5
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4005'
type SectionFormState = {
id: string
text: string
imageFile: File | null
existingImage?: string | null
previewUrl?: string | null
}
type MainImageState = {
file: File | null
existing?: string | null
previewUrl?: string | null
removed: boolean
}
function createId(prefix: string = 'section') {
if (typeof globalThis !== 'undefined' && globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
return globalThis.crypto.randomUUID()
}
return `${prefix}-${Math.random().toString(36).slice(2, 11)}`
}
function createEmptySection(): SectionFormState {
return {
id: createId(),
text: '',
imageFile: null,
existingImage: null,
previewUrl: null
}
}
function ensureSectionSlots(sections: SectionFormState[]): SectionFormState[] {
const filled = [...sections]
while (filled.length < MAX_SECTIONS) {
filled.push(createEmptySection())
}
return filled.slice(0, MAX_SECTIONS)
}
function normaliseText(value: string) {
return value.trim()
}
export default function AdminPage() {
const [posts, setPosts] = useState<BlogPost[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [editingPost, setEditingPost] = useState<BlogPost | null>(null)
const [showForm, setShowForm] = useState(false)
const [title, setTitle] = useState('')
const [linkUrl, setLinkUrl] = useState('')
const [footer, setFooter] = useState('')
const [isEditorsPick, setIsEditorsPick] = useState(false)
const [mainImage, setMainImage] = useState<MainImageState>({
file: null,
existing: null,
previewUrl: null,
removed: false
})
const [sections, setSections] = useState<SectionFormState[]>(ensureSectionSlots([]))
const editorsPickCount = useMemo(
() => posts.filter(post => post.isEditorsPick).length,
[posts]
)
useEffect(() => {
loadPosts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function loadPosts() {
setLoading(true)
setError(null)
try {
const response = await fetch(`${API_BASE_URL}/posts`)
if (!response.ok) {
throw new Error(`Failed to load posts (${response.status})`)
}
const payload = await response.json()
setPosts(Array.isArray(payload.data) ? payload.data : [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load posts')
} finally {
setLoading(false)
}
}
function resetForm() {
setTitle('')
setLinkUrl('')
setFooter('')
setIsEditorsPick(false)
setMainImage({ file: null, existing: null, previewUrl: null, removed: false })
setSections(ensureSectionSlots([]))
setEditingPost(null)
setShowForm(false)
}
function populateForm(post: BlogPost) {
setTitle(post.title)
setLinkUrl(post.linkUrl || '')
setFooter(post.footer || '')
setIsEditorsPick(post.isEditorsPick)
setMainImage({
file: null,
existing: post.previewImage || null,
previewUrl: resolveMediaUrl(post.previewImage) || null,
removed: !post.previewImage
})
const filledSections = ensureSectionSlots(
post.sections.map((section: BlogPostSection): SectionFormState => ({
id: section.id || createId(),
text: section.text || '',
imageFile: null,
existingImage: section.image || null,
previewUrl: resolveMediaUrl(section.image) || null
}))
)
setSections(filledSections)
}
function handleStartCreate() {
resetForm()
setShowForm(true)
}
function handleEdit(post: BlogPost) {
setEditingPost(post)
populateForm(post)
setShowForm(true)
}
function handleMainImageChange(fileList: FileList | null) {
if (!fileList || fileList.length === 0) {
setMainImage(prev => ({ ...prev, file: null, previewUrl: resolveMediaUrl(prev.existing) || null, removed: false }))
return
}
const nextFile = fileList[0]
const previewUrl = URL.createObjectURL(nextFile)
setMainImage(prev => {
if (prev.previewUrl && prev.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(prev.previewUrl)
}
return {
file: nextFile,
existing: prev.existing,
previewUrl,
removed: false
}
})
}
function handleRemoveMainImage() {
setMainImage(prev => {
if (prev.previewUrl && prev.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(prev.previewUrl)
}
return {
file: null,
existing: null,
previewUrl: null,
removed: true
}
})
}
function updateSection(index: number, partial: Partial<SectionFormState>) {
setSections(current => {
const clone = [...current]
const next = { ...clone[index], ...partial }
clone[index] = next
return ensureSectionSlots(clone)
})
}
function handleSectionTextChange(index: number, value: string) {
updateSection(index, { text: value })
}
function handleSectionImageChange(index: number, fileList: FileList | null) {
setSections(current => {
const clone = [...current]
const target = { ...clone[index] }
if (target.previewUrl && target.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(target.previewUrl)
}
if (!fileList || fileList.length === 0) {
target.imageFile = null
target.previewUrl = resolveMediaUrl(target.existingImage) || null
} else {
const file = fileList[0]
target.imageFile = file
target.previewUrl = URL.createObjectURL(file)
}
clone[index] = target
return ensureSectionSlots(clone)
})
}
function handleRemoveSectionImage(index: number) {
updateSection(index, {
imageFile: null,
existingImage: null,
previewUrl: null
})
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const trimmedTitle = normaliseText(title)
if (!trimmedTitle) {
setError('Title is required')
return
}
setSaving(true)
setError(null)
try {
const formData = new FormData()
if (mainImage.file) {
formData.append('mainImage', mainImage.file)
}
sections.forEach((section, index) => {
if (section.imageFile) {
formData.append(`section${index}Image`, section.imageFile)
}
})
const payload = {
title: trimmedTitle,
linkUrl: normaliseText(linkUrl) || undefined,
footer: normaliseText(footer) || undefined,
isEditorsPick,
existingMainImage: mainImage.file || mainImage.removed ? undefined : mainImage.existing,
removeMainImage: mainImage.removed,
sections: sections.map(section => ({
id: section.id,
text: normaliseText(section.text) || undefined,
existingImage: section.imageFile ? undefined : section.existingImage || undefined
}))
}
formData.append('payload', JSON.stringify(payload))
const method = editingPost ? 'PUT' : 'POST'
const endpoint = editingPost
? `${API_BASE_URL}/posts/${editingPost.id}`
: `${API_BASE_URL}/posts`
const response = await fetch(endpoint, {
method,
body: formData
})
const body = await response.json().catch(() => null)
if (!response.ok) {
const message = body?.error || 'Failed to save blog post'
throw new Error(message)
}
await loadPosts()
resetForm()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save blog post')
} finally {
setSaving(false)
}
}
async function handleDelete(postId: number) {
if (!window.confirm('Delete this blog post?')) {
return
}
setError(null)
try {
const response = await fetch(`${API_BASE_URL}/posts/${postId}`, {
method: 'DELETE'
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error || 'Failed to delete post')
}
await loadPosts()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to delete blog post')
}
}
async function handleToggleEditorsPick(post: BlogPost) {
setError(null)
try {
const response = await fetch(`${API_BASE_URL}/posts/${post.id}/editors-pick`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ isEditorsPick: !post.isEditorsPick })
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error || 'Failed to update editor pick')
}
await loadPosts()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to update editor pick')
}
}
return (
<div style={{ padding: '32px', maxWidth: '1200px', margin: '0 auto' }}>
<header style={{ marginBottom: '32px' }}>
<h1
style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '48px',
marginBottom: '12px'
}}
>
Admin - Blog Management
</h1>
<p
style={{
fontFamily: 'Spectral, serif',
fontSize: '18px',
lineHeight: 1.6,
color: '#4A4A4A'
}}
>
Create, edit, and curate your blog posts. You can select up to three Editor's Picks. The most recently published entry is automatically highlighted as the "Last Blog Post" on the homepage.
</p>
</header>
{error && (
<div
style={{
marginBottom: '24px',
padding: '16px',
border: '1px solid #c0392b',
backgroundColor: '#fceaea',
color: '#c0392b',
fontFamily: 'Space Mono, monospace'
}}
>
{error}
</div>
)}
<section style={{ marginBottom: '40px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}
>
<h2 style={{ fontFamily: 'Space Mono, monospace', letterSpacing: '0.2em' }}>
Posts Overview
</h2>
<button
type="button"
onClick={handleStartCreate}
style={{
padding: '10px 18px',
border: '2px solid #8B7D6B',
backgroundColor: '#1E1A17',
color: '#F7F1E1',
fontFamily: 'Space Mono, monospace',
letterSpacing: '0.15em',
textTransform: 'uppercase',
cursor: 'pointer'
}}
>
New Blog Post
</button>
</div>
<div style={{ marginBottom: '12px', fontFamily: 'Space Mono, monospace', fontSize: '12px' }}>
Editor's Picks selected: {editorsPickCount} / 3
</div>
{loading ? (
<p style={{ fontFamily: 'Spectral, serif' }}>Loading posts...</p>
) : posts.length === 0 ? (
<p style={{ fontFamily: 'Spectral, serif' }}>No posts yet. Create your first story.</p>
) : (
<table
style={{
width: '100%',
borderCollapse: 'collapse',
backgroundColor: 'white',
border: '1px solid #8B7D6B'
}}
>
<thead style={{ backgroundColor: '#F7F1E1' }}>
<tr>
<th style={{ padding: '12px', borderBottom: '1px solid #8B7D6B', textAlign: 'left' }}>Title</th>
<th style={{ padding: '12px', borderBottom: '1px solid #8B7D6B', textAlign: 'left' }}>Created</th>
<th style={{ padding: '12px', borderBottom: '1px solid #8B7D6B' }}>Editor's Pick</th>
<th style={{ padding: '12px', borderBottom: '1px solid #8B7D6B' }}>Actions</th>
</tr>
</thead>
<tbody>
{posts.map(post => (
<tr key={post.id}>
<td style={{ padding: '12px', borderBottom: '1px solid #E0D7C6' }}>{post.title}</td>
<td style={{ padding: '12px', borderBottom: '1px solid #E0D7C6' }}>
{new Date(post.createdAt).toLocaleString()}
</td>
<td style={{ padding: '12px', borderBottom: '1px solid #E0D7C6', textAlign: 'center' }}>
<button
type="button"
onClick={() => handleToggleEditorsPick(post)}
style={{
padding: '6px 10px',
border: '1px solid #8B7D6B',
backgroundColor: post.isEditorsPick ? '#C89C2B' : 'transparent',
color: post.isEditorsPick ? '#F7F1E1' : '#1E1A17',
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
letterSpacing: '0.1em',
textTransform: 'uppercase',
cursor: 'pointer'
}}
>
{post.isEditorsPick ? 'Selected' : 'Select'}
</button>
</td>
<td style={{ padding: '12px', borderBottom: '1px solid #E0D7C6', textAlign: 'center' }}>
<button
type="button"
onClick={() => handleEdit(post)}
style={{
marginRight: '8px',
padding: '6px 12px',
border: '1px solid #8B7D6B',
backgroundColor: '#F7F1E1',
cursor: 'pointer'
}}
>
Edit
</button>
<button
type="button"
onClick={() => handleDelete(post.id)}
style={{
padding: '6px 12px',
border: '1px solid #c0392b',
backgroundColor: '#c0392b',
color: '#fff',
cursor: 'pointer'
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{showForm && (
<section
style={{
border: '2px solid #8B7D6B',
padding: '24px',
backgroundColor: '#F7F1E1',
marginBottom: '40px'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontFamily: 'Space Mono, monospace', letterSpacing: '0.2em' }}>
{editingPost ? 'Edit Blog Post' : 'Create Blog Post'}
</h2>
<button
type="button"
onClick={resetForm}
style={{
border: 'none',
background: 'transparent',
fontSize: '20px',
cursor: 'pointer'
}}
aria-label="Close form"
>
X
</button>
</div>
<form onSubmit={handleSubmit}>
<div style={{ display: 'grid', gap: '18px', marginTop: '24px' }}>
<label style={{ display: 'grid', gap: '8px' }}>
<span style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px', letterSpacing: '0.15em' }}>
Title
</span>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
style={{ padding: '12px', border: '1px solid #8B7D6B' }}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px', letterSpacing: '0.15em' }}>
Main Picture (Preview Image)
</span>
<input
type="file"
accept="image/*"
onChange={(event) => handleMainImageChange(event.target.files)}
/>
{(mainImage.previewUrl || mainImage.existing) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{mainImage.previewUrl && (
<img
src={mainImage.previewUrl}
alt="Preview"
style={{ width: '120px', height: 'auto', border: '1px solid #8B7D6B' }}
/>
)}
<button
type="button"
onClick={handleRemoveMainImage}
style={{
padding: '6px 10px',
border: '1px solid #8B7D6B',
background: 'transparent',
cursor: 'pointer'
}}
>
Remove image
</button>
</div>
)}
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px', letterSpacing: '0.15em' }}>
Product Link (optional)
</span>
<input
type="url"
value={linkUrl}
placeholder="https://"
onChange={(event) => setLinkUrl(event.target.value)}
style={{ padding: '12px', border: '1px solid #8B7D6B' }}
/>
</label>
{sections.map((section, index) => (
<div
key={section.id}
style={{
borderTop: index === 0 ? 'none' : '1px dashed #8B7D6B',
paddingTop: index === 0 ? 0 : '18px'
}}
>
<h3 style={{ fontFamily: 'Space Mono, monospace', letterSpacing: '0.15em', fontSize: '12px' }}>
Section {index + 1}
</h3>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<textarea
value={section.text}
onChange={(event) => handleSectionTextChange(index, event.target.value)}
placeholder="Text"
rows={4}
style={{ padding: '12px', border: '1px solid #8B7D6B', resize: 'vertical' }}
/>
<input
type="file"
accept="image/*"
onChange={(event) => handleSectionImageChange(index, event.target.files)}
/>
{(section.previewUrl || section.existingImage) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{section.previewUrl && (
<img
src={section.previewUrl}
alt="Section preview"
style={{ width: '120px', height: 'auto', border: '1px solid #8B7D6B' }}
/>
)}
<button
type="button"
onClick={() => handleRemoveSectionImage(index)}
style={{
padding: '6px 10px',
border: '1px solid #8B7D6B',
background: 'transparent',
cursor: 'pointer'
}}
>
Remove image
</button>
</div>
)}
</div>
</div>
))}
<label style={{ display: 'grid', gap: '8px' }}>
<span style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px', letterSpacing: '0.15em' }}>
Possible Footer (optional)
</span>
<textarea
value={footer}
onChange={(event) => setFooter(event.target.value)}
rows={3}
style={{ padding: '12px', border: '1px solid #8B7D6B', resize: 'vertical' }}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input
type="checkbox"
checked={isEditorsPick}
onChange={(event) => setIsEditorsPick(event.target.checked)}
/>
<span style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px', letterSpacing: '0.15em' }}>
Mark as Editor's Pick (max 3)
</span>
</label>
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="submit"
disabled={saving}
style={{
padding: '12px 24px',
border: '2px solid #8B7D6B',
backgroundColor: '#1E1A17',
color: '#F7F1E1',
fontFamily: 'Space Mono, monospace',
letterSpacing: '0.2em',
textTransform: 'uppercase',
cursor: 'pointer',
opacity: saving ? 0.7 : 1
}}
>
{saving ? 'Saving...' : editingPost ? 'Save Changes' : 'Publish Post'}
</button>
<button
type="button"
onClick={resetForm}
style={{
padding: '12px 24px',
border: '2px solid #8B7D6B',
backgroundColor: 'transparent',
fontFamily: 'Space Mono, monospace',
letterSpacing: '0.2em',
textTransform: 'uppercase',
cursor: 'pointer'
}}
>
Cancel
</button>
</div>
</div>
</form>
</section>
)}
</div>
)
}

View File

@ -1,165 +1,29 @@
'use client'
import { HomePageClient } from '@/components/HomePageClient'
import { BlogPost } from '@/types/blog'
import { useState } from 'react'
import { ScrollEffects } from '@/components/ScrollEffects'
import { FloatingElements } from '@/components/FloatingElements'
import { ClientOnly } from '@/components/ClientOnly'
import { BlogPostCard, BlogPostModal } from '@/components/BlogPost'
import { blogPosts, BlogPost } from '@/data/blogPosts'
async function getBlogPosts(): Promise<BlogPost[]> {
const baseUrl = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4005'
export default function HomePage() {
const [selectedBlogPost, setSelectedBlogPost] = useState<BlogPost | null>(null)
try {
const response = await fetch(`${baseUrl}/posts`, {
cache: 'no-store',
next: { revalidate: 0 }
})
if (!response.ok) {
console.error(`[frontend] Failed to load posts: ${response.status}`)
return []
}
return (
<div style={{ minHeight: '100vh', backgroundColor: '#F7F1E1' }}>
{/* Scroll Effects */}
<ClientOnly>
<ScrollEffects />
</ClientOnly>
{/* Floating Background Elements */}
<ClientOnly>
<FloatingElements />
</ClientOnly>
{/* Header */}
<header className="vintage-header">
<div className="container">
{/* Masthead */}
<div className="masthead">
<h1>The Curated Finds</h1>
<div className="subtitle">BLOG</div>
<div className="newspaper-rule"></div>
<p className="tagline">Handpicked Treasures from the eBay Universe Est. 2024</p>
</div>
</div>
</header>
{/* Hero Section with Search */}
<section className="hero-section">
<div className="hero-content">
{/* Editor's Seal */}
<div className="editor-seal">
Editor's<br />Choice
</div>
{/* Main Headline with Drop Cap */}
<div>
<p className="drop-cap-text">
Handpicked treasures from the eBay universe updated weekly by experts who know quality when they see it. Every piece tells a story, every find has character, and every discovery connects you to the rich history of human craftsmanship.
</p>
</div>
<div className="newspaper-rule"></div>
{/* Subheading */}
<p className="hero-subheading">
Curated Finds Curated Collections Expert Insights
</p>
{/* CTAs */}
<div className="hero-ctas">
<a href="#blog-posts" className="ticket-button">
Read Collector's Chronicles
</a>
</div>
{/* Trust Badge */}
<div className="trust-badge">
<div className="trust-badge-icon"></div>
<p className="trust-badge-text">No spam, just curated finds 100% authentic</p>
</div>
</div>
</section>
{/* Blog Posts Section */}
<section id="blog-posts" style={{
padding: '64px 20px',
backgroundColor: '#F7F1E1',
borderTop: '2px solid #8B7D6B'
}}>
<div className="container">
<div style={{ textAlign: 'center', marginBottom: '48px' }}>
<h2 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '48px',
fontWeight: '900',
color: '#1E1A17',
marginBottom: '16px'
}}>
Collector's Chronicles
</h2>
<div className="newspaper-rule"></div>
<p style={{
fontFamily: 'Space Mono, monospace',
fontSize: '14px',
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: '#8B7D6B'
}}>
Deep dives into remarkable finds
</p>
</div>
<div style={{
maxWidth: '800px',
margin: '0 auto'
}}>
{blogPosts.map((post) => (
<BlogPostCard
key={post.id}
post={post}
onReadMore={setSelectedBlogPost}
/>
))}
</div>
</div>
</section>
{/* Footer */}
<footer style={{
backgroundColor: '#F7F1E1',
borderTop: '2px solid #8B7D6B',
padding: '32px 20px'
}}>
<div className="container">
<div style={{ textAlign: 'center' }}>
<h3 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
margin: 0,
color: '#1E1A17'
}}>
The Curated Finds
</h3>
<p style={{
fontFamily: 'Spectral, serif',
fontSize: '14px',
color: '#4A4A4A',
marginTop: '8px'
}}>
Handpicked treasures. Honest descriptions. Careful packaging.
</p>
<div className="newspaper-rule" style={{ margin: '16px auto', width: '120px' }} />
<p style={{ color: '#8B7D6B', fontFamily: 'Space Mono, monospace', fontSize: '12px' }}>
© {new Date().getFullYear()} The Curated Finds All rights reserved.
</p>
</div>
</div>
</footer>
{/* Blog Post Modal */}
{selectedBlogPost && (
<BlogPostModal
post={selectedBlogPost}
onClose={() => setSelectedBlogPost(null)}
/>
)}
</div>
)
const payload = await response.json()
return Array.isArray(payload.data) ? payload.data : []
} catch (error) {
console.error('[frontend] Failed to load posts', error)
return []
}
}
export default async function HomePage() {
const posts = await getBlogPosts()
return <HomePageClient posts={posts} />
}

View File

@ -1,163 +1,176 @@
import React from 'react'
import { BlogPost } from '@/data/blogPosts'
import React from 'react'
import { resolveMediaUrl } from '@/lib/media'
import { BlogPost, BlogPostSection } from '@/types/blog'
interface BlogPostCardProps {
post: BlogPost
onReadMore: (post: BlogPost) => void
isLatest: boolean
}
export function BlogPostCard({ post, onReadMore }: BlogPostCardProps) {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
const cardStyles = {
container: {
position: 'relative' as const,
backgroundColor: 'white',
border: '2px solid #8B7D6B',
padding: '24px',
marginBottom: '32px',
boxShadow: '4px 4px 0 rgba(139, 125, 107, 0.2)'
},
imageWrapper: {
marginBottom: '24px',
marginLeft: '-24px',
marginRight: '-24px',
marginTop: '-24px',
height: '280px',
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundColor: 'white',
borderBottom: '2px solid #8B7D6B'
},
title: {
fontFamily: 'Abril Fatface, serif',
fontSize: '28px',
fontWeight: 900,
color: '#1E1A17',
marginBottom: '16px',
lineHeight: 1.2
},
excerpt: {
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: 1.6,
marginBottom: '20px'
}
}
function formatDate(timestamp: string) {
const date = new Date(timestamp)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
export function BlogPostCard({ post, onReadMore, isLatest }: BlogPostCardProps) {
const previewUrl = resolveMediaUrl(post.previewImage)
return (
<article style={{
backgroundColor: 'white',
border: '2px solid #8B7D6B',
padding: '24px',
marginBottom: '32px',
position: 'relative',
boxShadow: '4px 4px 0 rgba(139, 125, 107, 0.2)'
}}>
{/* Featured Badge */}
{post.featured && (
<div style={{
position: 'absolute',
top: '-12px',
right: '24px',
backgroundColor: '#C89C2B',
color: '#F7F1E1',
padding: '4px 12px',
fontFamily: 'Pacifico, cursive',
fontSize: '14px',
transform: 'rotate(3deg)',
boxShadow: '2px 2px 4px rgba(0,0,0,0.2)'
}}>
Featured
<article style={cardStyles.container}>
{post.isEditorsPick && (
<div
style={{
position: 'absolute',
top: '32px',
left: '-60px',
padding: '8px 64px',
backgroundColor: '#C89C2B',
color: '#F7F1E1',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.2em',
textTransform: 'uppercase',
transform: 'rotate(-45deg)',
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
}}
>
Editor's Pick
</div>
)}
{/* Header Image */}
{(post.previewImage || post.images[0]) && (
<div style={{
marginBottom: '24px',
marginLeft: '-24px',
marginRight: '-24px',
marginTop: '-24px',
height: '300px',
backgroundImage: `url('${post.previewImage || post.images[0]}')`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundColor: 'white',
borderBottom: '2px solid #8B7D6B'
}} />
{isLatest && (
<div
style={{
position: 'absolute',
top: '16px',
right: '16px',
backgroundColor: '#1E1A17',
color: '#F7F1E1',
padding: '6px 12px',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.15em',
textTransform: 'uppercase'
}}
>
Last Blog Post
</div>
)}
{previewUrl && (
<div
style={{
...cardStyles.imageWrapper,
backgroundImage: `url('${previewUrl}')`
}}
/>
)}
{/* Category & Date */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
marginBottom: '12px'
}}>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.15em',
color: '#C89C2B',
fontWeight: 'bold'
}}>
{post.category}
</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#8B7D6B'
}}>
{formatDate(post.datePublished)}
{formatDate(post.createdAt)}
</span>
</div>
{/* Title */}
<h2 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '28px',
fontWeight: '900',
color: '#1E1A17',
marginBottom: '16px',
lineHeight: '1.2'
}}>
{post.title}
</h2>
<h2 style={cardStyles.title}>{post.title}</h2>
{/* Excerpt */}
<p style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.6',
marginBottom: '20px'
}}>
{post.excerpt}
</p>
{post.linkUrl && (
<div style={{ marginBottom: '16px' }}>
<a
href={post.linkUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
backgroundColor: '#1E1A17',
color: '#F7F1E1',
padding: '10px 18px',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.15em',
textTransform: 'uppercase',
border: '2px solid #8B7D6B'
}}
>
To Produkt
</a>
</div>
)}
{/* Tags */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '20px'
}}>
{post.tags.slice(0, 3).map(tag => (
<span key={tag} style={{
backgroundColor: '#F7F1E1',
border: '1px solid #8B7D6B',
padding: '4px 8px',
fontFamily: 'Space Mono, monospace',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#8B7D6B'
}}>
{tag}
</span>
))}
</div>
{post.excerpt && <p style={cardStyles.excerpt}>{post.excerpt}</p>}
{/* Read More Button */}
<button
type="button"
onClick={() => onReadMore(post)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '12px 18px',
border: '2px solid #8B7D6B',
backgroundColor: 'transparent',
border: '2px solid #C89C2B',
color: '#C89C2B',
padding: '12px 24px',
fontFamily: 'Staatliches, sans-serif',
fontSize: '16px',
letterSpacing: '0.1em',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.15em',
textTransform: 'uppercase',
cursor: 'pointer',
transition: 'all 0.3s ease',
position: 'relative',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#C89C2B'
e.currentTarget.style.color = '#F7F1E1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = '#C89C2B'
cursor: 'pointer'
}}
>
Read Full Article
Read More
</button>
</article>
)
@ -168,350 +181,198 @@ interface BlogPostModalProps {
onClose: () => void
}
export function BlogPostModal({ post, onClose }: BlogPostModalProps) {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
function renderSection(section: BlogPostSection) {
const imageUrl = resolveMediaUrl(section.image)
// Convert markdown-style formatting to HTML
const formatContent = (content: string) => {
return content
.split('\n\n')
.map((paragraph, index) => {
// Handle headers
if (paragraph.startsWith('## ')) {
return (
<h3 key={index} style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
fontWeight: '900',
color: '#1E1A17',
marginTop: '32px',
marginBottom: '16px'
}}>
{paragraph.replace('## ', '')}
</h3>
)
}
if (paragraph.startsWith('### ')) {
return (
<h4 key={index} style={{
fontFamily: 'Staatliches, sans-serif',
fontSize: '20px',
color: '#C89C2B',
marginTop: '24px',
marginBottom: '12px'
}}>
{paragraph.replace('### ', '')}
</h4>
)
}
// Handle lists
if (paragraph.startsWith('- ')) {
const items = paragraph.split('\n').filter(line => line.startsWith('- '))
return (
<ul key={index} style={{
marginBottom: '16px',
paddingLeft: '24px'
}}>
{items.map((item, i) => (
<li key={i} style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '8px'
}}>
{item.replace('- ', '').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')}
</li>
))}
</ul>
)
}
// Regular paragraphs with bold text support
const formattedText = paragraph.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
return (
<p key={index} style={{
return (
<div key={section.id} style={{ marginBottom: '32px' }}>
{section.text && (
<p
style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '16px'
}} dangerouslySetInnerHTML={{ __html: formattedText }} />
)
})
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(30, 26, 23, 0.8)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
overflowY: 'auto'
}} onClick={onClose}>
<article style={{
backgroundColor: '#F7F1E1',
maxWidth: '900px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
padding: '48px',
position: 'relative',
boxShadow: '0 10px 40px rgba(0,0,0,0.3)'
}} onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'transparent',
border: 'none',
fontSize: '32px',
cursor: 'pointer',
color: '#8B7D6B',
zIndex: 10
lineHeight: 1.8,
marginBottom: imageUrl ? '18px' : 0
}}
>
×
</button>
{/* Header */}
<div style={{ marginBottom: '32px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.15em',
color: '#C89C2B',
fontWeight: 'bold'
}}>
{post.category}
</span>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
color: '#8B7D6B'
}}>
{formatDate(post.datePublished)}
</span>
</div>
<h1 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '42px',
fontWeight: '900',
color: '#1E1A17',
lineHeight: '1.2',
marginBottom: '24px'
}}>
{post.title}
</h1>
<div className="newspaper-rule" style={{
width: '100px',
height: '2px',
backgroundColor: '#C89C2B',
margin: '24px 0'
}} />
{section.text}
</p>
)}
{imageUrl && (
<div
style={{
border: '2px solid #8B7D6B',
backgroundColor: 'white',
padding: '6px',
maxWidth: '420px',
width: '100%',
margin: '0 auto'
}}
>
<img
src={imageUrl}
alt="Blog section"
style={{
display: 'block',
width: '100%',
height: 'auto',
backgroundColor: '#F7F1E1'
}}
/>
</div>
{/* Content with interleaved images */}
<div style={{ marginBottom: '32px' }}>
{(() => {
const blocks = post.content.split('\n\n')
const nodes: React.ReactNode[] = []
const totalBlocks = blocks.length
const totalImages = post.images.length
const step = totalImages > 0 ? Math.ceil(totalBlocks / (totalImages + 1)) : Infinity
let imageIndex = 0
blocks.forEach((paragraph, index) => {
// Headers
if (paragraph.startsWith('## ')) {
nodes.push(
<h3 key={`h3-${index}`} style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
fontWeight: '900',
color: '#1E1A17',
marginTop: '32px',
marginBottom: '16px'
}}>
{paragraph.replace('## ', '')}
</h3>
)
} else if (paragraph.startsWith('### ')) {
nodes.push(
<h4 key={`h4-${index}`} style={{
fontFamily: 'Staatliches, sans-serif',
fontSize: '20px',
color: '#C89C2B',
marginTop: '24px',
marginBottom: '12px'
}}>
{paragraph.replace('### ', '')}
</h4>
)
} else if (paragraph.startsWith('- ')) {
const items = paragraph.split('\n').filter(line => line.startsWith('- '))
nodes.push(
<ul key={`ul-${index}`} style={{
marginBottom: '16px',
paddingLeft: '24px'
}}>
{items.map((item, i) => (
<li
key={`li-${index}-${i}`}
style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '8px'
}}
dangerouslySetInnerHTML={{ __html: item.replace('- ', '').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') }}
/>
))}
</ul>
)
} else {
// Paragraphs
const formattedText = paragraph.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
nodes.push(
<p key={`p-${index}`} style={{
fontFamily: 'Spectral, serif',
fontSize: '16px',
color: '#4A4A4A',
lineHeight: '1.8',
marginBottom: '16px'
}} dangerouslySetInnerHTML={{ __html: formattedText }} />
)
}
// Interleave images evenly through the content
if (totalImages > 0 && imageIndex < totalImages && (index + 1) % step === 0) {
nodes.push(
<div
key={`img-${imageIndex}`}
style={{
margin: '16px 0 32px',
display: 'flex',
justifyContent: 'center'
}}
>
<div
style={{
border: '2px solid #8B7D6B',
backgroundColor: 'white',
padding: '6px',
maxWidth: '360px',
width: '100%'
}}
>
<img
src={post.images[imageIndex]}
alt={`Image ${imageIndex + 1}`}
style={{
display: 'block',
width: '100%',
height: 'auto',
backgroundColor: '#F7F1E1'
}}
/>
</div>
</div>
)
imageIndex++
}
})
// If any images remain, append them at the end
while (imageIndex < totalImages) {
nodes.push(
<div
key={`img-tail-${imageIndex}`}
style={{
margin: '16px 0 32px',
display: 'flex',
justifyContent: 'center'
}}
>
<div
style={{
border: '2px solid #8B7D6B',
backgroundColor: 'white',
padding: '6px',
maxWidth: '360px',
width: '100%'
}}
>
<img
src={post.images[imageIndex]}
alt={`Image ${imageIndex + 1}`}
style={{
display: 'block',
width: '100%',
height: 'auto',
backgroundColor: '#F7F1E1'
}}
/>
</div>
</div>
)
imageIndex++
}
return nodes
})()}
</div>
{/* Tags */}
<div style={{
borderTop: '2px solid #8B7D6B',
paddingTop: '24px',
display: 'flex',
flexWrap: 'wrap',
gap: '8px'
}}>
{post.tags.map(tag => (
<span key={tag} style={{
backgroundColor: 'white',
border: '1px solid #8B7D6B',
padding: '6px 12px',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: '#8B7D6B'
}}>
{tag}
</span>
))}
</div>
</article>
)}
</div>
)
}
export function BlogPostModal({ post, onClose }: BlogPostModalProps) {
const heroImage = resolveMediaUrl(post.previewImage)
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
overflowY: 'auto',
padding: '48px 20px'
}}
onClick={onClose}
>
<div
style={{
position: 'relative',
maxWidth: '820px',
width: '100%',
backgroundColor: '#F7F1E1',
border: '2px solid #8B7D6B',
padding: '32px',
boxShadow: '0 20px 60px rgba(0,0,0,0.35)'
}}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
style={{
position: 'absolute',
top: '16px',
right: '16px',
background: 'transparent',
border: 'none',
fontSize: '24px',
cursor: 'pointer'
}}
aria-label="Close"
>
X
</button>
{heroImage && (
<div
style={{
marginLeft: '-32px',
marginRight: '-32px',
marginTop: '-32px',
borderBottom: '2px solid #8B7D6B'
}}
>
<img
src={heroImage}
alt={post.title}
style={{ display: 'block', width: '100%', height: 'auto' }}
/>
</div>
)}
<header style={{ marginTop: '24px', marginBottom: '32px' }}>
<div style={{
display: 'flex',
gap: '16px',
alignItems: 'center',
marginBottom: '12px'
}}>
{post.isEditorsPick && (
<span style={{
backgroundColor: '#C89C2B',
color: '#F7F1E1',
padding: '6px 12px',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.15em',
textTransform: 'uppercase'
}}>
Editor's Pick
</span>
)}
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.15em',
color: '#8B7D6B',
textTransform: 'uppercase'
}}>
{formatDate(post.createdAt)}
</span>
</div>
<h2 style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '42px',
color: '#1E1A17',
marginBottom: '16px'
}}>
{post.title}
</h2>
{post.linkUrl && (
<a
href={post.linkUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
backgroundColor: '#1E1A17',
color: '#F7F1E1',
padding: '12px 22px',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.15em',
textTransform: 'uppercase',
border: '2px solid #8B7D6B'
}}
>
To Produkt
</a>
)}
</header>
<div>
{post.sections.map(section => renderSection(section))}
</div>
{post.footer && (
<footer style={{
borderTop: '2px solid #8B7D6B',
marginTop: '32px',
paddingTop: '24px'
}}>
<p style={{
fontFamily: 'Spectral, serif',
fontSize: '15px',
color: '#4A4A4A',
lineHeight: 1.6
}}>
{post.footer}
</p>
</footer>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,210 @@
'use client'
import { useMemo, useState } from 'react'
import { BlogPost } from '@/types/blog'
import { ScrollEffects } from '@/components/ScrollEffects'
import { FloatingElements } from '@/components/FloatingElements'
import { ClientOnly } from '@/components/ClientOnly'
import { BlogPostCard, BlogPostModal } from '@/components/BlogPost'
type HomePageClientProps = {
posts: BlogPost[]
}
export function HomePageClient({ posts }: HomePageClientProps) {
const [selectedBlogPost, setSelectedBlogPost] = useState<BlogPost | null>(null)
const latestPostId = useMemo(() => {
if (!posts.length) {
return null
}
return posts.reduce((latestId, current) => {
if (!latestId) {
return current.id
}
const latest = posts.find(post => post.id === latestId)
if (!latest) {
return current.id
}
return new Date(current.createdAt) > new Date(latest.createdAt) ? current.id : latestId
}, posts[0].id)
}, [posts])
return (
<div style={{ minHeight: '100vh', backgroundColor: '#F7F1E1' }}>
<ClientOnly>
<ScrollEffects />
</ClientOnly>
<ClientOnly>
<FloatingElements />
</ClientOnly>
<header className="vintage-header">
<div className="container">
<div className="masthead">
<h1>The Curated Finds</h1>
<div className="subtitle">BLOG</div>
<div className="newspaper-rule"></div>
<p className="tagline">
Handpicked Treasures from the eBay Universe - Est. 2024
</p>
</div>
</div>
</header>
<section className="hero-section">
<div className="hero-content">
<div className="editor-seal">
Editor's
<br />
Choice
</div>
<div>
<p className="drop-cap-text">
Handpicked treasures from the eBay universe - updated weekly by experts who know quality when they see it. Every piece tells a story, every find has character, and every discovery connects you to the rich history of human craftsmanship.
</p>
</div>
<div className="newspaper-rule"></div>
<p className="hero-subheading">
Curated Finds - Curated Collections - Expert Insights
</p>
<div className="hero-ctas">
<a href="#blog-posts" className="ticket-button">
Read Collector's Chronicles
</a>
</div>
<div className="trust-badge">
<div className="trust-badge-icon">OK</div>
<p className="trust-badge-text">
No spam, just curated finds - 100% authentic
</p>
</div>
</div>
</section>
<section
id="blog-posts"
style={{
padding: '64px 20px',
backgroundColor: '#F7F1E1',
borderTop: '2px solid #8B7D6B'
}}
>
<div className="container">
<div style={{ textAlign: 'center', marginBottom: '48px' }}>
<h2
style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '48px',
fontWeight: 900,
color: '#1E1A17',
marginBottom: '16px'
}}
>
Collector's Chronicles
</h2>
<div className="newspaper-rule"></div>
<p
style={{
fontFamily: 'Space Mono, monospace',
fontSize: '14px',
textTransform: 'uppercase',
letterSpacing: '0.2em',
color: '#8B7D6B'
}}
>
Deep dives into remarkable finds
</p>
</div>
<div
style={{
maxWidth: '800px',
margin: '0 auto'
}}
>
{posts.length === 0 && (
<p
style={{
fontFamily: 'Spectral, serif',
fontSize: '18px',
textAlign: 'center',
color: '#8B7D6B'
}}
>
No blog posts published yet. Visit the admin panel to add your first story.
</p>
)}
{posts.map(post => (
<BlogPostCard
key={post.id}
post={post}
isLatest={latestPostId === post.id}
onReadMore={setSelectedBlogPost}
/>
))}
</div>
</div>
</section>
<footer
style={{
backgroundColor: '#F7F1E1',
borderTop: '2px solid #8B7D6B',
padding: '32px 20px'
}}
>
<div className="container">
<div style={{ textAlign: 'center' }}>
<h3
style={{
fontFamily: 'Abril Fatface, serif',
fontSize: '24px',
margin: 0,
color: '#1E1A17'
}}
>
The Curated Finds
</h3>
<p
style={{
fontFamily: 'Spectral, serif',
fontSize: '14px',
color: '#4A4A4A',
marginTop: '8px'
}}
>
Handpicked treasures. Honest descriptions. Careful packaging.
</p>
<div
className="newspaper-rule"
style={{ margin: '16px auto', width: '120px' }}
/>
<p
style={{
color: '#8B7D6B',
fontFamily: 'Space Mono, monospace',
fontSize: '12px'
}}
>
(c) {new Date().getFullYear()} The Curated Finds - All rights reserved.
</p>
</div>
</div>
</footer>
{selectedBlogPost && (
<BlogPostModal post={selectedBlogPost} onClose={() => setSelectedBlogPost(null)} />
)}
</div>
)
}

View File

@ -1,656 +0,0 @@
export interface BlogPost {
id: string
title: string
slug: string
excerpt: string
content: string
images: string[]
datePublished: Date
category: string
tags: string[]
featured: boolean
previewImage?: string
}
// Static image imports for bundling
import magazine1 from '@/app/blogpostimg/magazine1.jpg'
import magazine2 from '@/app/blogpostimg/magazine2.jpg'
import magazine3 from '@/app/blogpostimg/magazine3.jpg'
import magazine4 from '@/app/blogpostimg/magazine4.jpg'
import matches1 from '@/app/blogpostimg/matches1.jpg'
import matches2 from '@/app/blogpostimg/matches2.jpg'
import matches3 from '@/app/blogpostimg/matches3.jpg'
import matches4 from '@/app/blogpostimg/matches4.jpg'
import matches5 from '@/app/blogpostimg/matches5.jpg'
import leather_clutch1 from '@/app/blogpostimg/leather_clutch1.jpg'
import leather_clutch2 from '@/app/blogpostimg/leather_clutch2.jpg'
import leather_clutch3 from '@/app/blogpostimg/leather_clutch3.jpg'
import leather_clutch4 from '@/app/blogpostimg/leather_clutch4.jpg'
import leather_clutch5 from '@/app/blogpostimg/leather_clutch5.jpg'
import chatgptMagazine from '@/app/blogpostimg/ChatGPT _magazine.png'
import chatgptMatches from '@/app/blogpostimg/ChatGPT_matches.png'
import chatgptLeatherClutch from '@/app/blogpostimg/ChatGPT_leather_clutch.png'
import cardDeckBanner from '@/app/blogpostimg/card_deck_banner.png'
import deckCard1 from '@/app/blogpostimg/deck_card1.png'
import deckCard2 from '@/app/blogpostimg/deck_card2.png'
import deckCard3 from '@/app/blogpostimg/deck_card3.png'
import deckCard4 from '@/app/blogpostimg/deck_card4.png'
import deckCard5 from '@/app/blogpostimg/deck_card5.png'
import deckCard6 from '@/app/blogpostimg/deck_card6.png'
import dollBanner from '@/app/blogpostimg/doll_banner.png'
import doll1 from '@/app/blogpostimg/doll1.png'
import doll2 from '@/app/blogpostimg/doll2.png'
import doll3 from '@/app/blogpostimg/doll3.png'
import timeMagazineBanner from '@/app/blogpostimg/time_magazine_banner.png'
import timeMagazine1 from '@/app/blogpostimg/time_magazine1.png'
import timeMagazine2 from '@/app/blogpostimg/time_magazine2.png'
import timeMagazine3 from '@/app/blogpostimg/time_magazine3.png'
export const blogPosts: BlogPost[] = [
{
id: 'time-magazine-1946',
title: 'One remarkable 1946 TIME—original Omar Bradley cover, carefully graded and ready to ship',
slug: 'time-magazine-1946-omar-bradley',
excerpt: 'Every so often I list something I wish I could keep. This is one of those weeks. A single, well-kept original TIME magazine from April 1, 1946.',
content: `Every so often I list something I wish I could keep. This is one of those weeks. I'm putting up a single, well-kept original **TIME** magazine—**April 1, 1946**—the **Omar Bradley Time cover** you've probably seen in history books, but rarely in the wild. If you've been looking for a **vintage Time magazine for sale** that's more "holdable history" than dusty attic find, this one's worth a closer look.
I buy what I love, and I describe it like I'd want it described to me: specifics, not superlatives. I'm a collector first and a seller second, which is why you'll see both the highlights and the scuffs called out plainly.
## What I'm selling & why
This copy came from a modest estate lot I picked up near Düsseldorfan engineer's place with the neatest shelves I've ever seen. The magazines had been stored upright in a cedar cabinet, away from sunlight and damp. When I cracked the box, the first thing I noticed (after that faint library smell) was the cover: Boris Chaliapin's painted portrait of **General Omar N. Bradley**, strong stare, clean red border. Under the title, a line asks, "How long does a war last?"which is exactly what the features inside wrestle with.
I'm listing it because I already own a keeper of this exact **Time magazine 1946** issue. Duplicates go to people who'll actually read them, display them, and enjoy the odd mix of gravitas and mid-century charm.
## Featured item (full details)
**TIME Magazine April 1, 1946 ("The Veterans' General Bradley")**
- **Condition:** **Good to Very Good** for age. Tight staples, square spine for a weekly, gentle rub on the red frame, light edge wear, no writing or stamps, no missing pages. Paper is supple with the expected warm tone (no brittleness).
- **Standout details:**
- **Cover:** Original Boris Chaliapin portrait of Omar Bradley; color is still lively.
- **Back cover:** Iconic Camel ad"More Doctors Smoke Camels Than Any Other Cigarette!"a conversation piece all by itself.
- **Inside:** Post-war features on veterans' benefits, industry ramp-up, and domestic life. There's also a handsome B.F. Goodrich spread and, yes, **Elsie the Cow**.
- **Price range:** **1624**, depending on whether you choose bag+board only or the archival clam-shell upgrade.
- **Why it's special:** This is the moment Bradley shifts from wartime commander to the public face of veteran care. If you collect military history covers, early Cold War context, or just appreciate mid-century editorial design, it checks a lot of boxes while staying genuinely readable.
## Behind the scenes (sourcing, care, and packaging)
I don't do heavy "restoration." No pressing, bleaching, or trimming. Here's my routine:
- **Screening:** I reject anything with mold, active foxing, or insect damage. This copy passed easily.
- **Surface clean:** Light pass with a vinyl eraser on the back cover and margins to lift handling marksnothing aggressive.
- **Flattening:** A couple of days under weight between buffered sheets to relax minor waves from storage.
- **Documentation:** I log notable ads/features and shoot close-ups of edges, corners, and staples so you can eyeball condition yourself.
Shipping is nerdy on purpose. The magazine goes into **mylar** with a **half-back board**, wrapped in kraft, then into a **rigid box** with corner protection. If you choose the **archival clam-shell** upgrade, you'll get a labeled, acid-free case so it can live upright on a shelf without spine stress. (And yes, I've done the "knee-height drop test." My packaging holds up.)
## Trust & proof
I've been selling vintage periodicals for years and keep things simple: accurate descriptions, fast replies, careful packing. You'll see consistent **5-star feedback** about honesty and condition photos. I ship **within 12 business days** from NRW. If something isn't as described, I make it rightcollectors deserve straight dealing.
## FAQ
**Is this a reprint?**
No. This is an **original 1946** weekly magazine. You'll see the masthead date and period stock/printing consistent with **Time magazine 1946** issues. I'm happy to send extra spine and paper-tone photos on request.
**How do you grade condition?**
I don't assign numeric grades. Instead I call out the important stuff: staple tightness, spine wear, edge chips, page tone, and any writing or stamps. In comic terms, this copy sits around **Good/VG**, mostly held back by light border rub and expected age toning.
**Can you combine shipping if I add another item?**
Sure thing. If you grab something else from my store, I'll combine shipping and refund any overage automatically. This magazine will always ship **boxed**, even when combined.
**What's the return policy?**
**30 days.** If I missed a significant flaw, I cover return shipping; if you change your mind, no problemsend it back in the same protective materials and I'll process a quick refund.
**How do you confirm authenticity?**
Beyond dates and typography, I rely on period stock, staple style, and content cues (ads, features, layout grid). Provenance helps too; this one came from a tidy, smoke-free estate with a small run of mid-40s titles stored upright.
## Ready to make it yours?
If **vintage Time magazine for sale** is in your search bar, this is precisely the kind of copy you hope to click: honest condition, lots of photos, and no surprises. It reads beautifully, displays even better, and brings that immediate post-war voice into your handsan editorial team trying to make sense of victory, grief, and the next chapter.
**Grab it here:** <a href="https://www.ebay.com/itm/396997013864?itmmeta=01K3RFYKHM0J79JBGG54EEB6RW&hash=item5c6eddb168:g:4dwAAeSwoqJorKN5&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
I pack the same day when I can, the next business day when I can't. If you want the **archival clam-shell**, select the upgrade at checkout or just drop me a note—happy to add it before I seal the box. And if you're the framing type, say the word; I'll mark the Camel back cover edge that looks best in a mat so you don't lose the good bits under glass.
Thanks for readingand for keeping these survivors in circulation. I like to think the editors who put this issue together would be pleased to know it's still being opened, handled, and talked about in 2025.`,
images: [
magazine1.src,
magazine2.src,
magazine3.src,
magazine4.src
],
datePublished: new Date('2025-01-15'),
category: 'Vintage Magazines',
tags: ['TIME Magazine', 'Omar Bradley', '1946', 'Vintage', 'Military History', 'Collectibles'],
featured: true,
previewImage: chatgptMagazine.src
},
{
id: 'vintage-las-vegas-matchbooks',
title: 'Vintage Las Vegas Matchbooks — New eBay Drop',
slug: 'vintage-las-vegas-matchbooks-ebay-drop',
excerpt: 'If you love the thrill of neon and the hush of a carpeted casino hallway, you\'ll like this: a fresh batch of vintage Las Vegas matchbooks.',
content: `If you love the thrill of neon and the hush of a carpeted casino hallway, you'll like this: I've just listed a fresh batch of vintage Las Vegas matchbooks—clear photos, fast shipping, easy returns.
They're small, affordable, and they tell big stories. One flips open and suddenly you're back under a pink marquee.
## What I'm selling & why
This release is a curated mix of **vintage Las Vegas matchbooks**, plus a few road-trip cousins from Texas, Colorado, and New Mexico. You'll find **casino matchbooks** from the Strip and Fremont Street alongside **hotel matchbooks** from the motor-inn era. Most are unstruck; many still have that glossy sheen that pops in a frame.
Why me? My family kept a shoebox of "freebies" from road stops. Dad grabbed anything with a good logo. Mom favored the typography. My job now is to rescue the best ones, research the property era, photograph them honestly, and send them to people who'll actually enjoy them. I list these because ephemera deserves a life outside a carton.
## Featured listings (handpicked)
### 1) Landmark Hotel & Casino Space-Age Tower Cover
**Condition:** Unstruck; crisp fold; light edge rub only.
**Standout detail:** Stylized tower silhouette, all Jet-Age optimism.
**Price range:** $9$15.
**Why it's special:** The Landmark is long gone, which gives this cover that perfect "lost Vegas" energy. Pair it with Stardust in a two-piece frame for instant conversation.
_Caption:_ Landmark Hotel & Casinospace-age tower art, clean and glossy.
### 2) Stardust Resort & Casino Starburst Logo
**Condition:** Excellent; bright color; clean striker.
**Standout detail:** That exploding starburstVegas without saying Vegas.
**Price range:** $8$12.
**Why it's special:** If you collect **casino matchbooks**, this one's a keystone. I've had buyers tell me it's the piece that finally convinced their partner a mini-display was "actually kind of cool."
_Caption:_ Stardust starburstcrisp colors ready for a display frame.
### 3) Imperial Palace Two-Logo Mini Set
**Condition:** Both unstruck; mild natural toning on the ivory cover.
**Standout detail:** Matching blue "IP" monogramsone regular, one inverted.
**Price range:** $10$16 (set).
**Why it's special:** Sets display beautifully and pin a specific moment in Strip history. I like to stand these on a tiny easel next to a vintage room key.
_Caption:_ Imperial Palace twin monogramsclean lines, great shelf presence.
### 4) Tropicana "The Island of Las Vegas"
**Condition:** Near-mint; corners tight; colors saturated.
**Standout detail:** Breezy palm motif and that cheeky "Island" tagline.
**Price range:** $7$11.
**Why it's special:** The Trop sits at the crossroads of Rat Pack swagger and family-vacation warmth. It's the friendliest entry point for **hotel matchbooks** newcomers.
_Caption:_ Tropicana "Island of Las Vegas"palm-kissed getaway vibes.
### 5) Golden Gate Casino Fremont Street Bridge Art
**Condition:** Very Good; subtle edge wrinkle noted in photos.
**Standout detail:** Blue-and-cream bridge illustrationclean, almost nautical.
**Price range:** $10$14.
**Why it's special:** The oldest hotel-casino in town carries real local lore. This cover reads more "Old Town postcard" than ad, which anchors a Fremont Street trio (add a Pioneer Club or Binion's and you're set).
_Caption:_ Golden Gate on Fremontbridge artwork with old-town charm.
### 6) Copper Manor Motel (Silver City, NM) Road-Trip Classic
**Condition:** Unstruck; matte finish; honest drawer wear.
**Standout detail:** Bold red block letters, pure roadside Americana.
**Price range:** $5$8.
**Why it's special:** Not Vegas, but essential to the story: the miles between the big lights. Pair it with a Holiday Inn or La Quinta to tell the whole journey.
_Caption:_ Copper Manor Motelred block type that screams "road stop."
(You'll also see Sam's Town, Flamingo Hilton, Harrah's, Best Western, La Quinta, and some Texas piecesperfect filler for color-coordinated frames.)
## Behind the scenes (how I source, clean, and ship)
Sourcing is part luck, part listening. Estate sales. Family binders. A shoe store owner who kept a drawer of "good logos" under the registertrue story, and his taste was excellent. When a batch arrives, it goes on a clean mat. I sort by venue, then visual punch. Bold logos in one stack; understated typography in another.
Cleanup is gentle: a soft brush for dust; a barely damp cotton swab for glossy smudges; air-dry only. I don't polish, repaint, or press them flat—the slight tension helps a matchbook stand on a shelf. If a staple shows the first hint of rust, I neutralize and dry so the spot doesn't travel.
Photography happens under neutral light with a simple two-angle setup: straight-on for accuracy, tilted for sheen. Every honest quirktiny crease, touch of toningshows up in the close-ups and the description. You should know exactly what's coming.
For packaging, singles slip into archival polypropylene sleeves and a rigid mailer with chipboard. Lots go into a small box with interleaving. Complete books ship ground and get a little padded bridge over the striker so it won't abrade in transit. **Cover-only** listings are labeled that way from the title down.
## Trust & proof
I'm a careful solo seller who ships within **one business day** and writes the kind of descriptions I'd want to read: plain, specific, no mystery. Everything's backed by a **30-day no-fuss return policy**. If I missed something meaningful, return it in the sleeve and I'll make it right.
Buyers who've picked up postcards and ephemera from me mention the packaging and how the item looks even better in hand. That's the standard here.
## FAQ
**Are these originals or reprints?**
Originals. I source from personal collections, binders, and estates. If I ever list a reproduction, it'll be labeled clearly (and priced accordingly).
**How do you grade condition?**
I use Near-Mint / Excellent / Very Good / Good, with notes that pinpoint wear (edge rub, micro-crease, light toning). I always state whether the striker is clean and if matches are present.
**Do you combine shipping?**
Yes. Add items to your cart and checkout; eBay will auto-combine whenever possible. If something looks off, message me before paying and I'll adjust.
**What about safety or smell?**
Complete books ship via ground and are padded so the striker doesn't rub. If you're scent-sensitive, choose **cover-only** listingspaper tends to be fresher once the match heads are gone.
**What's your return policy?**
Thirty days. If it's not as described—or you just changed your mind—send it back in the original sleeve and I'll refund on receipt.
## Ready to browse?
Start with one. Or start with six. Build a tiny museum: a Stardust-and-Landmark duo for "lost Vegas," an IP/Tropicana pairing for weekend-trip energy, or a Fremont Street line-up anchored by Golden Gate. Whatever story you're telling, these little books have the range.
👉 **Explore the full drop:** TimoKnuthVintage on eBay`,
images: [
matches1.src,
matches2.src,
matches3.src,
matches4.src,
matches5.src
],
datePublished: new Date('2025-01-10'),
category: 'Vintage Ephemera',
tags: ['Las Vegas', 'Matchbooks', 'Casino', 'Vintage', 'Collectibles', 'Americana'],
featured: true,
previewImage: chatgptMatches.src
},
{
id: 'vintage-japanese-leather-clutch',
title: 'A Vintage Japanese Leather Clutch That Tells a Story—Now on eBay',
slug: 'vintage-japanese-leather-clutch',
excerpt: 'A hand-painted, hand-embossed clutch that reads like three postcards in one: temple on the front, bridge inside, and Mount Fuji on the back.',
content: `If you like objects that carry a little adventure, you'll like this one. I've just listed a **vintage Japanese leather clutch** that reads like three postcards in one: temple on the front, bridge inside, and Mount Fuji on the back.
It's the sort of piece you spot across a table and then keep noticing. The embossing is sharp, the colors are hand-brushed, and the surface has that warm, honest patina you can't fake.
## What I'm selling & why
Short version: a hand-painted, hand-embossed clutch made in Japan around the mid-century, often called **bunko leather**. The technique is wonderfully tactiledesigns are pressed into pale leather, then each area is brushed with pigment and sealed with a protective lacquer that settles into the recesses. Over decades it develops fine **craquelure** (those hairline networks you see in old varnish) and a depth that photos never quite capture.
Why this one? When I first opened the flap, I realized the maker had staged a tiny journey. The outside shows an ornate shrine façade with curved roofs and lattice rails. Open it, and there's a vermilion bridge crossing a rocky riverso close you can feel the riffles under your thumb. Flip it over and Fuji appears, snow-capped and calm. Three views, one carry. It felt like meeting a small, composed person who quietly has a lot to say.
I gravitate to pieces that were made to last and made by hand. This clutch carries those human tracespaint that occasionally wanders over a line, shading that pools in the grout of the stonework, a slightly soft corner that tells you it was used, but carefully.
## Featured item
**Nikkō Gate, Sacred Bridge & Fuji Hand-Painted Bunko Leather Clutch (1930s1950s)**
**Condition:** Good vintage. Even, fine craquelure in the lacquer; minor edge rubs; stitching intact. The metal turn-plate/snap closes with a tidy click. Interior lining shows age and remains tidy.
**Standout detail:** A "three-scene" composition. On the flap: an elaborate shrine façade with deep green roofs and delicate fretworkvery much in the spirit of Nikkō Tōshōgū. Inside: a **vermillion bridge** with black posts, set over embossed water and stone. Back panel: **Mount Fuji**, rendered in quiet tones that balance the bolder front.
**Approx. size:** about 25 × 13 × 3 cmroughly the footprint of an A5 notebook; big enough for a phone, slim card case, key, and a lipstick.
**Carry & interior:** slim hand strap across the back; fabric lining with a small vintage paper label in Japanese; inner panel fully painted with the bridge scene.
**Palette:** moss and cedar greens, soft stone greys, warm straw, and vermilion accents.
**Price range:** 89129 (check the live listing for the exact number).
**Why it's special:** Most scenic clutches settle for a single motif. This one offers a tour and does it with restraint: deep relief lines you can feel, balanced color, and a design that wears well with modern outfits. It's the collector-grade piece you can still use.
You'll see detailed photosfront, back, open, and close-ups of the embossingso you can read the surface for yourself. Nothing is hidden; the character is part of the value.
## Behind the scenes (sourcing, care, packaging)
**Sourcing.** I look for quality you can feel before you name it: crisp relief, confident brushwork (not printed), era-correct hardware, and scenes that make sense together. Temple, bridge, Fuji is a classic trio, and this example carries it with poise.
**Care.** Cleaning is minimal. I dust with a soft brush, wipe gently with a barely damp microfiber, and stop. Oils, alcohols, and common leather conditioners can soften or lift old pigments, so I don't use them. The goal is preservation, not refurbishment.
**Packaging.** The painted panels get a layer of glassine so nothing adheres or abrades. Cushioning is placed around edges and structure, not directly on the artwork. Everything is boxed firmly with corner guards. You'll receive tracking, and the unboxing is straightforward: no glitter, no drama, just safe transit for a delicate piece.
## Trust & proof
I keep my shop small on purpose so I can write precise descriptions and answer messages quickly. Buyers regularly mention accurate photos and careful packing in feedback. Orders ship from Germany within **12 business days** using a tracked service. There's a **30-day return window**, because buying vintage online should feel normal and safe. If you're new to this categorysometimes listed as a _bunko leather wallet_ or even a _hand painted Nikko Toshogu purse_I'm happy to explain the telltales that separate hand-worked pieces from later prints.
## FAQ
**Q: What's the exact size?**
A: Approximately **25 × 13 × 3 cm**. It fits a modern smartphone, slim wallet, keys, and a lipstick. I photographed it next to common items for scale.
**Q: Summarize the condition in one sentence?**
A: **Good vintage**fine, even craquelure to the lacquer, light edge wear, strong color, secure hardware, clean lining for its age.
**Q: Can I use it as an everyday clutch?**
A: It's sturdy for a vintage piece, but I recommend mindful use: avoid alcohol wipes and sharp keys against the painted areas; store it flat and out of direct light. Treat it like a well-made book with a painted cover.
**Q: Do you ship internationally?**
A: Yes. Tracked international shipping is available. EU deliveries typically arrive in **37 business days**; overseas parcels usually land in **13 weeks** depending on customs. If you need it quickly, message me before checkout and I'll quote an express option.
**Q: What's your return policy?**
A: **30 days** from delivery. Send it back in the same condition and original packaging; once it's back and checked, I issue a prompt refund.
**Q: How do I know it's hand-painted, not printed?**
A: Look for tiny variations in color density, the occasional brush touch outside an embossed line, and raised contours you can feel. Those are hallmarks of the bunko-leather emboss-and-paint method. I included macro photos so you can inspect them yourself.
## See the listing, more photos, and the current price
<a href="https://www.ebay.com/itm/396996956768?itmmeta=01K3RFYKHMQVR0JKTJCGF11E6R&hash=item5c6edcd260:g:-7cAAeSwwG5orJbJ&pfm=1" target="_blank" rel="noopener noreferrer"> View on eBay</a>
* * *
If you have a question about care, styling, or provenance, send me a note. I answer quickly and with the same detail you see here. Pieces like this don't shout; they just keep telling the story to anyone who slows down long enough to listen.`,
images: [
leather_clutch5.src,
leather_clutch4.src,
leather_clutch3.src,
leather_clutch2.src,
leather_clutch1.src
],
datePublished: new Date('2025-01-05'),
category: 'Vintage Fashion',
tags: ['Japanese', 'Bunko Leather', 'Vintage', 'Hand-painted', 'Clutch', 'Mount Fuji', 'Collectibles'],
featured: false,
previewImage: chatgptLeatherClutch.src
},
{
id: 'vintage-bicycle-808-rider-back-blue-sealed',
title: 'Vintage Bicycle 808 Rider Back deck (blue), sealed',
slug: 'vintage-bicycle-808-rider-back-blue-sealed',
excerpt: 'A sealed Bicycle Rider Back No. 808 poker deck from the tax-stamp era. Blue back, air-cushion finish, with the original revenue stamp and red tear-strip visible.',
content: `Every so often I list something I wish I could keep. This is one of those weeks. I'm putting up a sealed **Bicycle Rider Back No. 808** deck from the tax-stamp era—blue back, air-cushion finish, with the original stamp and red tear-strip visible.
It's the sort of piece that makes collectors pause. The intact revenue stamp, the period-correct typography, the way the cellophane has aged—this is a time capsule from the mid-20th century that's still factory-sealed.
## What it is
This post takes a closer look at a Bicycle Rider Back No. 808 poker-size deck made in the United States by The U.S. Playing Card Co. It's a factory-sealed, tax-stamp era example. In the photos, the U.S. revenue stamp bridges the top flap and the thin red ribbon sits beneath the outer wraptwo small packaging cues that place it pre-1965. The listing describes the deck as "brand-new old stock" in its original packaging with the familiar Air-Cushion Finish.
The back color is blue, and the box carries period side text"AIR-CUSHION FINISH," "808," "THE U. S. PLAYING CARD CO.," "MADE IN U.S.A."with no barcode, which matches the timeframe. The overall impression is a tidy, mid-century Bicycle that has stayed intact: warm ivory tuck paper, clean printing, and a sealed flap. It's the kind of small, practical collectible that sits easily on a shelf yet still speaks to everyday usestandard poker size, the classic Rider Back artwork, and typography most card players recognize at a glance.
If you care about subtle differences between runs, this one leans into that quiet mid-century look: softer paper tone, modest gloss on the wrap, and consistent halftone detail on the logos. If you just want a reliable piece of design history, it's simply a sealed Bicycle 808 that hasn't been fussed withnothing flashy, just well-kept.
## Small details that matter
**Front tuck artwork:** Tall BICYCLE logotype above the red Ace of Spades emblem. The ivory-toned paperboard shows an even patina, with crisp edges around the red spade.
**Back design panel:** The blue Rider Back artwork (mirrored angels, scrollwork, circular hub) reads clearly through the wrap; lines look sharp rather than mushy.
**Tax stamp placement:** A U.S. revenue stamp crosses the top flap. Edges and micro-print are visible beneath the cellophane; the stamp appears unbroken.
**Ribbon/tear-strip:** The thin red band is visible within the wraptypical on mid-century Bicycle decksand gives a faint linear reflection under light.
**Side typography:** "AIR-CUSHION FINISH," "808," "THE U. S. PLAYING CARD CO.," "MADE IN U.S.A." set in period fonts with neat baselines; no barcode present.
**Wrap condition:** The cellophane shows fine crinkles and micro-scuffs consistent with storage. Seams are tidy, with a soft gloss rather than high shine.
**Corners & edges:** A small compression at the top-left corner of the tuck; other corners look square with even folds.
**Color palette:** Warm ivory tuck stock, midnight blue back art, and red accents (tear-strip, tax stamp, spade graphic) produce a simple tri-color look.
**Scale:** Standard poker deck proportions. Exact measurements not specified; fits common card clips and cases.
**Era cues:** Lack of UPC, stamp across flap, red ribbon under wrapall typical pre-1965 signals.
**Print quality:** Halftones and outlines on logos appear intact; no obvious ink bleed visible in the photos.
**Paper texture:** Subtle paper tooth shows through in raking light; not glossy tuck stock.
**Flap/top panel:** "808" printed on the top panel is clear; edges around the fold look straight without tearing.
**Made in U.S.A. panel:** The "MADE IN U.S.A." side is readable; slight toning at the edges suggests normal age rather than heavy wear.
**Lighting reflections:** Highlights along the right edge reveal the cellophane's soft sheen; no blown-out glare.
## Everyday use
**Shelf or desk display:** A compact visual anchor for a bookcase, studio shelf, or office credenza. The blue back and red accents add a quiet bit of color without shouting.
**Gift for players and magicians:** A low-key present for someone who appreciates classic decks. The sealed state lets the recipient decide whether to keep it closed or open it later.
**Photo/film prop:** Useful for Americana, magic, or mid-century sets where real paper texture, tax stamps, and era-accurate packaging matter more than replicas.
**Reference for designers:** If you collect packaging or study typography, this tuck shows mid-century print tone, line thickness, and label placement in a compact format.
**Conversation piece:** Lives easily on a side table; guests recognize it and usually have a story about game nights, shuffles, or a card trick.
## Condition & notes
**Seller statement:** "Brand-new old stock — sealed, untouched deck in original packaging."
**Dating:** Pre-1965 based on tax stamp and ribbon seal; exact production year not verified.
**Observed wear (from photos):**
Mild age toning to the ivory tuck, even across panels.
Micro-scuffs and tiny wrinkles in the outer wrap from storage.
Small corner ding at the top-left; paper compression is visible but localized.
Printing remains crisp; panel edges look straight with no visible tears.
**Completeness:** Inner contents remain unopened; jokers/ad cards not specified.
**Grading:** No third-party grading or authentication mentioned.
**Odor/storage:** Not verified; listing doesn't note smoke exposure or storage environment.
**Provenance:** Not specified beyond photos and general era cues.
If you track production variants, a photo of the bottom flap (not shown here) can sometimes narrow down the print window further. As it stands, the visible stamp, ribbon, and side text are enough to keep it comfortably in the late 1950searly 1960s range without over-claiming.
## Care & compatibility
**Storage basics:** Keep upright in a cool, dry room (roughly 3050% RH if you track humidity). Avoid attics, basements, or windowsillsheat and UV can embrittle cellophane and fade inks.
**Light exposure:** Display in indirect light. If you like a brighter shelf, consider a spot with filtered daylight. Prolonged direct sun can yellow paper and loosen old adhesives.
**Handling:** Lift from the bottom panel and support the sides to avoid stressing the top-left corner. Finger oils can spot the tuckclean, dry hands help.
**Protection:** An acid-free sleeve or a small acrylic case keeps dust down while letting the graphics show. Avoid tape or adhesive tabs on the tuck or stamp.
**If opening:** Use the red tear-strip along the seam. Avoid peeling across the stampslice the wrap rather than lifting the tax stamp. Open over a clean surface in case any tiny paper fibers shed.
**After opening:** If you plan to use the deck, consider a card clip or a simple tuck case to limit warping. Finish and exact stock inside are presumed standard for Bicycle of the era but not verified without opening.
**Compatibility:** Standard poker size. Fits most modern card shufflers, holders, and shelves. Sleeve sizes vary by maker; check dimensions if you're buying a snug acrylic case.
**Cleaning the exterior:** Dust with a soft, dry cloth. Don't use liquids on cellophane; moisture can creep under edges and cloud the wrap.
## If you're curious
If you'd like more photos, exact measurements, or shipping details, check the seller's listing: <a href="https://www.ebay.com/itm/396997017344?itmmeta=01K3RFYKHMETXQNJ5A2YER0A4G&hash=item5c6eddbf00:g:WvUAAeSwesRorNwH&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
`,
images: [
cardDeckBanner.src,
deckCard1.src,
deckCard2.src,
deckCard3.src,
deckCard4.src,
deckCard5.src,
deckCard6.src
],
datePublished: new Date('2025-01-20'),
category: 'Vintage Collectibles',
tags: ['Bicycle', 'playing-cards', 'Rider-Back-808', 'vintage', 'blue', 'sealed', 'tax-stamp', 'collectibles'],
featured: true,
previewImage: cardDeckBanner.src
},
{
id: 'vintage-forest-troll-4-5in-fuchsia-hair',
title: 'Vintage Forest Troll, 4.5" with fuchsia hair',
slug: 'vintage-forest-troll-4-5in-fuchsia-hair',
excerpt: 'Palm-size vinyl troll with bright fuchsia hair and pink eyes. Stamped "FOREST TROLL ©" and "CHINA," approximately 4.5 inches; offered without clothing.',
content: `Every so often I list something that brings back memories. This is one of those weeks. I'm putting up a **Vintage Forest Troll** doll with bright fuchsia hair—about 4.5 inches tall, stamped "FOREST TROLL ©" and "CHINA" on the soles.
It's the sort of piece that makes collectors smile. The vivid hair, the familiar grin, the way it stands perfectly on a desk or shelf—this is a classic 90s toy that's still full of personality.
## What it is
This is a compact vintage Forest Troll figure with the familiar open-armed pose, a cheerful grin, and tall, up-swept fuchsia hair. The seller measures the body height at ~4.5 in / 11.5 cm (hair adds visible height but isn't part of the given measurement). The soles are stamped "FOREST TROLL ©" and "CHINA," with a small "3" that likely denotes mold or size. It's a straightforward, clothes-free exampleeasy to display on a shelf or desk, or to use as a base for simple customization.
## Small details that matter
**Hair color & texture:** saturated fuchsia/magenta with natural flyaways; the fibers lift and fan outward without heavy styling.
**Eyes:** pink, glossy inserts that catch small specular highlights; both eyes appear evenly colored in the photos.
**Face sculpt:** classic rounded cheeks, small nose, soft smile; no painted lips (the look comes from the vinyl tone).
**Vinyl finish:** tan tone with a light satin sheen; reflects softly under studio lighting; no tacky/shiny patches visible in the photos.
**Body sculpt:** crisp details at the belly button, toes, and ears; proportion typical for 45" trolls.
**Foot stamps:** FOREST TROLL © and CHINA are clear; the tiny "3" sits near the wording and reads sharply.
**Standing stance:** flat feet enable freestanding display on flat surfaces; a tiny bit of putty can improve stability if needed.
**Belly jewel:** not present (this is the plain-torso style, not the jeweled Treasure Troll type).
**Accessories:** none included; any comb or hang-tag seen in styled images is for presentation only.
**Scale cues:** roughly palm-size; fits beside a mug, pencil cup, or small succulent without crowding a surface.
**Color balance in photos:** neutral white background helps the hair read true; the fuchsia acts as the main accent color.
**Overall vibe:** bright hair + clean sculpt = a simple retro accent with a small footprint.
## Everyday use
**Desk companion:** adds a friendly pop of color next to a monitor or notebook. It occupies very little space and doesn't block screens or papers.
**Bookshelf accent:** sits well among paperbacks, small plants, or postcards; the hair adds vertical interest without needing a stand.
**Light craft base:** a good starting point for a mini outfit, ribbon bow, tiny hat, or a gentle hair refresh. No advanced tools required.
**Nostalgia gift:** a low-key 90s throwback for someone who remembers troll dollssimple, easy to wrap, and fun to place on a shelf.
**Photo prop:** brings a small burst of color to casual product shots, flat lays, or birthday table setups without stealing the scene.
## Condition & notes
**Ownership:** pre-owned.
**Hair:** bright, saturated color with the typical frizz these dolls often show; strands appear intact at the hairline.
**Vinyl:** minimal handling wear visible in photos; no cracks are apparent. Fine micro-scuffs consistent with age are possible.
**Marks & ID:** foot stamps are intact and legibleFOREST TROLL © / CHINA plus a small "3."
**Brand mention:** the seller lists IMM in item specifics; this brand is not verified on the doll itself (stamps show Forest Troll/China only).
**Year:** not specified. Styling suggests the 1990s gift-shop era, but that timing is not verified.
**Packaging & extras:** none included.
**Measurements:** body height of ~4.5" provided by the seller; hair-to-tip height not specified and would be taller.
**Photos vs. reality:** colors can vary by screen; white background shots aim to keep color honest, but exact tone may differ slightly in person.
If any detail is critical to you (exact hair height, weight, or color match), consider asking the seller for confirmation; measurements and hues are not independently verified here.
## Care & compatibility
**Quick refresh for hair:**
Comb gently with a wide-tooth comb from ends upward.
If needed, wash in cool water with a drop of mild shampoo; rinse well.
Optionally, a teaspoon of diluted fabric softener in the final rinse can calm frizz; do a brief water rinse afterward.
Air-dry; shape the hair as it dries. Avoid heat tools.
**Vinyl cleaning:** wipe with a soft, damp cloth. For marks, use a tiny amount of diluted dish soap, then dry immediately. A very light touch with a melamine sponge can reduce scuffstest a hidden spot first.
**Display tips:** keep out of direct sun to reduce color fade. On slick shelves, a dot of museum putty under one foot improves stability.
**Storage:** stand the doll upright in a small box; support the hair loosely to prevent flattening. Avoid high humidity, which can encourage frizz or dust cling.
**Safety:** contains small parts and loose fibers; use care around young children and pets. No batteries or electronics to maintain.
## If you're curious
If you'd like more photos, exact measurements, or shipping details, check the seller's listing: <a href="https://www.ebay.com/itm/396999861739?itmmeta=01K3RFYKHMRP2XWV0E8R1GGH0S&hash=item5c6f0925eb:g:VZ8AAeSwgvJoryzH&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
`,
images: [
dollBanner.src,
doll1.src,
doll2.src,
doll3.src
],
datePublished: new Date('2025-01-25'),
category: 'Vintage Toys',
tags: ['forest troll', 'troll doll', 'vintage toy', 'vinyl', 'fuchsia hair', 'pink eyes', 'made in china', '1990s style'],
featured: false,
previewImage: dollBanner.src
},
{
id: 'time-magazine-1955-01-10-bull-market-issue',
title: 'TIME Magazine, Jan 10, 1955 — The Bull Market Issue',
slug: 'time-magazine-1955-01-10-bull-market-issue',
excerpt: 'A 1955 TIME issue with Boris Chaliapin\'s bull market cover. Honest wear, clean interiors per seller, and the kind of mid-century ads people like to leaf through.',
content: `Every so often I list something that captures a moment in history. This is one of those weeks. I'm putting up an original **TIME Magazine** dated January 10, 1955 (Vol. LXV, No. 2) with the cover theme "The Bull Market Business Review & Forecast."
It's the sort of piece that makes collectors pause. The classic red border, the yellow diagonal band, the Boris Chaliapin cover art—this is a snapshot of post-war finance and magazine design that's still relevant today.
## What it is
An original TIME Magazine dated January 10, 1955 (Vol. LXV, No. 2). The cover feature is "The Bull Market Business Review & Forecast," illustrated by Boris Chaliapin. The seller describes it as Very Good to Good for its age: softcover/wraps, English language, all pages present, no writing or tears reported, and professionally handled. Packaging is noted as archival-safe sleeve with protective cardboard, which makes storage and gifting straightforward. For anyone who enjoys mid-century reporting, period graphics, and the look of vintage paper, this issue is a quiet, practical pick.
## Small details that matter
**Classic red TIME border** with a yellow diagonal sash for the cover theme.
**TWENTY CENTS cover price** at the toptypical of the era's layout.
**Chaliapin's art shows** a bull wrapped in ticker tape, with 1950s cars, skyline hints, and industrial motifs.
**Original subscriber mailing label** at the lower-left of the front cover (visible in photos).
**Back cover:** full-color Chesterfield ad featuring Rory Calhoun & Lita Baron beside a car.
**Interior masthead confirms** January 10, 1955 Vol. LXV, No. 2.
**Light spine stress lines;** modest corner/edge rub consistent with browsing.
**Small surface scuffs** on the cover; tones and inks remain strong for age.
**Coated magazine stock** with normal mid-century halftone dot pattern visible on close look.
**Age toning expected** on interior pages; paper color varies slightly by section.
**Binding:** softcover, wraps; exact dimensions not specified in the listing.
**Seller highlights** clean interiors, no writing, no tears observed.
**Shipped sleeved and backed,** which helps prevent additional edge wear in transit.
## Everyday use
**Desk or bookshelf display** in a study or officepairs easily with wood and brass accents.
**Frame the cover** (simple black or walnut frame) for low-key mid-century wall decor in a trading nook, workspace, or hallway.
**Photo/film prop** for finance, newsroom, or 1950s scenesrecognizable red border reads well on camera.
**Coffee-table flip-through** for guests who like period ads and historical snapshots without committing to a full book.
## Condition & notes
Listed as Very Good to Good overall. Expect light edge/corner wear, minor spine creasing, and small surface scuffs on the cover. Colors look lively for the age. The seller notes all pages present, no writing or tears observed, and clean interiors with typical age toning. The front mailing label remains, which some collectors prefer to keep for provenance. Page-by-page verification beyond the photos is not verified. Odor, annotations, or clipped coupons are not specified. If you need a specific article or ad checked, it's reasonable to ask the seller for a quick look before purchase.
## Care & compatibility
**Handle with clean, dry hands;** support the spine when opening.
**Store flat** in the acid-free sleeve with backing board (included per listing).
**Keep away from** direct sunlight, humidity, and temperature swings; a closet shelf or drawer works well.
**If framing,** use UV-filter glazing and an acid-free mat; avoid tight clamping that presses the spine.
**For dust,** use a soft brush onlyno liquids or solvent cleaners on coated paper.
**Do not attempt** label removal; adhesives on mid-century coated stock can pull ink or leave sheen changes.
**Allow light airing** in a dry room if there's typical vintage paper scent; avoid attic/basement storage.
## If you're curious
If you'd like more photos or specifics, check the seller's listing: <a href="https://www.ebay.com/itm/396997015021?itmmeta=01K3RFYKHM6RJ178JG8FB6BQ45&hash=item5c6eddb5ed:g:tYYAAeSwSYxoreBI&pfm=1" target="_blank" rel="noopener noreferrer">View on eBay</a>
`,
images: [
timeMagazineBanner.src,
timeMagazine1.src,
timeMagazine2.src,
timeMagazine3.src
],
datePublished: new Date('2025-01-30'),
category: 'Vintage Magazines',
tags: ['Time', 'magazine', 'paper', 'mid-century', '1950s', 'business-finance', 'red-yellow', 'vintage-ads'],
featured: false,
previewImage: timeMagazineBanner.src
}
]

19
src/lib/media.ts Normal file
View File

@ -0,0 +1,19 @@
export function resolveMediaUrl(path?: string | null): string | null {
if (!path) {
return null
}
if (/^https?:\/\//i.test(path)) {
return path
}
const base = process.env.NEXT_PUBLIC_API_BASE_URL || process.env.API_BASE_URL || ''
if (!base) {
return path
}
const normalisedBase = base.endsWith('/') ? base.slice(0, -1) : base
const normalisedPath = path.startsWith('/') ? path : `/${path}`
return `${normalisedBase}${normalisedPath}`
}

29
src/types/blog.ts Normal file
View File

@ -0,0 +1,29 @@
export interface BlogPostSection {
id: string
text?: string | null
image?: string | null
}
export interface BlogPost {
id: number
title: string
slug: string
previewImage?: string | null
linkUrl?: string | null
sections: BlogPostSection[]
footer?: string | null
isEditorsPick: boolean
excerpt?: string
createdAt: string
updatedAt: string
}
export interface ApiResponse<T> {
data: T
error?: string
}
export interface ApiListResponse<T> {
data: T[]
error?: string
}