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,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;
}