rc-1
@@ -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"]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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¹</p>
|
||||
<p class="document-item">Your IP address²</p>
|
||||
</div>
|
||||
<p class="document-paragraph">
|
||||
¹ 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">
|
||||
² 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 isn’t 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})()
|
||||
@@ -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 |
@@ -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)
|
||||
})()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1016 B |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# \_/
|
||||
# ()o_o) <( beep boop )
|
||||
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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()
|
||||
}}>
|
||||
<< 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>
|
||||
}
|
||||
@@ -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} × {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>
|
||||
)
|
||||
}
|
||||
@@ -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 */ `><> .o( blub blub )`,
|
||||
/* sleepy */ `( _ _) .zZ`,
|
||||
/* kitty! */ `(=^'w'^=) <( meow? )`,
|
||||
/* clueless */ `(>_< ") <( eek! )`,
|
||||
/* robot */ ` \\_/<br>()o_o) <( beep! )`,
|
||||
/* bunny */ ` /)/)<br>( . .) sorry...<br>( づ♥`,
|
||||
]
|
||||
const face = kamoji[Math.floor(Math.random() * kamoji.length)]
|
||||
|
||||
return (
|
||||
<div className="header-error">
|
||||
<span className="emote" dangerouslySetInnerHTML={{ __html: face }} />
|
||||
<span className="message" dangerouslySetInnerHTML={{ __html: reason }}></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import vectorIconThrobber from '../../vectors/throbber.svg'
|
||||
import './styles/HeaderLoading.css'
|
||||
|
||||
interface PropsForHeaderLoading {
|
||||
reason: string | undefined
|
||||
}
|
||||
|
||||
export default function HeaderLoading({ reason }: PropsForHeaderLoading) {
|
||||
return (
|
||||
<div className="header-loading animation-fade-in">
|
||||
<img className="icon" src={vectorIconThrobber} />
|
||||
<span className="hint">{(reason ?? 'Loading').toUpperCase()}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import './styles/HeaderMessage.css'
|
||||
|
||||
interface PropsForHeaderMessage {
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function HeaderMessage({ label }: PropsForHeaderMessage) {
|
||||
return (
|
||||
<div className="header-message">
|
||||
<div className="wrapper">
|
||||
<span className="title animation-scroll-in">
|
||||
{label.toUpperCase()}
|
||||
<span className="cursor animation-blink">_</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { useScrollRoot } from '../../functions/Context'
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import { CDN_BASE } from '../../functions/Backend'
|
||||
import './styles/LayoutBrowser.css'
|
||||
|
||||
interface PropsForLayoutBrowser {
|
||||
items: BackendArt[]
|
||||
position: number
|
||||
onEndReached?: () => void
|
||||
}
|
||||
|
||||
export interface RecoverForLayoutBrowser {
|
||||
position: number
|
||||
items: BackendArt[]
|
||||
}
|
||||
|
||||
export default function LayoutBrowser({ items, position, onEndReached }: PropsForLayoutBrowser) {
|
||||
const [columnCount, setColumnCount] = useState(3)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRoot = useScrollRoot()
|
||||
const didRestore = useRef(false)
|
||||
|
||||
// Endless Scrolling
|
||||
useEffect(() => {
|
||||
if (!onEndReached || !scrollRoot) return
|
||||
const onScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRoot
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
onEndReached()
|
||||
}
|
||||
}
|
||||
scrollRoot.addEventListener('scroll', onScroll)
|
||||
return () => scrollRoot.removeEventListener('scroll', onScroll)
|
||||
}, [onEndReached, scrollRoot])
|
||||
|
||||
// Restore Scrolling
|
||||
useEffect(() => {
|
||||
if (!scrollRoot || didRestore.current) return
|
||||
|
||||
// avoid race conditions
|
||||
const raf = requestAnimationFrame(() => {
|
||||
scrollRoot.scrollTo({ top: position })
|
||||
didRestore.current = true
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [scrollRoot, position, items])
|
||||
|
||||
// Calculate Column Count
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setColumnCount(Math.max(1, Math.floor(entry.contentRect.width / 160)))
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const columns: BackendArt[][] = Array.from({ length: columnCount }, () => [])
|
||||
items.forEach((item, i) => columns[i % columnCount].push(item))
|
||||
|
||||
return (
|
||||
<div className="layout-browser" ref={containerRef}>
|
||||
{columns.map((column, columnIdx) => (
|
||||
<div key={columnIdx} className="column">
|
||||
{column.map((item, itemIdx) => {
|
||||
const animationOrder = Math.min(columnIdx + itemIdx * columnCount, 25)
|
||||
const animationDelay = `calc(${animationOrder} * var(--animation-step-delay))`
|
||||
return (
|
||||
<a
|
||||
className="item"
|
||||
href={`/art/${item.id}`}
|
||||
onClick={(e) =>
|
||||
routeIntercept(e, item, {
|
||||
position: scrollRoot?.scrollTop ?? 0,
|
||||
items: items,
|
||||
} as RecoverForLayoutBrowser)
|
||||
}>
|
||||
<img
|
||||
style={{ animationDelay }}
|
||||
className="preview animation-fall-in"
|
||||
src={`${CDN_BASE}/${item.id}/preview.avif`}
|
||||
/>
|
||||
<div className="metadata">
|
||||
<div className="title">{item.title}</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CDN_BASE } from '../../functions/Backend'
|
||||
import './styles/MediaCanvas.css'
|
||||
|
||||
import vectorIconThrobber from '../../vectors/throbber.svg'
|
||||
import vectorIconCross from '../../vectors/cross.svg'
|
||||
|
||||
interface PropsForMediaCanvas {
|
||||
id: string
|
||||
background: boolean
|
||||
}
|
||||
|
||||
export default function MediaCanvas({ id, background }: PropsForMediaCanvas) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
const [fallback, setFallback] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const defer: (() => void)[] = []
|
||||
const canvas = canvasRef.current!
|
||||
const video = videoRef.current!
|
||||
if (!canvas || !video) return
|
||||
|
||||
video.onerror = () => {
|
||||
console.warn('Failed to load video, using fallback...')
|
||||
teardown()
|
||||
setFallback(true)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Initialize Canvas ---
|
||||
const gl = canvas.getContext('webgl', {
|
||||
powerPreference: 'low-power',
|
||||
preserveDrawingBuffer: true, // for download button
|
||||
premultipliedAlpha: false,
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
depth: false,
|
||||
})!
|
||||
if (!gl) {
|
||||
console.error('Context failed, using fallback...')
|
||||
setFallback(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const VERT = `
|
||||
precision mediump float;
|
||||
attribute vec2 aPos;
|
||||
uniform mat3 uMatrix;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
gl_Position = vec4(uMatrix * vec3(aPos, 1.0), 1.0);
|
||||
vUV = aPos;
|
||||
}`
|
||||
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
uniform sampler2D uFrame;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
|
||||
vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
|
||||
vec4 color = texture2D(uFrame, colorUV);
|
||||
float alpha = texture2D(uFrame, alphaUV).r;
|
||||
gl_FragColor = vec4(color.rgb, alpha);
|
||||
}`
|
||||
|
||||
function compileShader(type: number, src: string) {
|
||||
const s = gl.createShader(type)!
|
||||
gl.shaderSource(s, src)
|
||||
gl.compileShader(s)
|
||||
return s
|
||||
}
|
||||
|
||||
const prog = gl.createProgram()
|
||||
gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, VERT))
|
||||
gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, FRAG))
|
||||
gl.linkProgram(prog)
|
||||
gl.useProgram(prog)
|
||||
defer.push(() => gl.deleteProgram(prog))
|
||||
|
||||
// --- Quad ---
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), gl.STATIC_DRAW)
|
||||
defer.push(() => gl.deleteBuffer(buf))
|
||||
|
||||
const aPos = gl.getAttribLocation(prog, 'aPos')
|
||||
gl.enableVertexAttribArray(aPos)
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
|
||||
gl.uniformMatrix3fv(gl.getUniformLocation(prog, 'uMatrix'), false, [2, 0, 0, 0, -2, 0, -1, 1, 1])
|
||||
|
||||
// --- Texture ---
|
||||
const tex = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.uniform1i(gl.getUniformLocation(prog, 'uFrame'), 0)
|
||||
defer.push(() => gl.deleteTexture(tex))
|
||||
defer.push(() => gl.getExtension('WEBGL_lose_context')?.loseContext())
|
||||
} catch (error) {
|
||||
console.error('Init failed, using fallback...', error)
|
||||
setFallback(true)
|
||||
teardown()
|
||||
return
|
||||
}
|
||||
|
||||
// --- Draw Loop ---
|
||||
let cancel: number
|
||||
let sized = false
|
||||
let start = false
|
||||
function tick() {
|
||||
cancel = requestAnimationFrame(tick)
|
||||
if (!start) {
|
||||
setLoading(false)
|
||||
start = true
|
||||
}
|
||||
try {
|
||||
if (!sized && video.videoWidth > 0) {
|
||||
sized = true
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = Math.floor(video.videoHeight / 2)
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
if (!sized) return
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||
} catch (error) {
|
||||
// bugfix for safari browsers
|
||||
console.error('Draw failed, using fallback...', error)
|
||||
setFallback(true)
|
||||
teardown()
|
||||
return
|
||||
}
|
||||
}
|
||||
tick()
|
||||
defer.push(() => cancelAnimationFrame(cancel))
|
||||
video.play().catch(() => {})
|
||||
|
||||
// --- Disposal Functions ---
|
||||
function teardown() {
|
||||
let func
|
||||
while ((func = defer.shift())) {
|
||||
try {
|
||||
func()
|
||||
} catch (error) {
|
||||
console.error('Teardown Error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return teardown
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`media-canvas ${background ? 'background' : ''}`}>
|
||||
{!error && loading && (
|
||||
<div className="popup">
|
||||
<img className="icon" src={vectorIconThrobber} />
|
||||
<span className="hint">LOADING</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="popup">
|
||||
<img className="icon" src={vectorIconCross} />
|
||||
<span className="hint">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{!error && fallback && (
|
||||
<img
|
||||
className="render"
|
||||
src={`${CDN_BASE}/${id}/standard.avif`}
|
||||
onError={() => setError('Cannot Load Image')}
|
||||
onLoad={() => setLoading(false)}
|
||||
/>
|
||||
)}
|
||||
{!error && !fallback && (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
crossOrigin="anonymous"
|
||||
className="decode"
|
||||
src={`${CDN_BASE}/${id}/alpha.webm`}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
<canvas ref={canvasRef} className="render" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { type MouseEventHandler, forwardRef, useEffect, useMemo, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { WEB_BASE } from '../../functions/Backend'
|
||||
import { wtEvent } from '../../functions/Watchtower'
|
||||
import { toast } from '../../functions/Context'
|
||||
import './styles/ModalEmbed.css'
|
||||
|
||||
import VectorBackgroundEmbed from '../../vectors/background-embed.svg'
|
||||
import HeaderMessage from './HeaderMessage'
|
||||
import InputButtonRow from '../inputs/ButtonRow'
|
||||
import InputButton from '../inputs/Button'
|
||||
import InputDescription from '../inputs/Description'
|
||||
import InputLabel from '../inputs/Label'
|
||||
|
||||
interface PropsForModalEmbed {
|
||||
item: BackendArt
|
||||
onClose: MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
export default forwardRef<HTMLDialogElement, PropsForModalEmbed>(function ModalEmbed(
|
||||
{ item, onClose }: PropsForModalEmbed,
|
||||
ref,
|
||||
) {
|
||||
// Keep User Preferences
|
||||
const KEY_QUALITY = 'preference_embed_quality'
|
||||
const KEY_SCALE = 'preference_embed_scale'
|
||||
|
||||
const [preferQuality, setQuality] = useState<'standard' | 'transparent'>(
|
||||
(() => {
|
||||
let raw = localStorage.getItem(KEY_QUALITY) ?? 'standard'
|
||||
if (raw !== 'standard' && raw !== 'transparent') {
|
||||
return 'standard'
|
||||
} else {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
const [preferScale, setScale] = useState<number>(
|
||||
(() => {
|
||||
let raw = localStorage.getItem(KEY_SCALE) ?? String('1')
|
||||
let val = parseFloat(raw)
|
||||
if (isNaN(val) || val < 0 || val > 1) return 1
|
||||
return val
|
||||
})(),
|
||||
)
|
||||
|
||||
useEffect(() => localStorage.setItem(KEY_QUALITY, String(preferQuality)), [preferQuality])
|
||||
useEffect(() => localStorage.setItem(KEY_SCALE, String(preferScale)), [preferScale])
|
||||
|
||||
// Calculate Embed Values
|
||||
const embedScale = useMemo(() => {
|
||||
const maxDim = Math.max(item.width, item.height)
|
||||
const baseScale = maxDim > 640 ? 640 / maxDim : 1
|
||||
return baseScale * preferScale
|
||||
}, [item.width, item.height, preferScale])
|
||||
|
||||
const embedHeight = useMemo(() => (item.height * embedScale) | 0, [embedScale])
|
||||
const embedWidth = useMemo(() => (item.width * embedScale) | 0, [embedScale])
|
||||
|
||||
// const embedQuality = useMemo(() => {
|
||||
// if (preferQuality === 'standard.avif') return 'standard'
|
||||
// return 'transparent'
|
||||
// }, [preferQuality])
|
||||
|
||||
const embedHTML = useMemo(
|
||||
() =>
|
||||
`<iframe src="${WEB_BASE}/embed.html?id=${item.id}&quality=${preferQuality}" width="${embedWidth}" height="${embedHeight}" style="border:none; background: transparent" allowtransparency="true"></iframe>`,
|
||||
[preferQuality, embedScale],
|
||||
)
|
||||
|
||||
function onCopy() {
|
||||
navigator.clipboard.writeText(embedHTML)
|
||||
toast('action-copy', 'Copied Code to Clipboard!')
|
||||
wtEvent('action_animation_embed_copy', {
|
||||
id: item.id,
|
||||
height: embedHeight,
|
||||
width: embedWidth,
|
||||
scale: (embedScale * 100) | 0,
|
||||
quality: preferQuality,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog ref={ref} className="modal-embed animation-fall-in animation-caution">
|
||||
<HeaderMessage label="MENU: Embed Generator" />
|
||||
<div className="wrapper">
|
||||
{/* Left-Pane */}
|
||||
<div className="preview">
|
||||
<img className="background animation-fade-in" src={VectorBackgroundEmbed} />
|
||||
<iframe
|
||||
className="animation-fall-in"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
src={`${WEB_BASE}/embed.html?id=${item.id}&quality=${preferQuality}`}
|
||||
width={embedWidth}
|
||||
height={embedHeight}
|
||||
allowTransparency
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right-Pane */}
|
||||
<div className="toggles">
|
||||
<InputLabel for="" label="Quality" />
|
||||
<InputDescription>
|
||||
We recommend using Standard quality, if you require transparency use Alpha quality.
|
||||
</InputDescription>
|
||||
<InputDescription>
|
||||
Using more than three Alpha embeds may slow down your site, and up to twelve can be displayed at
|
||||
any given time.
|
||||
</InputDescription>
|
||||
<InputButtonRow split={false}>
|
||||
<InputButton
|
||||
id="quality-alpha"
|
||||
label="Alpha"
|
||||
onClick={() => setQuality('transparent')}
|
||||
selected={preferQuality === 'transparent'}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="quality-standard"
|
||||
label="Standard"
|
||||
onClick={() => setQuality('standard')}
|
||||
selected={preferQuality === 'standard'}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
|
||||
<InputLabel for="" label="Scale" />
|
||||
<InputDescription>
|
||||
Sizing is as follows: Small @ 320px; Medium @ 480px; Large @ 640px.
|
||||
</InputDescription>
|
||||
<InputDescription>If an image is too small, it wont get any larger.</InputDescription>
|
||||
<InputButtonRow split={false}>
|
||||
<InputButton
|
||||
id="size-small"
|
||||
label="Small"
|
||||
selected={preferScale < 0.6}
|
||||
onClick={() => setScale(0.5)}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="size-medium"
|
||||
label="Medium"
|
||||
selected={preferScale > 0.6 && preferScale < 0.9}
|
||||
onClick={() => setScale(0.75)}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="size-large"
|
||||
label="Large"
|
||||
selected={preferScale > 0.9}
|
||||
onClick={() => setScale(1)}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
|
||||
<InputLabel for="" label="Code" />
|
||||
<InputDescription>
|
||||
Use this code snippet to display this {item.sticker ? 'sticker' : 'animation'} on your website.
|
||||
</InputDescription>
|
||||
<InputDescription>Clicking on the embed will direct users to gifuu in a new tab.</InputDescription>
|
||||
|
||||
<textarea
|
||||
id="input-url"
|
||||
className="input-url"
|
||||
value={embedHTML}
|
||||
onKeyDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
|
||||
<InputLabel for="" label="" /* lazy divider */ />
|
||||
|
||||
<InputButtonRow split={true}>
|
||||
<InputButton
|
||||
id="action-copy"
|
||||
label="Copy HTML"
|
||||
onClick={onCopy}
|
||||
selected={false}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="action-exit"
|
||||
label="Exit"
|
||||
onClick={onClose}
|
||||
selected={false}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import vectorClose from '../../vectors/category-close.svg'
|
||||
import vectorOpen from '../../vectors/category-open.svg'
|
||||
import './styles/SidebarCategory.css'
|
||||
|
||||
interface PropsForSidebarCategory {
|
||||
label: string
|
||||
header?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function SidebarCategory({ label, header, children }: PropsForSidebarCategory) {
|
||||
const key = `category_closed_${label.toLowerCase()}`
|
||||
const [closed, setClosed] = useState(localStorage.getItem(key) === 'Y')
|
||||
useEffect(() => localStorage.setItem(key, closed ? 'Y' : 'N'), [closed])
|
||||
|
||||
return (
|
||||
<div className={`category ${closed ? 'close' : 'open'}`}>
|
||||
{header && (
|
||||
<button className="toggle" onClick={() => setClosed(!closed)}>
|
||||
<span className="label">{label.toUpperCase()}</span>
|
||||
<img
|
||||
className="icon"
|
||||
alt={`Toggle visibility for ${label}`}
|
||||
src={closed ? vectorClose : vectorOpen}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="items">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import './styles/SidebarItemIcon.css'
|
||||
|
||||
interface PropsForSidebarItemIcon {
|
||||
icon: string
|
||||
label: string
|
||||
description: string
|
||||
location: string
|
||||
}
|
||||
|
||||
export default function SidebarItemIcon({ icon, label, description, location }: PropsForSidebarItemIcon) {
|
||||
return (
|
||||
<a className="item-icon" href={location} onClick={routeIntercept}>
|
||||
<div className="section-left">
|
||||
<span className="header animation-scroll-in" aria-label={label}>
|
||||
{label.toUpperCase()}
|
||||
</span>
|
||||
<span className="subheader animation-scroll-in" aria-label={description}>
|
||||
{description.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="section-right animation-fade-in">
|
||||
<img className="foreground" src={icon} />
|
||||
<div className="background"></div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import vectorLogoFull from '../../vectors/logo-full.svg'
|
||||
import './styles/SidebarItemLogo.css'
|
||||
|
||||
export default function SidebarItemLogo() {
|
||||
return (
|
||||
<a className="item-logo" href="/" onClick={routeIntercept}>
|
||||
<img className="logo" src={vectorLogoFull} />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { formatTagTextContent, formatTagUsage } from '../../functions/Format'
|
||||
import type { BackendTag } from '../../functions/BackendTypes'
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import './styles/SidebarItemTag.css'
|
||||
|
||||
export default function SidebarItemTag({ id, label, usage }: BackendTag) {
|
||||
return (
|
||||
<a className="item-tag" href={`/search?tag=${id}`} onClick={routeIntercept}>
|
||||
<span className="label animation-scroll-in">{formatTagTextContent(label)}</span>
|
||||
<span className="usage animation-fade-in">{formatTagUsage(usage)}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { routeIntercept } from '../../functions/Route'
|
||||
import './styles/SidebarItemText.css'
|
||||
|
||||
interface PropsForSidebarItemText {
|
||||
location: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function SidebarItemText({ location, label }: PropsForSidebarItemText) {
|
||||
return (
|
||||
<a className="item-text animation-scroll-in" href={location} onClick={routeIntercept}>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.footer-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.footer-error span.text {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
div.footer-error img.icon {
|
||||
opacity: 0.5;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.footer-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.footer-loading span.text {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
div.footer-loading img.icon {
|
||||
opacity: 0.5;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
span.footer-text {
|
||||
padding: 8px 0;
|
||||
height: 16px;
|
||||
color: var(--font-color-secondary);
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.header-error {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
div.header-error span.emote {
|
||||
margin: 16px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
div.header-error span.message {
|
||||
color: var(--font-color-secondary);
|
||||
line-height: 2;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
div.header-loading {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 16px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
div.header-loading img.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
div.header-loading span.hint {
|
||||
color: var(--font-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
div.header-message {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
div.header-message div.wrapper {
|
||||
position: fixed;
|
||||
align-content: center;
|
||||
z-index: 999;
|
||||
box-sizing: border-box;
|
||||
inset: 0;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
div.header-message div.wrapper span.title {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
div.header-message div.wrapper span.title,
|
||||
div.header-message div.wrapper span.cursor {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
div.layout-browser {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
div.layout-browser div.column {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
div.layout-browser a.item {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
background-color: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.layout-browser a.item img.preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
div.layout-browser a.item div.metadata {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--animation-transition) ease-in-out;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
padding: 24px 8px 8px;
|
||||
}
|
||||
|
||||
div.layout-browser a.item:hover div.metadata,
|
||||
div.layout-browser a.item:focus-visible div.metadata {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
div.media-canvas {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.media-canvas div.popup {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.media-canvas div.popup img.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
div.media-canvas video.decode {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
div.media-canvas img.render.error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.media-canvas img.render,
|
||||
div.media-canvas canvas.render {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: inherit;
|
||||
height: 100%;
|
||||
max-height: inherit;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
div.media-canvas.background {
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
var(--background-secondary) 8px,
|
||||
var(--background-secondary) 9px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
var(--background-secondary) 8px,
|
||||
var(--background-secondary) 9px
|
||||
);
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
dialog.modal-embed::backdrop {
|
||||
animation: kf-modal-backdrop 1s linear forwards;
|
||||
/* animation-delay: 2s; */
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
@keyframes kf-modal-backdrop {
|
||||
0% {
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
100% {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
dialog.modal-embed {
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
width: 1024px;
|
||||
}
|
||||
|
||||
dialog.modal-embed:open {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.preview {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.preview > img.background {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.toggles {
|
||||
display: grid;
|
||||
flex-basis: 100%;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
max-width: 300px;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
dialog.modal-embed > div.wrapper > div.toggles > textarea {
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
resize: none;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
nav.layout-sidebar div.category {
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category button.toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-bottom: var(--border-thickness) solid transparent;
|
||||
padding: 8px 8px 8px 0px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category button.toggle img.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category.open button.toggle {
|
||||
margin-bottom: 8px;
|
||||
border-color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
nav.layout-sidebar div.category.close div.items {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
nav.layout-sidebar a.item-icon {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 8px 12px 4px;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon:hover,
|
||||
nav.layout-sidebar a.item-icon:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-left {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-left span.header,
|
||||
nav.layout-sidebar a.item-icon div.section-left span.subheader {
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
/* nav.layout-sidebar a.item-icon div.section-left span.header {} */
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-left span.subheader {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right div.background,
|
||||
nav.layout-sidebar a.item-icon div.section-right img.foreground {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right div.background {
|
||||
transform: rotate(45deg);
|
||||
background-color: var(--background-secondary);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-icon div.section-right img.foreground {
|
||||
z-index: 1;
|
||||
width: 16px;
|
||||
height: auto;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
nav.layout-sidebar a.item-logo img.logo {
|
||||
padding: 16px 0;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
nav.layout-sidebar a.item-tag {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-tag.dummy {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-tag[href]:hover,
|
||||
nav.layout-sidebar a.item-tag[href]:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-tag span.usage {
|
||||
color: var(--font-color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
nav.layout-sidebar a.item-text {
|
||||
padding: 4px;
|
||||
color: var(--font-color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav.layout-sidebar a.item-text:hover,
|
||||
nav.layout-sidebar a.item-text:focus-visible {
|
||||
color: var(--font-color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -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} × {data.height} • {new Date(data.created).toLocaleString()} •{' '}
|
||||
{data.rating < 0.8 ? 'Public' : 'Unlisted'} ({(data.rating * 100) | 0}%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="tags">
|
||||
{data.tags.map((t) => (
|
||||
<>
|
||||
<a
|
||||
key={t.id}
|
||||
className="item"
|
||||
href={`/search?tag=${t.id}`}
|
||||
onClick={(e) => routeIntercept(e, undefined, data)}>
|
||||
{formatTagTextContent(t.label)}
|
||||
<span className="usage">• {formatTagUsage(t.usage)}</span>
|
||||
</a>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<InputButtonRow split={true}>
|
||||
<InputButton
|
||||
id="action-share"
|
||||
label="Share"
|
||||
onClick={onShareOpen}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="action-embed"
|
||||
label="Embed"
|
||||
onClick={onEmbedOpen}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="action-download"
|
||||
label="Download"
|
||||
onClick={onDownload}
|
||||
disabled={isDownloading}
|
||||
rainbow={isDownloading}
|
||||
selected={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="action-delete"
|
||||
label="Delete"
|
||||
onClick={onDelete}
|
||||
disabled={!isOwner}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { BackendDebounce, BackendFetch } from '../../functions/Backend'
|
||||
import { setTitle, stateRecover } from '../../functions/Route'
|
||||
import { useScrollRoot } from '../../functions/Context'
|
||||
|
||||
import LayoutBrowser, { type RecoverForLayoutBrowser } from '../layout/LayoutBrowser'
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderLoading from '../layout/HeaderLoading'
|
||||
import FooterLoading from '../layout/FooterLoading'
|
||||
import FooterError from '../layout/FooterError'
|
||||
import FooterText from '../layout/FooterText'
|
||||
|
||||
export default function ViewHomepage() {
|
||||
const scrollRoot = useScrollRoot()
|
||||
const recovered = stateRecover<RecoverForLayoutBrowser>()
|
||||
|
||||
const [error, setError] = useState<string>()
|
||||
const [isFetching, setFetching] = useState(false)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [isBottom, setBottom] = useState(false)
|
||||
const [items, setItems] = useState(recovered?.items ?? [])
|
||||
|
||||
async function loadMore() {
|
||||
if (isFetching || isBottom) return
|
||||
setFetching(true)
|
||||
setLoading(true)
|
||||
try {
|
||||
const limit = 30
|
||||
const resp = await BackendFetch<BackendArt[]>(`/art/latest?limit=${limit}&after=${items.at(-1)?.id}`)
|
||||
|
||||
if (!resp.success) {
|
||||
const d = BackendDebounce(resp, 1)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
setLoading(false)
|
||||
}
|
||||
await d.sleep
|
||||
return
|
||||
}
|
||||
|
||||
if (resp.json.length < limit) {
|
||||
setBottom(true)
|
||||
}
|
||||
|
||||
setItems((prev) => [...prev, ...resp.json])
|
||||
setError(undefined)
|
||||
setLoading(false)
|
||||
} finally {
|
||||
setFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRoot) return
|
||||
if (scrollRoot.scrollHeight <= scrollRoot.clientHeight) {
|
||||
loadMore()
|
||||
}
|
||||
}, [items, scrollRoot])
|
||||
|
||||
setTitle('Homepage')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="View: Homepage <Sort: Latest>" />
|
||||
<LayoutBrowser position={recovered?.position ?? 0} items={items} onEndReached={loadMore} />
|
||||
|
||||
{error && <FooterError reason={error} />}
|
||||
{isBottom && <FooterText label="- You've reached the end, now be free my child -" />}
|
||||
{isLoading && (items.length ? <FooterLoading reason={undefined} /> : <HeaderLoading reason={undefined} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { KEY_PREFIX_UPLOAD, setTitle, stateRecover } from '../../functions/Route'
|
||||
import { BackendFetch } from '../../functions/Backend'
|
||||
import { wtEvent } from '../../functions/Watchtower'
|
||||
|
||||
import LayoutBrowser, { type RecoverForLayoutBrowser } from '../layout/LayoutBrowser'
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderLoading from '../layout/HeaderLoading'
|
||||
import HeaderError from '../layout/HeaderError'
|
||||
|
||||
export default function ViewPersonal() {
|
||||
const recovered = stateRecover<RecoverForLayoutBrowser>()
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [items, setItems] = useState(recovered?.items ?? [])
|
||||
const message = useMemo(() => `View: Personal <Count: ${items.length}>`, [items])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled(
|
||||
Object.keys(localStorage)
|
||||
.filter((key) => key.startsWith(KEY_PREFIX_UPLOAD))
|
||||
.map((key) => key.slice(KEY_PREFIX_UPLOAD.length))
|
||||
.sort((a, b) => Number(BigInt(b) - BigInt(a)))
|
||||
.map(
|
||||
(id) =>
|
||||
new Promise<BackendArt>(async (resolve, reject) => {
|
||||
// Check Caches
|
||||
const item = recovered?.items.find((i) => i.id === id)
|
||||
if (item) {
|
||||
return resolve(item)
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
const resp = await BackendFetch<BackendArt>(`/art/${id}`)
|
||||
if (!resp.success) {
|
||||
if (resp.status === 404) {
|
||||
// Was probably deleted, remove from storage so
|
||||
// this client can stop spamming the backend.
|
||||
localStorage.removeItem(KEY_PREFIX_UPLOAD + id)
|
||||
}
|
||||
console.error(`Request for Animation (${id}) failed:`, resp)
|
||||
return reject()
|
||||
}
|
||||
resolve(resp.json)
|
||||
}),
|
||||
),
|
||||
).then((p) => {
|
||||
const items = p.filter((p) => p.status === 'fulfilled').map((p) => p.value)
|
||||
setItems(items)
|
||||
setLoading(false)
|
||||
|
||||
wtEvent('view_personal', {
|
||||
ids: items.map((i) => i.id),
|
||||
item_total: items.length,
|
||||
item_error: p.filter((p) => p.status === 'rejected').length,
|
||||
item_count: p.filter((p) => p.status === 'fulfilled').length,
|
||||
nsfw_count: items.filter((i) => i.rating >= 0.8).length,
|
||||
nsfw_rating: items.reduce((sum, i) => sum + i.rating, 0) / items.length || 0,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="Retrieving Uploads" />
|
||||
<HeaderLoading reason="" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={message} />
|
||||
<HeaderError reason="No past uploads were found on this device. <br> Expecting something? Restore your lost data in Settings." />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
setTitle('Personal')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={message} />
|
||||
<LayoutBrowser items={items} position={recovered?.position ?? 0} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BackendArt } from '../../functions/BackendTypes'
|
||||
import { BackendDebounce, BackendFetch } from '../../functions/Backend'
|
||||
import { routeBackURI, setTitle } from '../../functions/Route'
|
||||
import { useScrollRoot } from '../../functions/Context'
|
||||
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderLoading from '../layout/HeaderLoading'
|
||||
import LayoutBrowser from '../layout/LayoutBrowser'
|
||||
import FooterLoading from '../layout/FooterLoading'
|
||||
import FooterError from '../layout/FooterError'
|
||||
import FooterText from '../layout/FooterText'
|
||||
import InputBack from '../inputs/Back'
|
||||
|
||||
export default function ViewSearch() {
|
||||
const scrollRoot = useScrollRoot()
|
||||
const [error, setError] = useState<string>()
|
||||
const [isFetching, setFetching] = useState(false)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [isBottom, setBottom] = useState(false)
|
||||
const [items, setItems] = useState<BackendArt[]>([])
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const query = params.getAll('tag')
|
||||
const limit = 30
|
||||
|
||||
async function loadMore() {
|
||||
if (query.length === 0) {
|
||||
setError('Enter tags to begin search.')
|
||||
return
|
||||
}
|
||||
|
||||
if (isFetching || isBottom) return
|
||||
setFetching(true)
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await BackendFetch<BackendArt[]>(
|
||||
`/art/search?limit=${limit}&after=${items.at(-1)?.id}` + query.map((q) => `&tag=${q}`).join(''),
|
||||
)
|
||||
if (!resp.success) {
|
||||
const d = BackendDebounce(resp, 1)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
setLoading(false)
|
||||
}
|
||||
await d.sleep
|
||||
return
|
||||
}
|
||||
|
||||
if (resp.json.length < limit) setBottom(true)
|
||||
setItems((prev) => [...prev, ...resp.json])
|
||||
setError(undefined)
|
||||
setLoading(false)
|
||||
} finally {
|
||||
setFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRoot) return
|
||||
if (scrollRoot.scrollHeight <= scrollRoot.clientHeight) {
|
||||
loadMore()
|
||||
}
|
||||
}, [items, scrollRoot])
|
||||
|
||||
setTitle(`Search (${query.join(', ')})`)
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={`View: Search <Sort: Latest> <TAGS: ${query.length ? query.join(', ') : 'NONE'}>`} />
|
||||
{routeBackURI()?.startsWith('/art/') && <InputBack />}
|
||||
<LayoutBrowser position={0} items={items} onEndReached={loadMore} />
|
||||
|
||||
{error && <FooterError reason={error} />}
|
||||
{isBottom && <FooterText label="- No More Results -" />}
|
||||
{isLoading && (items.length ? <FooterLoading reason={undefined} /> : <HeaderLoading reason={undefined} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { setTitle } from '../../functions/Route'
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import InputButton from '../inputs/Button'
|
||||
import InputButtonRow from '../inputs/ButtonRow'
|
||||
import InputLabel from '../inputs/Label'
|
||||
import InputDescription from '../inputs/Description'
|
||||
import './styles/Settings.css'
|
||||
|
||||
export default function ViewSettings() {
|
||||
const GIFUU_PREFIX = '_|GIFUU_BACKUP:DO_NOT_SHARE|_'
|
||||
|
||||
async function onDataBackup() {
|
||||
const password = prompt('Please enter a password for this backup (Leave empty for none):')
|
||||
if (password === null) return
|
||||
|
||||
// Generate Key
|
||||
const enc = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt'],
|
||||
)
|
||||
|
||||
// Encrypt String
|
||||
const data = JSON.stringify(localStorage)
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12))
|
||||
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, enc.encode(data))
|
||||
|
||||
// Package Contents
|
||||
const bundle = new Uint8Array(salt.byteLength + nonce.byteLength + cipher.byteLength)
|
||||
bundle.set(salt, 0)
|
||||
bundle.set(nonce, 16)
|
||||
bundle.set(new Uint8Array(cipher), 28)
|
||||
|
||||
const string = GIFUU_PREFIX + btoa(String.fromCharCode(...bundle))
|
||||
navigator.clipboard.writeText(string)
|
||||
|
||||
alert('Data has been copied to clipboard.')
|
||||
}
|
||||
|
||||
async function onDataRestore() {
|
||||
// Retrieve User Data
|
||||
const blob = await navigator.clipboard.readText()
|
||||
if (!blob.startsWith(GIFUU_PREFIX)) {
|
||||
alert('Contents stored in clipboard is not a backup.')
|
||||
return
|
||||
}
|
||||
const password = prompt('Please enter the password for this backup:')
|
||||
if (password === null) return
|
||||
|
||||
// Unpackage Contents
|
||||
const bundle = Uint8Array.from(atob(blob.slice(GIFUU_PREFIX.length)), (c) => c.charCodeAt(0))
|
||||
const salt = bundle.slice(0, 16)
|
||||
const iv = bundle.slice(16, 28)
|
||||
const cipher = bundle.slice(28)
|
||||
|
||||
// Generate Key
|
||||
const enc = new TextEncoder()
|
||||
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt'],
|
||||
)
|
||||
|
||||
// Decrypt String
|
||||
let raw: string
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
|
||||
raw = new TextDecoder().decode(plain)
|
||||
} catch (err) {
|
||||
console.error('Backup Error:', err)
|
||||
alert('Incorrect Password')
|
||||
return
|
||||
}
|
||||
|
||||
// Parse String
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
if (typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw 'Invalid Object'
|
||||
}
|
||||
Object.entries(data).forEach(([k, v]) => {
|
||||
if (typeof k === 'string' && typeof v === 'string') {
|
||||
localStorage.setItem(k, v)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Backup Error:', err)
|
||||
alert('Invalid data found in backup.')
|
||||
}
|
||||
|
||||
alert('Data restored, the webpage will now refresh.')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
setTitle('Settings')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="View: Settings" />
|
||||
<div className="view-settings">
|
||||
<div className="section">
|
||||
<InputLabel for="" label="Data Management" />
|
||||
<InputDescription>
|
||||
Export your local preferences and tokens for transfer or backup purposes to your devices
|
||||
clipboard. Do not share these with anybody!
|
||||
</InputDescription>
|
||||
<InputButtonRow split={true}>
|
||||
<InputButton
|
||||
id="action-backup"
|
||||
label="Backup"
|
||||
onClick={onDataBackup}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
<InputButton
|
||||
id="action-restore"
|
||||
label="Restore"
|
||||
onClick={onDataRestore}
|
||||
disabled={false}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
</InputButtonRow>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react'
|
||||
import { wtEvent } from '../../functions/Watchtower'
|
||||
import { setTitle } from '../../functions/Route'
|
||||
import './styles/Text.css'
|
||||
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import HeaderError from '../layout/HeaderError'
|
||||
|
||||
/**
|
||||
* Load article from index.html using the 'include-article' placeholder
|
||||
*/
|
||||
|
||||
interface PropsForViewText {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default function ViewText({ id }: PropsForViewText) {
|
||||
const template = document.head.querySelectorAll('template.article')
|
||||
const relevant = [...template].find((e) => e.id === id)
|
||||
|
||||
useEffect(() => {
|
||||
const t = Date.now()
|
||||
return () => {
|
||||
wtEvent('view_article', { id, duration: Date.now() - t })
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!relevant) {
|
||||
return <HeaderError reason="Unknown Article" />
|
||||
}
|
||||
|
||||
setTitle(`Article: ${id}`)
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label={`Article <ID: ${id}>`} />
|
||||
<article className="view-document" dangerouslySetInnerHTML={{ __html: relevant.innerHTML }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { BackendChallenge, BackendLimit, UploadEventType } from '../../functions/BackendTypes'
|
||||
import { API_BASE, BackendDebounce, BackendFetch } from '../../functions/Backend'
|
||||
import { KEY_PREFIX_UPLOAD, routeTo, setTitle } from '../../functions/Route'
|
||||
import './styles/Upload.css'
|
||||
|
||||
import HeaderMessage from '../layout/HeaderMessage'
|
||||
import InputFile, { type HandleForInputFile } from '../inputs/File'
|
||||
import InputText, { type HandleForInputText } from '../inputs/Text'
|
||||
import InputTags, { type HandleForInputTags } from '../inputs/Tags'
|
||||
import InputButton from '../inputs/Button'
|
||||
import FooterLoading from '../layout/FooterLoading'
|
||||
import FooterError from '../layout/FooterError'
|
||||
import FooterText from '../layout/FooterText'
|
||||
import { wtEvent } from '../../functions/Watchtower'
|
||||
|
||||
export default function ViewUpload() {
|
||||
const solveChallengeRef = useRef<() => void>(null)
|
||||
const fileRef = useRef<HandleForInputFile>(null)
|
||||
const titleRef = useRef<HandleForInputText>(null)
|
||||
const tagsRef = useRef<HandleForInputTags>(null)
|
||||
|
||||
const [workCounter, setWorkCounter] = useState(0)
|
||||
const [workNonce, setWorkNonce] = useState('')
|
||||
const [limits, setLimits] = useState<BackendLimit>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingReason, setLoadingReason] = useState<string>('')
|
||||
const [formError, setFormError] = useState<string>('')
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
async function onSubmit() {
|
||||
setFormError('')
|
||||
|
||||
// Validate Client
|
||||
if (workCounter === 0) {
|
||||
wtEvent('upload_validation_fail', { reason: 'pow_unsolved' })
|
||||
return setFormError('Waiting for worker to complete')
|
||||
}
|
||||
if (!limits) {
|
||||
wtEvent('upload_validation_fail', { reason: 'restrictions_loading' })
|
||||
return setFormError('Limits not loaded yet')
|
||||
}
|
||||
|
||||
// Validate Form
|
||||
const restrict = limits.upload
|
||||
const preview = fileRef.current?.getPreview()
|
||||
const file = fileRef.current?.getValue()
|
||||
const tags = tagsRef.current?.getValue()
|
||||
const title = titleRef.current?.getValue()
|
||||
|
||||
if (!file || !preview) {
|
||||
wtEvent('upload_validation_fail', { reason: 'missing_file' })
|
||||
return setFormError('Please select a file.')
|
||||
}
|
||||
if (!tags || tags.length === 0) {
|
||||
wtEvent('upload_validation_fail', { reason: 'missing_tags' })
|
||||
return setFormError('Please add at least one tag.')
|
||||
}
|
||||
if (!title || title.trim().length === 0) {
|
||||
wtEvent('upload_validation_fail', { reason: 'missing_title' })
|
||||
return setFormError('Please enter a title.')
|
||||
}
|
||||
|
||||
// Validate Media
|
||||
if (!restrict.mime_types.includes(file.type)) {
|
||||
wtEvent('upload_validation_fail', { reason: 'mime_type', value: file.type })
|
||||
return setFormError(`File type is not supported`)
|
||||
}
|
||||
if (file.size > restrict.filesize) {
|
||||
wtEvent('upload_validation_fail', { reason: 'file_size', value: file.size })
|
||||
return setFormError(`File exceeds ${Math.floor(restrict.filesize / 1024 / 1024)}MB limit`)
|
||||
}
|
||||
|
||||
if (preview instanceof HTMLVideoElement) {
|
||||
if (preview.videoWidth < restrict.input_width_min || preview.videoHeight < restrict.input_height_min) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_min',
|
||||
width: preview.videoWidth,
|
||||
height: preview.videoHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Animation is too small (Min: ${restrict.input_width_min}x${restrict.input_height_min})`,
|
||||
)
|
||||
}
|
||||
if (preview.videoWidth > restrict.video_width_max || preview.videoHeight > restrict.video_height_max) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_max',
|
||||
width: preview.videoWidth,
|
||||
height: preview.videoHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Animation is too large (Max: ${restrict.video_width_max}x${restrict.video_height_max})`,
|
||||
)
|
||||
}
|
||||
if (preview.duration > restrict.duration) {
|
||||
wtEvent('upload_validation_fail', { reason: 'duration', width: preview.duration })
|
||||
return setFormError(`Animation is too long (Max: ${restrict.duration} seconds)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (preview instanceof HTMLImageElement) {
|
||||
if (preview.naturalWidth < restrict.input_width_min || preview.naturalHeight < restrict.input_height_min) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_min',
|
||||
width: preview.naturalWidth,
|
||||
height: preview.naturalHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Sticker is too small (Min: ${restrict.input_width_min}x${restrict.input_height_min})`,
|
||||
)
|
||||
}
|
||||
if (preview.naturalWidth > restrict.image_width_max || preview.naturalHeight > restrict.image_height_max) {
|
||||
wtEvent('upload_validation_fail', {
|
||||
reason: 'dimension_max',
|
||||
width: preview.naturalWidth,
|
||||
height: preview.naturalHeight,
|
||||
})
|
||||
return setFormError(
|
||||
`Sticker is too large (Max: ${restrict.image_width_max}x${restrict.image_height_max})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload User Content
|
||||
setLoading(true)
|
||||
setLoadingReason('Uploading Content')
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append(
|
||||
'data',
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
title: title.trim(),
|
||||
tags: tags.map((t) => t.label),
|
||||
}),
|
||||
],
|
||||
{ type: 'application/json' },
|
||||
),
|
||||
)
|
||||
|
||||
fetch(`${API_BASE}/uploads`, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Challenge-Counter': String(workCounter),
|
||||
'X-Challenge-Nonce': workNonce,
|
||||
},
|
||||
})
|
||||
.then(async (resp) => {
|
||||
let requestID = 'N/A'
|
||||
|
||||
if (!resp.ok) {
|
||||
solveChallengeRef.current?.() // refresh consumed pow
|
||||
|
||||
let reason = `Request failed: ${resp.status} ${resp.statusText}`
|
||||
try {
|
||||
const raw = await resp.text()
|
||||
const dat = JSON.parse(raw)
|
||||
if (dat.message) reason = `${dat.message} (${requestID})`
|
||||
} catch {}
|
||||
|
||||
wtEvent('upload_fail', { reason })
|
||||
setFormError(reason)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = resp.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let parts = buffer.split('\n\n')
|
||||
buffer = parts.pop()!
|
||||
|
||||
for (const part of parts) {
|
||||
const lines = part.split('\n')
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue
|
||||
|
||||
const text = line.slice(5).trim()
|
||||
if (!text) continue
|
||||
|
||||
const body: UploadEventType = JSON.parse(text)
|
||||
switch (body.name) {
|
||||
// Simple Messages
|
||||
case 'id':
|
||||
requestID = body.data
|
||||
break
|
||||
case 'step':
|
||||
setLoadingReason(body.data.message)
|
||||
break
|
||||
case 'progress':
|
||||
setLoadingReason(`Processing: ${body.data.percent}%`)
|
||||
break
|
||||
|
||||
// Request Failed, Most likely due to NSFW
|
||||
case 'error':
|
||||
wtEvent('upload_fail', { reason: body.data.message })
|
||||
setFormError(`${body.data.message} (${requestID})`)
|
||||
setLoading(false)
|
||||
break
|
||||
|
||||
// Request Complete
|
||||
case 'finish':
|
||||
wtEvent('upload_success', {
|
||||
id: requestID,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
width:
|
||||
preview instanceof HTMLVideoElement
|
||||
? preview.videoWidth
|
||||
: preview.naturalWidth,
|
||||
height:
|
||||
preview instanceof HTMLVideoElement
|
||||
? preview.videoHeight
|
||||
: preview.naturalHeight,
|
||||
duration: preview instanceof HTMLVideoElement ? preview.duration : 0,
|
||||
})
|
||||
|
||||
// Little hack to send the user to their personal gifs instead of the upload pane
|
||||
// when they click back... it just feels more logical.
|
||||
|
||||
localStorage.setItem(`${KEY_PREFIX_UPLOAD}${requestID}`, body.data.edit_token)
|
||||
routeTo(`/personal`)
|
||||
routeTo(`/art/${requestID}`)
|
||||
break
|
||||
}
|
||||
|
||||
// For debugging purposes
|
||||
console.log(`[EVENT] ${body.name} => ${text}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Network Error:', err)
|
||||
setFormError('Network Error')
|
||||
setLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
solveChallengeRef.current?.()
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let iteration = 1
|
||||
async function handler() {
|
||||
const resp = await BackendFetch<BackendLimit>('/limits')
|
||||
if (resp.error) {
|
||||
console.log('Failed to fetch limits:', resp.error)
|
||||
const d = BackendDebounce(resp, iteration)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
}
|
||||
await d.sleep
|
||||
setError('')
|
||||
handler()
|
||||
return
|
||||
}
|
||||
setLimits(resp.json)
|
||||
}
|
||||
handler()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: number | undefined
|
||||
let worker: Worker
|
||||
let iteration = 1
|
||||
|
||||
async function handler() {
|
||||
console.log('[WORKER] Starting Challenge')
|
||||
setLoadingReason('Retrieving Settings')
|
||||
|
||||
// Request Challenge
|
||||
const resp = await BackendFetch<BackendChallenge>('/challenge?difficulty=20')
|
||||
if (resp.error) {
|
||||
console.log('[WORKER] Fetch Error:', resp.error)
|
||||
const d = BackendDebounce(resp, iteration)
|
||||
if (!d.ratelimit) {
|
||||
setError(resp.error)
|
||||
}
|
||||
await d.sleep
|
||||
setError('')
|
||||
handler()
|
||||
return
|
||||
}
|
||||
setLoadingReason('Solving Anti-Spam Challenge')
|
||||
|
||||
// Complete Challenge
|
||||
const { nonce, difficulty, expires } = resp.json
|
||||
const t = Date.now()
|
||||
|
||||
worker.postMessage({ nonce, difficulty })
|
||||
worker.onmessage = (m: MessageEvent<{ counter: number }>) => {
|
||||
const tt = Date.now() - t
|
||||
wtEvent('upload_pow_solve', { difficulty, time: tt })
|
||||
|
||||
console.log(`[WORKER] Work completed in ${tt}ms`)
|
||||
worker.onmessage = null
|
||||
setWorkCounter(m.data.counter)
|
||||
setWorkNonce(nonce)
|
||||
|
||||
const tl = Math.max(expires * 1000 - Date.now(), 0)
|
||||
timeout = setTimeout(handler, tl)
|
||||
console.log(`[WORKER] Next Challenge in ${tl}ms`)
|
||||
|
||||
setLoadingReason('Ready')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingReason('Spawning Worker')
|
||||
worker = new Worker('/worker-pow.js')
|
||||
solveChallengeRef.current = handler
|
||||
handler() // shouldn't fail
|
||||
} catch (error) {
|
||||
console.error('[WORKER] Startup Error:', error)
|
||||
setError('Init Error')
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
worker.onmessage = null
|
||||
worker.terminate()
|
||||
console.log('[WORKER] Cleanup')
|
||||
}
|
||||
}, [])
|
||||
|
||||
setTitle('Upload')
|
||||
return (
|
||||
<>
|
||||
<HeaderMessage label="View: Upload" />
|
||||
<div className="view-upload">
|
||||
<InputFile ref={fileRef} limits={limits?.upload} />
|
||||
<InputText ref={titleRef} label="Metadata: Title" placeholder="My Creation" />
|
||||
<InputTags ref={tagsRef} label="Metadata: Tags" allowCustom={true} onChange={undefined} />
|
||||
|
||||
{!error && !loading && !formError && <FooterText label="" /* lazy divider */ />}
|
||||
{!error && loading && <FooterLoading reason={loadingReason} />}
|
||||
{!error && formError && <FooterError reason={formError} />}
|
||||
{error && <FooterError reason={error} />}
|
||||
|
||||
<InputButton
|
||||
id="action-upload"
|
||||
label="Upload"
|
||||
onClick={onSubmit}
|
||||
disabled={loading || !!error}
|
||||
rainbow={false}
|
||||
selected={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
div.view-animation {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
div.view-animation div.preview {
|
||||
cursor: zoom-in;
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
div.view-animation div.preview div.media-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.view-animation div.metadata p.header {
|
||||
margin-bottom: 8px;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
div.view-animation div.metadata p.subheader {
|
||||
color: var(--font-color-secondary);
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
div.view-animation div.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
div.view-animation div.tags a.item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 8px;
|
||||
width: fit-content;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div.view-animation div.tags a.item:hover,
|
||||
div.view-animation div.tags a.item:focus-visible {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div.view-animation div.tags a.item span.usage {
|
||||
color: var(--font-color-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Fullscreen Preview */
|
||||
div.view-lightbox {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
cursor: zoom-out;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
div.view-lightbox div.media-canvas {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
div.view-settings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* View Layout */
|
||||
|
||||
article.view-document a {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Document Layout */
|
||||
|
||||
div.document-section {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* removes the ugly looking padding between the header and first element */
|
||||
div.document-section:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
div.document-section:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
div.document-spacer {
|
||||
margin: 8px 0;
|
||||
border-bottom: var(--border-thickness) dashed var(--background-secondary);
|
||||
}
|
||||
|
||||
div.document-divider {
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
background-color: var(--background-primary);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
div.document-divider::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
clip-path: polygon(
|
||||
100% 25%,
|
||||
62.5% 25%,
|
||||
50% 0%,
|
||||
37.5% 25%,
|
||||
0% 25%,
|
||||
25% 50%,
|
||||
0% 100%,
|
||||
50% 75%,
|
||||
100% 100%,
|
||||
75% 50%,
|
||||
100% 25%
|
||||
);
|
||||
background-color: var(--background-highlight);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* Document Elements */
|
||||
|
||||
div.document-section p,
|
||||
div.document-section pre {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
p.document-item::before {
|
||||
margin-right: 16px;
|
||||
margin-left: 8px;
|
||||
content: '◆';
|
||||
}
|
||||
|
||||
p.document-header {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
p.document-subheader {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
p.document-paragraph code {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background-translucent);
|
||||
padding: 4px;
|
||||
color: var(--font-color-accent);
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
pre.document-codeblock {
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background-translucent);
|
||||
|
||||
/* weird chin on the pre elements this padding here negates it */
|
||||
padding: 12px;
|
||||
padding-bottom: 0;
|
||||
overflow-x: scroll;
|
||||
overflow-y: scroll;
|
||||
color: var(--font-color-accent);
|
||||
font-size: small;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
div.view-upload {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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))
|
||||
// })
|
||||
// }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
]
|
||||
}
|
||||