sold
This commit is contained in:
parent
fd0542f2a4
commit
64d65d270e
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 $$
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue