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

View File

@ -31,6 +31,20 @@ async function runMigrations() {
END $$; 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(` await query(`
CREATE OR REPLACE FUNCTION set_updated_at() CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$

View File

@ -75,6 +75,7 @@ export default function AdminPage() {
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
const [footer, setFooter] = useState('') const [footer, setFooter] = useState('')
const [isEditorsPick, setIsEditorsPick] = useState(false) const [isEditorsPick, setIsEditorsPick] = useState(false)
const [isSold, setIsSold] = useState(false)
const [category, setCategory] = useState<string>('') const [category, setCategory] = useState<string>('')
const [mainImage, setMainImage] = useState<MainImageState>({ const [mainImage, setMainImage] = useState<MainImageState>({
file: null, file: null,
@ -116,6 +117,7 @@ export default function AdminPage() {
setLinkUrl('') setLinkUrl('')
setFooter('') setFooter('')
setIsEditorsPick(false) setIsEditorsPick(false)
setIsSold(false)
setCategory('') setCategory('')
setMainImage({ file: null, existing: null, previewUrl: null, removed: false }) setMainImage({ file: null, existing: null, previewUrl: null, removed: false })
setSections(ensureSectionSlots([])) setSections(ensureSectionSlots([]))
@ -128,6 +130,7 @@ export default function AdminPage() {
setLinkUrl(post.linkUrl || '') setLinkUrl(post.linkUrl || '')
setFooter(post.footer || '') setFooter(post.footer || '')
setIsEditorsPick(post.isEditorsPick) setIsEditorsPick(post.isEditorsPick)
setIsSold(post.isSold || false)
// Convert old format to new format // Convert old format to new format
const categoryMap: Record<string, string> = { const categoryMap: Record<string, string> = {
@ -276,6 +279,7 @@ export default function AdminPage() {
linkUrl: normaliseText(linkUrl) || undefined, linkUrl: normaliseText(linkUrl) || undefined,
footer: normaliseText(footer) || undefined, footer: normaliseText(footer) || undefined,
isEditorsPick, isEditorsPick,
isSold,
category: category || undefined, category: category || undefined,
existingMainImage: mainImage.file || mainImage.removed ? undefined : mainImage.existing, existingMainImage: mainImage.file || mainImage.removed ? undefined : mainImage.existing,
removeMainImage: mainImage.removed, removeMainImage: mainImage.removed,
@ -688,6 +692,17 @@ export default function AdminPage() {
</span> </span>
</label> </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' }}> <div style={{ display: 'flex', gap: '12px' }}>
<button <button
type="submit" type="submit"

View File

@ -5,6 +5,7 @@ import { BlogPost, BlogPostSection } from '@/types/blog'
interface BlogPostCardProps { interface BlogPostCardProps {
post: BlogPost post: BlogPost
isLatest: boolean isLatest: boolean
compact?: boolean
} }
const cardStyles = { 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 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 ( return (
<article style={cardStyles.container}> <article style={containerStyle}>
{post.isEditorsPick && ( {post.isEditorsPick && (
<div <div
style={{ style={{
@ -73,13 +101,36 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
letterSpacing: '0.2em', letterSpacing: '0.2em',
textTransform: 'uppercase', textTransform: 'uppercase',
transform: 'rotate(-45deg)', 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 Editor's Pick
</div> </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 && ( {isLatest && (
<div <div
style={{ style={{
@ -102,7 +153,7 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
{previewUrl && ( {previewUrl && (
<div <div
style={{ style={{
...cardStyles.imageWrapper, ...imageWrapperStyle,
backgroundImage: `url('${previewUrl}')` backgroundImage: `url('${previewUrl}')`
}} }}
/> />
@ -112,20 +163,20 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: '12px' marginBottom: compact ? '6px' : '12px'
}}> }}>
<span style={{ <span style={{
fontFamily: 'Space Mono, monospace', fontFamily: 'Space Mono, monospace',
fontSize: '12px', fontSize: compact ? '9px' : '12px',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.15em', letterSpacing: '0.1em',
color: '#8B7D6B' color: '#8B7D6B'
}}> }}>
{formatDate(post.createdAt)} {formatDate(post.createdAt)}
</span> </span>
</div> </div>
<h2 style={cardStyles.title}>{post.title}</h2> <h2 style={titleStyle}>{post.title}</h2>
{post.linkUrl && ( {post.linkUrl && (
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
@ -150,7 +201,7 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
</div> </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 <a
href={`/blog/${post.slug}`} href={`/blog/${post.slug}`}
@ -158,12 +209,12 @@ export function BlogPostCard({ post, isLatest }: BlogPostCardProps) {
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
padding: '12px 18px', padding: compact ? '8px 12px' : '12px 18px',
border: '2px solid #8B7D6B', border: compact ? '1px solid #8B7D6B' : '2px solid #8B7D6B',
backgroundColor: 'transparent', backgroundColor: 'transparent',
fontFamily: 'Space Mono, monospace', fontFamily: 'Space Mono, monospace',
fontSize: '12px', fontSize: compact ? '10px' : '12px',
letterSpacing: '0.15em', letterSpacing: '0.1em',
textTransform: 'uppercase', textTransform: 'uppercase',
textDecoration: 'none', textDecoration: 'none',
color: '#1E1A17', color: '#1E1A17',

View File

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

View File

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