mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
feat: color moments
This commit is contained in:
216
src/components/nostr/ColorPaletteDisplay.tsx
Normal file
216
src/components/nostr/ColorPaletteDisplay.tsx
Normal 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 }} />;
|
||||
}
|
||||
66
src/components/nostr/kinds/ColorMomentDetailRenderer.tsx
Normal file
66
src/components/nostr/kinds/ColorMomentDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/nostr/kinds/ColorMomentRenderer.tsx
Normal file
63
src/components/nostr/kinds/ColorMomentRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
62
src/lib/color-moment-helpers.ts
Normal file
62
src/lib/color-moment-helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user