This commit is contained in:
2026-05-23 17:17:56 -07:00
commit 448f2e33ef
135 changed files with 11817 additions and 0 deletions
@@ -0,0 +1,16 @@
import { routeBack, routeBackURI } from '../../functions/Route'
import './styles/Back.css'
export default function InputBack() {
return (
<a
className="input-back"
href={routeBackURI()}
onClick={(e) => {
e.preventDefault()
routeBack()
}}>
&lt;&lt; BACK
</a>
)
}
@@ -0,0 +1,23 @@
import { type MouseEventHandler } from 'react'
import './styles/Button.css'
interface PropsForInputButton {
id: string
label: string
rainbow: boolean
disabled: boolean
selected: boolean
onClick: MouseEventHandler<HTMLButtonElement>
}
export default function InputButton({ id, label, disabled, selected, rainbow, onClick }: PropsForInputButton) {
return (
<button
id={id}
onClick={onClick}
disabled={disabled || selected}
className={`input-button ${selected ? 'selected' : ''} ${rainbow ? 'rainbow' : ''}`}>
{label.toUpperCase()}
</button>
)
}
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
import './styles/ButtonRow.css'
interface PropsForInputButtonRow {
children: ReactNode
split: boolean
}
export default function InputButtonRow({ children, split }: PropsForInputButtonRow) {
return <div className={`input-button-line ${split ? 'split' : 'row'}`}>{children}</div>
}
@@ -0,0 +1,9 @@
import './styles/Description.css'
interface PropsForInputDescription {
children: string | string[]
}
export default function InputDescription({ children }: PropsForInputDescription) {
return <p className="input-description animation-fade-in">{children}</p>
}
+104
View File
@@ -0,0 +1,104 @@
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState, type RefObject } from 'react'
import VectorIconTop from '../../vectors/top.svg'
import './styles/File.css'
import type { BackendLimit } from '../../functions/BackendTypes'
export interface PropsForInputFile {
limits: BackendLimit['upload'] | undefined
}
export interface HandleForInputFile {
getPreview: () => HTMLVideoElement | HTMLImageElement | undefined
getValue: () => File | undefined
}
const InputFile = forwardRef<HandleForInputFile, PropsForInputFile>(({ limits }, ref) => {
const componentID = useId()
const previewRef = useRef<HTMLVideoElement | HTMLImageElement>(undefined)
const inputRef = useRef<HTMLInputElement>(null)
const [fileInstance, setFileInstance] = useState<File>()
const [fileObjectURL, setFileObjectURL] = useState<string>()
function updateInput(file?: File) {
if (fileObjectURL) {
URL.revokeObjectURL(fileObjectURL)
}
const accept = file && !!limits?.mime_types.find((t) => file.type === t)
if (accept) {
setFileObjectURL(URL.createObjectURL(file))
setFileInstance(file)
} else {
setFileObjectURL(undefined)
setFileInstance(undefined)
}
}
useImperativeHandle(ref, () => ({
getPreview: () => previewRef.current,
getValue: () => fileInstance,
}))
useEffect(() => {
return () => {
fileObjectURL && URL.revokeObjectURL(fileObjectURL)
}
}, [fileObjectURL])
return (
<div
id="input-file"
className="input-file"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
updateInput(e.dataTransfer.files.item(0)!)
}}>
<input
id={componentID}
ref={inputRef}
type="file"
accept={limits?.mime_types ? limits.mime_types.join(',') : '*/*'}
onChange={(e) => {
e.preventDefault()
updateInput(e.target.files?.item(0) ?? undefined)
}}
/>
{!fileInstance && (
<div className="prompt">
<img className="icon animation-fade-in" src={VectorIconTop} />
<span className="header animation-scroll-in">DRAG OR CLICK TO UPLOAD A FILE</span>
{limits && (
<span className="subheader animation-scroll-in">
MAX: {limits.video_width_max} &times; {limits.video_height_max}; SIZE:{' '}
{Math.floor(limits.filesize / 1024 / 1024)}MB; DURATION:{' '}
{Math.floor(limits.duration / 10) * 10} SECS;
</span>
)}
</div>
)}
{fileInstance && (
<div className="preview">
{fileInstance.type.startsWith('video') && (
<video
ref={previewRef as RefObject<HTMLVideoElement>}
autoPlay
loop
muted
src={fileObjectURL}
/>
)}
{fileInstance.type.startsWith('image') && (
<img ref={previewRef as RefObject<HTMLImageElement>} src={fileObjectURL} />
)}
</div>
)}
</div>
)
})
export default InputFile
@@ -0,0 +1,14 @@
import './styles/Label.css'
interface PropsForInputLabel {
for: string
label: string
}
export default function InputLabel(p: PropsForInputLabel) {
return (
<label htmlFor={p.for} className="input-label animation-scroll-in" aria-label={p.label}>
{p.label.toUpperCase()}
</label>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { type KeyboardEvent, forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { formatTagInput, formatTagTextContent, formatTagUsage } from '../../functions/Format'
import type { BackendTag } from '../../functions/BackendTypes'
import { BackendFetch } from '../../functions/Backend'
import InputLabel from './Label'
import './styles/Label.css'
import './styles/Tags.css'
const SEARCH_LIMIT = 5
const SEARCH_CACHE = new Map<string, BackendTag[]>()
export interface HandleForInputTags {
getValue: () => BackendTag[]
}
interface PropsForInputTags {
label: string
allowCustom: boolean
onChange: ((tags: BackendTag[]) => void) | undefined
}
const InputTags = forwardRef<HandleForInputTags, PropsForInputTags>(({ label, onChange, allowCustom }, ref) => {
const componentID = useId()
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [tagsSelected, setTagsSelected] = useState<BackendTag[]>([])
const [tagsAvailable, setTagsAvailable] = useState<BackendTag[]>([])
const [inputSelect, setInputSelect] = useState(0)
const [inputQuery, setInputQuery] = useState('')
const indexHighlight = useMemo(
() => ((inputSelect % tagsAvailable.length) + tagsAvailable.length) % tagsAvailable.length,
[inputSelect],
)
useImperativeHandle(ref, () => ({ getValue: () => tagsSelected }))
useEffect(() => {
// Cleanup State
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
if (inputQuery.length === 0) {
setTagsAvailable([])
return
}
// Small debounce window before search begins
timeoutRef.current = setTimeout(async () => {
// Pull Tags from Cache
const query = formatTagInput(inputQuery)
if (!query) return
if (SEARCH_CACHE.has(query)) {
setInputSelect(0)
setTagsAvailable(selectDedupe(SEARCH_CACHE.get(query) ?? []))
return
}
// Pull Tags from API
const resp = await BackendFetch<BackendTag[]>(
`/tags/autocomplete?limit=${SEARCH_LIMIT - (allowCustom ? 1 : 0)}&query=${query}`,
)
if (!resp.success) {
console.error('Autocomplete error:', resp)
return
}
// Store Results
if (resp.json.length) {
SEARCH_CACHE.set(query, resp.json)
}
if (allowCustom && !resp.json.find((t) => t.label == query)) {
resp.json.unshift({ id: 'CUSTOM', label: query, usage: 0 })
}
setTagsAvailable(selectDedupe(resp.json))
setInputSelect(0)
}, 200)
}, [inputQuery])
// Append a tag to the currently selected
function selectAppendTag(tag: BackendTag) {
const next = [...tagsSelected, tag]
setTagsSelected(next)
setTagsAvailable([])
setInputQuery('')
onChange?.(next)
}
// Remove a tag from the currently selected
function selectRemoveTag(tag: BackendTag) {
const next = tagsSelected.filter((t) => t.id !== tag.id)
setTagsSelected(next)
onChange?.(next)
}
// Remove currently selected tags from the available list
function selectDedupe(list: BackendTag[]) {
return list.filter((t) => !tagsSelected.some((s) => s.label === t.label))
}
function handleInputKeyDown(ev: KeyboardEvent<HTMLInputElement>) {
// Remove latest tag
if (ev.code === 'Backspace' && !inputQuery.length) {
ev.preventDefault()
const last = tagsSelected.at(-1)
if (last) selectRemoveTag(last)
return
}
// Append selected tag
if (ev.code === 'Enter' && tagsAvailable.length && !Number.isNaN(indexHighlight)) {
ev.preventDefault()
selectAppendTag(tagsAvailable[indexHighlight])
return
}
if (ev.code === 'Space' && inputQuery.at(-1) === ' ' && tagsAvailable.length && !Number.isNaN(indexHighlight)) {
ev.preventDefault()
selectAppendTag(tagsAvailable[indexHighlight])
return
}
// Move Highlight
if (ev.code === 'ArrowUp') {
ev.preventDefault()
setInputSelect(inputSelect - 1)
return
}
if (ev.code === 'ArrowDown') {
ev.preventDefault()
setInputSelect(inputSelect + 1)
return
}
}
return (
<>
{label && <InputLabel for={componentID} label={label} />}
<div className="input-tags" onClick={() => inputRef.current?.focus()}>
{/* Tag Search */}
<div className="search">
{tagsSelected.map((tag) => (
<button className="item" onClick={() => selectRemoveTag(tag)}>
{formatTagTextContent(tag.label)}
</button>
))}
<input
id={componentID}
ref={inputRef}
type="text"
className="query"
placeholder={tagsSelected.length ? '' : 'Search'}
onKeyDown={handleInputKeyDown}
onChange={(e) => setInputQuery(e.currentTarget.value)}
value={inputQuery}
/>
</div>
{/* Tag Results */}
<div className="results">
{tagsAvailable.map((tag, i) => {
const classSelect = i === indexHighlight ? 'select' : ''
const classCustom = tag.id === 'CUSTOM' ? 'custom' : ''
return (
<>
<button
className={`item ${classSelect} ${classCustom}`}
onClick={() => selectAppendTag(tag)}>
<span className="label animation-scroll-in">{formatTagTextContent(tag.label)}</span>
<span className="usage animation-fade-in">
{classCustom ? '<CREATE>' : formatTagUsage(tag.usage)}
</span>
</button>
</>
)
})}
</div>
</div>
</>
)
})
export default InputTags
@@ -0,0 +1,31 @@
import { forwardRef, useId, useImperativeHandle, useRef } from 'react'
import InputLabel from './Label'
import './styles/Label.css'
import './styles/Text.css'
export interface HandleForInputText {
getValue(): string
}
interface PropsForInputTags {
label: string
placeholder: string
}
const InputText = forwardRef<HandleForInputText, PropsForInputTags>(({ label, placeholder }, ref) => {
const componentID = useId()
const inputRef = useRef<HTMLInputElement>(null)
useImperativeHandle(ref, () => ({
getValue: () => inputRef.current?.value ?? '',
}))
return (
<>
{label && <InputLabel for={componentID} label={label} />}
<input id={componentID} ref={inputRef} type="text" className="input-text" placeholder={placeholder} />
</>
)
})
export default InputText
@@ -0,0 +1,9 @@
a.input-back {
margin-bottom: 16px;
text-decoration: none;
}
a.input-back:hover,
a.input-back:focus-visible {
text-decoration: underline;
}
@@ -0,0 +1,54 @@
button.input-button {
cursor: pointer;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 8px;
width: 100%;
}
button.input-button.selected {
background-color: var(--background-primary);
color: var(--font-color-primary);
}
button.input-button:disabled {
cursor: not-allowed;
}
button.input-button:not(.selected):not(.rainbow):disabled {
opacity: 0.5;
color: var(--font-color-secondary);
}
button.input-button:not(:disabled):hover,
button.input-button:not(:disabled):focus-visible {
border-color: var(--background-highlight);
}
button.input-button.rainbow {
position: relative;
animation: rainbow-border 1s linear infinite;
border: 2px solid transparent;
background-clip: padding-box;
}
button.input-button.rainbow::before {
position: absolute;
z-index: -1;
animation: input-button-rainbow-shift 1s linear infinite;
inset: -2px;
border-radius: inherit;
background: linear-gradient(90deg, red, orange, yellow, green, cyan, blue, violet, red);
background-size: 200%;
content: '';
}
@keyframes input-button-rainbow-shift {
from {
background-position: 0%;
}
to {
background-position: 200%;
}
}
@@ -0,0 +1,10 @@
div.input-button-line.row {
display: inline-flex;
width: 100%;
}
div.input-button-line.split {
display: inline-flex;
gap: 8px;
width: 100%;
}
@@ -0,0 +1,4 @@
p.input-description {
padding-bottom: 12px;
color: var(--font-color-secondary);
}
@@ -0,0 +1,56 @@
div.input-file {
cursor: pointer;
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
aspect-ratio: 21 / 9;
width: 100%;
height: 100%;
}
div.input-file input[type='file'] {
display: none;
}
div.input-file div.prompt {
display: grid;
align-content: center;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
}
div.input-file div.prompt img.icon {
margin: 16px;
width: 32px;
height: 32px;
}
div.input-file div.prompt span.hint {
font-size: large;
}
div.input-file div.prompt span.header {
text-align: center;
}
div.input-file div.prompt span.subheader {
color: var(--font-color-secondary);
font-size: small;
text-align: center;
}
div.input-file div.preview {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
}
div.input-file div.preview img,
div.input-file div.preview video {
max-width: 100%;
height: 100%;
object-fit: contain;
}
@@ -0,0 +1,7 @@
label.input-label:first-child {
padding-top: 0;
}
label.input-label {
padding-top: 12px;
padding-bottom: 8px;
}
@@ -0,0 +1,96 @@
div.input-tags {
position: relative;
z-index: 999;
height: fit-content;
}
div.input-tags div.search {
display: flex;
flex-wrap: wrap;
gap: 4px;
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
border-bottom: none;
background-color: var(--background-tertiary);
padding: 8px;
width: 100%;
height: fit-content;
}
div.input-tags div.search input.query {
border: none;
caret-color: var(--font-color-primary);
field-sizing: content;
}
div.input-tags div.search button.item {
cursor: pointer;
box-sizing: border-box;
background-color: var(--background-secondary);
padding: 0 4px;
}
div.input-tags div.search button.item:focus-visible,
div.input-tags div.search button.item:hover {
color: var(--font-color-accent);
}
/* Tag Search Results */
div.input-tags div.results {
display: grid;
position: absolute;
top: 100%;
left: 0;
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
border-right-width: 0;
border-bottom-width: 0;
border-left-width: 0;
background-color: var(--background-tertiary);
width: 100%;
}
div.input-tags div.results:has(button) {
border-right-width: 1px;
border-bottom-width: 1px;
/* give the search results a border but not a chin while empty */
border-left-width: 1px;
}
div.input-tags div.results button.item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
cursor: pointer;
padding: 8px;
width: 100%;
height: 32px;
line-height: 32px;
text-align: left;
}
div.input-tags div.results button.item span.label {
text-align: left;
}
div.input-tags div.results button.item:focus-visible,
div.input-tags div.results button.item:hover,
div.input-tags div.results button.item.select {
background-color: var(--background-primary);
}
div.input-tags div.results button.item span.usage {
color: var(--font-color-secondary);
}
div.input-tags div.search input.query,
div.input-tags div.search button.item,
div.input-tags div.search button.item {
/* prevent input from overflowing */
width: fit-content;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,7 @@
input.input-text {
box-sizing: border-box;
border: var(--border-thickness) solid var(--background-primary);
background-color: var(--background-tertiary);
padding: 8px;
width: 100%;
}