Files

275 lines
9.6 KiB
TypeScript
Raw Permalink Normal View History

2026-05-23 17:17:56 -07:00
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { KEY_PREFIX_UPLOAD, routeIntercept, stateRecover, setTitle, routeTo } from '../../functions/Route'
import { formatTagTextContent, formatTagUsage } from '../../functions/Format'
import type { BackendArt } from '../../functions/BackendTypes'
import { API_BASE, CDN_BASE, BackendFetch } from '../../functions/Backend'
import { wtEvent } from '../../functions/Watchtower'
import { toast } from '../../functions/Context'
import './styles/Animation.css'
import MediaCanvas from '../layout/MediaCanvas'
import ModalEmbed from '../layout/ModalEmbed'
import HeaderLoading from '../layout/HeaderLoading'
import HeaderMessage from '../layout/HeaderMessage'
import HeaderError from '../layout/HeaderError'
import InputButtonRow from '../inputs/ButtonRow'
import InputButton from '../inputs/Button'
import InputBack from '../inputs/Back'
interface PropsForViewAnimation {
id: string
}
export default function ViewAnimation({ id }: PropsForViewAnimation) {
wtEvent('view_animation', { id })
const embedRef = useRef<HTMLDialogElement>(null)
const [data, setData] = useState(stateRecover<BackendArt | undefined>())
const [error, setError] = useState<string | undefined>()
const [isDownloading, setDownloading] = useState(false)
const [isFullscreen, setFullscreen] = useState(false)
const [isLoading, setLoading] = useState(!data)
const isOwner = useMemo(() => Object.keys(localStorage).find((k) => k === `${KEY_PREFIX_UPLOAD}${id}`), [])
const titleTags = data?.tags.map((t) => formatTagTextContent(t.label)).join(', ') ?? ''
const titlePrefix = data ? (data.sticker ? 'Sticker' : 'Animation') : 'Item'
const titleMessage = `View: ${titlePrefix} <ID: ${id}>`
useEffect(() => {
const start = Date.now()
return () => {
wtEvent('view_animation_duration', {
id,
ms: Date.now() - start,
})
}
}, [])
useEffect(() => {
if (data) return
BackendFetch<BackendArt>(`/art/${id}`)
.then((resp) => {
if (!resp.success) {
setError(resp.error)
return
}
setData(resp.json)
})
.catch((err) => {
setError('Network Error')
console.error(err)
})
.finally(() => setLoading(false))
}, [])
function onFullscreenClose() {
wtEvent('action_animation_fullscreen_close', { id })
setFullscreen(false)
}
function onFullscreenOpen() {
wtEvent('action_animation_fullscreen_open', { id })
setFullscreen(true)
}
function onEmbedClose() {
wtEvent('action_animation_embed_close', { id })
embedRef.current?.close()
}
function onEmbedOpen() {
embedRef.current?.showModal()
wtEvent('action_animation_embed_open', { id })
}
function onShareOpen() {
const mobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
const url = window.location.href
if (mobile) {
navigator.share({ url, title: `${data?.title} ${titlePrefix} - gifuu` })
} else {
navigator.clipboard.writeText(url)
toast('action-share', 'Copied URL to Clipboard!')
}
wtEvent('action_animation_share_open', { id, mobile })
}
function onDownload() {
setDownloading(true)
function save(source: Blob, extension: string) {
const blob = URL.createObjectURL(source)
const a = document.createElement('a')
a.href = blob
a.download = `${data?.title} [gifuu-${data?.id}].${extension}`
a.click()
URL.revokeObjectURL(blob)
}
try {
if (!data) throw 'Missing Data'
// We can use the already decoded frame for stickers
if (data.sticker) {
const renderer = document.querySelector<HTMLCanvasElement>('canvas.render')
if (!renderer) {
throw 'Failed to find renderer'
}
renderer.toBlob(
(blob) => {
if (!blob) {
throw 'Failed to retrieve frame'
}
save(blob!, 'png')
},
'image/png',
0.9,
)
} else {
// Download Standard Animation
const url =
`${CDN_BASE}/${data.id}/standard.avif` +
`?utm_source=${window.location.hostname}&utm_medium=download&utm_term=${data.id}`
fetch(url, { cache: 'force-cache' })
.then((resp) => resp.blob())
.then((resp) => save(resp, 'avif'))
}
wtEvent('action_animation_download', { id, sticker: data?.sticker })
} catch (error) {
toast('action-download', String(error))
wtEvent('action_animation_download_failed', { id, reason: String(error) })
} finally {
setDownloading(false)
}
}
function onDelete() {
const accept = confirm('Delete this animation?')
const upload_key = `${KEY_PREFIX_UPLOAD}${id}`
const upload_token = localStorage.getItem(upload_key)
wtEvent('action_animation_delete', { id, cancel: !accept })
if (!upload_token) return
if (!accept) return
fetch(`${API_BASE}/art/${id}?token=${upload_token}`, { method: 'DELETE' })
.then(async (res) => {
if (!res.ok) {
alert(`Request Failed: ${await res.text()}`)
return
}
// Clear Local Data
localStorage.removeItem(upload_key)
routeTo('/personal')
})
.catch((error) => {
wtEvent('action_animation_delete_failed', { id, reason: String(error) })
console.error(error)
alert('Network Error')
})
}
if (isLoading) {
return (
<>
<HeaderMessage label={titleMessage} />
<HeaderLoading reason={undefined} />
</>
)
}
if (error || !data) {
return (
<>
<HeaderMessage label={titleMessage} />
<HeaderError reason={error!} />
</>
)
}
setTitle(`${titlePrefix}: ${data.title} (${titleTags})`)
return (
<>
{createPortal(<ModalEmbed ref={embedRef} item={data} onClose={onEmbedClose} />, document.body)}
{isFullscreen &&
createPortal(
<div className="view-lightbox animation-fall-in" onClick={onFullscreenClose}>
<MediaCanvas id={data.id} background={true} />
</div>,
document.body,
)}
<HeaderMessage label={titleMessage} />
<InputBack />
<div className="view-animation">
<div className="preview" onClick={onFullscreenOpen}>
<MediaCanvas id={id} background={false} />
</div>
<div className="metadata">
<p className="header">{data.title}</p>
<p className="subheader">
{data.width} &times; {data.height} &bull; {new Date(data.created).toLocaleString()} &bull;{' '}
{data.rating < 0.8 ? 'Public' : 'Unlisted'} ({(data.rating * 100) | 0}%)
</p>
</div>
<div className="tags">
{data.tags.map((t) => (
<>
<a
key={t.id}
className="item"
href={`/search?tag=${t.id}`}
onClick={(e) => routeIntercept(e, undefined, data)}>
{formatTagTextContent(t.label)}
<span className="usage">&bull; {formatTagUsage(t.usage)}</span>
</a>
</>
))}
</div>
<InputButtonRow split={true}>
<InputButton
id="action-share"
label="Share"
onClick={onShareOpen}
disabled={false}
rainbow={false}
selected={false}
/>
<InputButton
id="action-embed"
label="Embed"
onClick={onEmbedOpen}
disabled={false}
rainbow={false}
selected={false}
/>
<InputButton
id="action-download"
label="Download"
onClick={onDownload}
disabled={isDownloading}
rainbow={isDownloading}
selected={false}
/>
<InputButton
id="action-delete"
label="Delete"
onClick={onDelete}
disabled={!isOwner}
rainbow={false}
selected={false}
/>
</InputButtonRow>
</div>
</>
)
}