This commit is contained in:
Timo Knuth 2025-10-01 22:54:47 +02:00
parent fd0542f2a4
commit 64d65d270e
6 changed files with 109 additions and 19 deletions

View File

@ -150,6 +150,7 @@ function mapPostRow(row) {
sections: row.sections || [],
footer: row.footer,
isEditorsPick: row.is_editors_pick,
isSold: row.is_sold || false,
category: row.category,
createdAt: row.created_at,
updatedAt: row.updated_at
@ -273,9 +274,11 @@ app.post('/posts', upload.fields(getUploadFields()), async (req, res) => {
await ensureEditorsPickLimit(null, isEditorsPick)
const isSold = Boolean(payload.isSold)
const result = await query(
`INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`INSERT INTO blog_posts (title, slug, preview_image, link_url, sections, footer, is_editors_pick, is_sold, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
payload.title.trim(),
@ -285,6 +288,7 @@ app.post('/posts', upload.fields(getUploadFields()), async (req, res) => {
JSON.stringify(sections),
payload.footer || null,
isEditorsPick,
isSold,
payload.category || null
]
)
@ -333,6 +337,8 @@ app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => {
await ensureEditorsPickLimit(Number(id), true)
}
const isSold = Boolean(payload.isSold)
const result = await query(
`UPDATE blog_posts
SET title = $1,
@ -342,8 +348,9 @@ app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => {
sections = $5,
footer = $6,
is_editors_pick = $7,
category = $8
WHERE id = $9
is_sold = $8,
category = $9
WHERE id = $10
RETURNING *`,
[
payload.title.trim(),
@ -353,6 +360,7 @@ app.put('/posts/:id', upload.fields(getUploadFields()), async (req, res) => {
JSON.stringify(sections),
payload.footer || null,
isEditorsPick,
isSold,
payload.category || null,
id
]

View File

@ -31,6 +31,20 @@ async function runMigrations() {
END $$;
`)
await query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'blog_posts'
AND column_name = 'is_sold'
) THEN
ALTER TABLE blog_posts ADD COLUMN is_sold BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
END $$;
`)
await query(`
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$

View File

@ -75,6 +75,7 @@ export default function AdminPage() {
const [linkUrl, setLinkUrl] = useState('')
const [footer, setFooter] = useState('')
const [isEditorsPick, setIsEditorsPick] = useState(false)
const [isSold, setIsSold] = useState(false)
const [category, setCategory] = useState<string>('')
const [mainImage, setMainImage] = useState<MainImageState>({
file: null,
@ -116,6 +117,7 @@ export default function AdminPage() {
setLinkUrl('')
setFooter('')
setIsEditorsPick(false)
setIsSold(false)
setCategory('')
setMainImage({ file: null, existing: null, previewUrl: null, removed: false })
setSections(ensureSectionSlots([]))
@ -128,6 +130,7 @@ export default function AdminPage() {
setLinkUrl(post.linkUrl || '')
setFooter(post.footer || '')
setIsEditorsPick(post.isEditorsPick)
setIsSold(post.isSold || false)
// Convert old format to new format
const categoryMap: Record<string, string> = {
@ -276,6 +279,7 @@ export default function AdminPage() {
linkUrl: normaliseText(linkUrl) || undefined,
footer: normaliseText(footer) || undefined,
isEditorsPick,
isSold,
category: category || undefined,
existingMainImage: mainImage.file || mainImage.removed ? undefined : mainImage.existing,
removeMainImage: mainImage.removed,
@ -688,6 +692,17 @@ export default function AdminPage() {
</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<input
type="checkbox"
checked={isSold}
onChange={(event) => setIsSold(event.target.checked)}
/>
<span style={{ fontFamily: 'Space Mono, monospace', fontSize: '12px', letterSpacing: '0.15em' }}>
Mark as Sold
</span>
</label>
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="submit"

View File

@ -5,6 +5,7 @@ import { BlogPost, BlogPostSection } from '@/types/blog'
interface BlogPostCardProps {
post: BlogPost
isLatest: boolean
compact?: boolean
}
const cardStyles = {
@ -54,11 +55,38 @@ function formatDate(timestamp: string) {
})
}
export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
export function BlogPostCard({ post, isLatest, compact = false }: BlogPostCardProps) {
const previewUrl = resolveMediaUrl(post.previewImage)
const containerStyle = compact ? {
...cardStyles.container,
padding: '12px',
marginBottom: '0'
} : cardStyles.container
const imageWrapperStyle = compact ? {
...cardStyles.imageWrapper,
marginLeft: '-12px',
marginRight: '-12px',
marginTop: '-12px',
height: '220px'
} : cardStyles.imageWrapper
const titleStyle = compact ? {
...cardStyles.title,
fontSize: '18px',
marginBottom: '8px'
} : cardStyles.title
const excerptStyle = compact ? {
...cardStyles.excerpt,
fontSize: '13px',
marginBottom: '12px',
lineHeight: 1.4
} : cardStyles.excerpt
return (
<article style={cardStyles.container}>
<article style={containerStyle}>
{post.isEditorsPick && (
<div
style={{
@ -73,13 +101,36 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
letterSpacing: '0.2em',
textTransform: 'uppercase',
transform: 'rotate(-45deg)',
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
zIndex: 2
}}
>
Editor's Pick
</div>
)}
{post.isSold && (
<div
style={{
position: 'absolute',
top: '32px',
right: '-60px',
padding: '8px 64px',
backgroundColor: '#c0392b',
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)',
zIndex: 2
}}
>
Sold
</div>
)}
{isLatest && (
<div
style={{
@ -102,7 +153,7 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
{previewUrl && (
<div
style={{
...cardStyles.imageWrapper,
...imageWrapperStyle,
backgroundImage: `url('${previewUrl}')`
}}
/>
@ -112,20 +163,20 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px'
marginBottom: compact ? '6px' : '12px'
}}>
<span style={{
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
fontSize: compact ? '9px' : '12px',
textTransform: 'uppercase',
letterSpacing: '0.15em',
letterSpacing: '0.1em',
color: '#8B7D6B'
}}>
{formatDate(post.createdAt)}
</span>
</div>
<h2 style={cardStyles.title}>{post.title}</h2>
<h2 style={titleStyle}>{post.title}</h2>
{post.linkUrl && (
<div style={{ marginBottom: '16px' }}>
@ -150,7 +201,7 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
</div>
)}
{post.excerpt && <p style={cardStyles.excerpt}>{post.excerpt}</p>}
{post.excerpt && <p style={excerptStyle}>{compact ? `${post.excerpt.substring(0, 80)}...` : post.excerpt}</p>}
<a
href={`/blog/${post.slug}`}
@ -158,12 +209,12 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '12px 18px',
border: '2px solid #8B7D6B',
padding: compact ? '8px 12px' : '12px 18px',
border: compact ? '1px solid #8B7D6B' : '2px solid #8B7D6B',
backgroundColor: 'transparent',
fontFamily: 'Space Mono, monospace',
fontSize: '12px',
letterSpacing: '0.15em',
fontSize: compact ? '10px' : '12px',
letterSpacing: '0.1em',
textTransform: 'uppercase',
textDecoration: 'none',
color: '#1E1A17',

View File

@ -206,8 +206,8 @@ export function HomePageClient({ posts, selectedCategory }: HomePageClientProps)
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))',
gap: '32px',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '24px',
maxWidth: '1200px',
margin: '0 auto'
}}
@ -217,6 +217,7 @@ export function HomePageClient({ posts, selectedCategory }: HomePageClientProps)
key={post.id}
post={post}
isLatest={latestPostId === post.id}
compact={true}
/>
))}
</div>

View File

@ -26,6 +26,7 @@ export interface BlogPost {
sections: BlogPostSection[]
footer?: string | null
isEditorsPick: boolean
isSold?: boolean
category?: BlogCategory | null
excerpt?: string
createdAt: string