pl-fe: wip alt text modal

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk
2025-03-24 00:41:40 +01:00
parent d8e11d3d69
commit 5529d297f6
8 changed files with 206 additions and 84 deletions

View File

@ -529,11 +529,11 @@ const uploadComposeFail = (composeId: string, error: unknown) => ({
const changeUploadCompose = (composeId: string, mediaId: string, params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return;
if (!isLoggedIn(getState)) return Promise.resolve();
dispatch(changeUploadComposeRequest(composeId));
dispatch(updateMedia(mediaId, params)).then(response => {
return dispatch(updateMedia(mediaId, params)).then(response => {
dispatch(changeUploadComposeSuccess(composeId, response));
}).catch(error => {
dispatch(changeUploadComposeFail(composeId, mediaId, error));

View File

@ -8,7 +8,7 @@ import { getTextDirection } from 'pl-fe/utils/rtl';
import Stack from './stack';
import Text from './text';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'id' | 'maxLength' | 'onChange' | 'onClick' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'className' | 'id' | 'lang' | 'maxLength' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean;
/** Allows the textarea height to grow while typing */
@ -52,6 +52,7 @@ const Textarea = React.forwardRef(({
theme = 'default',
maxLength,
value,
className,
...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const length = value?.length || 0;
@ -98,7 +99,7 @@ const Textarea = React.forwardRef(({
'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable,
})}
}, className)}
dir={value?.length ? getTextDirection(value, { fallback: direction }) : undefined}
/>

View File

@ -4,11 +4,12 @@ import fileSpreadsheetIcon from '@tabler/icons/outline/file-spreadsheet.svg';
import fileTextIcon from '@tabler/icons/outline/file-text.svg';
import fileZipIcon from '@tabler/icons/outline/file-zip.svg';
import defaultIcon from '@tabler/icons/outline/paperclip.svg';
import editIcon from '@tabler/icons/outline/pencil.svg';
import presentationIcon from '@tabler/icons/outline/presentation.svg';
import xIcon from '@tabler/icons/outline/x.svg';
import zoomInIcon from '@tabler/icons/outline/zoom-in.svg';
import clsx from 'clsx';
import React, { useState } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { spring } from 'react-motion';
@ -67,14 +68,13 @@ interface IUpload extends Pick<React.HTMLAttributes<HTMLDivElement>, 'onDragStar
media: MediaAttachment;
onSubmit?(): void;
onDelete?(): void;
onDescriptionChange?(description: string): void;
onDescriptionChange?(description: string): Promise<void>;
descriptionLimit?: number;
withPreview?: boolean;
}
const Upload: React.FC<IUpload> = ({
media,
onSubmit,
onDelete,
onDescriptionChange,
onDragStart,
@ -86,17 +86,6 @@ const Upload: React.FC<IUpload> = ({
const intl = useIntl();
const { openModal } = useModalsStore();
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleInputBlur();
onSubmit();
}
};
const handleUndoClick: React.MouseEventHandler = e => {
if (onDelete) {
e.stopPropagation();
@ -104,41 +93,25 @@ const Upload: React.FC<IUpload> = ({
}
};
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
setDirtyDescription(e.target.value);
};
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleInputFocus = () => {
setFocused(true);
};
const handleClick = () => {
setFocused(true);
};
const handleInputBlur = () => {
setFocused(false);
setDirtyDescription(null);
if (dirtyDescription !== null && onDescriptionChange) {
onDescriptionChange(dirtyDescription);
}
};
const handleOpenModal = () => {
openModal('MEDIA', { media: [media], index: 0 });
};
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && media.description) || '';
const handleOpenAltTextModal = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!onDescriptionChange) return;
openModal('ALT_TEXT', {
media,
previousDescription: media.description,
descriptionLimit: descriptionLimit!,
onSubmit: (newDescription: string) => onDescriptionChange(newDescription),
});
};
const description = media.description;
const focusX = media.type === 'image' && media.meta?.focus?.x || undefined;
const focusY = media.type === 'image' && media.meta?.focus?.y || undefined;
const x = focusX ? ((focusX / 2) + .5) * 100 : undefined;
@ -157,9 +130,6 @@ const Upload: React.FC<IUpload> = ({
<div
className='relative m-[5px] min-w-[40%] flex-1 overflow-hidden rounded'
tabIndex={0}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
role='button'
draggable
onDragStart={onDragStart}
@ -178,6 +148,16 @@ const Upload: React.FC<IUpload> = ({
}}
>
<HStack className='absolute right-2 top-2 z-10' space={2}>
{onDescriptionChange && (
<IconButton
onClick={handleOpenAltTextModal}
src={editIcon}
theme='dark'
className='hover:scale-105 hover:bg-gray-900'
iconClassName='h-5 w-5'
title={intl.formatMessage(messages.description)}
/>
)}
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
@ -200,39 +180,20 @@ const Upload: React.FC<IUpload> = ({
)}
</HStack>
{onDescriptionChange && (
<div
className={clsx('absolute inset-x-0 bottom-0 z-[2px] bg-gradient-to-b from-transparent via-gray-900/50 to-gray-900/80 p-2.5 opacity-0 transition-opacity duration-100 ease-linear', {
'opacity-100': active,
})}
>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<HStack space={2} justifyContent='between' className='absolute inset-x-2 bottom-2 z-10'>
<span className='overflow-hidden text-ellipsis rounded bg-gray-900 px-2 py-1 text-xs font-medium text-white'>
{media.url.split('/').at(-1)}
</span>
<textarea
className='m-0 w-full rounded-md border border-solid border-white/25 bg-transparent p-2.5 text-sm text-white placeholder:text-white/60'
placeholder={intl.formatMessage(messages.description)}
value={description}
maxLength={descriptionLimit}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
{onDescriptionChange && !description && (
<button onClick={handleOpenAltTextModal}>
<AltIndicator
warning
title={intl.formatMessage(messages.descriptionMissingTitle)}
/>
</label>
</div>
)}
{!description && (
<AltIndicator
warning
title={intl.formatMessage(messages.descriptionMissingTitle)}
className={clsx('absolute bottom-2 left-2 z-10 transition-opacity duration-100 ease-linear', {
'opacity-0 pointer-events-none': active,
'opacity-100': !active,
})}
/>
)}
</button>
)}
</HStack>
<div className='absolute inset-0 z-[-1] size-full'>
{mediaType === 'video' && (

View File

@ -22,7 +22,7 @@ const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id, onSubmit, onDr
const media = useCompose(composeId).media_attachments.find(item => item.id === id)!;
const handleDescriptionChange = (description: string) => {
dispatch(changeUploadCompose(composeId, media.id, { description }));
return dispatch(changeUploadCompose(composeId, media.id, { description }));
};
const handleDelete = () => {

View File

@ -10,6 +10,7 @@ import ModalLoading from './modal-loading';
/* eslint sort-keys: "error" */
const MODAL_COMPONENTS = {
ACCOUNT_MODERATION: lazy(() => import('pl-fe/features/ui/components/modals/account-moderation-modal')),
ALT_TEXT: lazy(() => import('pl-fe/features/ui/components/modals/alt-text-modal')),
BIRTHDAYS: lazy(() => import('pl-fe/features/ui/components/modals/birthdays-modal')),
BOOST: lazy(() => import('pl-fe/features/ui/components/modals/boost-modal')),
COMPARE_HISTORY: lazy(() => import('pl-fe/features/ui/components/modals/compare-history-modal')),

View File

@ -0,0 +1,152 @@
import defaultIcon from '@tabler/icons/outline/paperclip.svg';
import React, { useCallback, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Blurhash from 'pl-fe/components/blurhash';
import FormGroup from 'pl-fe/components/ui/form-group';
import Icon from 'pl-fe/components/ui/icon';
import Modal from 'pl-fe/components/ui/modal';
import Stack from 'pl-fe/components/ui/stack';
import Textarea from 'pl-fe/components/ui/textarea';
import { MIMETYPE_ICONS } from 'pl-fe/components/upload';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useCompose } from 'pl-fe/hooks/use-compose';
import toast from 'pl-fe/toast';
import type { BaseModalProps } from '../modal-root';
import type { MediaAttachment } from 'pl-api';
const messages = defineMessages({
placeholderVisual: {
id: 'alt_text_modal.describe_for_people_with_visual_impairments',
defaultMessage: 'Describe this for people with visual impairments…',
},
placeholderHearing: {
id: 'alt_text_modal.describe_for_people_with_hearing_impairments',
defaultMessage: 'Describe this for people with hearing impairments…',
},
savingFailed: {
id: 'alt_text_modal.saving_failed',
defaultMessage: 'Failed to save alt text',
},
});
interface AltTextModalProps {
composeId?: string;
previousDescription: string;
descriptionLimit: number;
media: MediaAttachment;
onSubmit: (description: string) => Promise<void>;
}
const AltTextModal: React.FC<BaseModalProps & AltTextModalProps> = ({ composeId, media, onClose, onSubmit, previousDescription, descriptionLimit }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { language } = useCompose(composeId || 'default');
const [description, setDescription] = useState(media.description || '');
const [isSaving, setIsSaving] = useState(false);
const dirtyRef = useRef(previousDescription ? true : false);
const uploadIcon = media.type === 'unknown' && (
<Icon
className='mx-auto my-12 size-16 text-gray-800 dark:text-gray-200'
src={MIMETYPE_ICONS[media.mime_type || ''] || defaultIcon}
/>
);
const handleDescriptionChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(e.target.value);
dirtyRef.current = true;
},
[setDescription],
);
const handleSubmit = useCallback(() => {
setIsSaving(true);
onSubmit(description).then(() => {
setIsSaving(false);
dirtyRef.current = false;
onClose();
return '';
}).catch((err: unknown) => {
setIsSaving(false);
toast.error(messages.savingFailed);
});
}, [dispatch, setIsSaving, media.id, onClose, description]);
const handleKeyUp = useCallback((e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
}
}, [handleSubmit]);
const handleSave = () => {
handleSubmit();
// onClose();
};
return (
<Modal
title={<FormattedMessage id='alt_text_modal.header' defaultMessage='Add alt text' />}
// onClose={onClickClose}
confirmationAction={handleSave}
confirmationText={<FormattedMessage id='alt_text_modal.confirmation' defaultMessage='Save' />}
confirmationDisabled={isSaving}
>
<Stack space={2}>
<div className='relative overflow-hidden rounded-md'>
<Blurhash hash={media.blurhash} className='media-gallery__preview' />
<div
className='relative h-40 w-full overflow-hidden bg-contain bg-center bg-no-repeat'
style={{
backgroundImage: media.type === 'image' ? `url(${media.preview_url})` : undefined,
}}
>
<div className='absolute inset-0 size-full'>
{media.type === 'video' && (
<video className='size-full object-cover' autoPlay playsInline muted loop>
<source src={media.preview_url} />
</video>
)}
{uploadIcon}
</div>
<span className='absolute inset-x-2 bottom-2 w-fit overflow-hidden text-ellipsis rounded bg-gray-900 px-2 py-1 text-xs font-medium text-white'>
{media.url.split('/').at(-1)}
</span>
</div>
</div>
<form>
<FormGroup
labelText={intl.formatMessage(
media.type === 'audio'
? messages.placeholderHearing
: messages.placeholderVisual,
)}
>
<Textarea
className=''
value={description}
maxLength={descriptionLimit}
onChange={handleDescriptionChange}
onKeyUp={handleKeyUp}
lang={language || undefined}
minRows={3}
placeholder={intl.formatMessage(
media.type === 'audio'
? messages.placeholderHearing
: messages.placeholderVisual,
)}
disabled={isSaving}
/>
</FormGroup>
</form>
</Stack>
</Modal>
);
};
export { type AltTextModalProps, AltTextModal as default };

View File

@ -211,6 +211,11 @@
"aliases.search": "Search your old account",
"aliases.success.add": "Account alias created successfully",
"aliases.success.remove": "Account alias removed successfully",
"alt_text_modal.confirmation": "Save",
"alt_text_modal.describe_for_people_with_hearing_impairments": "Describe this for people with hearing impairments…",
"alt_text_modal.describe_for_people_with_visual_impairments": "Describe this for people with visual impairments…",
"alt_text_modal.header": "Add alt text",
"alt_text_modal.saving_failed": "Failed to save alt text",
"announcements.title": "Announcements",
"app_create.name_label": "App name",
"app_create.name_placeholder": "e.g. 'pl-fe'",

View File

@ -6,6 +6,7 @@ import { MuteModalProps } from 'pl-fe/features/ui/components/modals/mute-modal';
import type { ICryptoAddress } from 'pl-fe/features/crypto-donate/components/crypto-address';
import type { ModalType } from 'pl-fe/features/ui/components/modal-root';
import type { AccountModerationModalProps } from 'pl-fe/features/ui/components/modals/account-moderation-modal';
import type { AltTextModalProps } from 'pl-fe/features/ui/components/modals/alt-text-modal';
import type { BoostModalProps } from 'pl-fe/features/ui/components/modals/boost-modal';
import type { CompareHistoryModalProps } from 'pl-fe/features/ui/components/modals/compare-history-modal';
import type { ComponentModalProps } from 'pl-fe/features/ui/components/modals/component-modal';
@ -41,6 +42,7 @@ import type { VideoModalProps } from 'pl-fe/features/ui/components/modals/video-
type OpenModalProps =
| [type: 'ACCOUNT_MODERATION', props: AccountModerationModalProps]
| [type: 'ALT_TEXT', props: AltTextModalProps]
| [type: 'BIRTHDAYS' | 'CREATE_GROUP' | 'HOTKEYS']
| [type: 'BOOST', props: BoostModalProps]
| [type: 'COMPARE_HISTORY', props: CompareHistoryModalProps]