feat: color moments

This commit is contained in:
Alejandro Gómez
2026-04-03 12:04:31 +02:00
parent 075cdcb97b
commit ca6c1a6625
7 changed files with 423 additions and 1 deletions

View File

@@ -0,0 +1,216 @@
import { memo, useMemo } from "react";
import { cn } from "@/lib/utils";
import { safeHex, type LayoutMode } from "@/lib/color-moment-helpers";
interface ColorPaletteDisplayProps {
colors: string[];
layout?: LayoutMode;
emoji?: string;
emojiSize?: "md" | "lg";
className?: string;
}
/** Shared color palette renderer supporting all 6 layout modes */
export const ColorPaletteDisplay = memo(function ColorPaletteDisplay({
colors,
layout = "horizontal",
emoji,
emojiSize = "md",
className,
}: ColorPaletteDisplayProps) {
if (colors.length === 0) return null;
return (
<div
className={cn(
"color-palette-display relative overflow-hidden rounded-xl",
className,
)}
>
<LayoutRenderer colors={colors} layout={layout} />
{emoji && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span
className={cn(
"drop-shadow-lg",
emojiSize === "lg" ? "text-8xl" : "text-6xl",
)}
>
{emoji}
</span>
</div>
)}
</div>
);
});
function LayoutRenderer({
colors,
layout,
}: {
colors: string[];
layout: LayoutMode;
}) {
switch (layout) {
case "horizontal":
return (
<div className="flex flex-col w-full h-full">
{colors.map((color, i) => (
<div
key={i}
className="flex-1"
style={{ backgroundColor: safeHex(color) }}
/>
))}
</div>
);
case "vertical":
return (
<div className="flex flex-row w-full h-full">
{colors.map((color, i) => (
<div
key={i}
className="flex-1"
style={{ backgroundColor: safeHex(color) }}
/>
))}
</div>
);
case "grid":
return (
<div
className={cn(
"grid w-full h-full",
colors.length === 6
? "grid-cols-3 grid-rows-2"
: "grid-cols-2 grid-rows-2",
)}
>
{colors.map((color, i) => (
<div key={i} style={{ backgroundColor: safeHex(color) }} />
))}
</div>
);
case "star":
return <StarLayout colors={colors} />;
case "checkerboard":
return <CheckerboardLayout colors={colors} />;
case "diagonalStripes":
return <DiagonalStripesLayout colors={colors} />;
default:
return (
<div className="flex flex-col w-full h-full">
{colors.map((color, i) => (
<div
key={i}
className="flex-1"
style={{ backgroundColor: safeHex(color) }}
/>
))}
</div>
);
}
}
/** Radial pie slices from center using clip-path */
function StarLayout({ colors }: { colors: string[] }) {
const total = colors.length;
return (
<div className="relative w-full h-full">
{/* Background fill to cover center gap */}
<div
className="absolute inset-0"
style={{ backgroundColor: safeHex(colors[0]) }}
/>
{colors.map((color, index) => {
const angle = 360 / total;
const startAngle = index * angle - 90;
const scale = 1.5;
const overlap = 0.5;
const adjustedStartAngle = startAngle - overlap;
const adjustedAngle = angle + overlap * 2;
const points: string[] = ["50% 50%"];
const steps = 12;
for (let i = 0; i <= steps; i++) {
const currentAngle = adjustedStartAngle + (adjustedAngle * i) / steps;
const rad = (currentAngle * Math.PI) / 180;
const x = 50 + 50 * scale * Math.cos(rad);
const y = 50 + 50 * scale * Math.sin(rad);
points.push(`${x}% ${y}%`);
}
return (
<div
key={index}
className="absolute inset-0"
style={{
backgroundColor: safeHex(color),
clipPath: `polygon(${points.join(", ")})`,
}}
/>
);
})}
</div>
);
}
/** SVG data URI tiled checkerboard pattern */
function CheckerboardLayout({ colors }: { colors: string[] }) {
const backgroundImage = useMemo(() => {
const n = colors.length;
const cellSize = 1;
const svgSize = n * cellSize;
let rects = "";
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
const color = safeHex(colors[(row + col) % n]);
rects += `<rect x="${col * cellSize}" y="${row * cellSize}" width="${cellSize}" height="${cellSize}" fill="${color}"/>`;
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgSize}" height="${svgSize}" shape-rendering="crispEdges">${rects}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}, [colors]);
return (
<div
className="absolute inset-0"
style={{
backgroundImage,
backgroundSize: "50% 50%",
backgroundRepeat: "repeat",
imageRendering: "pixelated",
}}
/>
);
}
/** Diagonal stripes via CSS linear-gradient */
function DiagonalStripesLayout({ colors }: { colors: string[] }) {
const background = useMemo(() => {
const n = colors.length;
const stripePercent = 100 / n;
const stops = colors
.map((color, i) => {
const start = i * stripePercent;
const end = (i + 1) * stripePercent;
const hex = safeHex(color);
return `${hex} ${start}%, ${hex} ${end}%`;
})
.join(", ");
return `linear-gradient(135deg, ${stops})`;
}, [colors]);
return <div className="absolute inset-0" style={{ background }} />;
}

View File

@@ -0,0 +1,66 @@
import type { NostrEvent } from "@/types/nostr";
import {
getColorMomentColors,
getColorMomentLayout,
getColorMomentName,
getColorMomentEmoji,
safeHex,
} from "@/lib/color-moment-helpers";
import { ColorPaletteDisplay } from "@/components/nostr/ColorPaletteDisplay";
import { useCopy } from "@/hooks/useCopy";
/**
* Kind 3367 Detail Renderer - Color Moment (Detail View)
* Full-size palette with copyable color swatches below
*/
export function ColorMomentDetailRenderer({ event }: { event: NostrEvent }) {
const colors = getColorMomentColors(event);
const layout = getColorMomentLayout(event) || "horizontal";
const name = getColorMomentName(event);
const emoji = getColorMomentEmoji(event);
const { copy } = useCopy();
if (colors.length < 3) {
return (
<div className="p-4 text-center text-muted-foreground">
Invalid color moment (fewer than 3 colors)
</div>
);
}
return (
<div className="flex flex-col gap-4 p-4">
{name && <h1 className="text-2xl font-bold">{name}</h1>}
<ColorPaletteDisplay
colors={colors}
layout={layout}
emoji={emoji}
emojiSize="lg"
className="h-72"
/>
<div className="grid grid-cols-3 gap-2">
{colors.map((color, i) => {
const hex = safeHex(color);
return (
<button
key={i}
className="flex items-center gap-2 rounded-md border border-border p-2 hover:bg-muted/50 transition-colors"
onClick={() => copy(hex)}
title={`Copy ${hex}`}
>
<div
className="size-6 rounded shrink-0"
style={{ backgroundColor: hex }}
/>
<span className="text-xs font-mono text-muted-foreground">
{hex}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import {
getColorMomentColors,
getColorMomentLayout,
getColorMomentName,
getColorMomentEmoji,
} from "@/lib/color-moment-helpers";
import { ColorPaletteDisplay } from "@/components/nostr/ColorPaletteDisplay";
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { useAddWindow } from "@/core/state";
/**
* Kind 3367 Renderer - Color Moment (Feed View)
* Shows a color palette with optional emoji overlay and name
*/
export function ColorMomentRenderer({ event }: BaseEventProps) {
const colors = getColorMomentColors(event);
const layout = getColorMomentLayout(event);
const name = getColorMomentName(event);
const emoji = getColorMomentEmoji(event);
const addWindow = useAddWindow();
if (colors.length < 3) {
return (
<BaseEventContainer event={event}>
<div className="text-xs text-muted-foreground italic">
Invalid color moment (fewer than 3 colors)
</div>
</BaseEventContainer>
);
}
const openDetail = () => {
addWindow("open", { pointer: { id: event.id } });
};
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{name && (
<ClickableEventTitle
event={event}
className="text-sm font-medium text-foreground"
>
{name}
</ClickableEventTitle>
)}
<div onClick={openDetail} className="cursor-crosshair">
<ColorPaletteDisplay
colors={colors}
layout={layout}
emoji={emoji}
className="h-52"
/>
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -195,6 +195,8 @@ import {
NsiteNamedDetailRenderer,
NsiteLegacyDetailRenderer,
} from "./NsiteDetailRenderer";
import { ColorMomentRenderer } from "./ColorMomentRenderer";
import { ColorMomentDetailRenderer } from "./ColorMomentDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -222,6 +224,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
1311: LiveChatMessageRenderer, // Live Chat Message (NIP-53)
1244: VoiceMessageRenderer, // Voice Message Reply (NIP-A0)
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
3367: ColorMomentRenderer, // Color Moment
1617: PatchRenderer, // Patch (NIP-34)
1618: PullRequestRenderer, // Pull Request (NIP-34)
1621: IssueRenderer, // Issue (NIP-34)
@@ -354,6 +357,7 @@ const detailRenderers: Record<
777: SpellDetailRenderer, // Spell Detail
1068: PollDetailRenderer, // Poll Detail (NIP-88)
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
3367: ColorMomentDetailRenderer, // Color Moment Detail
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34)
1621: IssueDetailRenderer, // Issue Detail (NIP-34)

View File

@@ -71,6 +71,7 @@ import {
UserX,
Video,
Wallet,
Palette,
WandSparkles,
XCircle,
Zap,
@@ -614,6 +615,14 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
// icon: Coins,
// },
3367: {
kind: 3367,
name: "Color Moment",
description: "Color Moment",
nip: "",
icon: Palette,
},
// Community
4550: {
kind: 4550,

View File

@@ -399,7 +399,9 @@ body.animating-layout
border-radius: 0 !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window * {
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
*:not(.color-palette-display) {
border-radius: 0 !important;
}

View File

@@ -0,0 +1,62 @@
import { getOrComputeCachedValue, getTagValue } from "applesauce-core/helpers";
import type { NostrEvent } from "nostr-tools";
export type LayoutMode =
| "horizontal"
| "vertical"
| "grid"
| "star"
| "checkerboard"
| "diagonalStripes";
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
/** Validate hex color, fallback to black */
export function safeHex(color: string): string {
return HEX_RE.test(color) ? color : "#000000";
}
const ColorMomentColorsSymbol = Symbol("colorMomentColors");
/** Extract validated hex colors from `c` tags */
export function getColorMomentColors(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, ColorMomentColorsSymbol, () =>
event.tags
.filter((t) => t[0] === "c" && t[1] && HEX_RE.test(t[1]))
.map((t) => t[1]),
);
}
/** Get layout mode from `layout` tag */
export function getColorMomentLayout(
event: NostrEvent,
): LayoutMode | undefined {
const value = getTagValue(event, "layout");
if (!value) return undefined;
const valid: LayoutMode[] = [
"horizontal",
"vertical",
"grid",
"star",
"checkerboard",
"diagonalStripes",
];
return valid.includes(value as LayoutMode)
? (value as LayoutMode)
: undefined;
}
/** Get optional name from `name` tag */
export function getColorMomentName(event: NostrEvent): string | undefined {
return getTagValue(event, "name") || undefined;
}
/** Get single emoji from content (if present) */
export function getColorMomentEmoji(event: NostrEvent): string | undefined {
const content = event.content?.trim();
if (!content) return undefined;
// Match a single emoji (including compound emoji with ZWJ, skin tones, etc.)
const emojiRegex =
/^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*$/u;
return emojiRegex.test(content) ? content : undefined;
}