sold
This commit is contained in:
parent
fd0542f2a4
commit
64d65d270e
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 $$
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface BlogPost {
|
|||
sections: BlogPostSection[]
|
||||
footer?: string | null
|
||||
isEditorsPick: boolean
|
||||
isSold?: boolean
|
||||
category?: BlogCategory | null
|
||||
excerpt?: string
|
||||
createdAt: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue