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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { BackendDebounce, BackendFetch } from '../../functions/Backend'
|
||||
import { setTitle, stateRecover } from '../../functions/Route'
|
||||
import { useScrollRoot } from '../../functions/Context'
|
||||
|
||||
import LayoutBrowser, { type RecoverForLayoutBrowser } from '../layout/LayoutBrowser'
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderLoading from '../layout/HeaderLoading'
|
||||
import FooterLoading from '../layout/FooterLoading'
|
||||
import FooterError from '../layout/FooterError'
|
||||
import FooterText from '../layout/FooterText'
|
||||
|
||||
export default function ViewHomepage() {
|
||||
const scrollRoot = useScrollRoot()
|
||||
const recovered = stateRecover<RecoverForLayoutBrowser>()
|
||||
|
||||
const [error, setError] = useState<string>()
|
||||
const [isFetching, setFetching] = useState(false)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [isBottom, setBottom] = useState(false)
|
||||
const [items, setItems] = useState(recovered?.items ?? [])
|
||||
|
||||
async function loadMore() {
|
||||
if (isFetching || isBottom) return
|
||||
setFetching(true)
|
||||
setLoading(true)
|
||||
try {
|
||||
const limit = 30
|
||||
const resp = await BackendFetch<BackendArt[]>(`/art/latest?limit=${limit}&after=${items.at(-1)?.id}`)
|
||||
|
||||
if (!resp.success) {
|
||||
const d = BackendDebounce(resp, 1)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
setLoading(false)
|
||||
}
|
||||
await d.sleep
|
||||
return
|
||||
}
|
||||
|
||||
if (resp.json.length < limit) {
|
||||
setBottom(true)
|
||||
}
|
||||
|
||||
setItems((prev) => [...prev, ...resp.json])
|
||||
setError(undefined)
|
||||
setLoading(false)
|
||||
} finally {
|
||||
setFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRoot) return
|
||||
if (scrollRoot.scrollHeight <= scrollRoot.clientHeight) {
|
||||
loadMore()
|
||||
}
|
||||
}, [items, scrollRoot])
|
||||
|
||||
setTitle('Homepage')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="View: Homepage <Sort: Latest>" />
|
||||
<LayoutBrowser position={recovered?.position ?? 0} items={items} onEndReached={loadMore} />
|
||||
|
||||
{error && <FooterError reason={error} />}
|
||||
{isBottom && <FooterText label="- You've reached the end, now be free my child -" />}
|
||||
{isLoading && (items.length ? <FooterLoading reason={undefined} /> : <HeaderLoading reason={undefined} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { KEY_PREFIX_UPLOAD, setTitle, stateRecover } from '../../functions/Route'
|
||||
import { BackendFetch } from '../../functions/Backend'
|
||||
import { wtEvent } from '../../functions/Watchtower'
|
||||
|
||||
import LayoutBrowser, { type RecoverForLayoutBrowser } from '../layout/LayoutBrowser'
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderLoading from '../layout/HeaderLoading'
|
||||
import HeaderError from '../layout/HeaderError'
|
||||
|
||||
export default function ViewPersonal() {
|
||||
const recovered = stateRecover<RecoverForLayoutBrowser>()
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [items, setItems] = useState(recovered?.items ?? [])
|
||||
const message = useMemo(() => `View: Personal <Count: ${items.length}>`, [items])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled(
|
||||
Object.keys(localStorage)
|
||||
.filter((key) => key.startsWith(KEY_PREFIX_UPLOAD))
|
||||
.map((key) => key.slice(KEY_PREFIX_UPLOAD.length))
|
||||
.sort((a, b) => Number(BigInt(b) - BigInt(a)))
|
||||
.map(
|
||||
(id) =>
|
||||
new Promise<BackendArt>(async (resolve, reject) => {
|
||||
// Check Caches
|
||||
const item = recovered?.items.find((i) => i.id === id)
|
||||
if (item) {
|
||||
return resolve(item)
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
const resp = await BackendFetch<BackendArt>(`/art/${id}`)
|
||||
if (!resp.success) {
|
||||
if (resp.status === 404) {
|
||||
// Was probably deleted, remove from storage so
|
||||
// this client can stop spamming the backend.
|
||||
localStorage.removeItem(KEY_PREFIX_UPLOAD + id)
|
||||
}
|
||||
console.error(`Request for Animation (${id}) failed:`, resp)
|
||||
return reject()
|
||||
}
|
||||
resolve(resp.json)
|
||||
}),
|
||||
),
|
||||
).then((p) => {
|
||||
const items = p.filter((p) => p.status === 'fulfilled').map((p) => p.value)
|
||||
setItems(items)
|
||||
setLoading(false)
|
||||
|
||||
wtEvent('view_personal', {
|
||||
ids: items.map((i) => i.id),
|
||||
item_total: items.length,
|
||||
item_error: p.filter((p) => p.status === 'rejected').length,
|
||||
item_count: p.filter((p) => p.status === 'fulfilled').length,
|
||||
nsfw_count: items.filter((i) => i.rating >= 0.8).length,
|
||||
nsfw_rating: items.reduce((sum, i) => sum + i.rating, 0) / items.length || 0,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="Retrieving Uploads" />
|
||||
<HeaderLoading reason="" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={message} />
|
||||
<HeaderError reason="No past uploads were found on this device. <br> Expecting something? Restore your lost data in Settings." />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
setTitle('Personal')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={message} />
|
||||
<LayoutBrowser items={items} position={recovered?.position ?? 0} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { BackendDebounce, BackendFetch } from '../../functions/Backend'
|
||||
import { routeBackURI, setTitle } from '../../functions/Route'
|
||||
import { useScrollRoot } from '../../functions/Context'
|
||||
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderLoading from '../layout/HeaderLoading'
|
||||
import LayoutBrowser from '../layout/LayoutBrowser'
|
||||
import FooterLoading from '../layout/FooterLoading'
|
||||
import FooterError from '../layout/FooterError'
|
||||
import FooterText from '../layout/FooterText'
|
||||
import InputBack from '../inputs/Back'
|
||||
|
||||
export default function ViewSearch() {
|
||||
const scrollRoot = useScrollRoot()
|
||||
const [error, setError] = useState<string>()
|
||||
const [isFetching, setFetching] = useState(false)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [isBottom, setBottom] = useState(false)
|
||||
const [items, setItems] = useState<BackendArt[]>([])
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const query = params.getAll('tag')
|
||||
const limit = 30
|
||||
|
||||
async function loadMore() {
|
||||
if (query.length === 0) {
|
||||
setError('Enter tags to begin search.')
|
||||
return
|
||||
}
|
||||
|
||||
if (isFetching || isBottom) return
|
||||
setFetching(true)
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await BackendFetch<BackendArt[]>(
|
||||
`/art/search?limit=${limit}&after=${items.at(-1)?.id}` + query.map((q) => `&tag=${q}`).join(''),
|
||||
)
|
||||
if (!resp.success) {
|
||||
const d = BackendDebounce(resp, 1)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
setLoading(false)
|
||||
}
|
||||
await d.sleep
|
||||
return
|
||||
}
|
||||
|
||||
if (resp.json.length < limit) setBottom(true)
|
||||
setItems((prev) => [...prev, ...resp.json])
|
||||
setError(undefined)
|
||||
setLoading(false)
|
||||
} finally {
|
||||
setFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRoot) return
|
||||
if (scrollRoot.scrollHeight <= scrollRoot.clientHeight) {
|
||||
loadMore()
|
||||
}
|
||||
}, [items, scrollRoot])
|
||||
|
||||
setTitle(`Search (${query.join(', ')})`)
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={`View: Search <Sort: Latest> <TAGS: ${query.length ? query.join(', ') : 'NONE'}>`} />
|
||||
{routeBackURI()?.startsWith('/art/') && <InputBack />}
|
||||
<LayoutBrowser position={0} items={items} onEndReached={loadMore} />
|
||||
|
||||
{error && <FooterError reason={error} />}
|
||||
{isBottom && <FooterText label="- No More Results -" />}
|
||||
{isLoading && (items.length ? <FooterLoading reason={undefined} /> : <HeaderLoading reason={undefined} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { setTitle } from '../../functions/Route'
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import InputButton from '../inputs/Button'
|
||||
import InputButtonRow from '../inputs/ButtonRow'
|
||||
import InputLabel from '../inputs/Label'
|
||||
import InputDescription from '../inputs/Description'
|
||||
import './styles/Settings.css'
|
||||
|
||||
export default function ViewSettings() {
|
||||
const GIFUU_PREFIX = '_|GIFUU_BACKUP:DO_NOT_SHARE|_'
|
||||
|
||||
async function onDataBackup() {
|
||||
const password = prompt('Please enter a password for this backup (Leave empty for none):')
|
||||
if (password === null) return
|
||||
|
||||
// Generate Key
|
||||
const enc = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt'],
|
||||
)
|
||||
|
||||
// Encrypt String
|
||||
const data = JSON.stringify(localStorage)
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12))
|
||||
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, enc.encode(data))
|
||||
|
||||
// Package Contents
|
||||
const bundle = new Uint8Array(salt.byteLength + nonce.byteLength + cipher.byteLength)
|
||||
bundle.set(salt, 0)
|
||||
bundle.set(nonce, 16)
|
||||
bundle.set(new Uint8Array(cipher), 28)
|
||||
|
||||
const string = GIFUU_PREFIX + btoa(String.fromCharCode(...bundle))
|
||||
navigator.clipboard.writeText(string)
|
||||
|
||||
alert('Data has been copied to clipboard.')
|
||||
}
|
||||
|
||||
async function onDataRestore() {
|
||||
// Retrieve User Data
|
||||
const blob = await navigator.clipboard.readText()
|
||||
if (!blob.startsWith(GIFUU_PREFIX)) {
|
||||
alert('Contents stored in clipboard is not a backup.')
|
||||
return
|
||||
}
|
||||
const password = prompt('Please enter the password for this backup:')
|
||||
if (password === null) return
|
||||
|
||||
// Unpackage Contents
|
||||
const bundle = Uint8Array.from(atob(blob.slice(GIFUU_PREFIX.length)), (c) => c.charCodeAt(0))
|
||||
const salt = bundle.slice(0, 16)
|
||||
const iv = bundle.slice(16, 28)
|
||||
const cipher = bundle.slice(28)
|
||||
|
||||
// Generate Key
|
||||
const enc = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt'],
|
||||
)
|
||||
|
||||
// Decrypt String
|
||||
let raw: string
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
|
||||
raw = new TextDecoder().decode(plain)
|
||||
} catch (err) {
|
||||
console.error('Backup Error:', err)
|
||||
alert('Incorrect Password')
|
||||
return
|
||||
}
|
||||
|
||||
// Parse String
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
if (typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw 'Invalid Object'
|
||||
}
|
||||
Object.entries(data).forEach(([k, v]) => {
|
||||
if (typeof k === 'string' && typeof v === 'string') {
|
||||
localStorage.setItem(k, v)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Backup Error:', err)
|
||||
alert('Invalid data found in backup.')
|
||||
}
|
||||
|
||||
alert('Data restored, the webpage will now refresh.')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
setTitle('Settings')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="View: Settings" />
|
||||
<div className="view-settings">
|
||||
<div className="section">
|
||||
<InputLabel for="" label="Data Management" />
|
||||
<InputDescription>
|
||||
Export your local preferences and tokens for transfer or backup purposes to your devices
|
||||
clipboard. Do not share these with anybody!
|
||||
</InputDescription>
|
||||
<InputButtonRow split={true}>
|
||||
<InputButton
|
||||
id="action-backup"
|
||||
label="Backup"
|
||||
onClick={onDataBackup}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="action-restore"
|
||||
label="Restore"
|
||||
onClick={onDataRestore}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react'
|
||||
import { wtEvent } from '../../functions/Watchtower'
|
||||
import { setTitle } from '../../functions/Route'
|
||||
import './styles/Text.css'
|
||||
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderError from '../layout/HeaderError'
|
||||
|
||||
/**
|
||||
* Load article from index.html using the 'include-article' placeholder
|
||||
*/
|
||||
|
||||
interface PropsForViewText {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default function ViewText({ id }: PropsForViewText) {
|
||||
const template = document.head.querySelectorAll('template.article')
|
||||
const relevant = [...template].find((e) => e.id === id)
|
||||
|
||||
useEffect(() => {
|
||||
const t = Date.now()
|
||||
return () => {
|
||||
wtEvent('view_article', { id, duration: Date.now() - t })
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!relevant) {
|
||||
return <HeaderError reason="Unknown Article" />
|
||||
}
|
||||
|
||||
setTitle(`Article: ${id}`)
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={`Article <ID: ${id}>`} />
|
||||
<article className="view-document" dangerouslySetInnerHTML={{ __html: relevant.innerHTML }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { BackendChallenge, BackendLimit, UploadEventType } from '../../functions/BackendTypes'
|
||||
import { API_BASE, BackendDebounce, BackendFetch } from '../../functions/Backend'
|
||||
import { KEY_PREFIX_UPLOAD, routeTo, setTitle } from '../../functions/Route'
|
||||
import './styles/Upload.css'
|
||||
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import InputFile, { type HandleForInputFile } from '../inputs/File'
|
||||
import InputText, { type HandleForInputText } from '../inputs/Text'
|
||||
import InputTags, { type HandleForInputTags } from '../inputs/Tags'
|
||||
import InputButton from '../inputs/Button'
|
||||
import FooterLoading from '../layout/FooterLoading'
|
||||
import FooterError from '../layout/FooterError'
|
||||
import FooterText from '../layout/FooterText'
|
||||
import { wtEvent } from '../../functions/Watchtower'
|
||||
|
||||
export default function ViewUpload() {
|
||||
const solveChallengeRef = useRef<() => void>(null)
|
||||
const fileRef = useRef<HandleForInputFile>(null)
|
||||
const titleRef = useRef<HandleForInputText>(null)
|
||||
const tagsRef = useRef<HandleForInputTags>(null)
|
||||
|
||||
const [workCounter, setWorkCounter] = useState(0)
|
||||
const [workNonce, setWorkNonce] = useState('')
|
||||
const [limits, setLimits] = useState<BackendLimit>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingReason, setLoadingReason] = useState<string>('')
|
||||
const [formError, setFormError] = useState<string>('')
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
async function onSubmit() {
|
||||
setFormError('')
|
||||
|
||||
// Validate Client
|
||||
if (workCounter === 0) {
|
||||
wtEvent('upload_validation_fail', { reason: 'pow_unsolved' })
|
||||
return setFormError('Waiting for worker to complete')
|
||||
}
|
||||
if (!limits) {
|
||||
wtEvent('upload_validation_fail', { reason: 'restrictions_loading' })
|
||||
return setFormError('Limits not loaded yet')
|
||||
}
|
||||
|
||||
// Validate Form
|
||||
const restrict = limits.upload
|
||||
const preview = fileRef.current?.getPreview()
|
||||
const file = fileRef.current?.getValue()
|
||||
const tags = tagsRef.current?.getValue()
|
||||
const title = titleRef.current?.getValue()
|
||||
|
||||
if (!file || !preview) {
|
||||
wtEvent('upload_validation_fail', { reason: 'missing_file' })
|
||||
return setFormError('Please select a file.')
|
||||
}
|
||||
if (!tags || tags.length === 0) {
|
||||
wtEvent('upload_validation_fail', { reason: 'missing_tags' })
|
||||
return setFormError('Please add at least one tag.')
|
||||
}
|
||||
if (!title || title.trim().length === 0) {
|
||||
wtEvent('upload_validation_fail', { reason: 'missing_title' })
|
||||
return setFormError('Please enter a title.')
|
||||
}
|
||||
|
||||
// Validate Media
|
||||
if (!restrict.mime_types.includes(file.type)) {
|
||||
wtEvent('upload_validation_fail', { reason: 'mime_type', value: file.type })
|
||||
return setFormError(`File type is not supported`)
|
||||
}
|
||||
if (file.size > restrict.filesize) {
|
||||
wtEvent('upload_validation_fail', { reason: 'file_size', value: file.size })
|
||||
return setFormError(`File exceeds ${Math.floor(restrict.filesize / 1024 / 1024)}MB limit`)
|
||||
}
|
||||
|
||||
if (preview instanceof HTMLVideoElement) {
|
||||
if (preview.videoWidth < restrict.input_width_min || preview.videoHeight < restrict.input_height_min) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_min',
|
||||
width: preview.videoWidth,
|
||||
height: preview.videoHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Animation is too small (Min: ${restrict.input_width_min}x${restrict.input_height_min})`,
|
||||
)
|
||||
}
|
||||
if (preview.videoWidth > restrict.video_width_max || preview.videoHeight > restrict.video_height_max) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_max',
|
||||
width: preview.videoWidth,
|
||||
height: preview.videoHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Animation is too large (Max: ${restrict.video_width_max}x${restrict.video_height_max})`,
|
||||
)
|
||||
}
|
||||
if (preview.duration > restrict.duration) {
|
||||
wtEvent('upload_validation_fail', { reason: 'duration', width: preview.duration })
|
||||
return setFormError(`Animation is too long (Max: ${restrict.duration} seconds)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (preview instanceof HTMLImageElement) {
|
||||
if (preview.naturalWidth < restrict.input_width_min || preview.naturalHeight < restrict.input_height_min) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_min',
|
||||
width: preview.naturalWidth,
|
||||
height: preview.naturalHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Sticker is too small (Min: ${restrict.input_width_min}x${restrict.input_height_min})`,
|
||||
)
|
||||
}
|
||||
if (preview.naturalWidth > restrict.image_width_max || preview.naturalHeight > restrict.image_height_max) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_max',
|
||||
width: preview.naturalWidth,
|
||||
height: preview.naturalHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Sticker is too large (Max: ${restrict.image_width_max}x${restrict.image_height_max})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload User Content
|
||||
setLoading(true)
|
||||
setLoadingReason('Uploading Content')
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append(
|
||||
'data',
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
title: title.trim(),
|
||||
tags: tags.map((t) => t.label),
|
||||
}),
|
||||
],
|
||||
{ type: 'application/json' },
|
||||
),
|
||||
)
|
||||
|
||||
fetch(`${API_BASE}/uploads`, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Challenge-Counter': String(workCounter),
|
||||
'X-Challenge-Nonce': workNonce,
|
||||
},
|
||||
})
|
||||
.then(async (resp) => {
|
||||
let requestID = 'N/A'
|
||||
|
||||
if (!resp.ok) {
|
||||
solveChallengeRef.current?.() // refresh consumed pow
|
||||
|
||||
let reason = `Request failed: ${resp.status} ${resp.statusText}`
|
||||
try {
|
||||
const raw = await resp.text()
|
||||
const dat = JSON.parse(raw)
|
||||
if (dat.message) reason = `${dat.message} (${requestID})`
|
||||
} catch {}
|
||||
|
||||
wtEvent('upload_fail', { reason })
|
||||
setFormError(reason)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = resp.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let parts = buffer.split('\n\n')
|
||||
buffer = parts.pop()!
|
||||
|
||||
for (const part of parts) {
|
||||
const lines = part.split('\n')
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue
|
||||
|
||||
const text = line.slice(5).trim()
|
||||
if (!text) continue
|
||||
|
||||
const body: UploadEventType = JSON.parse(text)
|
||||
switch (body.name) {
|
||||
// Simple Messages
|
||||
case 'id':
|
||||
requestID = body.data
|
||||
break
|
||||
case 'step':
|
||||
setLoadingReason(body.data.message)
|
||||
break
|
||||
case 'progress':
|
||||
setLoadingReason(`Processing: ${body.data.percent}%`)
|
||||
break
|
||||
|
||||
// Request Failed, Most likely due to NSFW
|
||||
case 'error':
|
||||
wtEvent('upload_fail', { reason: body.data.message })
|
||||
setFormError(`${body.data.message} (${requestID})`)
|
||||
setLoading(false)
|
||||
break
|
||||
|
||||
// Request Complete
|
||||
case 'finish':
|
||||
wtEvent('upload_success', {
|
||||
id: requestID,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
width:
|
||||
preview instanceof HTMLVideoElement
|
||||
? preview.videoWidth
|
||||
: preview.naturalWidth,
|
||||
height:
|
||||
preview instanceof HTMLVideoElement
|
||||
? preview.videoHeight
|
||||
: preview.naturalHeight,
|
||||
duration: preview instanceof HTMLVideoElement ? preview.duration : 0,
|
||||
})
|
||||
|
||||
// Little hack to send the user to their personal gifs instead of the upload pane
|
||||
// when they click back... it just feels more logical.
|
||||
|
||||
localStorage.setItem(`${KEY_PREFIX_UPLOAD}${requestID}`, body.data.edit_token)
|
||||
routeTo(`/personal`)
|
||||
routeTo(`/art/${requestID}`)
|
||||
break
|
||||
}
|
||||
|
||||
// For debugging purposes
|
||||
console.log(`[EVENT] ${body.name} => ${text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Network Error:', err)
|
||||
setFormError('Network Error')
|
||||
setLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
solveChallengeRef.current?.()
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let iteration = 1
|
||||
async function handler() {
|
||||
const resp = await BackendFetch<BackendLimit>('/limits')
|
||||
if (resp.error) {
|
||||
console.log('Failed to fetch limits:', resp.error)
|
||||
const d = BackendDebounce(resp, iteration)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
}
|
||||
await d.sleep
|
||||
setError('')
|
||||
handler()
|
||||
return
|
||||
}
|
||||
setLimits(resp.json)
|
||||
}
|
||||
handler()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: number | undefined
|
||||
let worker: Worker
|
||||
let iteration = 1
|
||||
|
||||
async function handler() {
|
||||
console.log('[WORKER] Starting Challenge')
|
||||
setLoadingReason('Retrieving Settings')
|
||||
|
||||
// Request Challenge
|
||||
const resp = await BackendFetch<BackendChallenge>('/challenge?difficulty=20')
|
||||
if (resp.error) {
|
||||
console.log('[WORKER] Fetch Error:', resp.error)
|
||||
const d = BackendDebounce(resp, iteration)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
}
|
||||
await d.sleep
|
||||
setError('')
|
||||
handler()
|
||||
return
|
||||
}
|
||||
setLoadingReason('Solving Anti-Spam Challenge')
|
||||
|
||||
// Complete Challenge
|
||||
const { nonce, difficulty, expires } = resp.json
|
||||
const t = Date.now()
|
||||
|
||||
worker.postMessage({ nonce, difficulty })
|
||||
worker.onmessage = (m: MessageEvent<{ counter: number }>) => {
|
||||
const tt = Date.now() - t
|
||||
wtEvent('upload_pow_solve', { difficulty, time: tt })
|
||||
|
||||
console.log(`[WORKER] Work completed in ${tt}ms`)
|
||||
worker.onmessage = null
|
||||
setWorkCounter(m.data.counter)
|
||||
setWorkNonce(nonce)
|
||||
|
||||
const tl = Math.max(expires * 1000 - Date.now(), 0)
|
||||
timeout = setTimeout(handler, tl)
|
||||
console.log(`[WORKER] Next Challenge in ${tl}ms`)
|
||||
|
||||
setLoadingReason('Ready')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingReason('Spawning Worker')
|
||||
worker = new Worker('/worker-pow.js')
|
||||
solveChallengeRef.current = handler
|
||||
handler() // shouldn't fail
|
||||
} catch (error) {
|
||||
console.error('[WORKER] Startup Error:', error)
|
||||
setError('Init Error')
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
worker.onmessage = null
|
||||
worker.terminate()
|
||||
console.log('[WORKER] Cleanup')
|
||||
}
|
||||
}, [])
|
||||
|
||||
setTitle('Upload')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="View: Upload" />
|
||||
<div className="view-upload">
|
||||
<InputFile ref={fileRef} limits={limits?.upload} />
|
||||
<InputText ref={titleRef} label="Metadata: Title" placeholder="My Creation" />
|
||||
<InputTags ref={tagsRef} label="Metadata: Tags" allowCustom={true} onChange={undefined} />
|
||||
|
||||
{!error && !loading && !formError && <FooterText label="" /* lazy divider */ />}
|
||||
{!error && loading && <FooterLoading reason={loadingReason} />}
|
||||
{!error && formError && <FooterError reason={formError} />}
|
||||
{error && <FooterError reason={error} />}
|
||||
|
||||
<InputButton
|
||||
id="action-upload"
|
||||
label="Upload"
|
||||
onClick={onSubmit}
|
||||
disabled={loading || !!error}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
div.view-animation {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
div.view-animation div.preview {
|
||||
cursor: zoom-in;
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
div.view-animation div.preview div.media-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.view-animation div.metadata p.header {
|
||||
margin-bottom: 8px;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
div.view-animation div.metadata p.subheader {
|
||||
color: var(--font-color-secondary);
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
div.view-animation div.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
div.view-animation div.tags a.item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 8px;
|
||||
width: fit-content;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div.view-animation div.tags a.item:hover,
|
||||
div.view-animation div.tags a.item:focus-visible {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div.view-animation div.tags a.item span.usage {
|
||||
color: var(--font-color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Fullscreen Preview */
|
||||
div.view-lightbox {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
cursor: zoom-out;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
div.view-lightbox div.media-canvas {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
div.view-settings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* View Layout */
|
||||
|
||||
article.view-document a {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Document Layout */
|
||||
|
||||
div.document-section {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* removes the ugly looking padding between the header and first element */
|
||||
div.document-section:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
div.document-section:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
div.document-spacer {
|
||||
margin: 8px 0;
|
||||
border-bottom: var(--border-thickness) dashed var(--background-secondary);
|
||||
}
|
||||
|
||||
div.document-divider {
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
background-color: var(--background-primary);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
div.document-divider::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
clip-path: polygon(
|
||||
100% 25%,
|
||||
62.5% 25%,
|
||||
50% 0%,
|
||||
37.5% 25%,
|
||||
0% 25%,
|
||||
25% 50%,
|
||||
0% 100%,
|
||||
50% 75%,
|
||||
100% 100%,
|
||||
75% 50%,
|
||||
100% 25%
|
||||
);
|
||||
background-color: var(--background-highlight);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* Document Elements */
|
||||
|
||||
div.document-section p,
|
||||
div.document-section pre {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
p.document-item::before {
|
||||
margin-right: 16px;
|
||||
margin-left: 8px;
|
||||
content: '◆';
|
||||
}
|
||||
|
||||
p.document-header {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
p.document-subheader {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
p.document-paragraph code {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background-translucent);
|
||||
padding: 4px;
|
||||
color: var(--font-color-accent);
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
pre.document-codeblock {
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background-translucent);
|
||||
|
||||
/* weird chin on the pre elements this padding here negates it */
|
||||
padding: 12px;
|
||||
padding-bottom: 0;
|
||||
overflow-x: scroll;
|
||||
overflow-y: scroll;
|
||||
color: var(--font-color-accent);
|
||||
font-size: small;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
div.view-upload {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
Reference in New Issue
Block a user