mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-31 11:59:10 +00:00
f3cd541ef0
A value which is not a link was discarded on blur without any feedback: the text stayed in the field, so it looked like it had been saved. We now mark the field and say what is expected. Paths served by ontime itself (eg. /user/slide.png) are now accepted, they were rejected before. An image which cannot be loaded showed a broken icon with no explanation. This is what a dropbox share link does, since it serves an html page rather than the image, so it is worth naming the problem. We also reserve the space of an image while it is loading, using the aspect ratio of the last time we saw it. Rows are unmounted while out of view, so without it the row collapses and grows again on the way back, shifting the table under the user. Smaller items in the same cell: the lazy loading attribute only added a gate before the request (the row is only mounted when it is already close to the viewport), the image had no alt text, and two expressions could never run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
import { memo, useState } from 'react';
|
|
|
|
import Button from '../../../../common/components/buttons/Button';
|
|
import Input from '../../../../common/components/input/input/Input';
|
|
import { getRememberedAspectRatio, rememberAspectRatio } from '../../../../common/utils/imageDimensions';
|
|
|
|
import style from './EditableImage.module.scss';
|
|
|
|
interface EditableImageProps {
|
|
initialValue: string;
|
|
fieldLabel: string;
|
|
readOnly?: boolean;
|
|
updateValue: (newValue: string) => void;
|
|
}
|
|
|
|
export default memo(EditableImage);
|
|
|
|
/**
|
|
* An image is either hosted somewhere else
|
|
* or served by ontime itself (eg. a file placed in the user folder)
|
|
*/
|
|
export function isValidImageSource(value: string): boolean {
|
|
return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/');
|
|
}
|
|
|
|
function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: EditableImageProps) {
|
|
const [isRejected, setIsRejected] = useState(false);
|
|
/** we keep track of the source itself, so that the state follows the value being shown */
|
|
const [failedSource, setFailedSource] = useState<string | null>(null);
|
|
const [loadedSource, setLoadedSource] = useState<string | null>(null);
|
|
|
|
const handleUpdate = (newValue: string) => {
|
|
const value = newValue.trim();
|
|
|
|
if (value === initialValue) {
|
|
setIsRejected(false);
|
|
return;
|
|
}
|
|
|
|
if (value !== '' && !isValidImageSource(value)) {
|
|
setIsRejected(true);
|
|
return;
|
|
}
|
|
|
|
setIsRejected(false);
|
|
updateValue(value);
|
|
};
|
|
|
|
const openInNewTab = () => {
|
|
if (initialValue) {
|
|
window.open(initialValue, '_blank', 'noopener,noreferrer');
|
|
}
|
|
};
|
|
|
|
if (!initialValue && readOnly) {
|
|
return null;
|
|
}
|
|
|
|
if (!initialValue) {
|
|
return (
|
|
<>
|
|
<Input
|
|
variant='ghosted'
|
|
className={style.imageInput}
|
|
fluid
|
|
placeholder='Paste image URL'
|
|
data-invalid={isRejected || undefined}
|
|
onChange={() => setIsRejected(false)}
|
|
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
handleUpdate(event.currentTarget.value);
|
|
}
|
|
}}
|
|
/>
|
|
{isRejected && <span className={style.message}>Use a link (https://...) or a file in ontime (/user/...)</span>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
|
|
* When the row comes back, we reserve the space the image took
|
|
* so that the table does not shift while the browser makes it available.
|
|
*/
|
|
const knownAspectRatio = getRememberedAspectRatio(initialValue);
|
|
const isLoaded = loadedSource === initialValue;
|
|
|
|
return (
|
|
<div className={style.imageCell}>
|
|
{!readOnly && (
|
|
<div className={style.overlay}>
|
|
<Button onClick={openInNewTab}>Preview</Button>
|
|
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{failedSource === initialValue ? (
|
|
<span className={style.message}>Could not load image</span>
|
|
) : (
|
|
<img
|
|
src={initialValue}
|
|
alt={fieldLabel}
|
|
className={style.image}
|
|
onLoad={(event) => {
|
|
rememberAspectRatio(initialValue, event.currentTarget);
|
|
setLoadedSource(initialValue);
|
|
}}
|
|
onError={() => setFailedSource(initialValue)}
|
|
/** until the image is available, we reserve the space it took the last time we saw it */
|
|
style={!isLoaded && knownAspectRatio !== null ? { aspectRatio: knownAspectRatio, width: '100%' } : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|