This commit is contained in:
2026-05-23 17:17:56 -07:00
commit 448f2e33ef
135 changed files with 11817 additions and 0 deletions
@@ -0,0 +1,16 @@
import { routeBack, routeBackURI } from '../../functions/Route'
import './styles/Back.css'
export default function InputBack() {
return (
<a
className="input-back"
href={routeBackURI()}
onClick={(e) => {
e.preventDefault()
routeBack()
}}>
&lt;&lt; BACK
</a>
)
}
@@ -0,0 +1,23 @@
import { type MouseEventHandler } from 'react'
import './styles/Button.css'
interface PropsForInputButton {
id: string
label: string
rainbow: boolean
disabled: boolean
selected: boolean
onClick: MouseEventHandler<HTMLButtonElement>
}
export default function InputButton({ id, label, disabled, selected, rainbow, onClick }: PropsForInputButton) {
return (
<button
id={id}
onClick={onClick}
disabled={disabled || selected}
className={`input-button ${selected ? 'selected' : ''} ${rainbow ? 'rainbow' : ''}`}>
{label.toUpperCase()}
</button>
)
}
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
import './styles/ButtonRow.css'
interface PropsForInputButtonRow {
children: ReactNode
split: boolean
}
export default function InputButtonRow({ children, split }: PropsForInputButtonRow) {
return <div className={`input-button-line ${split ? 'split' : 'row'}`}>{children}</div>
}
@@ -0,0 +1,9 @@
import './styles/Description.css'
interface PropsForInputDescription {
children: string | string[]
}
export default function InputDescription({ children }: PropsForInputDescription) {
return <p className="input-description animation-fade-in">{children}</p>
}
+104
View File
@@ -0,0 +1,104 @@
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState, type RefObject } from 'react'
import VectorIconTop from '../../vectors/top.svg'
import './styles/File.css'
import type { BackendLimit } from '../../functions/BackendTypes'
export interface PropsForInputFile {
limits: BackendLimit['upload'] | undefined
}
export interface HandleForInputFile {
getPreview: () => HTMLVideoElement | HTMLImageElement | undefined
getValue: () => File | undefined
}
const InputFile = forwardRef<HandleForInputFile, PropsForInputFile>(({ limits }, ref) => {
const componentID = useId()
const previewRef = useRef<HTMLVideoElement | HTMLImageElement>(undefined)
const inputRef = useRef<HTMLInputElement>(null)
const [fileInstance, setFileInstance] = useState<File>()
const [fileObjectURL, setFileObjectURL] = useState<string>()
function updateInput(file?: File) {
if (fileObjectURL) {
URL.revokeObjectURL(fileObjectURL)
}
const accept = file && !!limits?.mime_types.find((t) => file.type === t)
if (accept) {
setFileObjectURL(URL.createObjectURL(file))
setFileInstance(file)
} else {
setFileObjectURL(undefined)
setFileInstance(undefined)
}
}
useImperativeHandle(ref, () => ({
getPreview: () => previewRef.current,
getValue: () => fileInstance,
}))
useEffect(() => {
return () => {
fileObjectURL && URL.revokeObjectURL(fileObjectURL)
}
}, [fileObjectURL])
return (
<div
id="input-file"
className="input-file"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
updateInput(e.dataTransfer.files.item(0)!)
}}>
<input
id={componentID}
ref={inputRef}
type="file"
accept={limits?.mime_types ? limits.mime_types.join(',') : '*/*'}
onChange={(e) => {
e.preventDefault()
updateInput(e.target.files?.item(0) ?? undefined)
}}
/>
{!fileInstance && (
<div className="prompt">
<img className="icon animation-fade-in" src={VectorIconTop} />
<span className="header animation-scroll-in">DRAG OR CLICK TO UPLOAD A FILE</span>
{limits && (
<span className="subheader animation-scroll-in">
MAX: {limits.video_width_max} &times; {limits.video_height_max}; SIZE:{' '}
{Math.floor(limits.filesize / 1024 / 1024)}MB; DURATION:{' '}
{Math.floor(limits.duration / 10) * 10} SECS;
</span>
)}
</div>
)}
{fileInstance && (
<div className="preview">
{fileInstance.type.startsWith('video') && (
<video
ref={previewRef as RefObject<HTMLVideoElement>}
autoPlay
loop
muted
src={fileObjectURL}
/>
)}
{fileInstance.type.startsWith('image') && (
<img ref={previewRef as RefObject<HTMLImageElement>} src={fileObjectURL} />
)}
</div>
)}
</div>
)
})
export default InputFile
@@ -0,0 +1,14 @@
import './styles/Label.css'
interface PropsForInputLabel {
for: string
label: string
}
export default function InputLabel(p: PropsForInputLabel) {
return (
<label htmlFor={p.for} className="input-label animation-scroll-in" aria-label={p.label}>
{p.label.toUpperCase()}
</label>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { type KeyboardEvent, forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { formatTagInput, formatTagTextContent, formatTagUsage } from '../../functions/Format'
import type { BackendTag } from '../../functions/BackendTypes'
import { BackendFetch } from '../../functions/Backend'
import InputLabel from './Label'
import './styles/Label.css'
import './styles/Tags.css'
const SEARCH_LIMIT = 5
const SEARCH_CACHE = new Map<string, BackendTag[]>()
export interface HandleForInputTags {
getValue: () => BackendTag[]
}
interface PropsForInputTags {
label: string
allowCustom: boolean
onChange: ((tags: BackendTag[]) => void) | undefined
}
const InputTags = forwardRef<HandleForInputTags, PropsForInputTags>(({ label, onChange, allowCustom }, ref) => {
const componentID = useId()
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [tagsSelected, setTagsSelected] = useState<BackendTag[]>([])
const [tagsAvailable, setTagsAvailable] = useState<BackendTag[]>([])
const [inputSelect, setInputSelect] = useState(0)
const [inputQuery, setInputQuery] = useState('')
const indexHighlight = useMemo(
() => ((inputSelect % tagsAvailable.length) + tagsAvailable.length) % tagsAvailable.length,
[inputSelect],
)
useImperativeHandle(ref, () => ({ getValue: () => tagsSelected }))
useEffect(() => {
// Cleanup State
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
if (inputQuery.length === 0) {
setTagsAvailable([])
return
}
// Small debounce window before search begins
timeoutRef.current = setTimeout(async () => {
// Pull Tags from Cache
const query = formatTagInput(inputQuery)
if (!query) return
if (SEARCH_CACHE.has(query)) {
setInputSelect(0)
setTagsAvailable(selectDedupe(SEARCH_CACHE.get(query) ?? []))
return
}
// Pull Tags from API
const resp = await BackendFetch<BackendTag[]>(
`/tags/autocomplete?limit=${SEARCH_LIMIT - (allowCustom ? 1 : 0)}&query=${query}`,
)
if (!resp.success) {
console.error('Autocomplete error:', resp)
return
}
// Store Results
if (resp.json.length) {
SEARCH_CACHE.set(query, resp.json)
}
if (allowCustom && !resp.json.find((t) => t.label == query)) {
resp.json.unshift({ id: 'CUSTOM', label: query, usage: 0 })
}
setTagsAvailable(selectDedupe(resp.json))
setInputSelect(0)
}, 200)
}, [inputQuery])
// Append a tag to the currently selected
function selectAppendTag(tag: BackendTag) {
const next = [...tagsSelected, tag]
setTagsSelected(next)
setTagsAvailable([])
setInputQuery('')
onChange?.(next)
}
// Remove a tag from the currently selected
function selectRemoveTag(tag: BackendTag) {
const next = tagsSelected.filter((t) => t.id !== tag.id)
setTagsSelected(next)
onChange?.(next)
}
// Remove currently selected tags from the available list
function selectDedupe(list: BackendTag[]) {
return list.filter((t) => !tagsSelected.some((s) => s.label === t.label))
}
function handleInputKeyDown(ev: KeyboardEvent<HTMLInputElement>) {
// Remove latest tag
if (ev.code === 'Backspace' && !inputQuery.length) {
ev.preventDefault()
const last = tagsSelected.at(-1)
if (last) selectRemoveTag(last)
return
}
// Append selected tag
if (ev.code === 'Enter' && tagsAvailable.length && !Number.isNaN(indexHighlight)) {
ev.preventDefault()
selectAppendTag(tagsAvailable[indexHighlight])
return
}
if (ev.code === 'Space' && inputQuery.at(-1) === ' ' && tagsAvailable.length && !Number.isNaN(indexHighlight)) {
ev.preventDefault()
selectAppendTag(tagsAvailable[indexHighlight])
return
}
// Move Highlight
if (ev.code === 'ArrowUp') {
ev.preventDefault()
setInputSelect(inputSelect - 1)
return
}
if (ev.code === 'ArrowDown') {
ev.preventDefault()
setInputSelect(inputSelect + 1)
return
}
}
return (
<>
{label && <InputLabel for={componentID} label={label} />}
<div className="input-tags" onClick={() => inputRef.current?.focus()}>
{/* Tag Search */}
<div className="search">
{tagsSelected.map((tag) => (
<button className="item" onClick={() => selectRemoveTag(tag)}>
{formatTagTextContent(tag.label)}
</button>
))}
<input
id={componentID}
ref={inputRef}
type="text"
className="query"
placeholder={tagsSelected.length ? '' : 'Search'}
onKeyDown={handleInputKeyDown}
onChange={(e) => setInputQuery(e.currentTarget.value)}
value={inputQuery}
/>
</div>
{/* Tag Results */}
<div className="results">
{tagsAvailable.map((tag, i) => {
const classSelect = i === indexHighlight ? 'select' : ''
const classCustom = tag.id === 'CUSTOM' ? 'custom' : ''
return (
<>
<button
className={`item ${classSelect} ${classCustom}`}
onClick={() => selectAppendTag(tag)}>
<span className="label animation-scroll-in">{formatTagTextContent(tag.label)}</span>
<span className="usage animation-fade-in">
{classCustom ? '<CREATE>' : formatTagUsage(tag.usage)}
</span>
</button>
</>
)
})}
</div>
</div>
</>
)
})
export default InputTags
@@ -0,0 +1,31 @@
import { forwardRef, useId, useImperativeHandle, useRef } from 'react'
import InputLabel from './Label'
import './styles/Label.css'
import './styles/Text.css'
export interface HandleForInputText {
getValue(): string
}
interface PropsForInputTags {
label: string
placeholder: string
}
const InputText = forwardRef<HandleForInputText, PropsForInputTags>(({ label, placeholder }, ref) => {
const componentID = useId()
const inputRef = useRef<HTMLInputElement>(null)
useImperativeHandle(ref, () => ({
getValue: () => inputRef.current?.value ?? '',
}))
return (
<>
{label && <InputLabel for={componentID} label={label} />}
<input id={componentID} ref={inputRef} type="text" className="input-text" placeholder={placeholder} />
</>
)
})
export default InputText
@@ -0,0 +1,9 @@
a.input-back {
margin-bottom: 16px;
text-decoration: none;
}
a.input-back:hover,
a.input-back:focus-visible {
text-decoration: underline;
}
@@ -0,0 +1,54 @@
button.input-button {
cursor: pointer;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 8px;
width: 100%;
}
button.input-button.selected {
background-color: var(--background-primary);
color: var(--font-color-primary);
}
button.input-button:disabled {
cursor: not-allowed;
}
button.input-button:not(.selected):not(.rainbow):disabled {
opacity: 0.5;
color: var(--font-color-secondary);
}
button.input-button:not(:disabled):hover,
button.input-button:not(:disabled):focus-visible {
border-color: var(--background-highlight);
}
button.input-button.rainbow {
position: relative;
animation: rainbow-border 1s linear infinite;
border: 2px solid transparent;
background-clip: padding-box;
}
button.input-button.rainbow::before {
position: absolute;
z-index: -1;
animation: input-button-rainbow-shift 1s linear infinite;
inset: -2px;
border-radius: inherit;
background: linear-gradient(90deg, red, orange, yellow, green, cyan, blue, violet, red);
background-size: 200%;
content: '';
}
@keyframes input-button-rainbow-shift {
from {
background-position: 0%;
}
to {
background-position: 200%;
}
}
@@ -0,0 +1,10 @@
div.input-button-line.row {
display: inline-flex;
width: 100%;
}
div.input-button-line.split {
display: inline-flex;
gap: 8px;
width: 100%;
}
@@ -0,0 +1,4 @@
p.input-description {
padding-bottom: 12px;
color: var(--font-color-secondary);
}
@@ -0,0 +1,56 @@
div.input-file {
cursor: pointer;
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
aspect-ratio: 21 / 9;
width: 100%;
height: 100%;
}
div.input-file input[type='file'] {
display: none;
}
div.input-file div.prompt {
display: grid;
align-content: center;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
}
div.input-file div.prompt img.icon {
margin: 16px;
width: 32px;
height: 32px;
}
div.input-file div.prompt span.hint {
font-size: large;
}
div.input-file div.prompt span.header {
text-align: center;
}
div.input-file div.prompt span.subheader {
color: var(--font-color-secondary);
font-size: small;
text-align: center;
}
div.input-file div.preview {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
div.input-file div.preview img,
div.input-file div.preview video {
max-width: 100%;
height: 100%;
object-fit: contain;
}
@@ -0,0 +1,7 @@
label.input-label:first-child {
padding-top: 0;
}
label.input-label {
padding-top: 12px;
padding-bottom: 8px;
}
@@ -0,0 +1,96 @@
div.input-tags {
position: relative;
z-index: 999;
height: fit-content;
}
div.input-tags div.search {
display: flex;
flex-wrap: wrap;
gap: 4px;
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
border-bottom: none;
background-color: var(--background-tertiary);
padding: 8px;
width: 100%;
height: fit-content;
}
div.input-tags div.search input.query {
border: none;
caret-color: var(--font-color-primary);
field-sizing: content;
}
div.input-tags div.search button.item {
cursor: pointer;
box-sizing: border-box;
background-color: var(--background-secondary);
padding: 0 4px;
}
div.input-tags div.search button.item:focus-visible,
div.input-tags div.search button.item:hover {
color: var(--font-color-accent);
}
/* Tag Search Results */
div.input-tags div.results {
display: grid;
position: absolute;
top: 100%;
left: 0;
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
border-right-width: 0;
border-bottom-width: 0;
border-left-width: 0;
background-color: var(--background-tertiary);
width: 100%;
}
div.input-tags div.results:has(button) {
border-right-width: 1px;
border-bottom-width: 1px;
/* give the search results a border but not a chin while empty */
border-left-width: 1px;
}
div.input-tags div.results button.item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
cursor: pointer;
padding: 8px;
width: 100%;
height: 32px;
line-height: 32px;
text-align: left;
}
div.input-tags div.results button.item span.label {
text-align: left;
}
div.input-tags div.results button.item:focus-visible,
div.input-tags div.results button.item:hover,
div.input-tags div.results button.item.select {
background-color: var(--background-primary);
}
div.input-tags div.results button.item span.usage {
color: var(--font-color-secondary);
}
div.input-tags div.search input.query,
div.input-tags div.search button.item,
div.input-tags div.search button.item {
/* prevent input from overflowing */
width: fit-content;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,7 @@
input.input-text {
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 8px;
width: 100%;
}
@@ -0,0 +1,26 @@
import { createPortal } from 'react-dom'
import './styles/EphemeralTooltip.css'
interface PropsForEphemeralTooltip {
forId: string
message: string
}
export default function EphemeralTooltip({ forId, message }: PropsForEphemeralTooltip) {
const rect = document.getElementById(forId)?.getBoundingClientRect()
if (!rect) return null
return createPortal(
<div
className="ephemeral-tooltip animation-scroll-in"
style={{
position: 'fixed',
left: rect.left + rect.width / 2,
top: rect.top - 8,
transform: 'translateX(-50%) translateY(-100%)',
}}>
<p>{message}</p>
</div>,
document.body,
)
}
@@ -0,0 +1,15 @@
import vectorIconCross from '../../vectors/cross.svg'
import './styles/FooterError.css'
interface PropsForFooterError {
reason: string
}
export default function FooterError({ reason }: PropsForFooterError) {
return (
<div className="footer-error">
<img className="icon" src={vectorIconCross} />
<span className="text">{reason}</span>
</div>
)
}
@@ -0,0 +1,15 @@
import vectorThrobbing from '../../vectors/throbber.svg'
import './styles/FooterLoading.css'
interface PropsForFooterLoading {
reason: string | undefined
}
export default function FooterLoading({ reason }: PropsForFooterLoading) {
return (
<div className="footer-loading">
<span className="text">{(reason ?? 'Loading').toUpperCase()}</span>
<img className="icon" src={vectorThrobbing} />
</div>
)
}
@@ -0,0 +1,9 @@
import './styles/FooterText.css'
interface PropsForFooterText {
label: string
}
export default function FooterText({ label }: PropsForFooterText) {
return <span className="footer-text">{label}</span>
}
@@ -0,0 +1,24 @@
import './styles/HeaderError.css'
interface PropsForHeaderError {
reason: string
}
export default function HeaderError({ reason }: PropsForHeaderError) {
const kamoji = [
/* fishy */ `&gt;&lt;&gt; .o( blub blub )`,
/* sleepy */ `( _ _) .zZ`,
/* kitty! */ `(=^'w'^=) <( meow? )`,
/* clueless */ `(>_< ") <( eek! )`,
/* robot */ `&nbsp;&nbsp;\\_/<br>()o_o) <( beep! )`,
/* bunny */ `&nbsp;/)/)<br>( . .)&nbsp;sorry...<br>(&nbsp;づ&hearts;`,
]
const face = kamoji[Math.floor(Math.random() * kamoji.length)]
return (
<div className="header-error">
<span className="emote" dangerouslySetInnerHTML={{ __html: face }} />
<span className="message" dangerouslySetInnerHTML={{ __html: reason }}></span>
</div>
)
}
@@ -0,0 +1,15 @@
import vectorIconThrobber from '../../vectors/throbber.svg'
import './styles/HeaderLoading.css'
interface PropsForHeaderLoading {
reason: string | undefined
}
export default function HeaderLoading({ reason }: PropsForHeaderLoading) {
return (
<div className="header-loading animation-fade-in">
<img className="icon" src={vectorIconThrobber} />
<span className="hint">{(reason ?? 'Loading').toUpperCase()}</span>
</div>
)
}
@@ -0,0 +1,18 @@
import './styles/HeaderMessage.css'
interface PropsForHeaderMessage {
label: string
}
export default function HeaderMessage({ label }: PropsForHeaderMessage) {
return (
<div className="header-message">
<div className="wrapper">
<span className="title animation-scroll-in">
{label.toUpperCase()}
<span className="cursor animation-blink">_</span>
</span>
</div>
</div>
)
}
@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import type { BackendArt } from '../../functions/BackendTypes'
import { useScrollRoot } from '../../functions/Context'
import { routeIntercept } from '../../functions/Route'
import { CDN_BASE } from '../../functions/Backend'
import './styles/LayoutBrowser.css'
interface PropsForLayoutBrowser {
items: BackendArt[]
position: number
onEndReached?: () => void
}
export interface RecoverForLayoutBrowser {
position: number
items: BackendArt[]
}
export default function LayoutBrowser({ items, position, onEndReached }: PropsForLayoutBrowser) {
const [columnCount, setColumnCount] = useState(3)
const containerRef = useRef<HTMLDivElement>(null)
const scrollRoot = useScrollRoot()
const didRestore = useRef(false)
// Endless Scrolling
useEffect(() => {
if (!onEndReached || !scrollRoot) return
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollRoot
if (scrollTop + clientHeight >= scrollHeight - 100) {
onEndReached()
}
}
scrollRoot.addEventListener('scroll', onScroll)
return () => scrollRoot.removeEventListener('scroll', onScroll)
}, [onEndReached, scrollRoot])
// Restore Scrolling
useEffect(() => {
if (!scrollRoot || didRestore.current) return
// avoid race conditions
const raf = requestAnimationFrame(() => {
scrollRoot.scrollTo({ top: position })
didRestore.current = true
})
return () => {
cancelAnimationFrame(raf)
}
}, [scrollRoot, position, items])
// Calculate Column Count
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(([entry]) => {
setColumnCount(Math.max(1, Math.floor(entry.contentRect.width / 160)))
})
ro.observe(el)
return () => ro.disconnect()
}, [])
const columns: BackendArt[][] = Array.from({ length: columnCount }, () => [])
items.forEach((item, i) => columns[i % columnCount].push(item))
return (
<div className="layout-browser" ref={containerRef}>
{columns.map((column, columnIdx) => (
<div key={columnIdx} className="column">
{column.map((item, itemIdx) => {
const animationOrder = Math.min(columnIdx + itemIdx * columnCount, 25)
const animationDelay = `calc(${animationOrder} * var(--animation-step-delay))`
return (
<a
className="item"
href={`/art/${item.id}`}
onClick={(e) =>
routeIntercept(e, item, {
position: scrollRoot?.scrollTop ?? 0,
items: items,
} as RecoverForLayoutBrowser)
}>
<img
style={{ animationDelay }}
className="preview animation-fall-in"
src={`${CDN_BASE}/${item.id}/preview.avif`}
/>
<div className="metadata">
<div className="title">{item.title}</div>
</div>
</a>
)
})}
</div>
))}
</div>
)
}
@@ -0,0 +1,200 @@
import { useEffect, useRef, useState } from 'react'
import { CDN_BASE } from '../../functions/Backend'
import './styles/MediaCanvas.css'
import vectorIconThrobber from '../../vectors/throbber.svg'
import vectorIconCross from '../../vectors/cross.svg'
interface PropsForMediaCanvas {
id: string
background: boolean
}
export default function MediaCanvas({ id, background }: PropsForMediaCanvas) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const [fallback, setFallback] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const defer: (() => void)[] = []
const canvas = canvasRef.current!
const video = videoRef.current!
if (!canvas || !video) return
video.onerror = () => {
console.warn('Failed to load video, using fallback...')
teardown()
setFallback(true)
return
}
// --- Initialize Canvas ---
const gl = canvas.getContext('webgl', {
powerPreference: 'low-power',
preserveDrawingBuffer: true, // for download button
premultipliedAlpha: false,
antialias: false,
alpha: true,
depth: false,
})!
if (!gl) {
console.error('Context failed, using fallback...')
setFallback(true)
return
}
try {
const VERT = `
precision mediump float;
attribute vec2 aPos;
uniform mat3 uMatrix;
varying vec2 vUV;
void main() {
gl_Position = vec4(uMatrix * vec3(aPos, 1.0), 1.0);
vUV = aPos;
}`
const FRAG = `
precision mediump float;
uniform sampler2D uFrame;
varying vec2 vUV;
void main() {
vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
vec4 color = texture2D(uFrame, colorUV);
float alpha = texture2D(uFrame, alphaUV).r;
gl_FragColor = vec4(color.rgb, alpha);
}`
function compileShader(type: number, src: string) {
const s = gl.createShader(type)!
gl.shaderSource(s, src)
gl.compileShader(s)
return s
}
const prog = gl.createProgram()
gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, VERT))
gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, FRAG))
gl.linkProgram(prog)
gl.useProgram(prog)
defer.push(() => gl.deleteProgram(prog))
// --- Quad ---
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), gl.STATIC_DRAW)
defer.push(() => gl.deleteBuffer(buf))
const aPos = gl.getAttribLocation(prog, 'aPos')
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
gl.uniformMatrix3fv(gl.getUniformLocation(prog, 'uMatrix'), false, [2, 0, 0, 0, -2, 0, -1, 1, 1])
// --- Texture ---
const tex = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.uniform1i(gl.getUniformLocation(prog, 'uFrame'), 0)
defer.push(() => gl.deleteTexture(tex))
defer.push(() => gl.getExtension('WEBGL_lose_context')?.loseContext())
} catch (error) {
console.error('Init failed, using fallback...', error)
setFallback(true)
teardown()
return
}
// --- Draw Loop ---
let cancel: number
let sized = false
let start = false
function tick() {
cancel = requestAnimationFrame(tick)
if (!start) {
setLoading(false)
start = true
}
try {
if (!sized && video.videoWidth > 0) {
sized = true
canvas.width = video.videoWidth
canvas.height = Math.floor(video.videoHeight / 2)
gl.viewport(0, 0, canvas.width, canvas.height)
}
if (!sized) return
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
gl.drawArrays(gl.TRIANGLES, 0, 6)
} catch (error) {
// bugfix for safari browsers
console.error('Draw failed, using fallback...', error)
setFallback(true)
teardown()
return
}
}
tick()
defer.push(() => cancelAnimationFrame(cancel))
video.play().catch(() => {})
// --- Disposal Functions ---
function teardown() {
let func
while ((func = defer.shift())) {
try {
func()
} catch (error) {
console.error('Teardown Error:', error)
}
}
}
return teardown
}, [])
return (
<div className={`media-canvas ${background ? 'background' : ''}`}>
{!error && loading && (
<div className="popup">
<img className="icon" src={vectorIconThrobber} />
<span className="hint">LOADING</span>
</div>
)}
{error && (
<div className="popup">
<img className="icon" src={vectorIconCross} />
<span className="hint">{error}</span>
</div>
)}
{!error && fallback && (
<img
className="render"
src={`${CDN_BASE}/${id}/standard.avif`}
onError={() => setError('Cannot Load Image')}
onLoad={() => setLoading(false)}
/>
)}
{!error && !fallback && (
<>
<video
ref={videoRef}
crossOrigin="anonymous"
className="decode"
src={`${CDN_BASE}/${id}/alpha.webm`}
autoPlay
loop
muted
playsInline
/>
<canvas ref={canvasRef} className="render" />
</>
)}
</div>
)
}
@@ -0,0 +1,199 @@
import { type MouseEventHandler, forwardRef, useEffect, useMemo, useState } from 'react'
import type { BackendArt } from '../../functions/BackendTypes'
import { WEB_BASE } from '../../functions/Backend'
import { wtEvent } from '../../functions/Watchtower'
import { toast } from '../../functions/Context'
import './styles/ModalEmbed.css'
import VectorBackgroundEmbed from '../../vectors/background-embed.svg'
import HeaderMessage from './HeaderMessage'
import InputButtonRow from '../inputs/ButtonRow'
import InputButton from '../inputs/Button'
import InputDescription from '../inputs/Description'
import InputLabel from '../inputs/Label'
interface PropsForModalEmbed {
item: BackendArt
onClose: MouseEventHandler<HTMLButtonElement>
}
export default forwardRef<HTMLDialogElement, PropsForModalEmbed>(function ModalEmbed(
{ item, onClose }: PropsForModalEmbed,
ref,
) {
// Keep User Preferences
const KEY_QUALITY = 'preference_embed_quality'
const KEY_SCALE = 'preference_embed_scale'
const [preferQuality, setQuality] = useState<'standard' | 'transparent'>(
(() => {
let raw = localStorage.getItem(KEY_QUALITY) ?? 'standard'
if (raw !== 'standard' && raw !== 'transparent') {
return 'standard'
} else {
return raw
}
})(),
)
const [preferScale, setScale] = useState<number>(
(() => {
let raw = localStorage.getItem(KEY_SCALE) ?? String('1')
let val = parseFloat(raw)
if (isNaN(val) || val < 0 || val > 1) return 1
return val
})(),
)
useEffect(() => localStorage.setItem(KEY_QUALITY, String(preferQuality)), [preferQuality])
useEffect(() => localStorage.setItem(KEY_SCALE, String(preferScale)), [preferScale])
// Calculate Embed Values
const embedScale = useMemo(() => {
const maxDim = Math.max(item.width, item.height)
const baseScale = maxDim > 640 ? 640 / maxDim : 1
return baseScale * preferScale
}, [item.width, item.height, preferScale])
const embedHeight = useMemo(() => (item.height * embedScale) | 0, [embedScale])
const embedWidth = useMemo(() => (item.width * embedScale) | 0, [embedScale])
// const embedQuality = useMemo(() => {
// if (preferQuality === 'standard.avif') return 'standard'
// return 'transparent'
// }, [preferQuality])
const embedHTML = useMemo(
() =>
`<iframe src="${WEB_BASE}/embed.html?id=${item.id}&quality=${preferQuality}" width="${embedWidth}" height="${embedHeight}" style="border:none; background: transparent" allowtransparency="true"></iframe>`,
[preferQuality, embedScale],
)
function onCopy() {
navigator.clipboard.writeText(embedHTML)
toast('action-copy', 'Copied Code to Clipboard!')
wtEvent('action_animation_embed_copy', {
id: item.id,
height: embedHeight,
width: embedWidth,
scale: (embedScale * 100) | 0,
quality: preferQuality,
})
}
return (
<dialog ref={ref} className="modal-embed animation-fall-in animation-caution">
<HeaderMessage label="MENU: Embed Generator" />
<div className="wrapper">
{/* Left-Pane */}
<div className="preview">
<img className="background animation-fade-in" src={VectorBackgroundEmbed} />
<iframe
className="animation-fall-in"
style={{ border: 'none', background: 'transparent' }}
src={`${WEB_BASE}/embed.html?id=${item.id}&quality=${preferQuality}`}
width={embedWidth}
height={embedHeight}
allowTransparency
/>
</div>
{/* Right-Pane */}
<div className="toggles">
<InputLabel for="" label="Quality" />
<InputDescription>
We recommend using Standard quality, if you require transparency use Alpha quality.
</InputDescription>
<InputDescription>
Using more than three Alpha embeds may slow down your site, and up to twelve can be displayed at
any given time.
</InputDescription>
<InputButtonRow split={false}>
<InputButton
id="quality-alpha"
label="Alpha"
onClick={() => setQuality('transparent')}
selected={preferQuality === 'transparent'}
disabled={false}
rainbow={false}
/>
<InputButton
id="quality-standard"
label="Standard"
onClick={() => setQuality('standard')}
selected={preferQuality === 'standard'}
disabled={false}
rainbow={false}
/>
</InputButtonRow>
<InputLabel for="" label="Scale" />
<InputDescription>
Sizing is as follows: Small @ 320px; Medium @ 480px; Large @ 640px.
</InputDescription>
<InputDescription>If an image is too small, it wont get any larger.</InputDescription>
<InputButtonRow split={false}>
<InputButton
id="size-small"
label="Small"
selected={preferScale < 0.6}
onClick={() => setScale(0.5)}
disabled={false}
rainbow={false}
/>
<InputButton
id="size-medium"
label="Medium"
selected={preferScale > 0.6 && preferScale < 0.9}
onClick={() => setScale(0.75)}
disabled={false}
rainbow={false}
/>
<InputButton
id="size-large"
label="Large"
selected={preferScale > 0.9}
onClick={() => setScale(1)}
disabled={false}
rainbow={false}
/>
</InputButtonRow>
<InputLabel for="" label="Code" />
<InputDescription>
Use this code snippet to display this {item.sticker ? 'sticker' : 'animation'} on your website.
</InputDescription>
<InputDescription>Clicking on the embed will direct users to gifuu in a new tab.</InputDescription>
<textarea
id="input-url"
className="input-url"
value={embedHTML}
onKeyDown={(e) => e.preventDefault()}
/>
<InputLabel for="" label="" /* lazy divider */ />
<InputButtonRow split={true}>
<InputButton
id="action-copy"
label="Copy HTML"
onClick={onCopy}
selected={false}
disabled={false}
rainbow={false}
/>
<InputButton
id="action-exit"
label="Exit"
onClick={onClose}
selected={false}
disabled={false}
rainbow={false}
/>
</InputButtonRow>
</div>
</div>
</dialog>
)
})
@@ -0,0 +1,33 @@
import { useEffect, useState, type ReactNode } from 'react'
import vectorClose from '../../vectors/category-close.svg'
import vectorOpen from '../../vectors/category-open.svg'
import './styles/SidebarCategory.css'
interface PropsForSidebarCategory {
label: string
header?: boolean
children?: ReactNode
}
export default function SidebarCategory({ label, header, children }: PropsForSidebarCategory) {
const key = `category_closed_${label.toLowerCase()}`
const [closed, setClosed] = useState(localStorage.getItem(key) === 'Y')
useEffect(() => localStorage.setItem(key, closed ? 'Y' : 'N'), [closed])
return (
<div className={`category ${closed ? 'close' : 'open'}`}>
{header && (
<button className="toggle" onClick={() => setClosed(!closed)}>
<span className="label">{label.toUpperCase()}</span>
<img
className="icon"
alt={`Toggle visibility for ${label}`}
src={closed ? vectorClose : vectorOpen}
/>
</button>
)}
<div className="items">{children}</div>
</div>
)
}
@@ -0,0 +1,28 @@
import { routeIntercept } from '../../functions/Route'
import './styles/SidebarItemIcon.css'
interface PropsForSidebarItemIcon {
icon: string
label: string
description: string
location: string
}
export default function SidebarItemIcon({ icon, label, description, location }: PropsForSidebarItemIcon) {
return (
<a className="item-icon" href={location} onClick={routeIntercept}>
<div className="section-left">
<span className="header animation-scroll-in" aria-label={label}>
{label.toUpperCase()}
</span>
<span className="subheader animation-scroll-in" aria-label={description}>
{description.toUpperCase()}
</span>
</div>
<div className="section-right animation-fade-in">
<img className="foreground" src={icon} />
<div className="background"></div>
</div>
</a>
)
}
@@ -0,0 +1,11 @@
import { routeIntercept } from '../../functions/Route'
import vectorLogoFull from '../../vectors/logo-full.svg'
import './styles/SidebarItemLogo.css'
export default function SidebarItemLogo() {
return (
<a className="item-logo" href="/" onClick={routeIntercept}>
<img className="logo" src={vectorLogoFull} />
</a>
)
}
@@ -0,0 +1,13 @@
import { formatTagTextContent, formatTagUsage } from '../../functions/Format'
import type { BackendTag } from '../../functions/BackendTypes'
import { routeIntercept } from '../../functions/Route'
import './styles/SidebarItemTag.css'
export default function SidebarItemTag({ id, label, usage }: BackendTag) {
return (
<a className="item-tag" href={`/search?tag=${id}`} onClick={routeIntercept}>
<span className="label animation-scroll-in">{formatTagTextContent(label)}</span>
<span className="usage animation-fade-in">{formatTagUsage(usage)}</span>
</a>
)
}
@@ -0,0 +1,15 @@
import { routeIntercept } from '../../functions/Route'
import './styles/SidebarItemText.css'
interface PropsForSidebarItemText {
location: string
label: string
}
export default function SidebarItemText({ location, label }: PropsForSidebarItemText) {
return (
<a className="item-text animation-scroll-in" href={location} onClick={routeIntercept}>
{label}
</a>
)
}
@@ -0,0 +1,17 @@
div.footer-error {
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 0;
width: 100%;
}
div.footer-error span.text {
color: var(--font-color-secondary);
}
div.footer-error img.icon {
opacity: 0.5;
width: 16px;
height: 16px;
}
@@ -0,0 +1,17 @@
div.footer-loading {
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 0;
width: 100%;
}
div.footer-loading span.text {
color: var(--font-color-secondary);
}
div.footer-loading img.icon {
opacity: 0.5;
width: 16px;
height: 16px;
}
@@ -0,0 +1,7 @@
span.footer-text {
padding: 8px 0;
height: 16px;
color: var(--font-color-secondary);
line-height: 16px;
text-align: center;
}
@@ -0,0 +1,17 @@
div.header-error {
display: grid;
align-content: center;
justify-items: center;
min-height: 300px;
}
div.header-error span.emote {
margin: 16px;
font-size: 2.5em;
}
div.header-error span.message {
color: var(--font-color-secondary);
line-height: 2;
text-align: center;
}
@@ -0,0 +1,17 @@
div.header-loading {
display: grid;
align-content: center;
justify-items: center;
gap: 16px;
min-height: 300px;
}
div.header-loading img.icon {
width: 64px;
height: 64px;
}
div.header-loading span.hint {
color: var(--font-color-secondary);
text-align: center;
}
@@ -0,0 +1,26 @@
div.header-message {
position: relative;
height: 32px;
}
div.header-message div.wrapper {
position: fixed;
align-content: center;
z-index: 999;
box-sizing: border-box;
inset: 0;
background-color: var(--background-secondary);
padding: 0 8px;
width: 100%;
height: 32px;
}
div.header-message div.wrapper span.title {
display: flex;
gap: 4px;
}
div.header-message div.wrapper span.title,
div.header-message div.wrapper span.cursor {
color: var(--font-color-secondary);
}
@@ -0,0 +1,41 @@
div.layout-browser {
display: flex;
gap: 8px;
}
div.layout-browser div.column {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
div.layout-browser a.item {
display: block;
position: relative;
margin-bottom: 8px;
background-color: black;
overflow: hidden;
}
div.layout-browser a.item img.preview {
display: block;
width: 100%;
height: fit-content;
}
div.layout-browser a.item div.metadata {
position: absolute;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
transition: opacity var(--animation-transition) ease-in-out;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
padding: 24px 8px 8px;
}
div.layout-browser a.item:hover div.metadata,
div.layout-browser a.item:focus-visible div.metadata {
opacity: 1;
}
@@ -0,0 +1,61 @@
div.media-canvas {
display: inline-block;
position: relative;
overflow: hidden;
}
div.media-canvas div.popup {
display: flex;
position: absolute;
top: 0;
justify-content: center;
align-items: center;
gap: 8px;
width: 100%;
height: 100%;
}
div.media-canvas div.popup img.icon {
width: 16px;
height: 16px;
}
div.media-canvas video.decode {
position: absolute;
visibility: hidden;
}
div.media-canvas img.render.error {
display: none;
}
div.media-canvas img.render,
div.media-canvas canvas.render {
display: block;
width: 100%;
max-width: inherit;
height: 100%;
max-height: inherit;
object-fit: contain;
}
div.media-canvas.background {
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 8px,
var(--background-secondary) 8px,
var(--background-secondary) 9px
),
repeating-linear-gradient(
-45deg,
transparent,
transparent 8px,
var(--background-secondary) 8px,
var(--background-secondary) 9px
);
background-color: var(--background-tertiary);
}
@@ -0,0 +1,70 @@
dialog.modal-embed::backdrop {
animation: kf-modal-backdrop 1s linear forwards;
/* animation-delay: 2s; */
background-color: rgb(0 0 0 / 50%);
}
@keyframes kf-modal-backdrop {
0% {
backdrop-filter: blur(0);
}
100% {
backdrop-filter: blur(4px);
}
}
dialog.modal-embed {
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
width: 1024px;
}
dialog.modal-embed:open {
display: flex;
justify-content: space-around;
}
dialog.modal-embed > div.wrapper {
display: flex;
justify-content: space-evenly;
align-items: center;
margin-top: 32px;
width: 100%;
height: 100%;
}
dialog.modal-embed > div.wrapper > div.preview {
display: flex;
position: relative;
justify-content: center;
align-items: center;
width: 640px;
height: 640px;
}
dialog.modal-embed > div.wrapper > div.preview > img.background {
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
}
dialog.modal-embed > div.wrapper > div.toggles {
display: grid;
flex-basis: 100%;
align-content: start;
align-items: start;
max-width: 300px;
height: 100%;
overflow-y: scroll;
}
dialog.modal-embed > div.wrapper > div.toggles > textarea {
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 8px;
min-height: 120px;
resize: none;
}
@@ -0,0 +1,33 @@
nav.layout-sidebar div.category {
margin-bottom: 8px;
width: 100%;
height: fit-content;
}
nav.layout-sidebar div.category:last-child {
margin-bottom: 0;
}
nav.layout-sidebar div.category button.toggle {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
border-bottom: var(--border-thickness) solid transparent;
padding: 8px 8px 8px 0px;
width: 100%;
}
nav.layout-sidebar div.category button.toggle img.icon {
width: 16px;
height: 16px;
}
nav.layout-sidebar div.category.open button.toggle {
margin-bottom: 8px;
border-color: var(--font-color-secondary);
}
nav.layout-sidebar div.category.close div.items {
display: none;
}
@@ -0,0 +1,54 @@
nav.layout-sidebar a.item-icon {
display: flex;
gap: 16px;
box-sizing: border-box;
padding: 12px 8px 12px 4px;
width: 100%;
text-decoration: none;
}
nav.layout-sidebar a.item-icon:hover,
nav.layout-sidebar a.item-icon:focus-visible {
text-decoration: underline;
}
nav.layout-sidebar a.item-icon div.section-left {
flex-basis: 100%;
}
nav.layout-sidebar a.item-icon div.section-left span.header,
nav.layout-sidebar a.item-icon div.section-left span.subheader {
font-size: 1em;
line-height: 1em;
}
/* nav.layout-sidebar a.item-icon div.section-left span.header {} */
nav.layout-sidebar a.item-icon div.section-left span.subheader {
color: var(--font-color-secondary);
}
nav.layout-sidebar a.item-icon div.section-right {
display: grid;
align-items: center;
justify-items: center;
}
nav.layout-sidebar a.item-icon div.section-right div.background,
nav.layout-sidebar a.item-icon div.section-right img.foreground {
grid-row: 1;
grid-column: 1;
}
nav.layout-sidebar a.item-icon div.section-right div.background {
transform: rotate(45deg);
background-color: var(--background-secondary);
width: 32px;
height: 32px;
}
nav.layout-sidebar a.item-icon div.section-right img.foreground {
z-index: 1;
width: 16px;
height: auto;
}
@@ -0,0 +1,6 @@
nav.layout-sidebar a.item-logo img.logo {
padding: 16px 0;
width: 100%;
height: 32px;
object-fit: contain;
}
@@ -0,0 +1,24 @@
nav.layout-sidebar a.item-tag {
display: flex;
justify-content: space-between;
gap: 8px;
box-sizing: border-box;
padding: 4px;
text-decoration: none;
}
nav.layout-sidebar a.item-tag.dummy {
justify-content: center;
width: 100%;
color: var(--font-color-secondary);
}
nav.layout-sidebar a.item-tag[href]:hover,
nav.layout-sidebar a.item-tag[href]:focus-visible {
text-decoration: underline;
}
nav.layout-sidebar a.item-tag span.usage {
color: var(--font-color-secondary);
text-decoration: none;
}
@@ -0,0 +1,11 @@
nav.layout-sidebar a.item-text {
padding: 4px;
color: var(--font-color-secondary);
text-decoration: none;
}
nav.layout-sidebar a.item-text:hover,
nav.layout-sidebar a.item-text:focus-visible {
color: var(--font-color-primary);
text-decoration: underline;
}
@@ -0,0 +1,58 @@
import { type ReactNode, useEffect, useState } from 'react'
import { ScrollContext } from '../../functions/Context'
import HeaderMessage from '../layout/HeaderMessage'
import HeaderError from '../layout/HeaderError'
import ViewAnimation from '../views/Animation'
import ViewHomepage from '../views/Homepage'
import ViewPersonal from '../views/Personal'
import ViewSearch from '../views/Search'
import ViewSettings from '../views/Settings'
import ViewText from '../views/Text'
import ViewUpload from '../views/Upload'
export default function PaneContent() {
const [mainElem, setMainElem] = useState<HTMLElement | null>(null)
const [path, setPath] = useState(window.location.pathname)
const [key, setKey] = useState(window.location.href)
// Track Path
useEffect(() => {
const onPop = () => {
setPath(window.location.pathname)
setKey(window.location.href)
}
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
// Match Component
const views = new Array<{ route: RegExp; scroll: boolean; component: (m: RegExpMatchArray) => ReactNode }>(
{ route: /^\/art\/([0-9]+)$/, scroll: false, component: (m) => <ViewAnimation key={key} id={m[1]} /> },
{ route: /^\/text\/([a-z-]+)$/, scroll: true, component: (m) => <ViewText key={key} id={m[1]} /> },
{ route: /^\/personal$/, scroll: true, component: (_) => <ViewPersonal /> },
{ route: /^\/upload$/, scroll: false, component: (_) => <ViewUpload key={key} /> },
{ route: /^\/settings$/, scroll: true, component: (_) => <ViewSettings key={key} /> },
{ route: /^\/search$/, scroll: true, component: (_) => <ViewSearch key={key} /> },
{ route: /^\/$/, scroll: true, component: (_) => <ViewHomepage key={key} /> },
)
const match = views.map((v) => ({ v, m: path.match(v.route) })).find(({ m }) => m !== null)
const relevant = match ? { ...match.v, component: match.v.component(match.m!) } : null
// Render Content
return (
<ScrollContext.Provider value={mainElem}>
<main ref={setMainElem} className={`layout-content ${relevant?.scroll ? 'layout-scrolling' : ''}`}>
{relevant ? (
relevant.component
) : (
<>
<HeaderMessage label="System Message" />
<HeaderError reason="The page you requested was not found." />
</>
)}
</main>
</ScrollContext.Provider>
)
}
@@ -0,0 +1,18 @@
import { type ReactNode } from 'react'
import './styles/PaneGlass.css'
interface PropsForPaneGlass {
children?: ReactNode
}
export default function PaneGlass({ children }: PropsForPaneGlass) {
return (
<div className="layout-glass-container">
<div className="layout-glass-corner" />
<div className="layout-glass-corner" />
<div className="layout-glass-corner" />
<div className="layout-glass-corner" />
{children}
</div>
)
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import type { BackendTag } from '../../functions/BackendTypes'
import { BackendFetch } from '../../functions/Backend'
import { routeTo } from '../../functions/Route'
import VectorIconStar from '../../vectors/star.svg'
import VectorIconFeed from '../../vectors/feed.svg'
import SidebarCategory from '../layout/SidebarCategory'
import SidebarItemLogo from '../layout/SidebarItemLogo'
import SidebarItemText from '../layout/SidebarItemText'
import SidebarItemIcon from '../layout/SidebarItemIcon'
import SidebarItemTag from '../layout/SidebarItemTag'
import InputTags from '../inputs/Tags'
export default function PaneSidebar() {
const [childrenTags, setChildrenTags] = useState([<a className="item-tag dummy">... LOADING ...</a>])
useEffect(() => {
BackendFetch<BackendTag[]>('/tags/popular?limit=5').then((resp) => {
if (!resp.success) {
console.error('Tags Unavailable:', resp)
setChildrenTags([
<a className="item-tag dummy">... ERROR ...</a>,
<a className="item-tag dummy">VIEW CONSOLE FOR DETAILS</a>,
])
return
}
setChildrenTags(
resp.json.map((i) => <SidebarItemTag key={i.id} id={i.id} usage={i.usage} label={i.label} />),
)
})
}, [])
function onTagChange(tags: BackendTag[]) {
const query = tags.map((t) => `tag=${t.id}`).join('&')
routeTo(`/search?${query}`)
}
return (
<nav className="layout-sidebar layout-scrolling">
<SidebarCategory label="Main">
<SidebarItemLogo />
<InputTags label="" allowCustom={false} onChange={onTagChange} />
</SidebarCategory>
<SidebarCategory label="Sections">
<SidebarItemIcon
icon={VectorIconFeed}
label="Upload"
description="Submit new animation"
location="/upload"
/>
<SidebarItemIcon
icon={VectorIconStar}
label="Personal"
description="From this device"
location="/personal"
/>
</SidebarCategory>
<SidebarCategory label="Popular" header={true}>
{childrenTags}
</SidebarCategory>
<SidebarCategory label="Links" header={true}>
<SidebarItemText label="Terms of Service" location="/text/terms-of-service" />
<SidebarItemText label="Privacy Policy" location="/text/privacy-policy" />
<SidebarItemText label="API Guide" location="/text/api-guide" />
<SidebarItemText label="Settings" location="/settings" />
</SidebarCategory>
</nav>
)
}
@@ -0,0 +1,45 @@
div.layout-glass-container {
backdrop-filter: blur(var(--effect-glass-blur));
margin: var(--effect-glass-corner-margin);
background: var(--effect-glass-tint);
height: fit-content;
}
div.layout-glass-corner {
position: absolute;
width: 24px;
height: 24px;
pointer-events: none;
}
div.layout-glass-corner:nth-child(1) {
/* Top-left */
top: var(--effect-glass-corner-offset);
left: var(--effect-glass-corner-offset);
border-top: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
border-left: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
}
div.layout-glass-corner:nth-child(2) {
/* Top-right */
top: var(--effect-glass-corner-offset);
right: var(--effect-glass-corner-offset);
border-top: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
border-right: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
}
div.layout-glass-corner:nth-child(3) {
/* Bottom-left */
bottom: var(--effect-glass-corner-offset);
left: var(--effect-glass-corner-offset);
border-bottom: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
border-left: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
}
div.layout-glass-corner:nth-child(4) {
right: var(--effect-glass-corner-offset);
/* Bottom-right */
bottom: var(--effect-glass-corner-offset);
border-right: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
border-bottom: var(--effect-glass-corner-thickness) solid var(--effect-glass-corner-color);
}
@@ -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} &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>
</>
)
}
@@ -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>
</>
)
}
+39
View File
@@ -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 }} />
</>
)
}
+362
View File
@@ -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;
}