Files
omnivore/packages/web/components/templates/article/AddBulkLabelsModal.tsx
2024-08-12 11:09:56 +08:00

263 lines
7.7 KiB
TypeScript

import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import {
ModalRoot,
ModalOverlay,
ModalContent,
ModalTitleBar,
} from '../../elements/ModalPrimitives'
import { SetLabelsControl } from './SetLabelsControl'
import { showSuccessToast } from '../../../lib/toastHelpers'
import { v4 as uuidv4 } from 'uuid'
import { randomLabelColorHex } from '../../../utils/settings-page/labels/labelColorObjects'
import { LabelAction } from '../../../lib/hooks/useSetPageLabels'
import { Button } from '../../elements/Button'
import {
useCreateLabel,
useGetLabels,
} from '../../../lib/networking/labels/useLabels'
type AddBulkLabelsModalProps = {
onOpenChange: (open: boolean) => void
bulkSetLabels: (labels: Label[]) => void
}
export function AddBulkLabelsModal(
props: AddBulkLabelsModalProps
): JSX.Element {
const { data: availableLabels } = useGetLabels()
const createLabel = useCreateLabel()
const [tabCount, setTabCount] = useState(-1)
const [inputValue, setInputValue] = useState('')
const [tabStartValue, setTabStartValue] = useState('')
const [errorMessage, setErrorMessage] = useState<string | undefined>(
undefined
)
const errorTimeoutRef = useRef<NodeJS.Timeout | undefined>()
const [highlightLastLabel, setHighlightLastLabel] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const labelsReducer = (
state: {
labels: Label[]
},
action: {
type: LabelAction
labels: Label[]
}
) => {
return {
labels: action.labels,
}
}
const [selectedLabels, dispatchLabels] = useReducer(labelsReducer, {
labels: [],
})
const showMessage = useCallback(
(msg: string, timeout?: number) => {
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = undefined
}
setErrorMessage(msg)
if (timeout) {
errorTimeoutRef.current = setTimeout(() => {
setErrorMessage(undefined)
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = undefined
}
}, timeout)
}
},
[errorTimeoutRef]
)
useEffect(() => {
const maxLengthMessage = 'Max label length: 48 chars'
if (inputValue.length >= 48) {
showMessage(maxLengthMessage)
} else if (errorMessage === maxLengthMessage) {
setErrorMessage(undefined)
}
if (inputValue.length > 0) {
setHighlightLastLabel(false)
}
}, [errorMessage, inputValue, showMessage])
const clearInputState = useCallback(() => {
setTabCount(-1)
setInputValue('')
setTabStartValue('')
setHighlightLastLabel(false)
}, [])
const createLabelAsync = useCallback(
(newLabels: Label[], tempLabel: Label) => {
;(async () => {
const currentLabels = newLabels
const newLabel = await createLabel.mutateAsync({
name: tempLabel.name,
color: tempLabel.color,
description: undefined,
})
const idx = currentLabels.findIndex((l) => l.id === tempLabel.id)
if (newLabel) {
showSuccessToast(`Created label ${newLabel.name}`, {
position: 'bottom-right',
})
if (idx !== -1) {
currentLabels[idx] = newLabel
dispatchLabels({ type: 'SAVE', labels: [...currentLabels] })
} else {
dispatchLabels({
type: 'SAVE',
labels: [...currentLabels, newLabel],
})
}
} else {
showMessage(`Error creating label ${tempLabel.name}`, 5000)
if (idx !== -1) {
currentLabels.splice(idx, 1)
dispatchLabels({ type: 'SAVE', labels: [...currentLabels] })
}
}
})()
},
[dispatchLabels, showMessage]
)
const selectOrCreateLabel = useCallback(
(value: string) => {
const trimmedValue = value.trim()
const current = selectedLabels.labels ?? []
const lowerCasedValue = trimmedValue.toLowerCase()
const existing = availableLabels?.find(
(l) => l.name.toLowerCase() == lowerCasedValue
)
if (lowerCasedValue.length < 1) {
return
}
if (existing) {
const isAdded = selectedLabels.labels.find(
(l) => l.name.toLowerCase() == lowerCasedValue
)
if (!isAdded) {
dispatchLabels({ type: 'SAVE', labels: [...current, existing] })
clearInputState()
} else {
showMessage(`label ${trimmedValue} already added.`, 5000)
}
} else {
const tempLabel = {
id: uuidv4(),
name: trimmedValue,
color: randomLabelColorHex(),
description: '',
createdAt: new Date(),
}
const newLabels = [...current, tempLabel]
dispatchLabels({ type: 'TEMP', labels: newLabels })
clearInputState()
createLabelAsync(newLabels, tempLabel)
}
},
[
availableLabels,
selectedLabels,
dispatchLabels,
clearInputState,
createLabelAsync,
showMessage,
]
)
const deleteLastLabel = useCallback(() => {
if (highlightLastLabel) {
const current = selectedLabels.labels
current.pop()
dispatchLabels({ type: 'SAVE', labels: [...current] })
setHighlightLastLabel(false)
} else {
setHighlightLastLabel(true)
}
}, [highlightLastLabel, selectedLabels, dispatchLabels])
const handleSave = useCallback(() => {
props.bulkSetLabels(selectedLabels.labels)
props.onOpenChange(true)
}, [selectedLabels])
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
tabIndex={0}
css={{
border: '1px solid $grayBorder',
backgroundColor: '$thBackground',
}}
onPointerDownOutside={(event) => {
event.preventDefault()
props.onOpenChange(false)
}}
onEscapeKeyDown={(event) => {
props.onOpenChange(false)
event.preventDefault()
}}
>
<VStack distribution="start" css={{ height: '100%' }}>
<SpanBox css={{ pt: '0px', px: '16px', width: '100%' }}>
<ModalTitleBar
title="Add labels to selected items"
onOpenChange={props.onOpenChange}
/>
</SpanBox>
<SetLabelsControl
inputValue={inputValue}
setInputValue={setInputValue}
clearInputState={clearInputState}
selectedLabels={selectedLabels.labels}
dispatchLabels={dispatchLabels}
tabCount={tabCount}
setTabCount={setTabCount}
tabStartValue={tabStartValue}
setTabStartValue={setTabStartValue}
highlightLastLabel={highlightLastLabel}
setHighlightLastLabel={setHighlightLastLabel}
deleteLastLabel={deleteLastLabel}
selectOrCreateLabel={selectOrCreateLabel}
errorMessage={errorMessage}
footer={
<HStack
distribution="end"
css={{ width: '100%', pb: '20px', px: '20px' }}
>
<Button
disabled={isSaving}
style="ctaDarkYellow"
onClick={(event) => {
handleSave()
event.preventDefault()
}}
css={{ fontSize: '10px', p: '10px', fontFamily: '$inter' }}
>
Add Labels
</Button>
</HStack>
}
/>
</VStack>
</ModalContent>
</ModalRoot>
)
}