mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05:00
Wired Pintura Editor to Admin X (#18100)
no issue This pull request adds the Pintura image editor as an optional feature for image settings in the admin-x. It introduces a `usePinturaEditor` hook that handles the loading and management of the Pintura editor instance, and a custom edit button for the `ImageUpload` component that can open the editor. It also modifies the `BrandSettings` and `UserDetailModel` component to use the hook and the button. --------- Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
This commit is contained in:
parent
f48b732ef1
commit
279ce77226
4 changed files with 307 additions and 3 deletions
|
@ -19,6 +19,9 @@ interface ImageUploadProps {
|
|||
deleteButtonClassName?: string;
|
||||
deleteButtonContent?: React.ReactNode;
|
||||
deleteButtonUnstyled?: boolean;
|
||||
editButtonClassName?: string;
|
||||
editButtonContent?: React.ReactNode;
|
||||
editButtonUnstyled?: boolean;
|
||||
|
||||
/**
|
||||
* Removes all the classnames from all elements so you can set a completely custom styling
|
||||
|
@ -27,6 +30,14 @@ interface ImageUploadProps {
|
|||
onUpload: (file: File) => void;
|
||||
onDelete: () => void;
|
||||
onImageClick?: MouseEventHandler<HTMLImageElement>;
|
||||
|
||||
/**
|
||||
* Pintura config
|
||||
*/
|
||||
pintura?: {
|
||||
isEnabled: boolean;
|
||||
openEditor: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const ImageUpload: React.FC<ImageUploadProps> = ({
|
||||
|
@ -46,7 +57,11 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
|
|||
unstyled = false,
|
||||
onUpload,
|
||||
onDelete,
|
||||
onImageClick
|
||||
onImageClick,
|
||||
pintura,
|
||||
editButtonClassName,
|
||||
editButtonContent,
|
||||
editButtonUnstyled = false
|
||||
}) => {
|
||||
if (!unstyled) {
|
||||
imageContainerClassName = clsx(
|
||||
|
@ -75,9 +90,17 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
|
|||
deleteButtonClassName
|
||||
);
|
||||
}
|
||||
|
||||
if (!editButtonUnstyled) {
|
||||
editButtonClassName = clsx(
|
||||
'absolute right-16 top-4 flex h-8 w-8 cursor-pointer items-center justify-center rounded bg-[rgba(0,0,0,0.75)] text-white hover:bg-black group-hover:!visible md:invisible',
|
||||
editButtonClassName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
deleteButtonContent = deleteButtonContent || <Icon colorClass='text-white' name='trash' size='sm' />;
|
||||
editButtonContent = editButtonContent || <Icon colorClass='text-white' name='pen' size='sm' />;
|
||||
|
||||
if (imageURL) {
|
||||
let image = (
|
||||
|
@ -89,6 +112,12 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
|
|||
width: (unstyled ? '' : width || '100%'),
|
||||
height: (unstyled ? '' : height || 'auto')
|
||||
}} onClick={onImageClick} />
|
||||
{
|
||||
pintura?.isEnabled && pintura?.openEditor &&
|
||||
<button className={editButtonClassName} type='button' onClick={pintura.openEditor}>
|
||||
{editButtonContent}
|
||||
</button>
|
||||
}
|
||||
<button className={deleteButtonClassName} type='button' onClick={onDelete}>
|
||||
{deleteButtonContent}
|
||||
</button>
|
||||
|
|
|
@ -15,6 +15,7 @@ import TextArea from '../../../admin-x-ds/global/form/TextArea';
|
|||
import TextField from '../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../admin-x-ds/global/form/Toggle';
|
||||
import useFeatureFlag from '../../../hooks/useFeatureFlag';
|
||||
import usePinturaEditor from '../../../hooks/usePinturaEditor';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import useStaffUsers from '../../../hooks/useStaffUsers';
|
||||
import validator from 'validator';
|
||||
|
@ -22,9 +23,11 @@ import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
|
|||
import {RoutingModalProps} from '../../providers/RoutingProvider';
|
||||
import {User, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword} from '../../../api/users';
|
||||
import {getImageUrl, useUploadImage} from '../../../api/images';
|
||||
import {getSettingValues} from '../../../api/settings';
|
||||
import {showToast} from '../../../admin-x-ds/global/Toast';
|
||||
import {toast} from 'react-hot-toast';
|
||||
import {useBrowseRoles} from '../../../api/roles';
|
||||
import {useGlobalData} from '../../providers/GlobalDataProvider';
|
||||
|
||||
interface CustomHeadingProps {
|
||||
children?: React.ReactNode;
|
||||
|
@ -460,6 +463,21 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
|||
const {mutateAsync: makeOwner} = useMakeOwner();
|
||||
const limiter = useLimiter();
|
||||
|
||||
// Pintura integration
|
||||
const {settings} = useGlobalData();
|
||||
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
|
||||
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
|
||||
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
|
||||
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
|
||||
|
||||
const editor = usePinturaEditor(
|
||||
{config: {
|
||||
jsUrl: pinturaJsUrl || '',
|
||||
cssUrl: pinturaCssUrl || ''
|
||||
},
|
||||
disabled: !pinturaEnabled}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState === 'saved') {
|
||||
setTimeout(() => {
|
||||
|
@ -639,6 +657,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
|||
|
||||
const fileUploadButtonClasses = 'absolute left-12 md:left-auto md:right-[104px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
|
||||
|
||||
const deleteButtonClasses = 'absolute left-12 md:left-auto md:right-[152px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
|
||||
|
||||
const editButtonClasses = 'absolute left-12 md:left-auto md:right-[102px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
|
||||
|
||||
const suspendedText = userData.status === 'inactive' ? ' (Suspended)' : '';
|
||||
|
||||
const validators = {
|
||||
|
@ -697,14 +719,26 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
|||
<div>
|
||||
<div className={`relative -mx-12 -mt-12 rounded-t bg-gradient-to-tr from-grey-900 to-black`}>
|
||||
<ImageUpload
|
||||
deleteButtonClassName={fileUploadButtonClasses}
|
||||
deleteButtonClassName={deleteButtonClasses}
|
||||
deleteButtonContent='Delete cover image'
|
||||
editButtonClassName={editButtonClasses}
|
||||
fileUploadClassName={fileUploadButtonClasses}
|
||||
height={userData.cover_image ? '100%' : '32px'}
|
||||
id='cover-image'
|
||||
imageClassName='w-full h-full object-cover'
|
||||
imageContainerClassName='absolute inset-0 bg-cover group bg-center rounded-t overflow-hidden'
|
||||
imageURL={userData.cover_image || ''}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: userData.cover_image || '',
|
||||
handleSave: async (file:File) => {
|
||||
handleImageUpload('cover_image', file);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
unstyled={true}
|
||||
onDelete={() => {
|
||||
handleImageDelete('cover_image');
|
||||
|
@ -718,13 +752,25 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
|||
</div>
|
||||
<div className='relative flex flex-col items-start gap-4 px-12 pb-60 pt-10 md:flex-row md:items-center md:pb-7 md:pt-60'>
|
||||
<ImageUpload
|
||||
deleteButtonClassName='md:invisible absolute -right-2 -top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-[rgba(0,0,0,0.75)] text-white hover:bg-black group-hover:!visible'
|
||||
deleteButtonClassName='md:invisible absolute pr-3 -right-2 -top-2 flex h-8 w-16 cursor-pointer items-center justify-end rounded-full bg-[rgba(0,0,0,0.75)] text-white group-hover:!visible'
|
||||
deleteButtonContent={<Icon colorClass='text-white' name='trash' size='sm' />}
|
||||
editButtonClassName='md:invisible absolute right-[22px] -top-2 flex h-8 w-8 cursor-pointer items-center justify-center text-white group-hover:!visible z-20'
|
||||
fileUploadClassName='rounded-full bg-black flex items-center justify-center opacity-80 transition hover:opacity-100 -ml-2 cursor-pointer h-[80px] w-[80px]'
|
||||
id='avatar'
|
||||
imageClassName='w-full h-full object-cover rounded-full'
|
||||
imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px]'
|
||||
imageURL={userData.profile_image}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: userData.profile_image || '',
|
||||
handleSave: async (file:File) => {
|
||||
handleImageUpload('profile_image', file);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
unstyled={true}
|
||||
width='80px'
|
||||
onDelete={() => {
|
||||
|
|
|
@ -5,9 +5,12 @@ import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
|
|||
import React, {useRef, useState} from 'react';
|
||||
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
|
||||
import {SettingValue} from '../../../../api/settings';
|
||||
import {debounce} from '../../../../utils/debounce';
|
||||
import {getImageUrl, useUploadImage} from '../../../../api/images';
|
||||
import {getSettingValues} from '../../../../api/settings';
|
||||
import {useGlobalData} from '../../../providers/GlobalDataProvider';
|
||||
|
||||
export interface BrandSettingValues {
|
||||
description: string
|
||||
|
@ -20,6 +23,10 @@ export interface BrandSettingValues {
|
|||
const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
|
||||
const {mutateAsync: uploadImage} = useUploadImage();
|
||||
const [siteDescription, setSiteDescription] = useState(values.description);
|
||||
const {settings} = useGlobalData();
|
||||
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
|
||||
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
|
||||
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
|
||||
|
||||
const updateDescriptionDebouncedRef = useRef(
|
||||
debounce((value: string) => {
|
||||
|
@ -28,6 +35,18 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||
);
|
||||
const updateSettingDebounced = debounce(updateSetting, 500);
|
||||
|
||||
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
|
||||
|
||||
const editor = usePinturaEditor(
|
||||
{config: {
|
||||
jsUrl: pinturaJsUrl || '',
|
||||
cssUrl: pinturaCssUrl || ''
|
||||
},
|
||||
disabled: !pinturaEnabled}
|
||||
);
|
||||
|
||||
// check if pintura !false and pintura_js_url and pintura_css_url are not '' or null or undefined
|
||||
|
||||
return (
|
||||
<div className='mt-7'>
|
||||
<SettingGroupContent>
|
||||
|
@ -59,6 +78,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||
<div className='flex gap-3'>
|
||||
<ImageUpload
|
||||
deleteButtonClassName='!top-1 !right-1'
|
||||
editButtonClassName='!top-1 !right-1'
|
||||
height={values.icon ? '66px' : '36px'}
|
||||
id='logo'
|
||||
imageBWCheckedBg={true}
|
||||
|
@ -94,9 +114,21 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||
<Heading className='mb-2' grey={(values.coverImage ? true : false)} level={6}>Publication cover</Heading>
|
||||
<ImageUpload
|
||||
deleteButtonClassName='!top-1 !right-1'
|
||||
editButtonClassName='!top-1 !right-10'
|
||||
height='180px'
|
||||
id='cover'
|
||||
imageURL={values.coverImage || ''}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: values.coverImage || '',
|
||||
handleSave: async (file:File) => {
|
||||
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
onDelete={() => updateSetting('cover_image', null)}
|
||||
onUpload={async (file) => {
|
||||
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
|
||||
|
|
197
apps/admin-x-settings/src/hooks/usePinturaEditor.ts
Normal file
197
apps/admin-x-settings/src/hooks/usePinturaEditor.ts
Normal file
|
@ -0,0 +1,197 @@
|
|||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
interface PinturaEditorConfig {
|
||||
jsUrl?: string;
|
||||
cssUrl?: string;
|
||||
}
|
||||
|
||||
interface OpenEditorParams {
|
||||
image: string;
|
||||
handleSave: (dest: File) => void;
|
||||
}
|
||||
|
||||
type FrameOptionType = 'solidSharp' | 'solidRound' | 'lineSingle' | 'hook' | 'polaroid' | undefined;
|
||||
interface PinturaLocale {
|
||||
labelNone: string;
|
||||
frameLabelMatSharp: string;
|
||||
frameLabelMatRound: string;
|
||||
frameLabelLineSingle: string;
|
||||
frameLabelCornerHooks: string;
|
||||
frameLabelPolaroid: string;
|
||||
labelButtonExport: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pintura: {
|
||||
openDefaultEditor: (params: {
|
||||
src: string;
|
||||
enableTransparencyGrid: boolean;
|
||||
util: string;
|
||||
utils: string[];
|
||||
stickerStickToImage: boolean;
|
||||
frameOptions: [FrameOptionType, (locale: PinturaLocale) => string][];
|
||||
cropSelectPresetFilter: string;
|
||||
cropSelectPresetOptions: [number | undefined, string][];
|
||||
locale: {
|
||||
labelButtonExport: string;
|
||||
};
|
||||
previewPad: boolean;
|
||||
}) => {
|
||||
on: (event: string, callback: (result: { dest: File }) => void) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function usePinturaEditor({
|
||||
config,
|
||||
disabled = false
|
||||
}: {
|
||||
config: PinturaEditorConfig;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [scriptLoaded, setScriptLoaded] = useState<boolean>(false);
|
||||
const [cssLoaded, setCssLoaded] = useState<boolean>(false);
|
||||
|
||||
const isEnabled = !disabled && scriptLoaded && cssLoaded;
|
||||
|
||||
useEffect(() => {
|
||||
const jsUrl = config?.jsUrl;
|
||||
|
||||
if (!jsUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.pintura) {
|
||||
setScriptLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(jsUrl);
|
||||
const importUrl = `${url.protocol}//${url.host}${url.pathname}`;
|
||||
const importScriptPromise = import(/* @vite-ignore */ importUrl);
|
||||
|
||||
importScriptPromise
|
||||
.then(() => {
|
||||
setScriptLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// log script loading failure (실패: failure)
|
||||
});
|
||||
} catch (e) {
|
||||
// Log script loading error
|
||||
}
|
||||
}, [config?.jsUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cssUrl = config?.cssUrl;
|
||||
if (!cssUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if the CSS file is already present in the document's head
|
||||
let cssLink = document.querySelector(`link[href="${cssUrl}"]`);
|
||||
if (cssLink) {
|
||||
setCssLoaded(true);
|
||||
} else {
|
||||
let link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = cssUrl;
|
||||
link.onload = () => {
|
||||
setCssLoaded(true);
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
} catch (e) {
|
||||
// Log css loading error
|
||||
}
|
||||
}, [config?.cssUrl]);
|
||||
|
||||
const openEditor = useCallback(
|
||||
({image, handleSave}: OpenEditorParams) => {
|
||||
if (image && isEnabled) {
|
||||
const imageUrl = new URL(image);
|
||||
if (!imageUrl.searchParams.has('v')) {
|
||||
imageUrl.searchParams.set('v', Date.now().toString());
|
||||
}
|
||||
|
||||
const imageSrc = imageUrl.href;
|
||||
|
||||
const editor = window.pintura.openDefaultEditor({
|
||||
src: imageSrc,
|
||||
enableTransparencyGrid: true,
|
||||
util: 'crop',
|
||||
utils: [
|
||||
'crop',
|
||||
'filter',
|
||||
'finetune',
|
||||
'redact',
|
||||
'annotate',
|
||||
'trim',
|
||||
'frame',
|
||||
'sticker'
|
||||
],
|
||||
stickerStickToImage: true,
|
||||
frameOptions: [
|
||||
// No frame
|
||||
[undefined, locale => locale.labelNone],
|
||||
|
||||
// Sharp edge frame
|
||||
['solidSharp', locale => locale.frameLabelMatSharp],
|
||||
|
||||
// Rounded edge frame
|
||||
['solidRound', locale => locale.frameLabelMatRound],
|
||||
|
||||
// A single line frame
|
||||
['lineSingle', locale => locale.frameLabelLineSingle],
|
||||
|
||||
// A frame with cornenr hooks
|
||||
['hook', locale => locale.frameLabelCornerHooks],
|
||||
|
||||
// A polaroid frame
|
||||
['polaroid', locale => locale.frameLabelPolaroid]
|
||||
],
|
||||
cropSelectPresetFilter: 'landscape',
|
||||
cropSelectPresetOptions: [
|
||||
[undefined, 'Custom'],
|
||||
[1, 'Square'],
|
||||
// shown when cropSelectPresetFilter is set to 'landscape'
|
||||
[2 / 1, '2:1'],
|
||||
[3 / 2, '3:2'],
|
||||
[4 / 3, '4:3'],
|
||||
[16 / 10, '16:10'],
|
||||
[16 / 9, '16:9'],
|
||||
// shown when cropSelectPresetFilter is set to 'portrait'
|
||||
[1 / 2, '1:2'],
|
||||
[2 / 3, '2:3'],
|
||||
[3 / 4, '3:4'],
|
||||
[10 / 16, '10:16'],
|
||||
[9 / 16, '9:16']
|
||||
],
|
||||
locale: {
|
||||
labelButtonExport: 'Save and close'
|
||||
},
|
||||
previewPad: true
|
||||
});
|
||||
|
||||
editor.on('loaderror', () => {
|
||||
// TODO: log error message
|
||||
});
|
||||
|
||||
editor.on('process', (result) => {
|
||||
handleSave(result.dest);
|
||||
});
|
||||
}
|
||||
},
|
||||
[isEnabled]
|
||||
);
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
openEditor
|
||||
};
|
||||
}
|
Loading…
Add table
Reference in a new issue