mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 18:51:21 +02:00
feat: Add configurable media rendering options for RichText
Extends RichTextOptions interface with display preferences including: - Media size presets (compact/normal/large) - Adaptive grid columns (auto or fixed 1-4) - Aspect ratio preservation toggle - Visual polish options (transitions, rounded corners) - Interaction controls (zoom enable/disable) Implements adaptive grid layout in Gallery component: - Auto-adapts columns based on media count (1 col for single, 2 for pair, 3 for multiple) - Respects fixed column preferences when specified - Preserves aspect ratios or applies crops based on configuration Creates media preset configurations for different contexts: - CHAT_PRESET: Balanced for conversation flow with adaptive columns - REPLY_PRESET: Minimal for lightweight reply previews - FEED_PRESET: Efficient for scrolling with compact size - DETAIL_PRESET: Full quality for focused viewing - COMPACT_PRESET: Minimal footprint for constrained spaces Updates ChatViewer to use CHAT_PRESET for optimal chat experience.
This commit is contained in:
@@ -30,6 +30,7 @@ import { parseSlashCommand } from "@/lib/chat/slash-command-parser";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { RichText } from "./nostr/RichText";
|
||||
import Timestamp from "./Timestamp";
|
||||
import { CHAT_PRESET } from "@/lib/media-presets";
|
||||
import { ReplyPreview } from "./chat/ReplyPreview";
|
||||
import { MembersDropdown } from "./chat/MembersDropdown";
|
||||
import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
@@ -174,7 +175,11 @@ const ComposerReplyPreview = memo(function ComposerReplyPreview({
|
||||
<div className="flex-1 min-w-0 line-clamp-1 overflow-hidden text-muted-foreground">
|
||||
<RichText
|
||||
event={replyEvent}
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
options={{
|
||||
showMedia: false,
|
||||
showEventEmbeds: false,
|
||||
enableTransitions: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -270,7 +275,11 @@ const MessageItem = memo(function MessageItem({
|
||||
<RichText
|
||||
event={zapRequest || message.event}
|
||||
className="text-sm leading-tight break-words"
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
options={{
|
||||
showMedia: false,
|
||||
showEventEmbeds: false,
|
||||
enableTransitions: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -300,7 +309,11 @@ const MessageItem = memo(function MessageItem({
|
||||
</div>
|
||||
<div className="break-words overflow-hidden">
|
||||
{message.event ? (
|
||||
<RichText className="text-sm leading-tight" event={message.event}>
|
||||
<RichText
|
||||
className="text-sm leading-tight"
|
||||
event={message.event}
|
||||
options={CHAT_PRESET}
|
||||
>
|
||||
{message.replyTo && (
|
||||
<ReplyPreview
|
||||
replyToId={message.replyTo}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function useDepth() {
|
||||
* Configuration options for RichText rendering behavior
|
||||
*/
|
||||
export interface RichTextOptions {
|
||||
// === Visibility Controls ===
|
||||
/** Show images inline (default: true) */
|
||||
showImages?: boolean;
|
||||
/** Show videos inline (default: true) */
|
||||
@@ -45,6 +46,24 @@ export interface RichTextOptions {
|
||||
showMedia?: boolean;
|
||||
/** Show event embeds for note/nevent/naddr mentions (default: true) */
|
||||
showEventEmbeds?: boolean;
|
||||
|
||||
// === Display Preferences ===
|
||||
/** Media size preset: "compact" | "normal" | "large" (default: "normal") */
|
||||
mediaSize?: "compact" | "normal" | "large";
|
||||
/** Gallery columns: "auto" (adaptive) or fixed 1-4 (default: "auto") */
|
||||
galleryColumns?: "auto" | 1 | 2 | 3 | 4;
|
||||
|
||||
// === Visual Polish ===
|
||||
/** Preserve original aspect ratios instead of forcing crops (default: true) */
|
||||
preserveAspectRatio?: boolean;
|
||||
/** Enable smooth fade-in animations (default: true) */
|
||||
enableTransitions?: boolean;
|
||||
/** Border radius: "none" | "sm" | "md" | "lg" (default: "md") */
|
||||
roundedCorners?: "none" | "sm" | "md" | "lg";
|
||||
|
||||
// === Interaction ===
|
||||
/** Enable image zoom on click (default: true) */
|
||||
enableZoom?: boolean;
|
||||
}
|
||||
|
||||
// Default options
|
||||
@@ -54,6 +73,12 @@ const defaultOptions: Required<RichTextOptions> = {
|
||||
showAudio: true,
|
||||
showMedia: true,
|
||||
showEventEmbeds: true,
|
||||
mediaSize: "normal",
|
||||
galleryColumns: "auto",
|
||||
preserveAspectRatio: true,
|
||||
enableTransitions: true,
|
||||
roundedCorners: "md",
|
||||
enableZoom: true,
|
||||
};
|
||||
|
||||
// Context for passing options through RichText rendering
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { MediaDialog } from "../MediaDialog";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { useRichTextOptions } from "../RichText";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function MediaPlaceholder({ type }: { type: "image" | "video" | "audio" }) {
|
||||
return <span className="text-muted-foreground">[{type}]</span>;
|
||||
@@ -18,6 +19,50 @@ interface GalleryNodeProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine adaptive column count based on media count
|
||||
* Logic: 1 image = 1 col, 2 images = 2 cols, 3+ = 3 cols
|
||||
*/
|
||||
function getAdaptiveColumns(mediaCount: number): number {
|
||||
if (mediaCount === 1) return 1;
|
||||
if (mediaCount === 2) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grid column class based on column count
|
||||
*/
|
||||
function getGridColumnsClass(columns: number): string {
|
||||
switch (columns) {
|
||||
case 1:
|
||||
return "grid-cols-1";
|
||||
case 2:
|
||||
return "grid-cols-2";
|
||||
case 3:
|
||||
return "grid-cols-3";
|
||||
case 4:
|
||||
return "grid-cols-4";
|
||||
default:
|
||||
return "grid-cols-3";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media preset based on mediaSize option
|
||||
*/
|
||||
function getMediaPreset(
|
||||
mediaSize: "compact" | "normal" | "large",
|
||||
): "thumbnail" | "grid" | "preview" {
|
||||
switch (mediaSize) {
|
||||
case "compact":
|
||||
return "thumbnail";
|
||||
case "large":
|
||||
return "preview";
|
||||
default:
|
||||
return "grid";
|
||||
}
|
||||
}
|
||||
|
||||
export function Gallery({ node }: GalleryNodeProps) {
|
||||
const options = useRichTextOptions();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -30,19 +75,39 @@ export function Gallery({ node }: GalleryNodeProps) {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// Determine media preset based on size option
|
||||
const mediaPreset = getMediaPreset(options.mediaSize);
|
||||
|
||||
const renderLink = (url: string, index: number) => {
|
||||
// Check if media should be shown
|
||||
const shouldShowMedia = options.showMedia;
|
||||
|
||||
if (isImageURL(url)) {
|
||||
if (shouldShowMedia && options.showImages) {
|
||||
return <MediaEmbed url={url} type="image" preset="grid" enableZoom />;
|
||||
return (
|
||||
<MediaEmbed
|
||||
url={url}
|
||||
type="image"
|
||||
preset={mediaPreset}
|
||||
enableZoom={options.enableZoom}
|
||||
fadeIn={options.enableTransitions}
|
||||
aspectRatio={options.preserveAspectRatio ? "auto" : "1/1"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <MediaPlaceholder type="image" />;
|
||||
}
|
||||
if (isVideoURL(url)) {
|
||||
if (shouldShowMedia && options.showVideos) {
|
||||
return <MediaEmbed url={url} type="video" preset="grid" />;
|
||||
return (
|
||||
<MediaEmbed
|
||||
url={url}
|
||||
type="video"
|
||||
preset={mediaPreset}
|
||||
fadeIn={options.enableTransitions}
|
||||
aspectRatio={options.preserveAspectRatio ? "auto" : "16/9"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <MediaPlaceholder type="video" />;
|
||||
}
|
||||
@@ -69,11 +134,26 @@ export function Gallery({ node }: GalleryNodeProps) {
|
||||
const imageLinks = links.filter((url) => isImageURL(url) || isVideoURL(url));
|
||||
const audioOnlyLinks = links.filter((url) => isAudioURL(url));
|
||||
|
||||
// Determine grid columns (adaptive or fixed)
|
||||
const gridColumns =
|
||||
options.galleryColumns === "auto"
|
||||
? getAdaptiveColumns(imageLinks.length)
|
||||
: options.galleryColumns;
|
||||
const gridClass = getGridColumnsClass(gridColumns);
|
||||
|
||||
// Determine gap size based on media size
|
||||
const gapClass =
|
||||
options.mediaSize === "compact"
|
||||
? "gap-1"
|
||||
: options.mediaSize === "large"
|
||||
? "gap-2"
|
||||
: "gap-1.5";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Grid layout for images/videos */}
|
||||
{imageLinks.length > 0 && (
|
||||
<div className="my-2 grid grid-cols-3 gap-1.5">
|
||||
<div className={cn("my-2 grid", gridClass, gapClass)}>
|
||||
{imageLinks.map((url: string, i: number) => (
|
||||
<div key={`${url}-${i}`}>{renderLink(url, links.indexOf(url))}</div>
|
||||
))}
|
||||
|
||||
@@ -19,6 +19,22 @@ interface LinkNodeProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media preset based on mediaSize option for inline links
|
||||
*/
|
||||
function getMediaPreset(
|
||||
mediaSize: "compact" | "normal" | "large",
|
||||
): "thumbnail" | "inline" | "preview" {
|
||||
switch (mediaSize) {
|
||||
case "compact":
|
||||
return "thumbnail";
|
||||
case "large":
|
||||
return "preview";
|
||||
default:
|
||||
return "inline";
|
||||
}
|
||||
}
|
||||
|
||||
export function Link({ node }: LinkNodeProps) {
|
||||
const options = useRichTextOptions();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -31,6 +47,9 @@ export function Link({ node }: LinkNodeProps) {
|
||||
// Check if media should be shown
|
||||
const shouldShowMedia = options.showMedia;
|
||||
|
||||
// Determine media preset based on size option
|
||||
const mediaPreset = getMediaPreset(options.mediaSize);
|
||||
|
||||
// Render appropriate link type
|
||||
if (isImageURL(href)) {
|
||||
if (shouldShowMedia && options.showImages) {
|
||||
@@ -38,8 +57,10 @@ export function Link({ node }: LinkNodeProps) {
|
||||
<MediaEmbed
|
||||
url={href}
|
||||
type="image"
|
||||
preset="inline"
|
||||
enableZoom
|
||||
preset={mediaPreset}
|
||||
enableZoom={options.enableZoom}
|
||||
fadeIn={options.enableTransitions}
|
||||
aspectRatio={options.preserveAspectRatio ? "auto" : undefined}
|
||||
className="my-2 inline-block"
|
||||
/>
|
||||
);
|
||||
@@ -53,7 +74,9 @@ export function Link({ node }: LinkNodeProps) {
|
||||
<MediaEmbed
|
||||
url={href}
|
||||
type="video"
|
||||
preset="inline"
|
||||
preset={mediaPreset}
|
||||
fadeIn={options.enableTransitions}
|
||||
aspectRatio={options.preserveAspectRatio ? "auto" : undefined}
|
||||
className="my-2 inline-block"
|
||||
/>
|
||||
);
|
||||
|
||||
104
src/lib/media-presets.ts
Normal file
104
src/lib/media-presets.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { RichTextOptions } from "@/components/nostr/RichText";
|
||||
|
||||
/**
|
||||
* Media rendering presets for different contexts
|
||||
*
|
||||
* These presets provide optimized configurations for common use cases,
|
||||
* balancing visual quality, performance, and user experience.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Chat message preset - Balanced for conversation flow
|
||||
* - Adaptive grid columns based on media count
|
||||
* - Normal size with preserved aspect ratios
|
||||
* - Full interactivity (zoom, transitions)
|
||||
*/
|
||||
export const CHAT_PRESET: RichTextOptions = {
|
||||
showMedia: true,
|
||||
showImages: true,
|
||||
showVideos: true,
|
||||
showAudio: true,
|
||||
showEventEmbeds: true,
|
||||
mediaSize: "normal",
|
||||
galleryColumns: "auto", // Adaptive: 1 col for single, 2 for pair, 3 for multiple
|
||||
preserveAspectRatio: true,
|
||||
enableTransitions: true,
|
||||
roundedCorners: "md",
|
||||
enableZoom: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reply preview preset - Minimal for context
|
||||
* - No media shown (or use compact with showMedia: true)
|
||||
* - Keeps replies lightweight and focused on text
|
||||
*/
|
||||
export const REPLY_PRESET: RichTextOptions = {
|
||||
showMedia: false,
|
||||
showEventEmbeds: false,
|
||||
mediaSize: "compact",
|
||||
galleryColumns: 1,
|
||||
preserveAspectRatio: true,
|
||||
enableTransitions: false,
|
||||
roundedCorners: "sm",
|
||||
enableZoom: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Feed item preset - Efficient for scrolling
|
||||
* - Compact media size for quick scanning
|
||||
* - Fixed 2-column grid for consistency
|
||||
* - Reduced animations for smoother scrolling
|
||||
*/
|
||||
export const FEED_PRESET: RichTextOptions = {
|
||||
showMedia: true,
|
||||
showImages: true,
|
||||
showVideos: true,
|
||||
showAudio: true,
|
||||
showEventEmbeds: true,
|
||||
mediaSize: "compact",
|
||||
galleryColumns: 2,
|
||||
preserveAspectRatio: true,
|
||||
enableTransitions: false, // Disable for better scroll performance
|
||||
roundedCorners: "md",
|
||||
enableZoom: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Detail view preset - Full quality for focused viewing
|
||||
* - Large media size for detail
|
||||
* - 2-column grid for elegant presentation
|
||||
* - All features enabled
|
||||
*/
|
||||
export const DETAIL_PRESET: RichTextOptions = {
|
||||
showMedia: true,
|
||||
showImages: true,
|
||||
showVideos: true,
|
||||
showAudio: true,
|
||||
showEventEmbeds: true,
|
||||
mediaSize: "large",
|
||||
galleryColumns: 2,
|
||||
preserveAspectRatio: true,
|
||||
enableTransitions: true,
|
||||
roundedCorners: "lg",
|
||||
enableZoom: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact preset - Minimal footprint
|
||||
* - Small media with tight spacing
|
||||
* - Fixed single column
|
||||
* - Reduced visual effects
|
||||
*/
|
||||
export const COMPACT_PRESET: RichTextOptions = {
|
||||
showMedia: true,
|
||||
showImages: true,
|
||||
showVideos: true,
|
||||
showAudio: true,
|
||||
showEventEmbeds: false,
|
||||
mediaSize: "compact",
|
||||
galleryColumns: 1,
|
||||
preserveAspectRatio: true,
|
||||
enableTransitions: false,
|
||||
roundedCorners: "sm",
|
||||
enableZoom: false,
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.3"}
|
||||
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user