Files
echoboard/packages/web/src/pages/EmbedBoard.tsx

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>
)
}