Move preview toggle to top of compose modal

- Moved Edit/Preview toggle from toolbar to modal header
- Added controlled preview mode to ComposeBox component
- Toggle now appears next to close button in modal header
- Added border-bottom to header for visual separation
- ComposeBox can be controlled externally or use internal state
- Fixes layout issues and improves UX in modal

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-19 06:52:45 -06:00
parent 2042477d6a
commit d3d6d56e49
2 changed files with 77 additions and 14 deletions
+33 -6
View File
@@ -34,6 +34,10 @@ interface ComposeBoxProps {
forceExpanded?: boolean;
/** If true, hides the avatar (useful inside modals with their own layout). */
hideAvatar?: boolean;
/** Controlled preview mode (for modal usage). */
previewMode?: boolean;
/** Callback to notify parent of previewable content changes. */
onHasPreviewableContentChange?: (hasContent: boolean) => void;
}
/** Circular progress ring for character count. */
@@ -73,7 +77,17 @@ function CharRing({ count, max }: { count: number; max: number }) {
);
}
export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", compact = false, replyTo, quotedEvent, forceExpanded = false, hideAvatar = false }: ComposeBoxProps) {
export function ComposeBox({
onSuccess,
placeholder = "What's on your mind?",
compact = false,
replyTo,
quotedEvent,
forceExpanded = false,
hideAvatar = false,
previewMode: controlledPreviewMode,
onHasPreviewableContentChange,
}: ComposeBoxProps) {
const { user, metadata } = useCurrentUser();
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@@ -85,12 +99,15 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
const [cwEnabled, setCwEnabled] = useState(false);
const [cwText, setCwText] = useState('');
const [emojiOpen, setEmojiOpen] = useState(false);
const [previewMode, setPreviewMode] = useState(false);
const [internalPreviewMode, setInternalPreviewMode] = useState(false);
const [removedEmbeds, setRemovedEmbeds] = useState<Set<string>>(new Set());
const [uploadedFileTags, setUploadedFileTags] = useState<string[][]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
// Auto-expand when quotedEvent is provided
useEffect(() => {
if (quotedEvent) {
@@ -215,6 +232,13 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
return visibleEmbeds.length > 0 || previewImages.length > 0 || previewVideos.length > 0;
}, [visibleEmbeds, previewImages, previewVideos]);
// Notify parent of previewable content changes
useEffect(() => {
if (onHasPreviewableContentChange) {
onHasPreviewableContentChange(hasPreviewableContent);
}
}, [hasPreviewableContent, onHasPreviewableContentChange]);
// Include quoted event if provided and not removed
const quotedEventId = quotedEvent ? nip19.neventEncode({ id: quotedEvent.id, author: quotedEvent.pubkey }) : null;
const quotedEventKey = quotedEventId ? `nostr:${quotedEventId}` : null;
@@ -593,11 +617,11 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
</Tooltip>
</div>
{/* Center: Preview toggle */}
{hasPreviewableContent && (
{/* Center: Preview toggle (only show if not controlled by parent) */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg mx-2">
<button
onClick={() => setPreviewMode(false)}
onClick={() => setInternalPreviewMode(false)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
!previewMode
@@ -608,7 +632,7 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
Edit
</button>
<button
onClick={() => setPreviewMode(true)}
onClick={() => setInternalPreviewMode(true)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
previewMode
@@ -621,6 +645,9 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
</div>
)}
{/* Spacer when preview toggle is in modal */}
{hasPreviewableContent && controlledPreviewMode !== undefined && <div className="flex-1" />}
{/* Right: char count + post button */}
<div className="flex items-center gap-3">
{charCount > 0 && (
+44 -8
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
@@ -15,6 +15,7 @@ import { ComposeBox } from '@/components/ComposeBox';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
interface ReplyComposeModalProps {
/** The event being replied to. When `null`, the modal acts as a "New post" composer. */
@@ -34,21 +35,54 @@ function extractImages(content: string): string[] {
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange }: ReplyComposeModalProps) {
const isReply = !!event;
const isQuote = !!quotedEvent;
const [previewMode, setPreviewMode] = useState(false);
const [hasPreviewableContent, setHasPreviewableContent] = useState(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[520px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<div className="flex items-center justify-between px-4 h-12 border-b border-border">
<DialogTitle className="text-base font-semibold">
{isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post'}
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
<div className="flex items-center gap-2">
{/* Preview toggle */}
{hasPreviewableContent && (
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
<button
onClick={() => setPreviewMode(false)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
!previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Edit
</button>
<button
onClick={() => setPreviewMode(true)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Preview
</button>
</div>
)}
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
</div>
{/* Embedded original post (reply only - quote embeds are shown in ComposeBox) */}
@@ -62,6 +96,8 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange }: Re
placeholder={isReply ? "What's on your mind?" : isQuote ? "Add a comment..." : "What's happening?"}
forceExpanded
hideAvatar
previewMode={previewMode}
onHasPreviewableContentChange={setHasPreviewableContent}
/>
</DialogContent>
</Dialog>