This commit is contained in:
2026-05-23 17:17:56 -07:00
commit 448f2e33ef
135 changed files with 11817 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"tabWidth": 4,
"useTabs": false,
"printWidth": 120,
"singleQuote": true,
"semi": false,
"endOfLine": "lf",
"arrowParens": "always",
"bracketSameLine": true,
"bracketSpacing": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-css-order"]
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gifuu embed</title>
{{ include-env }}
{{ include-tag 'style' 'include/embed/critical.css' }}
</head>
<body>
<a class="layout-wrapper" title="Click me to visit gifuu!" target="_blank">
<img class="watermark" src="{{ include-b64 'image/svg+xml' 'source/vectors/logo-full.svg' }}" />
</a>
{{ include-tag 'script' 'include/embed/foreground.ts' }}
</body>
</html>
+405
View File
@@ -0,0 +1,405 @@
<div class="document-section">
<p class="document-header">API Guide</p>
<p class="document-paragraph">Last Updated: April 13th 2026</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Introduction ]</p>
<p class="document-paragraph">
The gifuu API is publicly accessible and requires no authentication
for read operations. We recommend that you have the client make requests
on their own instead of proxying requests for them to avoid issues with
our rate limits.
</p>
<p>Use the following URLs for HTTP requests:</p>
<pre class="document-codeblock">
https://api.gifuu.pancakz.net/ // Base URL for API Requests
https://cdn.gifuu.pancakz.net/ // Base URL for CDN Requests
https://cdn.gifuu.pancakz.net/{id}/preview.avif // Up to 240px at 16fps
https://cdn.gifuu.pancakz.net/{id}/standard.avif // Up to 720px at 60fps
https://cdn.gifuu.pancakz.net/{id}/alpha.webm // See "Transparency" section below
https://cdn.gifuu.pancakz.net/{id}/standard.ogg // See "Audio" section below
</pre>
<p>Ratelimit headers are provided with each request:</p>
<pre class="document-codeblock">
X-Ratelimit-Category // Endpoint Category (a.k.a Bucket)
X-Ratelimit-Reset // Seconds until reset (float string)
X-Ratelimit-Limit // Requests allowed per period
X-Ratelimit-Remaining // Requests left before 429 errors appear
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Transparency ]</p>
<p class="document-paragraph">
gifuu stores animations (also referred to as Art) as AVIF a very new and modern format.
We sacrifice compatibility with older devices to gain massive efficiency in file size and visual quality.
Unfortunately AVIF doesn't natively support transparency, so we use a stacked video technique to encode
the alpha channel (transparency) alongside the color data.
</p>
<p class="document-paragraph">
The <code>alpha.webm</code> file is a double-height video where the top half contains the
color data and the bottom half contains the alpha channel encoded as a grayscale luma
(brightness) map. White pixels signify opaque, black pixels signify transparent.
</p>
<p class="document-paragraph">
To render this correctly in the browser you must use a WebGL fragment shader to composite
the two halves together. The following shader can be used as a reference:
</p>
<pre class="document-codeblock">
// Sample color from top half, alpha from bottom half
vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
// Apply colors to a texture
vec4 color = texture2D(uFrame, colorUV);
float alpha = texture2D(uFrame, alphaUV).r;
gl_FragColor = vec4(color.rgb, alpha);
</pre>
<p class="document-paragraph">
This technique was pioneered by
<a target="_blank" href="https://jakearchibald.com/2024/video-with-transparency/">Jake Archibald</a>.
If you don't want to implement this yourself, you can embed our player by clicking the
<code>EMBED</code> button while viewing any animation.
</p>
<p class="document-header">[ Audio ]</p>
<p class="document-paragraph">
You can check for audio by reading the <code>audio</code> field on Art objects, or by
requesting it from the CDN and checking for a <code>404 Not Found</code> response.
</p>
<p class="document-paragraph">
Content is encoded with the <a target="_blank" href="https://en.wikipedia.org/wiki/Opus_(audio_format)">Opus</a>
codec inside an <a target="_blank" href="https://en.wikipedia.org/wiki/Ogg">Ogg</a> container
for compatibility with Apple devices.
</p>
<div class="document-divider"></div>
<p class="document-header">[ Object Types ]</p>
<p class="document-paragraph">The following types are returned as responses across API endpoints:</p>
<p class="document-subheader">Object: Tag</p>
<pre class="document-codeblock">
{
"id": string // Tag ID (snowflake string)
"label": string // Tag Name
"usage": number // Number of animations using this tag
}
</pre>
<p class="document-subheader">Object: Art</p>
<pre class="document-codeblock">
{
"id": string // Animation ID (snowflake string)
"created": string // ISO 8601 Timestamp
"sticker": boolean // Is Static?
"audio": boolean // Has Audio?
"framerate": number // Approximate Framerate
"width": number // Approximate Width
"height": number // Approximate Height
"rating": string // NSFW Rating (string float, range: 0.0 - 1.0)
"title": string // Associated Title
"tags": Tag[] // Associated Tags
}
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Special ]</p>
<div class="document-spacer"></div>
<p class="document-subheader">GET /limits</p>
<p class="document-paragraph">
Returns upload constraints and validation rules for all user input fields.
The regex patterns and normalizer rules here are authoritative.
You must sanitize your inputs against them before submitting or the server will reject your request.
</p>
<pre class="document-codeblock">
Response Body:
{
"upload": {
"input_width_min": number // Minimum input width (64px)
"input_height_min": number // Minimum input height (64px)
"video_width_max": number // Maximum video width (3840px)
"video_height_max": number // Maximum video height (2160px)
"image_width_max": number // Maximum image width (7680px)
"image_height_max": number // Maximum image height (4320px)
"duration": number // Maximum duration in seconds (62s)
"filesize": number // Maximum file size in bytes
"mime_types": string[] // Accepted MIME types
}
"title": {
"normalizers": NormalizerRule[] // Apply before validating
"matcher": string // Regex pattern
"max_length": number // 80
"min_length": number // 1
}
"tag": {
"normalizers": NormalizerRule[]
"matcher": string // ^[\p{L}\p{N}_]{1,32}$
"max_length": number // 32
"min_length": number // 1
}
"comment": {
"normalizers": NormalizerRule[]
"matcher": string
"max_length": number // 240
"min_length": number // 10
}
"report": {
"values": [ // Valid report reason types
{ "id": number, "title": string, "description": string }
]
"normalizers": NormalizerRule[]
"matcher": string
"max_length": number // 240
"min_length": number // 10
}
}
Object: NormalizerRule
{
"match": string // Regex pattern to find
"replace": string // Replacement string
"comment": string // Human-readable description
}
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /challenge</p>
<p class="document-paragraph">
Returns a fresh Proof of Work challenge.
You select the difficulty, the server enforces a minimum of <code>18</code>.
Challenges expire after 5 minutes.
They are consumed immediately upon use even if the request fails.
</p>
<p class="document-paragraph">
Provide your completed counter and given nonce to endpoints that require PoW via the
<code>X-Pow-Counter</code> and <code>X-Pow-Nonce</code> headers respectively.
</p>
<pre class="document-codeblock">
Query Parameters:
difficulty number // Desired difficulty (minimum: 18)
Response Body:
{
"nonce": string // Hex-encoded nonce
"difficulty": number // Confirmed difficulty
"expires": number // Expiry as UNIX timestamp
}
</pre>
<p class="document-paragraph">
Some endpoints enforce a higher minimum difficulty than the global floor.
Request at least the required difficulty for the endpoint you intend to call or it will be rejected:
</p>
<pre class="document-codeblock">
Endpoint Minimum Difficulty
------------------------ ------------------
POST /uploads 20
</pre>
<p class="document-paragraph">Example Solver (JavaScript):</p>
<pre class="document-codeblock">
const { nonce, difficulty } = await fetch("/challenge?difficulty=18").then(r => r.json())
const encoder = new TextEncoder()
let counter = 0
while (true) {
const data = encoder.encode(nonce + counter)
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", data))
let zeroBits = 0
for (const byte of hash) {
if (byte === 0) { zeroBits += 8 }
else { zeroBits += Math.clz32(byte) - 24; break }
}
if (zeroBits >= difficulty) break
counter++
}
// Submit nonce + counter with your upload via X-Pow-Nonce and X-Pow-Counter headers
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Tags ]</p>
<p class="document-paragraph">
gifuu uses tags to make its database queryable. Sanitize tag strings against the rules
provided by <code>limits.tags</code> or the server will reject your request.
</p>
<div class="document-spacer"></div>
<p class="document-subheader">GET /tags/popular</p>
<p class="document-paragraph">Returns the most popular tags (highest usage) on the platform.</p>
<pre class="document-codeblock">
Query Parameters:
limit number // Amount of results to return (range 1-100)
Response Body:
Tag[]
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /tags/autocomplete</p>
<p class="document-paragraph">Search for tags with a similar spelling using word similarity ranking.</p>
<pre class="document-codeblock">
Query Parameters:
query string // Search query — must pass validation rules from 'limits.tag'
limit number // Amount of results to return (range 1-100)
Response Body:
Tag[]
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Art ]</p>
<p class="document-paragraph">
Content is processed and served as AVIF files for efficiency. Most modern web browsers
and operating systems <a target="_blank" href="https://caniuse.com/avif">support this format</a>.
</p>
<div class="document-spacer"></div>
<p class="document-subheader">GET /art/latest</p>
<p class="document-paragraph">Returns the most recently uploaded animations, newest first.</p>
<pre class="document-codeblock">
Query Parameters:
limit number // Amount of results to return (range 1-100)
after string? // Cursor for pagination — snowflake ID of last seen item (optional)
Response Body:
Art[]
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /art/search</p>
<p class="document-paragraph">
Returns animations matching all provided tags (AND logic). At least one
<code>tag</code> parameter is required.
</p>
<pre class="document-codeblock">
Query Parameters:
tag string // Tag ID to filter by (snowflake string) — repeat for multiple tags
limit number // Amount of results to return (range 1-100)
after string? // Cursor for pagination — snowflake ID of last seen item (optional)
// Example: /art/search?tag=123&tag=456&limit=20
Response Body:
Art[]
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">GET /art/{id}</p>
<p class="document-paragraph">Returns metadata for a single animation.</p>
<pre class="document-codeblock">
Response Body:
Art
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">DELETE /art/{id}</p>
<p class="document-paragraph">
Deletes an animation. Requires the edit token returned at upload time,
passed as a query parameter. Responds with <code>204 No Content</code> on success.
</p>
<pre class="document-codeblock">
Query Parameters:
token string // Edit token from upload response
</pre>
<div class="document-spacer"></div>
<p class="document-subheader">POST /art/{id}/reports</p>
<p class="document-paragraph">
Submits a moderation report for an animation.
Valid reason type IDs are listed in <code>limits.report.values</code></code>.
The reason text must pass the <code>report</code> validation rules from the same endpoint.
Responds with <code>204 No Content</code> on success.
</p>
<pre class="document-codeblock">
Request Body:
{
"type": number // Report reason ID (see 'limits.report.values')
"reason": string // Description of the issue (10-240 characters)
}
</pre>
<div class="document-divider"></div>
<p class="document-header">[ Uploads ]</p>
<p class="document-paragraph">Endpoints for creating and monitoring uploads.</p>
<div class="document-spacer"></div>
<p class="document-subheader">POST /uploads</p>
<p class="document-paragraph">
Uploads an animation to the site. To prevent spam this endpoint requires a valid
Proof of Work challenge solved via <code>GET /challenge</code> with a minimum difficulty of <code>20</code>.
</p>
<p class="document-paragraph">
This endpoint responds as a <strong>Server-Sent Events (SSE) stream</strong>. Events are
emitted throughout processing to report progress. The connection closes after the final
<code>finish</code> event or on any error.
</p>
<p class="document-paragraph">
NOTE: Requests exceeding the <code>limits.upload.filesize</code> limit will be aborted immediately.
</p>
<pre class="document-codeblock">
Request Body: [multipart/form-data]
field: data (text/JSON)
{
"title": string // Animation title (1-80 characters, see 'limits.title')
"tags": string[] // Tag names to attach, plaintext (see 'limits.tag')
}
field: file (binary)
// Accepted MIME types may change, fetch the current list from 'limits.upload.mime_types'
image/jpeg, image/png, image/gif, image/webp, image/heic, image/heif,
image/avif, image/jxl, image/tiff, image/bmp,
video/mp4, video/webm, video/quicktime, video/x-matroska,
video/avi, video/x-ms-wmv,
Request Headers:
X-Pow-Nonce // Nonce from GET /challenge
X-Pow-Counter // Your solved counter value
</pre>
<pre class="document-codeblock">
SSE Event Stream:
event: id // Emitted early — the assigned snowflake ID for this upload
{ "id": string }
event: step // Processing stage updates
{ "id": string, "message": string }
// Known step IDs: PROBE_QUEUE, PROBE_START, SERVER_FINALIZE
event: progress // Encoding/classification progress
{ "percent": string } // Float string, e.g. "42.50"
event: finish // Final event on success — save edit_token, it is not recoverable!
{
"id": string // Animation snowflake ID
"edit_token": string // Required to delete this animation later
}
event: error // Emitted on client or server error, stream closes after
{ "code": number, "message": string }
</pre>
</div>
@@ -0,0 +1,51 @@
<div class="document-section">
<p class="document-header">Privacy Policy</p>
<p class="document-paragraph">Last Updated: March 21st 2026</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Data Collection ]</p>
<p class="document-paragraph">We collect the following data in order to provide you with our services:</p>
<div class="document-list">
<p class="document-item">Content that you upload</p>
<p class="document-item">Your edit tokens&sup1;</p>
<p class="document-item">Your IP address&sup2;</p>
</div>
<p class="document-paragraph">
&sup1; Edit tokens are kept on your device when using our website.
Please back them up via the settings menu, as we cannot assist in recovery.
</p>
<p class="document-paragraph">
&sup2; Your IP address is stored in hashed form via a one-way algorithm to reduce direct identification.
We use this data to prevent abuse on our platform and issue disciplinary actions towards bad actors.
Decisions are made at our discretion and are final.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Third Party ]</p>
<p class="document-paragraph">
We use a self-hosted instance of <a target="_blank" href="https://umami.is/">Umami</a>
for analytics to see how people arrive at and interact with our site.
We use <a href="https://en.wikipedia.org/wiki/UTM_parameters">UTM parameters</a>,
which can be manually removed if desired.
</p>
<p class="document-paragraph">
All of this data is stored anonymously on our own servers and isnt shared with any third parties.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[ Contact ]</p>
<p class="document-paragraph">
gifuu is a personal project operated by bakonpancakz.
For privacy or legal concerns, please visit:
<a target="_blank" href="https://pancakz.net/">https://pancakz.net/</a>
</p>
</div>
@@ -0,0 +1,69 @@
<div class="document-section">
<p class="document-header">Terms of Service</p>
<p class="document-paragraph">Last Updated: March 21st 2026</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[1] Acceptance</p>
<p class="document-paragraph">
By using gifuu, you agree to the following terms of service.
If you do not agree to these terms, do not use our platform.
</p>
<p class="document-paragraph">
These terms may be updated at any time without prior notice. Continued use constitutes acceptance.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[2] Content</p>
<p class="document-paragraph">
You are solely responsible for any content you upload to gifuu.
You may not upload, distribute, or store content that:
</p>
<p class="document-paragraph">
Violates any applicable law or regulation;
infringes on any copyright, trademark, or other intellectual property right;
depicts any being in a sexual or exploitative manner;
constitutes targeted harassment, hate speech, or incitement of violence;
depicts graphic violence, gore, or abuse;
promotes or depicts self-harm, dangerous activity, or seizure-inducing imagery;
constitutes spam, advertising, or unsolicited solicitation;
or that you do not have the rights to distribute.
</p>
<p class="document-paragraph">
We reserve the right to moderate, remove content, or restrict access to our platform at our discretion.
Violations may be reported by users and are reviewed by our moderation team.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[3] Data Collection</p>
<p class="document-paragraph">
You agree to our data collection and privacy policies.
A description of how we collect and process your data is available <a href="/text/privacy-policy">here</a>.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[4] Availability</p>
<p class="document-paragraph">
We are not liable for any loss of data or damages resulting from use of the platform.
</p>
</div>
<div class="document-spacer"></div>
<div class="document-section">
<p class="document-header">[5] Other</p>
<p class="document-paragraph">
You agree that bunnies are adorable.
</p>
</div>
+106
View File
@@ -0,0 +1,106 @@
:root {
--animation-transition: 200ms;
--border-thickness: 2px;
--background-tertiary: hsl(0, 0%, 0%);
--background-secondary: hsl(0, 0%, 16%);
--background-primary: hsl(0, 0%, 32%);
--font-color-accent: hsl(0, 50%, 80%);
--font-color-primary: hsl(0, 0%, 95%);
--font-color-secondary: hsl(0, 0%, 65%);
}
html,
body {
box-sizing: border-box;
margin: 0;
background: transparent;
padding: 0;
overflow: hidden;
}
.effect-centered {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
a.layout-wrapper {
display: inline-block;
position: relative;
cursor: pointer;
width: 100vw;
height: 100vh;
overflow: hidden;
}
a.layout-wrapper img.watermark {
position: absolute;
right: 16px;
bottom: 16px;
opacity: 0.8;
transition: var(--animation-transition) ease-in-out opacity;
height: 4vw;
min-height: 20px;
}
a.layout-wrapper:hover img.watermark,
a.layout-wrapper:focus-visible img.watermark {
opacity: 1;
}
/* Layout: Error */
a.layout-wrapper div.error {
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);
width: 100%;
height: 100%;
}
a.layout-wrapper div.error p {
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 16px 32px;
width: fit-content;
color: var(--font-color-primary);
font-size: large;
font-family: monospace;
text-align: center;
}
/* Layout: Canvas */
a.layout-wrapper video.decoder {
position: absolute;
visibility: hidden;
}
a.layout-wrapper canvas.render {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Layout: Image */
a.layout-wrapper img.render {
width: 100%;
height: 100%;
object-fit: contain;
}
+269
View File
@@ -0,0 +1,269 @@
;(() => {
let defer: (() => void)[] = []
let running = false
let paramQuality: 'standard' | 'transparent'
let paramID: bigint
const elemHost = document.querySelector<HTMLAnchorElement>('a.layout-wrapper')
// @ts-expect-error
const BASE_CDN = window.__ENV__.CDN
// @ts-expect-error
const BASE_WEB = window.__ENV__.WEB
if (BASE_CDN === undefined || BASE_WEB === undefined || !elemHost) {
console.error('[gifuu] Invalid Document')
return
}
try {
const search = new URLSearchParams(window.location.search)
const givenQuality = search.get('quality')
const givenID = BigInt(search.get('id') ?? '0')
if (givenID < 1) {
return exit('Invalid ID')
}
if (givenQuality !== 'standard' && givenQuality !== 'transparent') {
return exit('Invalid Quality')
}
paramQuality = givenQuality
paramID = givenID
} catch (error) {
exit(error)
return
}
function setupGL() {
if (!elemHost) throw 'Missing Anchor Node'
// Setup Elements
const elemCanvas = document.createElement('canvas')
elemCanvas.classList.add('render')
elemCanvas.addEventListener('webglcontextlost', (ev) => {
ev.preventDefault()
console.warn('[gifuu] Failed to allocate a WebGL context for us! Using image fallback...')
teardown()
setupImage()
})
elemHost.appendChild(elemCanvas)
defer.push(() => elemCanvas.remove())
const elemVideo = document.createElement('video')
elemVideo.classList.add('decode')
elemVideo.crossOrigin = 'anonymous'
elemVideo.playsInline = true
elemVideo.autoplay = true
elemVideo.muted = true
elemVideo.loop = true
elemHost.appendChild(elemVideo)
defer.push(() => elemVideo.remove())
// Setup Context
const gl = elemCanvas.getContext('webgl', {
powerPreference: 'low-power',
premultipliedAlpha: false,
antialias: false,
alpha: true,
depth: false,
})
if (!gl) {
console.warn('[gifuu] WebGL is unsupported, using image fallback...')
teardown()
setupImage()
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) {
if (!gl) throw 'Missing GL Context'
const s = gl.createShader(type)
if (!s) throw 'Shader compilation failed'
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))
} catch (error) {
console.error('[gifuu] Failed to Initialize WebGL dependencies:', error)
teardown()
setupImage()
return
}
// Tick Function
let cancel = 0
let sized = false
function tick() {
cancel = requestAnimationFrame(tick)
try {
if (!gl) throw 'Missing GL Context'
if (!sized && elemVideo.videoWidth > 0) {
sized = true
elemCanvas.width = elemVideo.videoWidth
elemCanvas.height = Math.floor(elemVideo.videoHeight / 2)
gl.viewport(0, 0, elemCanvas.width, elemCanvas.height)
}
if (!sized) return
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, elemVideo)
gl.drawArrays(gl.TRIANGLES, 0, 6)
} catch (error) {
console.warn('[gifuu] Draw failed, using image fallback...', error)
teardown()
setupImage()
return
}
}
defer.push(() => cancelAnimationFrame(cancel))
// Download Video
fetch(`${BASE_CDN}/${paramID}/alpha.webm`, { mode: 'cors', cache: 'force-cache' })
.then((r) => r.blob())
.then((blob) => {
const content = URL.createObjectURL(blob)
defer.push(() => URL.revokeObjectURL(content))
elemVideo.src = content
elemVideo.play().catch(() => {})
tick()
})
.catch((error) => {
console.warn('[gifuu] Video download failed, using image fallback...', error)
teardown()
setupImage()
})
}
function setupImage() {
if (!elemHost) throw 'Missing Image Node'
// Create Element
const elemImage = document.createElement('img')
elemImage.classList.add('render')
elemHost.appendChild(elemImage)
defer.push(() => elemImage.remove())
// Download Image
fetch(`${BASE_CDN}/${paramID}/${paramQuality}.avif`, { mode: 'cors', cache: 'force-cache' })
.then((r) => r.blob())
.then((blob) => {
const content = URL.createObjectURL(blob)
defer.push(() => URL.revokeObjectURL(content))
elemImage.src = content
})
.catch((error) => {
console.warn('[gifuu] Image download failed, quitting...', error)
teardown()
exit('Media Unavailable')
})
}
function teardown() {
let func
while ((func = defer.shift())) {
try {
func()
} catch (error) {
console.error('[gifuu] Teardown failed:', error)
}
}
}
function exit(error: any) {
console.error('[gifuu] Exiting Embed:', error)
teardown()
if (!elemHost) return
elemHost.href = BASE_WEB
const container = document.createElement('div')
container.classList.add('error')
const message = document.createElement('p')
message.classList.add('message', 'effect-centered')
message.textContent = String(error)
container.append(message)
elemHost.append(container)
}
// Lifecycle
elemHost.href = `${BASE_WEB}/art/${paramID}`
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !running) {
running = true
console.log('[gifuu] Showtime! Setting up...')
paramQuality === 'transparent' ? setupGL() : setupImage()
} else if (!entries[0].isIntersecting && running) {
running = false
console.log('[gifuu] Out of View! Tearing down...')
teardown()
}
},
{ threshold: 0.1 },
)
observer.observe(elemHost)
window.addEventListener('pagehide', () => {
console.log('[gifuu] Goodbye!')
observer.disconnect()
elemHost.remove()
teardown()
})
})()
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 103.11 55.18">
<path style="fill: #f0f0f0; stroke: #242424;" d="M54.36,48.13c0,3.62,2.93,6.55,6.55,6.55h22.75c6.86,0,10.07-1.56,13.62-4.68,3.55-3.12,5.33-7.66,5.33-13.61s-1.78-10.42-5.33-13.54c-3.55-3.12-6.76-4.68-13.62-4.68h-11.65c-3.62,0-6.55-2.93-6.55-6.55v-4.58c0-3.62-.93-6.55-4.55-6.55h0c-3.62,0-6.55,2.93-6.55,6.55v41.08ZM67.46,34.02c0-3.62,2.93-6.55,6.55-6.55h7.78c2.11,0,1.82.25,3.11.76,1.3.5,2.3,1.18,3.02,2.02.72.84,1.21,1.79,1.48,2.84.26,1.06.4,2.16.4,3.31s-.13,2.27-.4,3.35c-.26,1.08-.76,2.04-1.48,2.88-.72.84-1.73,1.51-3.02,2.02-1.3.5-1,.76-3.11.76h-7.78c-3.62,0-6.55-2.93-6.55-6.55v-4.82Z" />
<path style="fill: #f0f0f0; stroke: #242424;" d="M48.76,48.13c0,3.62-2.93,6.55-6.55,6.55h-22.75c-6.86,0-10.07-1.56-13.62-4.68-3.55-3.12-5.33-7.66-5.33-13.61s1.78-10.42,5.33-13.54c3.55-3.12,6.76-4.68,13.62-4.68h11.65c3.62,0,6.55-2.93,6.55-6.55v-6.58c0-2.51,2.04-4.55,4.55-4.55h0c3.62,0,6.55,2.93,6.55,6.55v41.08ZM35.65,34.02c0-3.62-2.93-6.55-6.55-6.55h-7.78c-2.11,0-1.82.25-3.11.76-1.3.5-2.3,1.18-3.02,2.02-.72.84-1.21,1.79-1.48,2.84-.26,1.06-.4,2.16-.4,3.31s.13,2.27.4,3.35c.26,1.08.76,2.04,1.48,2.88.72.84,1.73,1.51,3.02,2.02,1.3.5,1,.76,3.11.76h7.78c3.62,0,6.55-2.93,6.55-6.55v-4.82Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+567
View File
@@ -0,0 +1,567 @@
;(() => {
const elemParent = document.querySelector('div.layout-background')
const elemSprite = document.querySelector<HTMLLinkElement>('link[rel="texture"]')
if (!elemParent || !elemSprite) throw 'Invalid Document'
const FRAME_INTERVAL = 1000
const FRAME_TIME_IDLE = 12
const FRAME_TIME_ACTIVE = 60
const CAM_ACCEL = 2
const CAM_FRICTION = 0.85
const PARTICLE_COUNT = 160
const COLOR_PARTICLE = 0x484848
const COLOR_FOREGROUND = 0x363636
const COLOR_BACKGROUND = 0x000000
let keysHeld = new Set()
let frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE
let lastTime = 0
let lastFrame = 0
let time = 0
let cameraMVP = mat4()
let cameraRotX = 0
let cameraRotY = 0
let camVelRotX = 0
let camVelRotY = 0
let spriteTexture: WebGLTexture
let spriteModel: Float32Array
let particlePos: Float32Array
let particleColor: Float32Array
let particleData: {
x: number
y: number
z: number
vx: number
vy: number
vz: number
life: number
maxLife: number
}[] = []
let planeW: number
let planeH: number
let planeSegX: number
let planeSegY: number
let planeVerts: Float32Array
let planeOrig: Float32Array
let planeIdx
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl')
if (!gl) throw 'Failed to allocate WebGL Context'
// --- Camera Controls ---
document.addEventListener('keydown', (e) => {
if (!e.shiftKey && !e.ctrlKey) return
if (e.target instanceof HTMLTextAreaElement) return
if (e.target instanceof HTMLInputElement) return
if ((e as any).isContentEditable) return
keysHeld.add(e.key)
})
document.addEventListener('keyup', (e) => {
keysHeld.delete(e.key)
})
// --- Math Functions ---
function mat4(): Float32Array {
return new Float32Array(16)
}
function mat4Identity(m: Float32Array): Float32Array {
m[0] = 1
m[1] = 0
m[2] = 0
m[3] = 0
m[4] = 0
m[5] = 1
m[6] = 0
m[7] = 0
m[8] = 0
m[9] = 0
m[10] = 1
m[11] = 0
m[12] = 0
m[13] = 0
m[14] = 0
m[15] = 1
return m
}
function mat4Multiply(out: Float32Array, a: Float32Array, b: Float32Array): Float32Array {
for (let i = 0; i < 4; i++)
for (let j = 0; j < 4; j++) {
out[j * 4 + i] = 0
for (let k = 0; k < 4; k++) out[j * 4 + i] += a[k * 4 + i] * b[j * 4 + k]
}
return out
}
function mat4Perspective(m: Float32Array, fovY: number, aspect: number, near: number, far: number): Float32Array {
const f = 1.0 / Math.tan(fovY / 2)
m[0] = f / aspect
m[1] = 0
m[2] = 0
m[3] = 0
m[4] = 0
m[5] = f
m[6] = 0
m[7] = 0
m[8] = 0
m[9] = 0
m[10] = (far + near) / (near - far)
m[11] = -1
m[12] = 0
m[13] = 0
m[14] = (2 * far * near) / (near - far)
m[15] = 0
return m
}
function mat4RotateX(m: Float32Array, angle: number): Float32Array {
const c = Math.cos(angle)
const s = Math.sin(angle)
const t = mat4Identity(mat4())
t[5] = c
t[6] = s
t[9] = -s
t[10] = c
return mat4Multiply(mat4(), t, m)
}
function mat4RotateY(m: Float32Array, angle: number): Float32Array {
const c = Math.cos(angle)
const s = Math.sin(angle)
const t = mat4Identity(mat4())
t[0] = c
t[2] = -s
t[8] = s
t[10] = c
return mat4Multiply(mat4(), t, m)
}
function mat4Translate(m: Float32Array, x: number, y: number, z: number): Float32Array {
const t = mat4Identity(mat4())
t[12] = x
t[13] = y
t[14] = z
return mat4Multiply(mat4(), t, m)
}
function randFloat(lo: number, hi: number): number {
return lo + Math.random() * (hi - lo)
}
function randFloatSpread(range: number): number {
return randFloat(-range / 2, range / 2)
}
function degToRad(d: number): number {
return (d * Math.PI) / 180
}
function intToRGB(i: number): [number, number, number] {
return [((i >> 16) & 0xff) / 255, ((i >> 8) & 0xff) / 255, ((i >> 0) & 0xff) / 255]
}
// --- Prepare Shaders ---
function createShader(type: number, src: string): WebGLShader {
if (!gl) throw 'Missing Global GL Context'
const s = gl.createShader(type)
if (!s) throw 'Shader Compilation Failed'
gl.shaderSource(s, src)
gl.compileShader(s)
return s
}
function createProgram(vert: string, frag: string): WebGLProgram {
if (!gl) throw 'Missing Global GL Context'
const p = gl.createProgram()
gl.attachShader(p, createShader(gl.VERTEX_SHADER, vert))
gl.attachShader(p, createShader(gl.FRAGMENT_SHADER, frag))
gl.linkProgram(p)
return p
}
const planeProg = createProgram(
`attribute vec3 aPosition;
uniform mat4 uMVP;
uniform float uTime;
varying float vDist;
void main() {
float dist = sqrt(aPosition.x * aPosition.x + aPosition.z * aPosition.z);
float wave = sin(dist * 0.5 - uTime);
float cave = -exp(-dist * 0.1) * 3.5;
vec3 pos = vec3(aPosition.x, aPosition.y + wave + cave, aPosition.z);
vDist = length((uMVP * vec4(pos, 1.0)).xyz);
gl_Position = uMVP * vec4(pos, 1.0);
}`,
`precision mediump float;
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;
varying float vDist;
void main() {
float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0);
vec3 color = mix(vec3(${intToRGB(COLOR_FOREGROUND).join(',')}), uFogColor, fog);
gl_FragColor = vec4(color, 1.0);
}`,
)
const particleProg = createProgram(
`attribute vec3 aPosition;
attribute vec4 aColor;
uniform mat4 uMVP;
varying vec4 vColor;
varying float vDist;
void main() {
vec4 pos = uMVP * vec4(aPosition, 1.0);
vDist = length(pos.xyz);
vColor = aColor;
gl_PointSize = 3.0;
gl_Position = pos;
}`,
`precision mediump float;
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;
varying vec4 vColor;
varying float vDist;
void main() {
float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0);
float alpha = vColor.a * (1.0 - fog);
gl_FragColor = vec4(mix(vColor.rgb, uFogColor, fog), alpha);
}`,
)
const spriteProg = createProgram(
`attribute vec2 aPosition;
attribute vec2 aUV;
uniform mat4 uMVP;
varying vec2 vUV;
void main() {
vUV = aUV;
gl_Position = uMVP * vec4(aPosition.x , 0, aPosition.y, 1);
}`,
`precision mediump float;
uniform sampler2D uTex;
varying vec2 vUV;
void main() {
gl_FragColor = texture2D(uTex, vUV);
}`,
)
const planeBuf = gl.createBuffer()
const planeOrigBuf = gl.createBuffer()
const planeIdxBuf = gl.createBuffer()
const uTimePlane = gl.getUniformLocation(planeProg, 'uTime')
const uMVPPlane = gl.getUniformLocation(planeProg, 'uMVP')
const uFogColorPlane = gl.getUniformLocation(planeProg, 'uFogColor')
const uFogNearPlane = gl.getUniformLocation(planeProg, 'uFogNear')
const uFogFarPlane = gl.getUniformLocation(planeProg, 'uFogFar')
const particlePosBuf = gl.createBuffer()
const particleColorBuf = gl.createBuffer()
const uMVPParticle = gl.getUniformLocation(particleProg, 'uMVP')
const uFogColorParticle = gl.getUniformLocation(particleProg, 'uFogColor')
const uFogNearParticle = gl.getUniformLocation(particleProg, 'uFogNear')
const uFogFarParticle = gl.getUniformLocation(particleProg, 'uFogFar')
const spriteImage = new Image()
spriteImage.src = elemSprite.href
const spriteBuf = gl.createBuffer()
const spriteIdxBuf = gl.createBuffer()
const uMVPSprite = gl.getUniformLocation(spriteProg, 'uMVP')
const uTexSprite = gl.getUniformLocation(spriteProg, 'uTex')
function spawnParticle(i: number) {
// Put them in the center because its out of frame anyways save some resources
const x = randFloat(-planeW / 2, planeW / 2)
const z = randFloat(-planeH / 2, planeH / 2)
const l = randFloat(3.0, 6.0)
particleData[i] = {
x,
y: 0,
z,
vx: randFloatSpread(0.05),
vy: randFloat(0.02, 0.05),
vz: randFloatSpread(0.05),
life: l,
maxLife: l,
}
const p = i * 3
particlePos[p + 0] = x
particlePos[p + 1] = 0
particlePos[p + 2] = z
const c = i * 4
const [r, g, b] = intToRGB(COLOR_PARTICLE)
particleColor[c + 0] = r
particleColor[c + 1] = g
particleColor[c + 2] = b
particleColor[c + 3] = 0
}
function startup() {
if (!gl) throw 'Missing Global GL Context'
// Render Resolution
canvas.width = Math.floor(window.innerWidth * 0.3)
canvas.height = Math.floor(window.innerHeight * 0.3)
canvas.style.width = window.innerWidth + 'px'
canvas.style.height = window.innerHeight + 'px'
canvas.style.imageRendering = 'pixelated'
// Build Sprite
spriteImage.onload = () => {
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, 1, 1, -1, 0, 1, 1, 1, 0, 0, -1, 1, 1, 0]),
gl.STATIC_DRAW,
)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW)
spriteModel = mat4Identity(mat4())
spriteModel = mat4RotateY(spriteModel, degToRad(180))
spriteModel = mat4Translate(spriteModel, 0, 4, 16)
// Upload Texture
spriteTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, spriteImage)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
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.useProgram(spriteProg)
gl.uniform1i(uTexSprite, 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
}
// Build Plane
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(...intToRGB(COLOR_BACKGROUND), 1)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
const ratio = canvas.width / canvas.height
const scale = 24
planeSegX = Math.round(12 * ratio)
planeSegY = 12
planeW = scale * ratio
planeH = scale
const nx = planeSegX + 1
const ny = planeSegY + 1
planeVerts = new Float32Array(nx * ny * 3)
planeOrig = new Float32Array(nx * ny * 3)
let vi = 0
for (let iy = 0; iy < ny; iy++) {
for (let ix = 0; ix < nx; ix++) {
const x = (ix / planeSegX - 0.5) * planeW
const z = (iy / planeSegY - 0.5) * planeH
planeVerts[vi] = x
planeVerts[vi + 1] = 0
planeVerts[vi + 2] = z
planeOrig[vi] = x
planeOrig[vi + 1] = 0
planeOrig[vi + 2] = z
vi += 3
}
}
const lines = []
for (let iy = 0; iy < ny; iy++) {
for (let ix = 0; ix < nx; ix++) {
const idx = iy * nx + ix
// wireframe indices two triangles per quad
if (ix < planeSegX) {
lines.push(idx, idx + 1)
}
if (iy < planeSegY) {
lines.push(idx, idx + nx)
}
if (ix < planeSegX && iy < planeSegY) {
lines.push(idx, idx + nx + 1)
}
}
}
planeIdx = new Uint16Array(lines)
// Upload plane buffers
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
gl.bufferData(gl.ARRAY_BUFFER, planeVerts, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, planeOrigBuf)
gl.bufferData(gl.ARRAY_BUFFER, planeOrig, gl.STATIC_DRAW)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, planeIdx, gl.STATIC_DRAW)
// Initialize particles if first time
if (particleData.length === 0) {
particlePos = new Float32Array(PARTICLE_COUNT * 3)
particleColor = new Float32Array(PARTICLE_COUNT * 4)
for (let i = 0; i < PARTICLE_COUNT; i++) {
spawnParticle(i)
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
gl.bufferData(gl.ARRAY_BUFFER, particlePos, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
gl.bufferData(gl.ARRAY_BUFFER, particleColor, gl.DYNAMIC_DRAW)
// Initialize Fog
gl.useProgram(planeProg)
gl.uniform3f(uFogColorPlane, ...intToRGB(COLOR_BACKGROUND))
gl.uniform1f(uFogNearPlane, 2)
gl.uniform1f(uFogFarPlane, 22)
gl.useProgram(particleProg)
gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP)
gl.uniform3f(uFogColorParticle, ...intToRGB(COLOR_BACKGROUND))
gl.uniform1f(uFogNearParticle, 6)
gl.uniform1f(uFogFarParticle, 24)
// Force dirty camera reset
camVelRotY = 0.005
}
function animate(now: number) {
requestAnimationFrame(animate)
// Sleep (warning this sucks)
if (now - lastFrame < frameTime) return
const delta = (now - lastTime) * 0.00008 || 0
lastFrame = now
lastTime = now
time += delta
if (!gl) throw 'Missing Global GL Context'
gl.clear(gl.COLOR_BUFFER_BIT)
// Camera Movement
if (keysHeld.has('ArrowLeft')) camVelRotY -= CAM_ACCEL
if (keysHeld.has('ArrowRight')) camVelRotY += CAM_ACCEL
if (keysHeld.has('ArrowUp')) camVelRotX -= CAM_ACCEL
if (keysHeld.has('ArrowDown')) camVelRotX += CAM_ACCEL + 8
camVelRotX *= CAM_FRICTION
camVelRotY *= CAM_FRICTION
const dirty = Math.abs(camVelRotX) + Math.abs(camVelRotY) > 0.001
if (dirty) {
cameraRotX += camVelRotX * delta * 1000
cameraRotY += camVelRotY * delta * 1000
// Update Camera
const ratio = canvas.width / canvas.height
const proj = mat4Perspective(mat4(), degToRad(70), ratio, 0.1, 100)
let view = mat4Identity(mat4())
view = mat4Translate(view, 0, -7.5, -15)
view = mat4RotateX(view, degToRad(camVelRotX + 33.75))
view = mat4RotateY(view, degToRad(camVelRotY))
cameraMVP = mat4Multiply(mat4(), proj, view)
gl.useProgram(planeProg)
gl.uniformMatrix4fv(uMVPPlane, false, cameraMVP)
gl.useProgram(particleProg)
gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP)
frameTime = FRAME_INTERVAL / FRAME_TIME_ACTIVE
} else {
frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE
}
// Update Sprite
if (spriteTexture) {
const spriteMVP = mat4Multiply(mat4(), cameraMVP, spriteModel)
gl.useProgram(spriteProg)
gl.uniformMatrix4fv(uMVPSprite, false, spriteMVP)
gl.uniform1i(uTexSprite, 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
const aPos = gl.getAttribLocation(spriteProg, 'aPosition')
const aUV = gl.getAttribLocation(spriteProg, 'aUV')
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0)
gl.enableVertexAttribArray(aUV)
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf)
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0)
}
{
// Update Particles
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = particleData[i]
// fade out
p.life -= delta
if (p.life <= 0) {
spawnParticle(i)
continue
}
particleColor[i * 4 + 3] = Math.min((p.life / p.maxLife) * 1.2, 1)
// drift away
p.x += p.vx * delta * 40
p.y += p.vy * delta * 20
p.z += p.vz * delta * 40
const pi = i * 3
particlePos[pi + 0] = p.x
particlePos[pi + 1] = p.y
particlePos[pi + 2] = p.z
}
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particlePos)
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particleColor)
// Draw Particles
gl.useProgram(particleProg)
const aPos = gl.getAttribLocation(particleProg, 'aPosition')
const aCol = gl.getAttribLocation(particleProg, 'aColor')
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
gl.enableVertexAttribArray(aCol)
gl.vertexAttribPointer(aCol, 4, gl.FLOAT, false, 0, 0)
gl.drawArrays(gl.POINTS, 0, PARTICLE_COUNT)
}
{
// Update Plane
for (let i = 0; i < planeVerts.length; i += 3) {
const x = planeOrig[i]
const z = planeOrig[i + 2]
const dist = Math.sqrt(x * x + z * z)
planeVerts[i] = x
planeVerts[i + 1] = Math.sin(dist * 0.5 - time) * 0.5 + -Math.exp(-dist * 0.1) * 3.5
planeVerts[i + 2] = z
}
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, planeVerts)
// Draw Plane
gl.useProgram(planeProg)
gl.uniform1f(uTimePlane, time)
const aPos = gl.getAttribLocation(planeProg, 'aPosition')
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
gl.enableVertexAttribArray(aPos)
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf)
gl.drawElements(gl.LINES, planeIdx.length, gl.UNSIGNED_SHORT, 0)
}
}
// --- Prepare Canvas ---
window.addEventListener('resize', startup)
startup()
animate(0)
elemParent.append(canvas)
})()
+209
View File
@@ -0,0 +1,209 @@
:root {
--animation-duration: 500ms;
--animation-transition: 200ms;
--animation-load-delay: 200ms;
--animation-step-delay: 50ms;
--border-thickness: 1px;
--background-tertiary: hsl(0, 0%, 0%);
--background-secondary: hsl(0, 0%, 16%);
--background-primary: hsl(0, 0%, 32%);
--background-highlight: hsl(0, 0%, 80%);
--background-translucent: hsla(0, 0%, 0%, 0.3);
--font-color-accent: hsl(0, 50%, 80%);
--font-color-primary: hsl(0, 0%, 95%);
--font-color-secondary: hsl(0, 0%, 65%);
--effect-glass-corner-thickness: 1px;
--effect-glass-corner-offset: -16px;
--effect-glass-corner-margin: 16px;
--effect-glass-corner-color: hsl(0, 0%, 30%);
--effect-glass-tint: hsla(0, 0%, 100%, 0.075);
--effect-glass-blur: 4px;
}
::-webkit-scrollbar {
width: 0;
}
html,
body {
margin: 0;
background-color: var(--background-tertiary);
padding: 0;
}
p,
a,
pre,
code,
span,
input,
textarea,
label,
button {
display: block;
margin: 0;
border: none;
background-color: transparent;
padding: 0;
color: var(--font-color-primary);
font-style: normal;
font-weight: normal;
font-size: 1em;
line-height: 1em;
font-family: 'Terminus', monospace;
}
input::placeholder {
color: var(--font-color-secondary);
}
/* Global Layout */
span.layout-tooltip {
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 4px 8px;
}
div.layout-wrapper {
display: grid;
width: 100%;
height: 100%;
}
div.layout-background {
position: fixed;
width: 100vw;
height: 100vh;
}
div.layout-foreground {
display: flex;
justify-content: center;
gap: 16px;
margin: auto;
margin-top: 16px;
width: 100%;
max-width: 1024px;
max-height: 100vh;
}
.layout-scrolling {
max-height: calc(100vh - var(--effect-glass-corner-margin) * 4);
overflow-x: hidden;
overflow-y: scroll;
}
nav.layout-sidebar {
box-sizing: border-box;
padding: 16px;
width: 300px;
}
main.layout-content {
box-sizing: border-box;
padding: 16px;
width: 600px;
}
/* Global Effects */
.animation-blink {
animation: kf-blink 1s infinite step-start;
}
@keyframes kf-blink {
50% {
opacity: 0;
}
}
.animation-scroll-in {
animation: kf-scroll-in 1s forwards linear;
box-sizing: border-box;
max-width: fit-content;
overflow: hidden;
text-wrap-mode: nowrap;
}
@keyframes kf-scroll-in {
0% {
width: 0%;
}
100% {
width: 100%;
}
}
.animation-fade-in {
animation: kf-fade-in 500ms forwards linear;
}
@keyframes kf-fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.animation-fall-in {
opacity: 0;
animation: kf-fall-in var(--animation-transition) ease forwards;
}
@keyframes kf-fall-in {
from {
transform: scale(1.05);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animation-caution {
position: relative;
overflow: visible;
}
.animation-caution::before {
position: absolute;
transform: translate(16px, 16px);
clip-path: polygon(
calc(100% - 12px) 0,
100% 0,
100% 100%,
0 100%,
0 calc(100% - 12px),
calc(100% - 12px) calc(100% - 12px)
);
filter: opacity(0.33);
animation: kf-caution 1200s infinite linear;
box-sizing: border-box;
inset: 0;
background: repeating-linear-gradient(
45deg,
var(--background-primary) 0,
var(--background-primary) 8px,
black 8px,
black 16px
);
content: '';
}
@keyframes kf-caution {
0% {
background-position-y: 0px;
}
100% {
background-position-y: 7200px;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="theme-color" content="#a0a0a0" />
<meta property="description" content="gifuu (/gif-oo/) may contain animations and stickers" />
<title>gifuu</title>
<link rel="icon" href="{{ include-b64 'image/svg+xml' 'include/favicon.svg' }}" />
<link rel="texture" href="{{ include-b64 'image/png' 'include/texture.png' }}" />
{{ include-env }}
{{ include-article 'terms-of-service' 'include/articles/terms-of-service.html' }}
{{ include-article 'privacy-policy' 'include/articles/privacy-policy.html' }}
{{ include-article 'api-guide' 'include/articles/api-guide.html' }}
{{ include-tag 'style' 'public/fonts/terminus/Terminus.css' }}
{{ include-tag 'style' 'include/index/critical.css' }}
</head>
<body>
<!-- Frontend -->
<div class="layout-wrapper">
<div class="layout-pane layout-background"></div>
<div class="layout-pane layout-foreground"></div>
</div>
<script type="module" src="/source/index.tsx"></script>
{{ include-tag 'script' 'include/index/background.ts' }}
<noscript>
<style>
:root {
--noscript-corner-thickness: 1px;
--noscript-corner-margin: -16px;
--noscript-corner-color: #606060;
}
/* Layout */
div.noscript-layout {
display: flex;
justify-content: center;
gap: 16px;
box-sizing: border-box;
margin: auto;
padding: 16px 0;
width: 100%;
max-width: 1024px;
}
div.noscript-wrapper {
position: relative;
margin: 16px;
}
main.noscript-container {
background-color: #161616;
padding: 16px;
}
/* Elements */
p.noscript-message {
margin: 0;
padding: 0;
color: #f0f0f0;
font-family: monospace;
text-align: center;
}
/* Effects */
div.noscript-corner {
position: absolute;
width: 16px;
height: 16px;
}
div.noscript-corner:nth-child(1) {
top: var(--noscript-corner-margin);
left: var(--noscript-corner-margin);
border-top: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
border-left: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
}
div.noscript-corner:nth-child(2) {
top: var(--noscript-corner-margin);
right: var(--noscript-corner-margin);
border-top: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
border-right: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
}
div.noscript-corner:nth-child(3) {
bottom: var(--noscript-corner-margin);
left: var(--noscript-corner-margin);
border-bottom: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
border-left: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
}
div.noscript-corner:nth-child(4) {
right: var(--noscript-corner-margin);
bottom: var(--noscript-corner-margin);
border-right: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
border-bottom: var(--noscript-corner-thickness) solid var(--noscript-corner-color);
}
</style>
<div class="noscript-layout">
<div class="noscript-wrapper">
<div class="noscript-corner"></div>
<div class="noscript-corner"></div>
<div class="noscript-corner"></div>
<div class="noscript-corner"></div>
<main class="noscript-container">
<p class="noscript-message"><b>NOTICE:</b> JavaScript is required to view this site.</p>
</main>
</div>
</div>
</noscript>
</body>
</html>
+2758
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"private": true,
"name": "gifuu-frontend",
"type": "module",
"scripts": {
"dev": "npx vite --host",
"build": "npx tsc --noEmit -p tsconfig.app.json && npx vite build",
"format": "npx prettier --write **/*.tsx **/*.ts **/*.js **/*.css"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"@types/node": "^24.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"html-minifier-next": "^5.2.2",
"preact": "^10.29.0",
"prettier": "^3.8.1",
"prettier-plugin-css-order": "^2.2.0",
"typescript": "~5.9.3",
"vite": "^7.1.7"
}
}
+97
View File
@@ -0,0 +1,97 @@
Copyright (c) 2010 Dimitar Toshkov Zhekov,
with Reserved Font Name "Terminus Font".
Copyright (c) 2011-2023 Tilman Blumenbach,
with Reserved Font Name "Terminus (TTF)".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,33 @@
/* Terminus Regular */
@font-face {
font-style: normal;
font-weight: 400;
src: url(/fonts/terminus/TerminusRegular.woff2);
font-family: 'Terminus';
font-display: swap;
}
@font-face {
font-style: italic;
font-weight: 400;
src: url(/fonts/terminus/TerminusItalic.woff2);
font-family: 'Terminus';
font-display: swap;
}
/* Terminus Bold */
@font-face {
font-style: normal;
font-weight: 700;
src: url(/fonts/terminus/TerminusBold.woff2);
font-family: 'Terminus';
font-display: swap;
}
@font-face {
font-style: italic;
font-weight: 700;
src: url(/fonts/terminus/TerminusBoldItalic.woff2);
font-family: 'Terminus';
font-display: swap;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
# \_/
# ()o_o) <( beep boop )
User-agent: *
Disallow:
+32
View File
@@ -0,0 +1,32 @@
self.onmessage = async (e) => {
const { nonce, difficulty } = e.data
const ENCODER = new TextEncoder()
const BATCH = 1000
let counter = 0
while (true) {
const batch = await Promise.all(
Array.from({ length: BATCH }, (_, i) => {
const data = ENCODER.encode(nonce + (counter + i))
return crypto.subtle.digest('SHA-256', data)
}),
)
for (let i = 0; i < BATCH; i++) {
const hash = new Uint8Array(batch[i])
let zeroBits = 0
for (const byte of hash) {
if (byte === 0) {
zeroBits += 8
} else {
zeroBits += Math.clz32(byte) - 24
break
}
}
if (zeroBits >= difficulty) {
self.postMessage({ counter: counter + i })
return
}
}
counter += BATCH
}
}
@@ -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;
}
+138
View File
@@ -0,0 +1,138 @@
import type { BackendResponse } from './BackendTypes'
declare global {
interface Window {
__ENV__: {
CDN: string
API: string
WEB: string
}
}
}
export const API_BASE = window.__ENV__.API
export const CDN_BASE = window.__ENV__.CDN
export const WEB_BASE = window.__ENV__.WEB
export function BackendDebounce(resp: BackendResponse<any>, iteration = 1) {
const ratelimit = resp.status === 429 && resp.ratelimit.remains === 0
return {
ratelimit,
sleep: new Promise<void>((next) => {
if (ratelimit) {
const sleep = resp.ratelimit.reset * 1000
console.warn(`Ratelimited! sleeping for ${sleep}ms`)
setTimeout(next, sleep)
} else {
setTimeout(next, iteration * 2000)
}
}),
}
}
export function BackendFetch<T>(path: string): Promise<BackendResponse<T>> {
return new Promise((resolve) => {
const data: BackendResponse<T> = {
success: false,
status: -1,
error: '',
ratelimit: { remains: 0, reset: 0, limit: 0 },
text: '',
json: null as T,
}
fetch(API_BASE + path)
.then(async (res) => {
data.status = res.status
data.success = res.ok
data.ratelimit.limit = parseInt(res.headers.get('X-Ratelimit-Limit') || '0')
data.ratelimit.reset = parseFloat(res.headers.get('X-Ratelimit-Reset') || '0')
data.ratelimit.remains = parseInt(res.headers.get('X-Ratelimit-Remaining') || '0')
data.text = await res.text()
data.json = (() => {
try {
return JSON.parse(data.text)
} catch {
return null
}
})()
// Check for Backend Error
if (!res.ok) {
data.error = (data.json as any)?.message ?? `Request failed: ${res.status} ${res.statusText}`
return
}
})
.catch((err) => {
console.error('Request failed due to a network error:', err)
data.error = 'Network Unavailable'
})
.finally(() => resolve(data))
})
}
// export function BackendGET<T>(path: string): Promise<BackendResponse<T>> {
// return BackendPOST(path, undefined, {})
// }
// export function BackendPOST<T>(path: string, body: any, headers: Record<string, string>): Promise<BackendResponse<T>> {
// return new Promise((resolve) => {
// let requestHeaders = new Headers()
// let requestMethod = 'GET'
// Object.entries(headers).forEach(([k, v]) => requestHeaders.set(k, v))
// if (body) {
// if (!(body instanceof FormData)) {
// requestHeaders.set('Content-Type', 'application/json')
// body = JSON.stringify(body)
// }
// requestMethod = 'POST'
// }
// const data: BackendResponse<T> = {
// success: false,
// status: -1,
// error: '',
// ratelimit: { remains: 0, reset: 0, limit: 0 },
// text: '',
// json: null as T,
// }
// fetch(API_BASE + path, {
// method: requestMethod,
// headers: requestHeaders,
// body: body,
// })
// .then(async (res) => {
// data.status = res.status
// data.success = res.ok
// data.ratelimit.limit = parseInt(res.headers.get('X-Ratelimit-Limit') || '0')
// data.ratelimit.reset = parseFloat(res.headers.get('X-Ratelimit-Reset') || '0')
// data.ratelimit.remains = parseInt(res.headers.get('X-Ratelimit-Remaining') || '0')
// data.text = await res.text()
// data.json = (() => {
// try {
// return JSON.parse(data.text)
// } catch {
// return null
// }
// })()
// // Check for Backend Error
// if (!res.ok) {
// const debugID = res.headers.get('X-Debug-ID')
// const message = (data.json as any)?.message ?? `Request failed: ${res.status} ${res.statusText}`
// data.error = `${message} ${debugID ? `(${debugID})` : ''}`
// return
// }
// })
// .catch((err) => {
// console.error('Request failed due to a network error:', err)
// data.error = 'Network Unavailable'
// })
// .finally(() => resolve(data))
// })
// }
+119
View File
@@ -0,0 +1,119 @@
export interface BackendResponse<T> {
success: boolean
status: number
error: string
ratelimit: {
remains: number
limit: number
reset: number
}
text: string
json: T
}
export interface BackendLimitReportOption {
id: number
title: string
description: string
}
export interface BackendLimitType<T = undefined> {
normalizers: {
match: string
replace: string
comment: string
}[]
matcher: string
max_length: number
min_length: number
values: T
}
export interface BackendLimit {
upload: {
input_width_min: number
input_height_min: number
video_width_max: number
video_height_max: number
image_width_max: number
image_height_max: number
duration: number
filesize: number
mime_types: string[]
}
title: BackendLimitType
tag: BackendLimitType
comment: BackendLimitType
report: BackendLimitType<BackendLimitReportOption>
}
export interface BackendChallenge {
nonce: string
difficulty: number
/** UNIX Timestamp */
expires: number
}
export interface BackendTag {
id: string
label: string
usage: number
}
export interface BackendArt {
id: string
created: string
sticker: boolean
audio: boolean
framerate: number
width: number
height: number
/** Float 0-1 */
rating: number
title: string
tags: BackendTag[]
}
export type UploadEventType =
| UploadEventID
| UploadEventError
| UploadEventStep
| UploadEventProgress
| UploadEventFinish
interface UploadEventID {
name: 'id'
data: string
}
interface UploadEventError {
name: 'error'
data: {
code: number
message: string
}
}
interface UploadEventStep {
name: 'step'
data: {
id: 'PROBE_QUEUE' | 'PROBE_START' | 'ENCODE_QUEUE' | 'ENCODE_START' | 'SERVER_FINALIZE'
message: string
}
}
interface UploadEventProgress {
name: 'progress'
data: {
/** float string (e.g. 45.12) */
percent: string
}
}
interface UploadEventFinish {
name: 'finish'
data: {
id: string
edit_token: string
}
}
+41
View File
@@ -0,0 +1,41 @@
import { createContext, useContext } from 'react'
export const ScrollContext = createContext<HTMLElement | null>(null)
export const useScrollRoot = () => useContext(ScrollContext)
// delete all the toastz
export function toastNuke() {
document.querySelectorAll('.layout-tooltip').forEach((e) => e.remove())
}
window.addEventListener('popstate', toastNuke)
// janky tooltips which are evil!1!
export function toast(anchorId: string, message: string) {
const anchor = document.getElementById(anchorId)
if (!anchor) return
const dialog = anchor.closest('dialog') ?? document.body
const parent = dialog.getBoundingClientRect()
const rect = anchor.getBoundingClientRect()
const elem = document.createElement('span')
// monospace font 4 da win
const tooltipWidth = message.length * 8
const clampedLeft = Math.max(
tooltipWidth / 2 + 8,
Math.min(rect.left + rect.width / 2, window.innerWidth - tooltipWidth / 2 - 8),
)
elem.classList.add('layout-tooltip', 'animation-scroll-in')
elem.textContent = message
elem.style.cssText = `
position: absolute;
left: ${clampedLeft - parent.left}px;
top: ${rect.top - parent.top - 8}px;
transform: translateX(-50%) translateY(-100%);
`
toastNuke()
dialog.appendChild(elem)
setTimeout(() => elem.remove(), 2000)
}
+21
View File
@@ -0,0 +1,21 @@
export const validTagMatcher = new RegExp(/^[\p{L}\p{N}_]+$/u)
// Format user input for api requests, returns an empty string if invalid
export function formatTagInput(str: string): string | false {
const normal = str.trim().toLowerCase().replaceAll(/\s\s+/g, ' ').replaceAll(' ', '_')
if (!validTagMatcher.test(normal)) {
return false
}
return normal
}
// Format tag label for display in html
export function formatTagTextContent(str: string): string {
return str.trim().toUpperCase().replace('_', ' ')
}
// Format tag usage for display in html
export function formatTagUsage(num: number): string {
return num.toLocaleString()
}
+55
View File
@@ -0,0 +1,55 @@
import { type MouseEvent } from 'react'
export const KEY_PREFIX_UPLOAD = 'upload_'
let route_index = 0
let route_state: { path: string; with: any; keep: any }[] = [{ path: getWindowPath(), with: null, keep: null }]
window.addEventListener('popstate', (e) => {
if (e.state?.route_index !== undefined) {
route_index = e.state.route_index
}
})
export function getWindowPath() {
return window.location.href.slice(window.location.origin.length)
}
export function setTitle(title: string) {
document.title = `${title} • gifuu`
}
export function routeTo(path: string, withData?: any, keepData?: any) {
route_state[route_index].keep = keepData ?? null
route_state.splice(route_index + 1)
route_state.push({ path, with: withData, keep: null })
route_index++
window.history.pushState({ route_index }, '', path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
export function routeIntercept(event: MouseEvent<HTMLAnchorElement>, withData?: any, keepData?: any) {
if (event.currentTarget instanceof HTMLAnchorElement) {
event.preventDefault()
routeTo(event.currentTarget.getAttribute('href') ?? '/', withData, keepData)
}
}
export function routeBack() {
if (route_index <= 0) {
routeTo('/')
return
}
route_index--
const entry = route_state[route_index]
window.history.pushState({ route_index }, '', entry.path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
export function routeBackURI() {
return route_state[route_index - 1]?.path ?? '/'
}
export function stateRecover<T>(): T | undefined {
return (route_state[route_index]?.with ?? route_state[route_index]?.keep) as T
}
+32
View File
@@ -0,0 +1,32 @@
declare global {
interface Window {
umami?: {
track: (name: string, data?: Record<string, any>) => Promise<void>
identify: (id: string, data?: Record<string, any>) => Promise<void>
}
}
}
export function wtEvent(eventName: string, data?: Record<string, any>) {
console.log('[WATCHTOWER] Event:', eventName, data)
window.umami?.track(eventName, data).catch((error) => {
console.error('[WatchTower] Event Failed:', { eventName, data }, error)
})
}
// // Do not use, this goes against the privacy policy!
// export function wtIdentify(who: string | number, data?: object) {
// console.log('[WATCHTOWER] Identify:', who, data)
// window.umami?.identify(String(who), data).catch((error) => {
// console.error('[WatchTower] Identify Failed:', { who, data }, error)
// })
// }
// Not yet in service!
// if (import.meta.env.PROD) {
// const script = document.createElement('script')
// script.defer = true
// script.src = 'https://watchtower.pancakz.net/script.js'
// script.dataset.websiteId = '...'
// document.body.appendChild(script)
// }
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import PaneContent from './components/main/PaneContent.tsx'
import PaneGlass from './components/main/PaneGlass.tsx'
import PaneSidebar from './components/main/PaneSidebar.tsx'
createRoot(document.querySelector('div.layout-foreground')!).render(
<StrictMode>
<PaneGlass>
<PaneSidebar />
</PaneGlass>
<PaneGlass>
<PaneContent />
</PaneGlass>
</StrictMode>,
)
@@ -0,0 +1,203 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<defs><circle fill="#404040" id="c" r="1"/></defs>
<use href="#c" x="34" y="122" />
<use href="#c" x="30" y="164" />
<use href="#c" x="27" y="207" />
<use href="#c" x="25" y="252" />
<use href="#c" x="24" y="297" />
<use href="#c" x="24" y="343" />
<use href="#c" x="25" y="388" />
<use href="#c" x="27" y="433" />
<use href="#c" x="30" y="476" />
<use href="#c" x="34" y="518" />
<use href="#c" x="77" y="77" />
<use href="#c" x="73" y="118" />
<use href="#c" x="69" y="160" />
<use href="#c" x="66" y="205" />
<use href="#c" x="64" y="250" />
<use href="#c" x="63" y="297" />
<use href="#c" x="63" y="343" />
<use href="#c" x="64" y="390" />
<use href="#c" x="66" y="435" />
<use href="#c" x="69" y="480" />
<use href="#c" x="73" y="522" />
<use href="#c" x="77" y="563" />
<use href="#c" x="122" y="34" />
<use href="#c" x="118" y="73" />
<use href="#c" x="114" y="114" />
<use href="#c" x="111" y="157" />
<use href="#c" x="108" y="202" />
<use href="#c" x="106" y="249" />
<use href="#c" x="104" y="296" />
<use href="#c" x="104" y="344" />
<use href="#c" x="106" y="391" />
<use href="#c" x="108" y="438" />
<use href="#c" x="111" y="483" />
<use href="#c" x="114" y="526" />
<use href="#c" x="118" y="567" />
<use href="#c" x="122" y="606" />
<use href="#c" x="164" y="30" />
<use href="#c" x="160" y="69" />
<use href="#c" x="157" y="111" />
<use href="#c" x="154" y="154" />
<use href="#c" x="151" y="200" />
<use href="#c" x="149" y="247" />
<use href="#c" x="148" y="295" />
<use href="#c" x="148" y="345" />
<use href="#c" x="149" y="393" />
<use href="#c" x="151" y="440" />
<use href="#c" x="154" y="486" />
<use href="#c" x="157" y="529" />
<use href="#c" x="160" y="571" />
<use href="#c" x="164" y="610" />
<use href="#c" x="207" y="27" />
<use href="#c" x="205" y="66" />
<use href="#c" x="202" y="108" />
<use href="#c" x="200" y="151" />
<use href="#c" x="197" y="197" />
<use href="#c" x="196" y="245" />
<use href="#c" x="194" y="295" />
<use href="#c" x="194" y="345" />
<use href="#c" x="196" y="395" />
<use href="#c" x="197" y="443" />
<use href="#c" x="200" y="489" />
<use href="#c" x="202" y="532" />
<use href="#c" x="205" y="574" />
<use href="#c" x="207" y="613" />
<use href="#c" x="252" y="25" />
<use href="#c" x="250" y="64" />
<use href="#c" x="249" y="106" />
<use href="#c" x="247" y="149" />
<use href="#c" x="245" y="196" />
<use href="#c" x="244" y="244" />
<use href="#c" x="243" y="294" />
<use href="#c" x="243" y="346" />
<use href="#c" x="244" y="396" />
<use href="#c" x="245" y="444" />
<use href="#c" x="247" y="491" />
<use href="#c" x="249" y="534" />
<use href="#c" x="250" y="576" />
<use href="#c" x="252" y="615" />
<use href="#c" x="297" y="24" />
<use href="#c" x="297" y="63" />
<use href="#c" x="296" y="104" />
<use href="#c" x="295" y="148" />
<use href="#c" x="295" y="194" />
<use href="#c" x="294" y="243" />
<use href="#c" x="294" y="294" />
<use href="#c" x="294" y="346" />
<use href="#c" x="294" y="397" />
<use href="#c" x="295" y="446" />
<use href="#c" x="295" y="492" />
<use href="#c" x="296" y="536" />
<use href="#c" x="297" y="577" />
<use href="#c" x="297" y="616" />
<use href="#c" x="343" y="24" />
<use href="#c" x="343" y="63" />
<use href="#c" x="344" y="104" />
<use href="#c" x="345" y="148" />
<use href="#c" x="345" y="194" />
<use href="#c" x="346" y="243" />
<use href="#c" x="346" y="294" />
<use href="#c" x="346" y="346" />
<use href="#c" x="346" y="397" />
<use href="#c" x="345" y="446" />
<use href="#c" x="345" y="492" />
<use href="#c" x="344" y="536" />
<use href="#c" x="343" y="577" />
<use href="#c" x="343" y="616" />
<use href="#c" x="388" y="25" />
<use href="#c" x="390" y="64" />
<use href="#c" x="391" y="106" />
<use href="#c" x="393" y="149" />
<use href="#c" x="395" y="196" />
<use href="#c" x="396" y="244" />
<use href="#c" x="397" y="294" />
<use href="#c" x="397" y="346" />
<use href="#c" x="396" y="396" />
<use href="#c" x="395" y="444" />
<use href="#c" x="393" y="491" />
<use href="#c" x="391" y="534" />
<use href="#c" x="390" y="576" />
<use href="#c" x="388" y="615" />
<use href="#c" x="433" y="27" />
<use href="#c" x="435" y="66" />
<use href="#c" x="438" y="108" />
<use href="#c" x="440" y="151" />
<use href="#c" x="443" y="197" />
<use href="#c" x="444" y="245" />
<use href="#c" x="446" y="295" />
<use href="#c" x="446" y="345" />
<use href="#c" x="444" y="395" />
<use href="#c" x="443" y="443" />
<use href="#c" x="440" y="489" />
<use href="#c" x="438" y="532" />
<use href="#c" x="435" y="574" />
<use href="#c" x="433" y="613" />
<use href="#c" x="476" y="30" />
<use href="#c" x="480" y="69" />
<use href="#c" x="483" y="111" />
<use href="#c" x="486" y="154" />
<use href="#c" x="489" y="200" />
<use href="#c" x="491" y="247" />
<use href="#c" x="492" y="295" />
<use href="#c" x="492" y="345" />
<use href="#c" x="491" y="393" />
<use href="#c" x="489" y="440" />
<use href="#c" x="486" y="486" />
<use href="#c" x="483" y="529" />
<use href="#c" x="480" y="571" />
<use href="#c" x="476" y="610" />
<use href="#c" x="518" y="34" />
<use href="#c" x="522" y="73" />
<use href="#c" x="526" y="114" />
<use href="#c" x="529" y="157" />
<use href="#c" x="532" y="202" />
<use href="#c" x="534" y="249" />
<use href="#c" x="536" y="296" />
<use href="#c" x="536" y="344" />
<use href="#c" x="534" y="391" />
<use href="#c" x="532" y="438" />
<use href="#c" x="529" y="483" />
<use href="#c" x="526" y="526" />
<use href="#c" x="522" y="567" />
<use href="#c" x="518" y="606" />
<use href="#c" x="563" y="77" />
<use href="#c" x="567" y="118" />
<use href="#c" x="571" y="160" />
<use href="#c" x="574" y="205" />
<use href="#c" x="576" y="250" />
<use href="#c" x="577" y="297" />
<use href="#c" x="577" y="343" />
<use href="#c" x="576" y="390" />
<use href="#c" x="574" y="435" />
<use href="#c" x="571" y="480" />
<use href="#c" x="567" y="522" />
<use href="#c" x="563" y="563" />
<use href="#c" x="606" y="122" />
<use href="#c" x="610" y="164" />
<use href="#c" x="613" y="207" />
<use href="#c" x="615" y="252" />
<use href="#c" x="616" y="297" />
<use href="#c" x="616" y="343" />
<use href="#c" x="615" y="388" />
<use href="#c" x="613" y="433" />
<use href="#c" x="610" y="476" />
<use href="#c" x="606" y="518" />
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#a0a0a0" d="M8,0L0,8l8,8,8-8L8,0ZM3,8l5-5,5,5-5,5-5-5Z" />
</svg>

After

Width:  |  Height:  |  Size: 143 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<polygon fill="#a0a0a0" points="8,0 16,8 8,16 0,8" />
</svg>

After

Width:  |  Height:  |  Size: 126 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<polygon fill="#c0c0c0" points="0 0 0 3 6 8 0 13 0 16 3 16 8 10 13 16 16 16 16 13 10 8 16 3 16 0 13 0 8 6 3 0 0 0" />
</svg>

After

Width:  |  Height:  |  Size: 190 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<polygon fill="#c0c0c0" points="6.4 64 0 57.6 6.4 48 16 57.6 6.4 64"/>
<polyline fill="#c0c0c0" points="25.6 64 38.4 64 33.6 30.4 0 25.6 0 38.4 20.8 43.2"/>
<polyline fill="#c0c0c0" points="51.2 64 64 64 59.2 4.8 0 0 0 12.8 46.4 17.6"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 13">
<polygon fill="#c0c0c0" points="9 13 0 4 4 0 9 3 14 0 18 4 9 13"/>
</svg>

After

Width:  |  Height:  |  Size: 139 B

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="33.6" height="10.64" viewBox="0 0 33.6 10.64">
<path fill="#f0f0f0" d="M.59,10.64v-1.49h4.48c.44,0,.74-.06.89-.19.16-.13.23-.36.23-.68v-.28h-2.7c-1.14,0-2.01-.24-2.6-.71-.59-.48-.89-1.19-.89-2.13s.3-1.65.89-2.14c.59-.49,1.46-.74,2.6-.74h1.72c.54,0,1,.06,1.4.19s.73.3.99.52.46.5.59.82c.13.32.19.68.19,1.07v3.53c0,.46-.05.83-.15,1.12-.1.29-.27.52-.5.69s-.55.28-.95.34-.89.09-1.49.09H.59ZM6.19,4.72c0-.24-.08-.46-.25-.65-.16-.19-.43-.29-.81-.29h-1.33c-.35,0-.64.04-.85.11-.22.08-.38.18-.5.31s-.2.28-.25.44c-.04.17-.07.34-.07.52s.02.33.07.5.13.31.25.44.29.23.5.31c.22.08.5.12.85.12h1.72c.17,0,.3,0,.39,0,.09,0,.19.01.28.03v-1.85Z"/>
<path fill="#f0f0f0" d="M9.28,1.6V0h2.17v1.6h-2.17ZM9.28,8.36V2.4h2.17v5.96h-2.17Z"/>
<path fill="#f0f0f0" d="M12.29,8.36V2.16c0-.44.05-.8.14-1.08.1-.28.26-.5.5-.66.24-.16.55-.27.95-.33s.89-.09,1.49-.09h.83v1.44h-.59c-.22,0-.41.01-.55.04-.14.03-.26.07-.35.14-.09.06-.15.15-.19.26-.04.11-.05.24-.05.4h1.68v1.46h-1.68v4.62h-2.18Z"/>
<path fill="#f0f0f0" d="M19.85,8.36c-.47,0-.9-.05-1.28-.16s-.71-.28-.98-.5-.48-.52-.62-.88-.22-.78-.22-1.27v-3.28h2.18v3.28c0,.38.09.69.27.92s.52.34,1.03.34h1.67c.17,0,.3,0,.39.01.09,0,.19.02.28.04V2.28h2.2v6.08h-4.92Z"/>
<path fill="#f0f0f0" d="M28.68,8.36c-.47,0-.9-.05-1.28-.16-.38-.11-.71-.28-.98-.5-.27-.23-.48-.52-.62-.88-.14-.36-.22-.78-.22-1.27v-3.28h2.18v3.28c0,.38.09.69.27.92s.52.34,1.03.34h1.67c.17,0,.3,0,.39.01.09,0,.19.02.28.04V2.28h2.2v6.08h-4.92Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8">
<polygon fill="#c0c0c0" points="8 2 5 2 4 0 3 2 0 2 2 4 0 8 4 6 8 8 6 4 8 2" />
</svg>

After

Width:  |  Height:  |  Size: 150 B

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60">
<!-- why is it called a throbber? -->
<style>
rect {fill: #323232; animation: a 800ms infinite}
rect:nth-child(1) {animation-delay: 0ms}
rect:nth-child(2) {animation-delay: 100ms}
rect:nth-child(3) {animation-delay: 200ms}
rect:nth-child(4) {animation-delay: 300ms}
rect:nth-child(5) {animation-delay: 400ms}
rect:nth-child(6) {animation-delay: 500ms}
rect:nth-child(7) {animation-delay: 600ms}
rect:nth-child(8) {animation-delay: 700ms}
@keyframes a {
0%, 100% {fill: #323232}
50% {fill: #f0f0f0}
}
</style>
<g>
<rect x="1" y="1" width="18" height="18"/>
<rect x="21" y="1" width="18" height="18"/>
<rect x="41" y="1" width="18" height="18"/>
<rect x="41" y="21" width="18" height="18"/>
<rect x="41" y="41" width="18" height="18"/>
<rect x="21" y="41" width="18" height="18"/>
<rect x="1" y="41" width="18" height="18"/>
<rect x="1" y="21" width="18" height="18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<polygon fill="#c0c0c0" points="1 0 31 0 32 6 0 6 1 0"/>
<polygon fill="#c0c0c0" points="0 32 0 24 16 8 32 24 32 32 16 20 0 32"/>
</svg>

After

Width:  |  Height:  |  Size: 206 B

+47
View File
@@ -0,0 +1,47 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"alwaysStrict": true,
"useUnknownInCatchVariables": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitThis": true,
"strictPropertyInitialization": true,
"strictBindCallApply": true,
"strictBuiltinIteratorReturn": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"source",
"public",
"include*",
"source/functions/Watchtower.ts"
]
}

Some files were not shown because too many files have changed in this diff Show More