Files
SkinbaseNova/resources/js/components/artwork/AuthorBioPopover.jsx
2026-04-18 17:02:56 +02:00

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>
)
}