mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
[Glitch] Profile editing: Uploading avatar and header images
Port 21c27eb3af to glitch-soc
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
@@ -273,3 +273,7 @@ export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
|||||||
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
||||||
'compose/setQuotePolicy',
|
'compose/setQuotePolicy',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const setDragUploadEnabled = createAction<boolean>(
|
||||||
|
'compose/setDragUploadEnabled',
|
||||||
|
);
|
||||||
|
|||||||
@@ -67,5 +67,5 @@ export const apiGetFamiliarFollowers = (id: string) =>
|
|||||||
|
|
||||||
export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
|
export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
|
||||||
|
|
||||||
export const apiPatchProfile = (params: ApiProfileUpdateParams) =>
|
export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) =>
|
||||||
apiRequestPatch<ApiProfileJSON>('v1/profile', params);
|
apiRequestPatch<ApiProfileJSON>('v1/profile', params);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const CharacterCounter = polymorphicForwardRef<
|
|||||||
maxLength,
|
maxLength,
|
||||||
as: Component = 'span',
|
as: Component = 'span',
|
||||||
recommended = false,
|
recommended = false,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -39,6 +40,7 @@ export const CharacterCounter = polymorphicForwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
className,
|
||||||
classes.counter,
|
classes.counter,
|
||||||
currentLength > maxLength && !recommended && classes.counterError,
|
currentLength > maxLength && !recommended && classes.counterError,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ import { openModal } from '@/flavours/glitch/actions/modal';
|
|||||||
import { Dropdown } from '@/flavours/glitch/components/dropdown_menu';
|
import { Dropdown } from '@/flavours/glitch/components/dropdown_menu';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu';
|
||||||
import {
|
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||||
createAppSelector,
|
import { selectImageInfo } from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||||
useAppDispatch,
|
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||||
useAppSelector,
|
|
||||||
} from '@/flavours/glitch/store';
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
@@ -50,36 +48,15 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ImageLocation = 'avatar' | 'header';
|
|
||||||
|
|
||||||
const selectImageInfo = createAppSelector(
|
|
||||||
[
|
|
||||||
(state) => state.profileEdit.profile,
|
|
||||||
(_, location: ImageLocation) => location,
|
|
||||||
],
|
|
||||||
(profile, location) => {
|
|
||||||
if (!profile) {
|
|
||||||
return {
|
|
||||||
hasImage: false,
|
|
||||||
hasAlt: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasImage: !!profile[`${location}Static`],
|
|
||||||
hasAlt: !!profile[`${location}Description`],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const AccountImageEdit: FC<{
|
export const AccountImageEdit: FC<{
|
||||||
className?: string;
|
className?: string;
|
||||||
location: ImageLocation;
|
location: ImageLocation;
|
||||||
}> = ({ className, location }) => {
|
}> = ({ className, location }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { hasAlt, hasImage } = useAppSelector((state) =>
|
const { alt, src } = useAppSelector((state) =>
|
||||||
selectImageInfo(state, location),
|
selectImageInfo(state, location),
|
||||||
);
|
);
|
||||||
|
const hasAlt = !!alt;
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const handleModal = useCallback(
|
const handleModal = useCallback(
|
||||||
@@ -125,7 +102,7 @@ export const AccountImageEdit: FC<{
|
|||||||
|
|
||||||
const iconClassName = classNames(classes.imageButton, className);
|
const iconClassName = classNames(classes.imageButton, className);
|
||||||
|
|
||||||
if (!hasImage) {
|
if (!src) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
title={intl.formatMessage(messages.add)}
|
title={intl.formatMessage(messages.add)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||||
|
|
||||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||||
import type { ImageLocation } from '../components/image_edit';
|
|
||||||
|
|
||||||
export const ImageAltModal: FC<
|
export const ImageAltModal: FC<
|
||||||
DialogModalProps & { location: ImageLocation }
|
DialogModalProps & { location: ImageLocation }
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||||
|
|
||||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||||
import type { ImageLocation } from '../components/image_edit';
|
|
||||||
|
|
||||||
export const ImageDeleteModal: FC<
|
export const ImageDeleteModal: FC<
|
||||||
DialogModalProps & { location: ImageLocation }
|
DialogModalProps & { location: ImageLocation }
|
||||||
|
|||||||
@@ -1,11 +1,487 @@
|
|||||||
import type { FC } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ChangeEventHandler, FC } from 'react';
|
||||||
|
|
||||||
|
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import type { Area } from 'react-easy-crop';
|
||||||
|
import Cropper from 'react-easy-crop';
|
||||||
|
|
||||||
|
import { setDragUploadEnabled } from '@/flavours/glitch/actions/compose_typed';
|
||||||
|
import { Button } from '@/flavours/glitch/components/button';
|
||||||
|
import { Callout } from '@/flavours/glitch/components/callout';
|
||||||
|
import { CharacterCounter } from '@/flavours/glitch/components/character_counter';
|
||||||
|
import { TextAreaField } from '@/flavours/glitch/components/form_fields';
|
||||||
|
import { RangeInput } from '@/flavours/glitch/components/form_fields/range_input_field';
|
||||||
|
import {
|
||||||
|
selectImageInfo,
|
||||||
|
uploadImage,
|
||||||
|
} from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||||
|
import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
||||||
|
|
||||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||||
import type { ImageLocation } from '../components/image_edit';
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
import 'react-easy-crop/react-easy-crop.css';
|
||||||
|
|
||||||
export const ImageUploadModal: FC<
|
export const ImageUploadModal: FC<
|
||||||
DialogModalProps & { location: ImageLocation }
|
DialogModalProps & { location: ImageLocation }
|
||||||
> = ({ onClose }) => {
|
> = ({ onClose, location }) => {
|
||||||
return <DialogModal title='TODO' onClose={onClose} />;
|
const { src: oldSrc } = useAppSelector((state) =>
|
||||||
|
selectImageInfo(state, location),
|
||||||
|
);
|
||||||
|
const hasImage = !!oldSrc;
|
||||||
|
const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select');
|
||||||
|
|
||||||
|
// State for individual steps.
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [imageBlob, setImageBlob] = useState<Blob | null>(null);
|
||||||
|
|
||||||
|
const handleFile = useCallback((file: File) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', () => {
|
||||||
|
const result = reader.result;
|
||||||
|
if (typeof result === 'string' && result.length > 0) {
|
||||||
|
setImageSrc(result);
|
||||||
|
setStep('crop');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCrop = useCallback(
|
||||||
|
(crop: Area) => {
|
||||||
|
if (!imageSrc) {
|
||||||
|
setStep('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void calculateCroppedImage(imageSrc, crop).then((blob) => {
|
||||||
|
setImageBlob(blob);
|
||||||
|
setStep('alt');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[imageSrc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleSave = useCallback(
|
||||||
|
(altText: string) => {
|
||||||
|
if (!imageBlob) {
|
||||||
|
setStep('crop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void dispatch(uploadImage({ location, imageBlob, altText })).then(
|
||||||
|
onClose,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, imageBlob, location, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
switch (step) {
|
||||||
|
case 'crop':
|
||||||
|
setImageSrc(null);
|
||||||
|
setStep('select');
|
||||||
|
break;
|
||||||
|
case 'alt':
|
||||||
|
setImageBlob(null);
|
||||||
|
setStep('crop');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose, step]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogModal
|
||||||
|
title={
|
||||||
|
hasImage ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.title_replace'
|
||||||
|
defaultMessage='Replace profile photo'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.title_add'
|
||||||
|
defaultMessage='Add profile photo'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
wrapperClassName={classes.uploadWrapper}
|
||||||
|
noCancelButton
|
||||||
|
>
|
||||||
|
{step === 'select' && (
|
||||||
|
<StepUpload location={location} onFile={handleFile} />
|
||||||
|
)}
|
||||||
|
{step === 'crop' && imageSrc && (
|
||||||
|
<StepCrop
|
||||||
|
src={imageSrc}
|
||||||
|
location={location}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onComplete={handleCrop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'alt' && imageBlob && (
|
||||||
|
<StepAlt
|
||||||
|
imageBlob={imageBlob}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onComplete={handleSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogModal>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Taken from app/models/concerns/account/header.rb and app/models/concerns/account/avatar.rb
|
||||||
|
const ALLOWED_MIME_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const StepUpload: FC<{
|
||||||
|
location: ImageLocation;
|
||||||
|
onFile: (file: File) => void;
|
||||||
|
}> = ({ location, onFile }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const handleUploadClick = useCallback(() => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
if (!file || !ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFile(file);
|
||||||
|
},
|
||||||
|
[onFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle drag and drop
|
||||||
|
const [isDragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!event.dataTransfer?.types.includes('Files')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(event.dataTransfer.items);
|
||||||
|
if (
|
||||||
|
!items.some(
|
||||||
|
(item) =>
|
||||||
|
item.kind === 'file' && ALLOWED_MIME_TYPES.includes(item.type),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragging(true);
|
||||||
|
}, []);
|
||||||
|
const handleDragDrop = useCallback(
|
||||||
|
(event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
|
||||||
|
if (!event.dataTransfer?.files) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = Array.from(event.dataTransfer.files).find((f) =>
|
||||||
|
ALLOWED_MIME_TYPES.includes(f.type),
|
||||||
|
);
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFile(file);
|
||||||
|
},
|
||||||
|
[onFile],
|
||||||
|
);
|
||||||
|
const handleDragLeave = useCallback((event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setDragUploadEnabled(false));
|
||||||
|
document.addEventListener('dragover', handleDragOver);
|
||||||
|
document.addEventListener('drop', handleDragDrop);
|
||||||
|
document.addEventListener('dragleave', handleDragLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dragover', handleDragOver);
|
||||||
|
document.removeEventListener('drop', handleDragDrop);
|
||||||
|
document.removeEventListener('dragleave', handleDragLeave);
|
||||||
|
dispatch(setDragUploadEnabled(true));
|
||||||
|
};
|
||||||
|
}, [handleDragLeave, handleDragDrop, handleDragOver, dispatch]);
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
return (
|
||||||
|
<div className={classes.uploadStepSelect}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_upload.dragging'
|
||||||
|
defaultMessage='Drop to upload'
|
||||||
|
tagName='h2'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.uploadStepSelect}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_upload.header'
|
||||||
|
defaultMessage='Choose an image'
|
||||||
|
tagName='h2'
|
||||||
|
/>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_upload.hint'
|
||||||
|
defaultMessage='WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.'
|
||||||
|
description='Guideline for avatar and header images.'
|
||||||
|
values={{
|
||||||
|
br: <br />,
|
||||||
|
limit: 8,
|
||||||
|
width: location === 'avatar' ? 400 : 1500,
|
||||||
|
height: location === 'avatar' ? 400 : 500,
|
||||||
|
}}
|
||||||
|
tagName='p'
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is the main input, so auto-focus on it.
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_upload.button'
|
||||||
|
defaultMessage='Browse files'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
hidden
|
||||||
|
type='file'
|
||||||
|
ref={inputRef}
|
||||||
|
accept={ALLOWED_MIME_TYPES.join(',')}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomLabel = defineMessage({
|
||||||
|
id: 'account_edit.upload_modal.step_crop.zoom',
|
||||||
|
defaultMessage: 'Zoom',
|
||||||
|
});
|
||||||
|
|
||||||
|
const StepCrop: FC<{
|
||||||
|
src: string;
|
||||||
|
location: ImageLocation;
|
||||||
|
onCancel: () => void;
|
||||||
|
onComplete: (crop: Area) => void;
|
||||||
|
}> = ({ src, location, onCancel, onComplete }) => {
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
|
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setZoom(event.currentTarget.valueAsNumber);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => {
|
||||||
|
setCroppedArea(croppedAreaPixels);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (croppedArea) {
|
||||||
|
onComplete(croppedArea);
|
||||||
|
}
|
||||||
|
}, [croppedArea, onComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={classes.cropContainer}>
|
||||||
|
<Cropper
|
||||||
|
image={src}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onCropComplete={handleCropComplete}
|
||||||
|
aspect={location === 'avatar' ? 1 : 3 / 1}
|
||||||
|
disableAutomaticStylesInjection
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.cropActions}>
|
||||||
|
<RangeInput
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.1}
|
||||||
|
value={zoom}
|
||||||
|
onChange={handleZoomChange}
|
||||||
|
className={classes.zoomControl}
|
||||||
|
aria-label={intl.formatMessage(zoomLabel)}
|
||||||
|
/>
|
||||||
|
<Button onClick={onCancel} secondary>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.back'
|
||||||
|
defaultMessage='Back'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNext} disabled={!croppedArea}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.next'
|
||||||
|
defaultMessage='Next'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StepAlt: FC<{
|
||||||
|
imageBlob: Blob;
|
||||||
|
onCancel: () => void;
|
||||||
|
onComplete: (altText: string) => void;
|
||||||
|
}> = ({ imageBlob, onCancel, onComplete }) => {
|
||||||
|
const [altText, setAltText] = useState('');
|
||||||
|
|
||||||
|
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setAltText(event.currentTarget.value);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleComplete = useCallback(() => {
|
||||||
|
onComplete(altText);
|
||||||
|
}, [altText, onComplete]);
|
||||||
|
|
||||||
|
const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]);
|
||||||
|
const altLimit = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.server.getIn(
|
||||||
|
['server', 'configuration', 'media_attachments', 'description_limit'],
|
||||||
|
150,
|
||||||
|
) as number,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img src={imageSrc} alt='' className={classes.altImage} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TextAreaField
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_alt.text_label'
|
||||||
|
defaultMessage='Alt text'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_alt.text_hint'
|
||||||
|
defaultMessage='E.g. “Close-up photo of me wearing glasses and a blue shirt”'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<CharacterCounter
|
||||||
|
currentString={altText}
|
||||||
|
maxLength={altLimit}
|
||||||
|
className={classes.altCounter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Callout
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_alt.callout_title'
|
||||||
|
defaultMessage='Let’s make Mastodon accessible for all'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.step_alt.callout_text'
|
||||||
|
defaultMessage='Adding alt text to media helps people using screen readers to understand your content.'
|
||||||
|
/>
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<div className={classes.cropActions}>
|
||||||
|
<Button onClick={onCancel} secondary>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.back'
|
||||||
|
defaultMessage='Back'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={handleComplete}>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account_edit.upload_modal.done'
|
||||||
|
defaultMessage='Done'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function calculateCroppedImage(
|
||||||
|
imageSrc: string,
|
||||||
|
crop: Area,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const image = await dataUriToImage(imageSrc);
|
||||||
|
const canvas = new OffscreenCanvas(crop.width, crop.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Failed to get canvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// Draw the image
|
||||||
|
ctx.drawImage(
|
||||||
|
image,
|
||||||
|
crop.x,
|
||||||
|
crop.y,
|
||||||
|
crop.width,
|
||||||
|
crop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
crop.width,
|
||||||
|
crop.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
return canvas.convertToBlob({
|
||||||
|
quality: 0.7,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUriToImage(dataUri: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.addEventListener('load', () => {
|
||||||
|
resolve(image);
|
||||||
|
});
|
||||||
|
image.addEventListener('error', (event) => {
|
||||||
|
if (event.error instanceof Error) {
|
||||||
|
reject(event.error);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
image.src = dataUri;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uploadWrapper {
|
||||||
|
min-height: min(400px, 70vh);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadStepSelect {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropContainer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropActions {
|
||||||
|
margin-top: 8px; // 16px gap from DialogModal, plus 8px = 24px to look like normal action buttons.
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.zoomControl {
|
||||||
|
width: min(100%, 200px);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.altImage {
|
||||||
|
max-height: 300px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.altCounter {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.verifiedSteps {
|
.verifiedSteps {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ const messages = defineMessages({
|
|||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
layout: state.getIn(['meta', 'layout']),
|
layout: state.getIn(['meta', 'layout']),
|
||||||
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
|
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
|
||||||
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
|
canUploadMore:
|
||||||
|
!state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type')))
|
||||||
|
&& state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']),
|
||||||
|
isUploadEnabled:
|
||||||
|
state.getIn(['compose', 'isDragDisabled']) !== true,
|
||||||
isWide: state.getIn(['local_settings', 'stretch']),
|
isWide: state.getIn(['local_settings', 'stretch']),
|
||||||
fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']),
|
fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']),
|
||||||
unreadNotifications: selectUnreadNotificationGroupsCount(state),
|
unreadNotifications: selectUnreadNotificationGroupsCount(state),
|
||||||
@@ -339,6 +343,9 @@ class UI extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleDragEnter = (e) => {
|
handleDragEnter = (e) => {
|
||||||
|
if (!this.props.isUploadEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!this.dragTargets) {
|
if (!this.dragTargets) {
|
||||||
@@ -355,6 +362,9 @@ class UI extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleDragOver = (e) => {
|
handleDragOver = (e) => {
|
||||||
|
if (!this.props.isUploadEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.dataTransferIsText(e.dataTransfer)) return false;
|
if (this.dataTransferIsText(e.dataTransfer)) return false;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -370,6 +380,9 @@ class UI extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleDrop = (e) => {
|
handleDrop = (e) => {
|
||||||
|
if (!this.props.isUploadEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.dataTransferIsText(e.dataTransfer)) return;
|
if (this.dataTransferIsText(e.dataTransfer)) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -442,7 +455,6 @@ class UI extends PureComponent {
|
|||||||
document.addEventListener('dragover', this.handleDragOver, false);
|
document.addEventListener('dragover', this.handleDragOver, false);
|
||||||
document.addEventListener('drop', this.handleDrop, false);
|
document.addEventListener('drop', this.handleDrop, false);
|
||||||
document.addEventListener('dragleave', this.handleDragLeave, false);
|
document.addEventListener('dragleave', this.handleDragLeave, false);
|
||||||
document.addEventListener('dragend', this.handleDragEnd, false);
|
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
|
navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
|
||||||
@@ -502,7 +514,6 @@ class UI extends PureComponent {
|
|||||||
document.removeEventListener('dragover', this.handleDragOver);
|
document.removeEventListener('dragover', this.handleDragOver);
|
||||||
document.removeEventListener('drop', this.handleDrop);
|
document.removeEventListener('drop', this.handleDrop);
|
||||||
document.removeEventListener('dragleave', this.handleDragLeave);
|
document.removeEventListener('dragleave', this.handleDragLeave);
|
||||||
document.removeEventListener('dragend', this.handleDragEnd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setRef = c => {
|
setRef = c => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
setComposeQuotePolicy,
|
setComposeQuotePolicy,
|
||||||
pasteLinkCompose,
|
pasteLinkCompose,
|
||||||
cancelPasteLinkCompose,
|
cancelPasteLinkCompose,
|
||||||
|
setDragUploadEnabled,
|
||||||
} from '@/flavours/glitch/actions/compose_typed';
|
} from '@/flavours/glitch/actions/compose_typed';
|
||||||
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
|
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ const initialState = ImmutableMap({
|
|||||||
is_submitting: false,
|
is_submitting: false,
|
||||||
is_changing_upload: false,
|
is_changing_upload: false,
|
||||||
is_uploading: false,
|
is_uploading: false,
|
||||||
|
isDragDisabled: false,
|
||||||
should_redirect_to_compose_page: false,
|
should_redirect_to_compose_page: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
isUploadingThumbnail: false,
|
isUploadingThumbnail: false,
|
||||||
@@ -184,6 +186,7 @@ function clearAll(state) {
|
|||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
map.set('quoted_status_id', null);
|
map.set('quoted_status_id', null);
|
||||||
map.set('quote_policy', state.get('default_quote_policy'));
|
map.set('quote_policy', state.get('default_quote_policy'));
|
||||||
|
map.set('isDragDisabled', false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +441,8 @@ export const composeReducer = (state = initialState, action) => {
|
|||||||
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
|
return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state;
|
||||||
} else if (cancelPasteLinkCompose.match(action)) {
|
} else if (cancelPasteLinkCompose.match(action)) {
|
||||||
return state.set('fetching_link', null);
|
return state.set('fetching_link', null);
|
||||||
|
} else if (setDragUploadEnabled.match(action)) {
|
||||||
|
return state.set('isDragDisabled', !action.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ const profileEditSlice = createSlice({
|
|||||||
state.isPending = false;
|
state.isPending = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.addCase(uploadImage.pending, (state) => {
|
||||||
|
state.isPending = true;
|
||||||
|
});
|
||||||
|
builder.addCase(uploadImage.rejected, (state) => {
|
||||||
|
state.isPending = false;
|
||||||
|
});
|
||||||
|
builder.addCase(uploadImage.fulfilled, (state, action) => {
|
||||||
|
state.profile = action.payload;
|
||||||
|
state.isPending = false;
|
||||||
|
});
|
||||||
|
|
||||||
builder.addCase(addFeaturedTag.pending, (state) => {
|
builder.addCase(addFeaturedTag.pending, (state) => {
|
||||||
state.isPending = true;
|
state.isPending = true;
|
||||||
});
|
});
|
||||||
@@ -229,6 +240,41 @@ export const patchProfile = createDataLoadingThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type ImageLocation = 'avatar' | 'header';
|
||||||
|
|
||||||
|
export const selectImageInfo = createAppSelector(
|
||||||
|
[
|
||||||
|
(state) => state.profileEdit.profile,
|
||||||
|
(_, location: ImageLocation) => location,
|
||||||
|
],
|
||||||
|
(profile, location) => {
|
||||||
|
if (!profile) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
src: profile[location],
|
||||||
|
static: profile[`${location}Static`],
|
||||||
|
alt: profile[`${location}Description`],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const uploadImage = createDataLoadingThunk(
|
||||||
|
`${profileEditSlice.name}/uploadImage`,
|
||||||
|
(arg: { location: ImageLocation; imageBlob: Blob; altText: string }) => {
|
||||||
|
// Note: Alt text is not actually supported by the API yet.
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(arg.location, arg.imageBlob);
|
||||||
|
|
||||||
|
return apiPatchProfile(formData);
|
||||||
|
},
|
||||||
|
transformProfile,
|
||||||
|
{
|
||||||
|
useLoadingBar: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const selectFieldById = createAppSelector(
|
export const selectFieldById = createAppSelector(
|
||||||
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
|
[(state) => state.profileEdit.profile?.fields, (_, id?: string) => id],
|
||||||
(fields, fieldId) => {
|
(fields, fieldId) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user