security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
194
packages/web/src/pages/EmbedBoard.tsx
Normal file
194
packages/web/src/pages/EmbedBoard.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user