rc-1
This commit is contained in:
@@ -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 */ `><> .o( blub blub )`,
|
||||
/* sleepy */ `( _ _) .zZ`,
|
||||
/* kitty! */ `(=^'w'^=) <( meow? )`,
|
||||
/* clueless */ `(>_< ") <( eek! )`,
|
||||
/* robot */ ` \\_/<br>()o_o) <( beep! )`,
|
||||
/* bunny */ ` /)/)<br>( . .) sorry...<br>( づ♥`,
|
||||
]
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user