import { useMemo } from 'react'; import { cn } from '@/lib/utils'; import { getColors } from '@/lib/colorMomentUtils'; import type { NostrEvent } from '@nostrify/nostrify'; type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes'; function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } /** Compute a best-fit grid: cols × rows for n items. */ function gridDimensions(n: number): { cols: number; rows: number } { if (n <= 3) return { cols: n, rows: 1 }; if (n === 4) return { cols: 2, rows: 2 }; if (n === 5) return { cols: 3, rows: 2 }; return { cols: 3, rows: 2 }; } /** Relative luminance of a hex color (0-1). */ function luminance(hex: string): number { const r = parseInt(hex.slice(1, 3), 16) / 255; const g = parseInt(hex.slice(3, 5), 16) / 255; const b = parseInt(hex.slice(5, 7), 16) / 255; const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); } // ─── Layout renderers ────────────────────────────────────── function HorizontalLayout({ colors }: { colors: string[] }) { return (
{colors.map((color, i) => (
))}
); } function VerticalLayout({ colors }: { colors: string[] }) { return (
{colors.map((color, i) => (
))}
); } function GridLayout({ colors }: { colors: string[] }) { const { cols } = gridDimensions(colors.length); return (
{colors.map((color, i) => (
))}
); } /** Build a clip-path polygon for a pie slice with slight overlap to prevent gaps. */ function pieSliceClipPath(index: number, total: number): string { const angle = 360 / total; const overlap = 0.5; const startAngle = index * angle - 90 - overlap; const sweepAngle = angle + overlap * 2; const scale = 1.5; // extend past edges so slices cover the full rect const steps = 12; const points: string[] = ['50% 50%']; for (let i = 0; i <= steps; i++) { const a = startAngle + (sweepAngle * i) / steps; const rad = (a * Math.PI) / 180; const x = 50 + 50 * scale * Math.cos(rad); const y = 50 + 50 * scale * Math.sin(rad); points.push(`${x}% ${y}%`); } return `polygon(${points.join(', ')})`; } function StarLayout({ colors }: { colors: string[] }) { return (
{/* Background fill to cover the center seam where all slices meet */}
{colors.map((color, i) => (
))}
); } function CheckerboardLayout({ colors }: { colors: string[] }) { const n = colors.length; const height = 180; // Target ~6 rows so squares are large and legible const rows = n * Math.max(2, 4 - n); const cellSize = height / rows; // Enough columns to cover any container width (overflow is clipped) const cols = Math.ceil(600 / cellSize); const cells: { color: string; key: string }[] = []; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const colorIndex = (row + col) % n; cells.push({ color: colors[colorIndex], key: `${row}-${col}` }); } } return (
{cells.map(({ color, key }) => (
))}
); } function DiagonalStripesLayout({ colors }: { colors: string[] }) { const n = colors.length; const H = 180; // Use a canvas wider than the visible area by H so that parallelogram stripes // at a 45° diagonal fill the rectangle exactly. The viewBox is (W+H) × H but // we clip it back to W × H via the wrapper div + overflow-hidden. // Each stripe is a true parallelogram with vertical width W/n and a horizontal // shift of H from top to bottom, giving a consistent 45° diagonal boundary. const W = 400; const totalW = W + H; // canvas wide enough so last stripe reaches bottom-right const stripeW = totalW / n; return (
{colors.map((color, i) => { // Stripe i: parallelogram where the top edge is at x=[i*sw, (i+1)*sw] // and the bottom edge shifts left by H (height), giving a 45° angle. const tx0 = i * stripeW; // top-left x const tx1 = (i + 1) * stripeW; // top-right x const bx0 = i * stripeW - H; // bottom-left x (shifted left by H) const bx1 = (i + 1) * stripeW - H; // bottom-right x const points = [ `${tx0},0`, `${tx1},0`, `${bx1},${H}`, `${bx0},${H}`, ].join(' '); return ; })}
); } // ─── Main component ────────────────────────────────────── const LAYOUT_MAP: Record> = { horizontal: HorizontalLayout, vertical: VerticalLayout, grid: GridLayout, star: StarLayout, checkerboard: CheckerboardLayout, diagonalStripes: DiagonalStripesLayout, }; /** Standalone blinking eye button — REMOVED. * * This component used to let any user apply an arbitrary kind-3367 event's * palette as their active theme via `applyCustomTheme(paletteToTheme(...))`. * Agora no longer supports user-customizable themes, so the apply path has * been removed entirely. Color Moments still render as palette art via * `ColorMomentContent` below. */ export function ColorMomentContent({ event }: { event: NostrEvent }) { const colors = useMemo(() => getColors(event.tags), [event.tags]); const layout = (getTag(event.tags, 'layout') ?? 'horizontal') as Layout; const name = getTag(event.tags, 'name'); const rawContent = event.content.trim(); // Only treat content as an emoji if it's a single grapheme cluster (emoji or short symbol). // Long text should not be overlaid on the palette. const isSingleEmoji = rawContent.length > 0 && [...rawContent].length <= 2 && /\p{Emoji}/u.test(rawContent); const emoji = isSingleEmoji ? rawContent : undefined; const LayoutComponent = LAYOUT_MAP[layout] ?? HorizontalLayout; // Determine whether overlay text should be light or dark const avgLum = useMemo(() => { if (colors.length === 0) return 0.5; return colors.reduce((sum, c) => sum + luminance(c), 0) / colors.length; }, [colors]); const overlayTextClass = avgLum > 0.5 ? 'text-black/80' : 'text-white/90'; if (colors.length === 0) return null; return (
{/* Name above the palette */} {name && (

{name}

)} {/* Color palette with emoji overlay */}
{/* Emoji centered over the palette */} {emoji && (
{emoji}
)}
{/* Color hex swatches */}
{colors.map((color, i) => ( ))}
); }