195 lines
7.9 KiB
TypeScript
195 lines
7.9 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useParams, useSearchParams } from 'react-router-dom'
|
|
import { useBranding } from '../hooks/useBranding'
|
|
|
|
interface EmbedPost {
|
|
id: string
|
|
title: string
|
|
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
|
status: string
|
|
voteCount: number
|
|
isPinned: boolean
|
|
commentCount: number
|
|
createdAt: string
|
|
}
|
|
|
|
interface EmbedData {
|
|
board: { name: string; slug: string }
|
|
posts: EmbedPost[]
|
|
}
|
|
|
|
const STATUS_DARK: Record<string, { bg: string; color: string; label: string }> = {
|
|
OPEN: { bg: 'rgba(245,158,11,0.12)', color: '#F59E0B', label: 'Open' },
|
|
UNDER_REVIEW: { bg: 'rgba(8,196,228,0.12)', color: '#08C4E4', label: 'Under Review' },
|
|
PLANNED: { bg: 'rgba(109,181,252,0.12)', color: '#6DB5FC', label: 'Planned' },
|
|
IN_PROGRESS: { bg: 'rgba(234,179,8,0.12)', color: '#EAB308', label: 'In Progress' },
|
|
DONE: { bg: 'rgba(34,197,94,0.12)', color: '#22C55E', label: 'Done' },
|
|
DECLINED: { bg: 'rgba(249,138,138,0.12)', color: '#F98A8A', label: 'Declined' },
|
|
}
|
|
const STATUS_LIGHT: Record<string, { bg: string; color: string; label: string }> = {
|
|
OPEN: { bg: 'rgba(112,73,9,0.12)', color: '#704909', label: 'Open' },
|
|
UNDER_REVIEW: { bg: 'rgba(10,92,115,0.12)', color: '#0A5C73', label: 'Under Review' },
|
|
PLANNED: { bg: 'rgba(26,64,176,0.12)', color: '#1A40B0', label: 'Planned' },
|
|
IN_PROGRESS: { bg: 'rgba(116,67,12,0.12)', color: '#74430C', label: 'In Progress' },
|
|
DONE: { bg: 'rgba(22,101,52,0.12)', color: '#166534', label: 'Done' },
|
|
DECLINED: { bg: 'rgba(153,25,25,0.12)', color: '#991919', label: 'Declined' },
|
|
}
|
|
|
|
function timeAgo(date: string): string {
|
|
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
|
if (s < 60) return 'just now'
|
|
const m = Math.floor(s / 60)
|
|
if (m < 60) return `${m}m ago`
|
|
const h = Math.floor(m / 60)
|
|
if (h < 24) return `${h}h ago`
|
|
const d = Math.floor(h / 24)
|
|
if (d < 30) return `${d}d ago`
|
|
return `${Math.floor(d / 30)}mo ago`
|
|
}
|
|
|
|
export default function EmbedBoard() {
|
|
const { boardSlug } = useParams<{ boardSlug: string }>()
|
|
const [searchParams] = useSearchParams()
|
|
const [data, setData] = useState<EmbedData | null>(null)
|
|
const [error, setError] = useState(false)
|
|
const { appName, poweredByVisible } = useBranding()
|
|
|
|
const theme = searchParams.get('theme') || 'dark'
|
|
const limit = searchParams.get('limit') || '10'
|
|
const sort = searchParams.get('sort') || 'top'
|
|
|
|
const isDark = theme === 'dark'
|
|
|
|
useEffect(() => {
|
|
if (!boardSlug) return
|
|
const params = new URLSearchParams({ limit, sort })
|
|
fetch(`/api/v1/embed/${boardSlug}/posts?${params}`)
|
|
.then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
|
.then(setData)
|
|
.catch(() => setError(true))
|
|
}, [boardSlug, limit, sort])
|
|
|
|
const colors = isDark
|
|
? { bg: '#161616', surface: '#1e1e1e', border: 'rgba(255,255,255,0.08)', text: '#f0f0f0', textSec: 'rgba(240,240,240,0.72)', textTer: 'rgba(240,240,240,0.71)', accent: '#F59E0B' }
|
|
: { bg: '#f7f8fa', surface: '#ffffff', border: 'rgba(0,0,0,0.08)', text: '#1a1a1a', textSec: '#4a4a4a', textTer: '#545454', accent: '#704909' }
|
|
|
|
if (error) {
|
|
return (
|
|
<div style={{ padding: 24, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', color: colors.textSec, fontSize: 14, background: colors.bg, minHeight: '100vh' }}>
|
|
Board not found
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!data) {
|
|
return (
|
|
<div style={{ padding: 24, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', color: colors.textTer, fontSize: 14, background: colors.bg, minHeight: '100vh' }}>
|
|
Loading...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// build the base URL for linking back to the main app
|
|
const origin = window.location.origin
|
|
|
|
return (
|
|
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', background: colors.bg, color: colors.text, minHeight: '100vh', padding: '16px 20px' }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<a
|
|
href={`${origin}/b/${data.board.slug}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{ color: colors.text, textDecoration: 'none', fontWeight: 600, fontSize: 16 }}
|
|
>
|
|
{data.board.name}
|
|
</a>
|
|
<a
|
|
href={`${origin}/b/${data.board.slug}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{ color: colors.accent, textDecoration: 'none', fontSize: 12, fontWeight: 500 }}
|
|
>
|
|
View all
|
|
</a>
|
|
</div>
|
|
|
|
{/* Posts */}
|
|
{data.posts.length === 0 && (
|
|
<div style={{ color: colors.textTer, fontSize: 13, padding: '20px 0', textAlign: 'center' }}>
|
|
No posts yet
|
|
</div>
|
|
)}
|
|
{data.posts.map((post) => {
|
|
const statusMap = isDark ? STATUS_DARK : STATUS_LIGHT
|
|
const sc = statusMap[post.status] || { bg: colors.surface, color: colors.textSec, label: post.status }
|
|
return (
|
|
<a
|
|
key={post.id}
|
|
href={`${origin}/b/${data.board.slug}/post/${post.id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
display: 'flex',
|
|
gap: 12,
|
|
padding: '12px 14px',
|
|
marginBottom: 4,
|
|
borderRadius: 8,
|
|
background: colors.surface,
|
|
border: `1px solid ${colors.border}`,
|
|
textDecoration: 'none',
|
|
color: colors.text,
|
|
transition: 'border-color 0.15s ease',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = colors.accent }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = colors.border }}
|
|
onFocus={(e) => { e.currentTarget.style.borderColor = colors.accent }}
|
|
onBlur={(e) => { e.currentTarget.style.borderColor = colors.border }}
|
|
>
|
|
{/* Vote count */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 36 }}>
|
|
<svg width="12" height="8" viewBox="0 0 12 8" style={{ marginBottom: 2 }}>
|
|
<path d="M6 0L12 8H0Z" fill={colors.accent} />
|
|
</svg>
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: colors.accent }}>{post.voteCount}</span>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{post.isPinned && <span style={{ color: colors.accent, marginRight: 4, fontSize: 12, fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.04em' }}>Pinned</span>}
|
|
{post.title}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: 12, padding: '1px 6px', borderRadius: 4, background: sc.bg, color: sc.color, fontWeight: 500 }}>
|
|
{sc.label}
|
|
</span>
|
|
<span style={{ fontSize: 12, color: colors.textTer }}>
|
|
{post.commentCount} comment{post.commentCount !== 1 ? 's' : ''}
|
|
</span>
|
|
<time dateTime={post.createdAt} style={{ fontSize: 12, color: colors.textTer }}>
|
|
{timeAgo(post.createdAt)}
|
|
</time>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
)
|
|
})}
|
|
|
|
{/* Footer */}
|
|
{poweredByVisible && (
|
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
|
<a
|
|
href={`${origin}/b/${data.board.slug}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{ color: colors.textTer, textDecoration: 'none', fontSize: 12 }}
|
|
>
|
|
Powered by {appName}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|