rc-1
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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} × {data.height} • {new Date(data.created).toLocaleString()} •{' '}
|
||||
{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">• {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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user