rc-1
@@ -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 |