rc-1
This commit is contained in:
@@ -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%;
|
||||
}
|
||||
Reference in New Issue
Block a user