UNPKG

@medalsocial/meda

Version:

Shared Meda UI shell and runtime package.

138 lines (137 loc) 10 kB
'use client'; import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; import { Bookmark, Heart, MessageCircle, MoreHorizontal, Repeat2, Share } from 'lucide-react'; import { useCallback, useRef, useState } from 'react'; import { cn } from '../../lib/utils.js'; import { Avatar } from '../internal/avatar.js'; import { Textarea } from '../internal/textarea.js'; export const DEFAULT_TWITTER_LABELS = { placeholder: "What's happening?", now: 'now', moreOptions: 'More options', reply: 'Reply', retweet: 'Retweet', like: 'Like', save: 'Save', share: 'Share', viewAnalytics: 'View post analytics', }; function useMentionTrigger(textareaRef, value, onChange) { const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(''); const triggerStartRef = useRef(null); const close = useCallback(() => { setIsOpen(false); setQuery(''); triggerStartRef.current = null; }, []); const handleInput = useCallback(() => { const ta = textareaRef.current; /* v8 ignore next — ref is always attached when the textarea event handler fires */ if (!ta) return; /* v8 ignore next — selectionStart is always a number in a real browser/jsdom */ const cursor = ta.selectionStart ?? 0; const text = ta.value; // Walk back from cursor to find `@` until whitespace or start. let i = cursor - 1; let foundAt = -1; while (i >= 0) { const ch = text[i]; if (ch === '@') { // Must be at start or preceded by whitespace if (i === 0 || /\s/.test(text[i - 1])) { foundAt = i; } break; } if (/\s/.test(ch)) break; i -= 1; } if (foundAt === -1) { if (isOpen) close(); return; } triggerStartRef.current = foundAt; setQuery(text.slice(foundAt + 1, cursor)); setIsOpen(true); }, [textareaRef, isOpen, close]); const insertMention = useCallback((mention) => { const ta = textareaRef.current; /* v8 ignore next — ref is always attached when insertMention is callable */ if (!ta) return; const start = triggerStartRef.current; /* v8 ignore next — triggerStartRef is set before insertMention becomes accessible */ if (start === null) return; /* v8 ignore next — selectionStart is always a number in a real browser/jsdom */ const cursor = ta.selectionStart ?? 0; const before = value.slice(0, start); const after = value.slice(cursor); const formatted = mention.startsWith('@') ? mention : `@${mention}`; const next = `${before}${formatted} ${after}`; onChange?.(next); close(); }, [textareaRef, value, onChange, close]); return { isOpen, query, close, insertMention, handleInput }; } function ContentWithMentions({ text, accent }) { // Build typed parts with stable offsets so keys don't rely on positional index. const segments = []; let offset = 0; for (const part of text.split(/(@[A-Za-z0-9_]+)/g)) { if (!part) continue; segments.push({ key: `${offset}-${part}`, isMention: /^@[A-Za-z0-9_]+$/.test(part), value: part, }); offset += part.length; } return (_jsx(_Fragment, { children: segments.map((seg) => seg.isMention ? (_jsx("span", { className: accent, children: seg.value }, seg.key)) : (_jsx("span", { children: seg.value }, seg.key))) })); } const THEME_CLASSES = { light: { surface: 'bg-white', text: 'text-gray-900', muted: 'text-gray-500', border: 'border-gray-200', mention: 'text-blue-500', }, dim: { surface: 'bg-[#15202B]', text: 'text-white', muted: 'text-gray-400', border: 'border-[#38444D]', mention: 'text-[#1D9BF0]', }, 'lights-out': { surface: 'bg-black', text: 'text-white', muted: 'text-gray-500', border: 'border-gray-800', mention: 'text-blue-400', }, }; /** * Twitter / X feed-card preview. Supports light, dim, and lights-out * themes plus an optional `@mention` picker render slot. */ export function TwitterPreview({ displayName, username, avatarUrl, content, mediaUrls, characterLimit, editable = false, onContentChange, className, labels, theme = 'lights-out', renderMentionPicker, }) { const l = { ...DEFAULT_TWITTER_LABELS, ...labels }; const t = THEME_CLASSES[theme]; const formattedUsername = username.startsWith('@') ? username : `@${username}`; const textareaRef = useRef(null); const mention = useMentionTrigger(textareaRef, content, onContentChange); return (_jsx("div", { "data-slot": "post-preview", "data-platform": "twitter", "data-theme": theme, className: cn('@container p-4 @[420px]:p-5', t.surface, t.text, className), children: _jsxs("div", { className: "flex gap-3", children: [_jsx(Avatar, { src: avatarUrl, displayName: displayName, className: "h-10 w-10 flex-shrink-0" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1 text-[15px]", children: [_jsx("span", { className: cn('truncate font-bold', t.text), children: displayName }), _jsx("span", { className: cn('truncate', t.muted), children: formattedUsername }), _jsx("span", { className: t.muted, children: "\u00B7" }), _jsx("span", { className: t.muted, children: l.now })] }), _jsx("button", { type: "button", "aria-label": l.moreOptions, className: cn('-m-1.5 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full p-1.5', t.muted, theme === 'light' ? 'hover:bg-gray-100' : 'hover:bg-gray-800'), children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) })] }), editable ? (_jsxs("div", { className: "relative", children: [_jsx(Textarea, { ref: textareaRef, value: content, onChange: (e) => onContentChange?.(e.target.value), onInput: mention.handleInput, onKeyUp: mention.handleInput, placeholder: l.placeholder, className: cn('mt-1 min-h-[60px] text-[15px]', t.text, theme === 'light' ? 'placeholder:text-gray-500' : 'placeholder:text-gray-500') }), renderMentionPicker && mention.isOpen ? renderMentionPicker({ query: mention.query, onPick: mention.insertMention, onCancel: mention.close, }) : null, characterLimit !== undefined && (_jsxs("div", { "data-slot": "twitter-character-counter", className: cn('mt-1 text-right text-xs', content.length > characterLimit ? 'text-red-500' : t.muted), children: [_jsx("span", { children: characterLimit - content.length }), _jsx("span", { className: "mx-1", children: "/" }), _jsx("span", { children: characterLimit })] }))] })) : (_jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-[15px]", children: _jsx(ContentWithMentions, { text: content, accent: t.mention }) })), mediaUrls && mediaUrls.length > 0 && (_jsx("div", { className: cn('mt-3 overflow-hidden rounded-2xl border', t.border, mediaUrls.length === 1 && 'aspect-video', mediaUrls.length >= 2 && 'grid aspect-video grid-cols-2 gap-0.5'), children: mediaUrls.slice(0, 4).map((url, i) => (_jsx("div", { className: cn('overflow-hidden bg-gray-800', mediaUrls.length === 1 && 'h-full', mediaUrls.length === 3 && i === 0 && 'row-span-2'), children: _jsx("img", { src: url, alt: `Media ${i + 1}`, loading: "lazy", decoding: "async", className: "h-full w-full object-cover" }) }, url))) })), _jsxs("div", { className: cn('mt-3 flex max-w-[425px] items-center justify-between @[420px]:max-w-[480px]', t.muted), children: [_jsxs("button", { type: "button", "aria-label": l.reply, className: "-m-2 inline-flex min-h-11 min-w-11 items-center gap-1 rounded-full p-2 hover:bg-blue-500/10 hover:text-blue-400", children: [_jsx(MessageCircle, { className: "h-[18px] w-[18px]" }), _jsx("span", { className: "text-[13px]", children: "0" })] }), _jsxs("button", { type: "button", "aria-label": l.retweet, className: "-m-2 inline-flex min-h-11 min-w-11 items-center gap-1 rounded-full p-2 hover:bg-green-500/10 hover:text-green-400", children: [_jsx(Repeat2, { className: "h-[18px] w-[18px]" }), _jsx("span", { className: "text-[13px]", children: "0" })] }), _jsxs("button", { type: "button", "aria-label": l.like, className: "-m-2 inline-flex min-h-11 min-w-11 items-center gap-1 rounded-full p-2 hover:bg-pink-500/10 hover:text-pink-400", children: [_jsx(Heart, { className: "h-[18px] w-[18px]" }), _jsx("span", { className: "text-[13px]", children: "0" })] }), _jsx("button", { type: "button", "aria-label": l.viewAnalytics, className: "-m-2 inline-flex min-h-11 min-w-11 items-center gap-1 rounded-full p-2 hover:bg-blue-500/10 hover:text-blue-400", children: _jsx("svg", { className: "h-[18px] w-[18px]", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z" }) }) }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", "aria-label": l.save, className: "-m-2 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full p-2 hover:bg-blue-500/10 hover:text-blue-400", children: _jsx(Bookmark, { className: "h-[18px] w-[18px]" }) }), _jsx("button", { type: "button", "aria-label": l.share, className: "-m-2 inline-flex min-h-11 min-w-11 items-center justify-center rounded-full p-2 hover:bg-blue-500/10 hover:text-blue-400", children: _jsx(Share, { className: "h-[18px] w-[18px]" }) })] })] })] })] }) })); }