162 lines
6.2 KiB
JavaScript
162 lines
6.2 KiB
JavaScript
import React, { useEffect, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
function galleryUrlFor(author) {
|
|
if (!author?.username) return null
|
|
return `/@${author.username}/gallery`
|
|
}
|
|
|
|
export default function AuthorBioPopover({ author }) {
|
|
const [open, setOpen] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [bio, setBio] = useState(undefined)
|
|
const [error, setError] = useState('')
|
|
|
|
const username = author?.username || ''
|
|
const profileUrl = author?.profile_url || (username ? `/@${username}` : null)
|
|
const galleryUrl = galleryUrlFor(author)
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
function onKeyDown(event) {
|
|
if (event.key === 'Escape') {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', onKeyDown)
|
|
const previousOverflow = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
document.body.style.overflow = previousOverflow
|
|
}
|
|
}, [open])
|
|
|
|
async function loadBio() {
|
|
if (!username || loading || bio !== undefined) {
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/ai-biography`, {
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load biography (${response.status})`)
|
|
}
|
|
|
|
const payload = await response.json()
|
|
setBio(payload?.data?.text || null)
|
|
} catch {
|
|
setError('Biography is unavailable right now.')
|
|
setBio(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (!username || !profileUrl) {
|
|
return null
|
|
}
|
|
|
|
const dialog = open ? createPortal(
|
|
<div className="fixed inset-0 z-[220] overflow-y-auto">
|
|
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
|
|
<div className="flex min-h-screen items-center justify-center p-4 sm:p-6 lg:p-8">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={`About ${author?.name || author?.username || 'author'}`}
|
|
className="relative z-[221] flex max-h-[min(88vh,52rem)] w-full max-w-2xl flex-col overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/96 p-5 shadow-[0_36px_100px_rgba(2,6,23,0.75)] backdrop-blur-xl sm:p-6 lg:p-7"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="Close author biography overlay"
|
|
onClick={() => setOpen(false)}
|
|
className="absolute inset-0"
|
|
/>
|
|
|
|
<div className="relative z-10 flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/70">About the author</p>
|
|
<p className="mt-1 text-xl font-semibold text-white sm:text-2xl">{author?.name || author?.username}</p>
|
|
<p className="text-sm text-white/40 sm:text-base">@{username}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Close author biography"
|
|
onClick={() => setOpen(false)}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-white/60 transition hover:bg-white/[0.08] hover:text-white"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="relative z-10 mt-5 min-h-0 flex-1 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03]">
|
|
<div className="max-h-full overflow-y-auto p-4 text-[15px] leading-8 text-white/85 sm:p-5 sm:text-base lg:text-[17px] lg:leading-8">
|
|
{loading ? <p className="text-white/60">Loading biography...</p> : null}
|
|
{!loading && error ? <p className="text-rose-200/90">{error}</p> : null}
|
|
{!loading && !error && bio ? <p>{bio}</p> : null}
|
|
{!loading && !error && bio === null ? <p className="text-white/60">No public biography available yet.</p> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-10 mt-5 flex shrink-0 flex-wrap gap-3">
|
|
<a
|
|
href={profileUrl}
|
|
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/[0.08]"
|
|
>
|
|
View profile
|
|
</a>
|
|
{galleryUrl ? (
|
|
<a
|
|
href={galleryUrl}
|
|
className="inline-flex items-center gap-2 rounded-xl border border-sky-300/20 bg-sky-300/10 px-4 py-2.5 text-sm font-medium text-sky-100 transition hover:border-sky-300/30 hover:bg-sky-300/16"
|
|
>
|
|
Open gallery
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
) : null
|
|
|
|
return (
|
|
<span className="relative inline-flex items-center">
|
|
<button
|
|
type="button"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open ? 'true' : 'false'}
|
|
aria-label={`More about ${author?.name || author?.username || 'this author'}`}
|
|
onClick={() => {
|
|
const nextOpen = !open
|
|
setOpen(nextOpen)
|
|
if (!open) {
|
|
void loadBio()
|
|
}
|
|
}}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-sky-300/20 bg-sky-300/8 text-sky-100/80 transition hover:border-sky-300/35 hover:bg-sky-300/14 hover:text-sky-50"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25 12 12v4.5m0-8.25h.008v.008H12V8.25Zm9 3.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
</button>
|
|
{dialog}
|
|
</span>
|
|
)
|
|
} |