457 lines
17 KiB
JavaScript
457 lines
17 KiB
JavaScript
import React, { useState, useRef, useCallback, useEffect, lazy, Suspense } from 'react'
|
||
import axios from 'axios'
|
||
import ShareArtworkModal from './ShareArtworkModal'
|
||
import LinkPreviewCard from './LinkPreviewCard'
|
||
import TagPeopleModal from './TagPeopleModal'
|
||
|
||
// Lazy-load the heavy emoji picker only when first opened
|
||
// @emoji-mart/react only has a default export (the Picker); m.Picker is undefined
|
||
const EmojiPicker = lazy(() => import('@emoji-mart/react'))
|
||
|
||
const VISIBILITY_OPTIONS = [
|
||
{ value: 'public', icon: 'fa-globe', label: 'Public' },
|
||
{ value: 'followers', icon: 'fa-user-friends', label: 'Followers' },
|
||
{ value: 'private', icon: 'fa-lock', label: 'Private' },
|
||
]
|
||
|
||
const URL_RE = /https?:\/\/[^\s\])"'>]{4,}/gi
|
||
|
||
function extractFirstUrl(text) {
|
||
const m = text.match(URL_RE)
|
||
return m ? m[0].replace(/[.,;:!?)]+$/, '') : null
|
||
}
|
||
|
||
/**
|
||
* PostComposer
|
||
*
|
||
* Props:
|
||
* user object { id, username, name, avatar }
|
||
* onPosted function(newPost)
|
||
*/
|
||
export default function PostComposer({ user, onPosted }) {
|
||
const [expanded, setExpanded] = useState(false)
|
||
const [body, setBody] = useState('')
|
||
const [visibility, setVisibility] = useState('public')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
const [shareModal, setShareModal] = useState(false)
|
||
const [linkPreview, setLinkPreview] = useState(null)
|
||
const [previewLoading, setPreviewLoading] = useState(false)
|
||
const [previewDismissed, setPreviewDismissed] = useState(false)
|
||
const [lastPreviewUrl, setLastPreviewUrl] = useState(null)
|
||
const [emojiOpen, setEmojiOpen] = useState(false)
|
||
const [emojiData, setEmojiData] = useState(null) // loaded lazily
|
||
const [tagModal, setTagModal] = useState(false)
|
||
const [taggedUsers, setTaggedUsers] = useState([]) // [{ id, username, name, avatar_url }]
|
||
const [scheduleOpen, setScheduleOpen] = useState(false)
|
||
const [scheduledAt, setScheduledAt] = useState('') // ISO datetime-local string
|
||
|
||
const textareaRef = useRef(null)
|
||
const debounceTimer = useRef(null)
|
||
const emojiWrapRef = useRef(null) // wraps button + popover for outside-click
|
||
|
||
// Load emoji-mart data lazily the first time the picker opens
|
||
const openEmojiPicker = useCallback(async () => {
|
||
if (!emojiData) {
|
||
const { default: data } = await import('@emoji-mart/data')
|
||
setEmojiData(data)
|
||
}
|
||
setEmojiOpen((v) => !v)
|
||
}, [emojiData])
|
||
|
||
// Close picker on outside click
|
||
useEffect(() => {
|
||
if (!emojiOpen) return
|
||
const handler = (e) => {
|
||
if (emojiWrapRef.current && !emojiWrapRef.current.contains(e.target)) {
|
||
setEmojiOpen(false)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [emojiOpen])
|
||
|
||
// Insert emoji at current cursor position
|
||
const insertEmoji = useCallback((emoji) => {
|
||
const native = emoji.native ?? emoji.shortcodes ?? ''
|
||
const ta = textareaRef.current
|
||
if (!ta) {
|
||
setBody((b) => b + native)
|
||
return
|
||
}
|
||
const start = ta.selectionStart ?? body.length
|
||
const end = ta.selectionEnd ?? body.length
|
||
const next = body.slice(0, start) + native + body.slice(end)
|
||
setBody(next)
|
||
// Restore cursor after the inserted emoji
|
||
requestAnimationFrame(() => {
|
||
ta.focus()
|
||
const pos = start + native.length
|
||
ta.setSelectionRange(pos, pos)
|
||
})
|
||
setEmojiOpen(false)
|
||
}, [body])
|
||
|
||
const handleFocus = () => {
|
||
setExpanded(true)
|
||
setTimeout(() => textareaRef.current?.focus(), 50)
|
||
}
|
||
|
||
const fetchLinkPreview = useCallback(async (url) => {
|
||
setPreviewLoading(true)
|
||
try {
|
||
const { data } = await axios.get('/api/link-preview', { params: { url } })
|
||
if (data?.url) {
|
||
setLinkPreview(data)
|
||
}
|
||
} catch {
|
||
// silently ignore – preview is optional
|
||
} finally {
|
||
setPreviewLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
const handleBodyChange = (e) => {
|
||
const val = e.target.value
|
||
setBody(val)
|
||
|
||
// Detect URLs and auto-fetch preview (debounced)
|
||
clearTimeout(debounceTimer.current)
|
||
debounceTimer.current = setTimeout(() => {
|
||
const url = extractFirstUrl(val)
|
||
if (!url || previewDismissed) return
|
||
if (url === lastPreviewUrl) return
|
||
setLastPreviewUrl(url)
|
||
setLinkPreview(null)
|
||
fetchLinkPreview(url)
|
||
}, 700)
|
||
}
|
||
|
||
const handleDismissPreview = () => {
|
||
setLinkPreview(null)
|
||
setPreviewDismissed(true)
|
||
}
|
||
|
||
const resetComposer = () => {
|
||
setBody('')
|
||
setExpanded(false)
|
||
setLinkPreview(null)
|
||
setPreviewLoading(false)
|
||
setPreviewDismissed(false)
|
||
setLastPreviewUrl(null)
|
||
setEmojiOpen(false)
|
||
setTaggedUsers([])
|
||
setTagModal(false)
|
||
setScheduleOpen(false)
|
||
setScheduledAt('')
|
||
}
|
||
|
||
const handleSubmit = async (e) => {
|
||
e?.preventDefault()
|
||
if (!body.trim()) return
|
||
setSubmitting(true)
|
||
setError(null)
|
||
try {
|
||
const { data } = await axios.post('/api/posts', {
|
||
type: 'text',
|
||
visibility,
|
||
body,
|
||
link_preview: linkPreview ?? undefined,
|
||
tagged_users: taggedUsers.length > 0 ? taggedUsers.map(({ id, username, name }) => ({ id, username, name })) : undefined,
|
||
publish_at: scheduledAt || undefined,
|
||
})
|
||
onPosted?.(data.post)
|
||
resetComposer()
|
||
} catch (err) {
|
||
setError(err.response?.data?.message ?? 'Failed to post.')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleShared = (newPost) => {
|
||
onPosted?.(newPost)
|
||
setShareModal(false)
|
||
}
|
||
|
||
const showPreview = (linkPreview || previewLoading) && !previewDismissed
|
||
|
||
return (
|
||
<>
|
||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.025] px-5 py-4">
|
||
{/* Collapsed: click-to-expand placeholder */}
|
||
{!expanded ? (
|
||
<div
|
||
onClick={handleFocus}
|
||
className="flex items-center gap-3 cursor-text"
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleFocus()}
|
||
aria-label="Create a post"
|
||
>
|
||
<img
|
||
src={user.avatar ?? '/images/avatar_default.webp'}
|
||
alt={user.name}
|
||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 shrink-0"
|
||
loading="lazy"
|
||
/>
|
||
<span className="text-sm text-slate-500 flex-1 bg-white/[0.04] rounded-xl px-4 py-2.5 hover:bg-white/[0.07] transition-colors">
|
||
Share an update with your followers.
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<form onSubmit={handleSubmit} className="space-y-3">
|
||
{/* Textarea */}
|
||
<div className="flex gap-3">
|
||
<a href={`/@${user.username}`} className="shrink-0" tabIndex={-1}>
|
||
<img
|
||
src={user.avatar ?? '/images/avatar_default.webp'}
|
||
alt={user.name}
|
||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all mt-0.5"
|
||
loading="lazy"
|
||
/>
|
||
</a>
|
||
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
|
||
{/* User identity byline */}
|
||
<div className="flex items-center gap-1.5">
|
||
<a
|
||
href={`/@${user.username}`}
|
||
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors leading-tight"
|
||
tabIndex={-1}
|
||
>
|
||
{user.name || `@${user.username}`}
|
||
</a>
|
||
<span className="text-xs text-slate-500 leading-tight">@{user.username}</span>
|
||
</div>
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={body}
|
||
onChange={handleBodyChange}
|
||
maxLength={2000}
|
||
rows={3}
|
||
placeholder="Share an update with your followers."
|
||
autoFocus
|
||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tagged people pills */}
|
||
{taggedUsers.length > 0 && (
|
||
<div className="pl-12 flex flex-wrap gap-1.5 items-center">
|
||
<span className="text-xs text-slate-500">With:</span>
|
||
{taggedUsers.map((u) => (
|
||
<span key={u.id} className="flex items-center gap-1 px-2 py-0.5 bg-sky-500/10 border border-sky-500/20 rounded-full text-xs text-sky-400">
|
||
<img src={u.avatar_url ?? '/images/avatar_default.webp'} alt="" className="w-3.5 h-3.5 rounded-full object-cover" />
|
||
@{u.username}
|
||
<button
|
||
type="button"
|
||
onClick={() => setTaggedUsers((prev) => prev.filter((x) => x.id !== u.id))}
|
||
className="opacity-60 hover:opacity-100 ml-0.5"
|
||
>
|
||
<i className="fa-solid fa-xmark fa-xs" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Link preview */}
|
||
{showPreview && (
|
||
<div className="pl-12">
|
||
<LinkPreviewCard
|
||
preview={linkPreview}
|
||
loading={previewLoading && !linkPreview}
|
||
onDismiss={handleDismissPreview}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Schedule date picker */}
|
||
{scheduleOpen && (
|
||
<div className="pl-12">
|
||
<div className="flex items-center gap-2.5 p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||
<i className="fa-regular fa-calendar-plus text-violet-400 text-sm fa-fw shrink-0" />
|
||
<div className="flex-1">
|
||
<label className="block text-[11px] text-slate-400 mb-1">Publish on</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={scheduledAt}
|
||
onChange={(e) => setScheduledAt(e.target.value)}
|
||
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
|
||
className="bg-transparent text-sm text-white border-none outline-none w-full [color-scheme:dark]"
|
||
/>
|
||
<p className="text-[10px] text-slate-500 mt-1">
|
||
{Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||
</p>
|
||
</div>
|
||
{scheduledAt && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setScheduledAt('')}
|
||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||
title="Clear"
|
||
>
|
||
<i className="fa-solid fa-xmark fa-sm" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer row */}
|
||
<div className="flex items-center gap-2 pl-12">
|
||
{/* Visibility selector */}
|
||
<div className="flex gap-1">
|
||
{VISIBILITY_OPTIONS.map((v) => (
|
||
<button
|
||
key={v.value}
|
||
type="button"
|
||
onClick={() => setVisibility(v.value)}
|
||
title={v.label}
|
||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||
visibility === v.value
|
||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||
}`}
|
||
>
|
||
<i className={`fa-solid ${v.icon} fa-fw`} />
|
||
{visibility === v.value && <span>{v.label}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Emoji picker trigger */}
|
||
<div ref={emojiWrapRef} className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={openEmojiPicker}
|
||
title="Add emoji"
|
||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||
emojiOpen
|
||
? 'bg-amber-500/15 text-amber-400 border border-amber-500/30'
|
||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||
}`}
|
||
>
|
||
<i className="fa-regular fa-face-smile fa-fw" />
|
||
</button>
|
||
|
||
{emojiOpen && (
|
||
<div className="absolute bottom-full mb-2 left-0 z-50 shadow-2xl">
|
||
<Suspense fallback={
|
||
<div className="w-[352px] h-[400px] rounded-2xl bg-[#10192e] border border-white/10 flex items-center justify-center text-slate-600">
|
||
<i className="fa-solid fa-spinner fa-spin text-xl" />
|
||
</div>
|
||
}>
|
||
{emojiData && (
|
||
<EmojiPicker
|
||
data={emojiData}
|
||
onEmojiSelect={insertEmoji}
|
||
theme="dark"
|
||
set="native"
|
||
previewPosition="none"
|
||
skinTonePosition="search"
|
||
navPosition="bottom"
|
||
perLine={9}
|
||
maxFrequentRows={2}
|
||
/>
|
||
)}
|
||
</Suspense>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tag people button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setTagModal(true)}
|
||
title="Tag people"
|
||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||
taggedUsers.length > 0
|
||
? 'bg-sky-500/15 text-sky-400 border border-sky-500/30'
|
||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||
}`}
|
||
>
|
||
<i className="fa-solid fa-user-tag fa-fw" />
|
||
{taggedUsers.length > 0 && <span>{taggedUsers.length}</span>}
|
||
</button>
|
||
|
||
{/* Schedule button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setScheduleOpen((v) => !v)}
|
||
title="Schedule post"
|
||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs transition-all ${
|
||
scheduleOpen || scheduledAt
|
||
? 'bg-violet-500/15 text-violet-400 border border-violet-500/30'
|
||
: 'text-slate-500 hover:text-white hover:bg-white/5'
|
||
}`}
|
||
>
|
||
<i className="fa-regular fa-clock fa-fw" />
|
||
{scheduledAt && <span className="max-w-[80px] truncate">{new Date(scheduledAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>}
|
||
</button>
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
{/* Share artwork button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setShareModal(true)}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
|
||
title="Share an artwork"
|
||
>
|
||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||
Share artwork
|
||
</button>
|
||
|
||
{/* Cancel */}
|
||
<button
|
||
type="button"
|
||
onClick={resetComposer}
|
||
className="px-3 py-1.5 rounded-lg text-xs text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
|
||
{/* Post */}
|
||
<button
|
||
type="submit"
|
||
disabled={submitting || !body.trim()}
|
||
className={`px-4 py-1.5 rounded-xl disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors ${
|
||
scheduledAt ? 'bg-violet-600 hover:bg-violet-500' : 'bg-sky-600 hover:bg-sky-500'
|
||
}`}
|
||
>
|
||
{submitting ? 'Posting…' : scheduledAt ? 'Schedule' : 'Post'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Char count */}
|
||
{body.length > 1800 && (
|
||
<p className="text-right text-[10px] text-amber-400/70 pr-1">{body.length}/2000</p>
|
||
)}
|
||
|
||
{error && (
|
||
<p className="text-xs text-rose-400">{error}</p>
|
||
)}
|
||
</form>
|
||
)}
|
||
</div>
|
||
|
||
{/* Share artwork modal */}
|
||
<ShareArtworkModal
|
||
isOpen={shareModal}
|
||
onClose={() => setShareModal(false)}
|
||
onShared={handleShared}
|
||
/>
|
||
|
||
{/* Tag people modal */}
|
||
<TagPeopleModal
|
||
isOpen={tagModal}
|
||
onClose={() => setTagModal(false)}
|
||
selected={taggedUsers}
|
||
onConfirm={(users) => { setTaggedUsers(users); setTagModal(false) }}
|
||
/>
|
||
</>
|
||
)
|
||
}
|