mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 00:52:36 +03:00
Wired up adding recommendations via admin-x (#17878)
refs https://github.com/TryGhost/Product/issues/3773
This commit is contained in:
parent
37512a712d
commit
875fe939a5
32
apps/admin-x-settings/src/api/oembed.ts
Normal file
32
apps/admin-x-settings/src/api/oembed.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {apiUrl, useFetchApi} from '../utils/apiRequests';
|
||||
|
||||
export type OembedResponse = {
|
||||
metadata: {
|
||||
title: string | null,
|
||||
description:string | null,
|
||||
author: string | null,
|
||||
publisher: string | null,
|
||||
thumbnail: string | null,
|
||||
icon: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type OembedRequest = {
|
||||
url: string,
|
||||
type: 'mention'
|
||||
}
|
||||
|
||||
export const useGetOembed = () => {
|
||||
const fetchApi = useFetchApi();
|
||||
const path = '/oembed/';
|
||||
|
||||
return {
|
||||
async query(searchParams: OembedRequest) {
|
||||
const url = apiUrl(path, searchParams);
|
||||
const result = await fetchApi(url, {
|
||||
method: 'GET'
|
||||
});
|
||||
return result as OembedResponse;
|
||||
}
|
||||
};
|
||||
};
|
@ -13,11 +13,16 @@ export type Recommendation = {
|
||||
updated_at: string|null
|
||||
}
|
||||
|
||||
export type EditOrAddRecommendation = Omit<Recommendation, 'id'|'created_at'|'updated_at'> & {id?: string};
|
||||
|
||||
export interface RecommendationResponseType {
|
||||
meta?: Meta
|
||||
recommendations: Recommendation[]
|
||||
}
|
||||
|
||||
export interface RecommendationEditResponseType extends RecommendationResponseType {
|
||||
}
|
||||
|
||||
export interface RecommendationDeleteResponseType {}
|
||||
|
||||
const dataType = 'RecommendationResponseType';
|
||||
@ -41,3 +46,32 @@ export const useDeleteRecommendation = createMutation<RecommendationDeleteRespon
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
export const useEditRecommendation = createMutation<RecommendationEditResponseType, Recommendation>({
|
||||
method: 'PUT',
|
||||
path: recommendation => `/recommendations/${recommendation.id}/`,
|
||||
body: recommendation => ({recommendations: [recommendation]}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as RecommendationResponseType),
|
||||
recommendations: (currentData as RecommendationResponseType).recommendations.map((recommendation) => {
|
||||
const newRecommendation = newData.recommendations.find(({id}) => id === recommendation.id);
|
||||
return newRecommendation || recommendation;
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
export const useAddRecommendation = createMutation<RecommendationResponseType, Partial<Recommendation>>({
|
||||
method: 'POST',
|
||||
path: () => '/recommendations/',
|
||||
body: ({...recommendation}) => ({recommendations: [recommendation]}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as RecommendationResponseType),
|
||||
recommendations: (currentData as RecommendationResponseType).recommendations.concat(newData.recommendations)
|
||||
})
|
||||
}
|
||||
});
|
||||
|
@ -1,40 +1,144 @@
|
||||
import AddRecommendationModalConfirm from './AddRecommendationModalConfirm';
|
||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {EditOrAddRecommendation} from '../../../../api/recommendations';
|
||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
||||
import {toast} from 'react-hot-toast';
|
||||
import {useGetOembed} from '../../../../api/oembed';
|
||||
|
||||
interface AddRecommendationModalProps {}
|
||||
interface AddRecommendationModalProps {
|
||||
recommendation?: EditOrAddRecommendation,
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
const AddRecommendationModal: React.FC<AddRecommendationModalProps> = () => {
|
||||
const AddRecommendationModal: React.FC<AddRecommendationModalProps> = ({recommendation, animate}) => {
|
||||
const modal = useModal();
|
||||
const {updateRoute} = useRouting();
|
||||
const {query: queryOembed} = useGetOembed();
|
||||
|
||||
const openAddNewRecommendationModal = () => {
|
||||
updateRoute('recommendations/add-confirm');
|
||||
};
|
||||
const {formState, updateForm, handleSave, errors, validate, saveState, clearError} = useForm({
|
||||
initialState: recommendation ?? {
|
||||
title: '',
|
||||
url: '',
|
||||
reason: '',
|
||||
excerpt: null,
|
||||
featured_image: null,
|
||||
favicon: null,
|
||||
one_click_subscribe: false
|
||||
},
|
||||
onSave: async () => {
|
||||
// Todo: Fetch metadata and pass it along
|
||||
const oembed = await queryOembed({
|
||||
url: formState.url,
|
||||
type: 'mention'
|
||||
});
|
||||
|
||||
if (!oembed) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Could not fetch metadata for this URL, please try again later'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch modal without changing the route (the second modal is not reachable by URL)
|
||||
modal.remove();
|
||||
NiceModal.show(AddRecommendationModalConfirm, {
|
||||
animate: false,
|
||||
recommendation: {
|
||||
...formState,
|
||||
title: oembed.metadata.title ?? formState.title,
|
||||
excerpt: oembed.metadata.description ?? formState.excerpt,
|
||||
featured_image: oembed.metadata.thumbnail ?? formState.featured_image,
|
||||
favicon: oembed.metadata.icon ?? formState.favicon
|
||||
}
|
||||
});
|
||||
},
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const u = new URL(formState.url);
|
||||
|
||||
// Check domain includes a dot
|
||||
if (!u.hostname.includes('.')) {
|
||||
newErrors.url = 'Please enter a valid URL';
|
||||
}
|
||||
} catch (e) {
|
||||
newErrors.url = 'Please enter a valid URL';
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
}
|
||||
});
|
||||
|
||||
let okLabel = 'Next';
|
||||
|
||||
if (saveState === 'saving') {
|
||||
okLabel = 'Checking...';
|
||||
}
|
||||
|
||||
// TODO: Add error message
|
||||
return <Modal
|
||||
afterClose={() => {
|
||||
// Closed without saving: reset route
|
||||
updateRoute('recommendations');
|
||||
}}
|
||||
animate={animate ?? true}
|
||||
okColor='black'
|
||||
okLabel='Next'
|
||||
okLabel={okLabel}
|
||||
size='sm'
|
||||
testId='add-recommendation-modal'
|
||||
title='Add recommendation'
|
||||
>
|
||||
<Form
|
||||
onOk={async () => {
|
||||
if (saveState === 'saving') {
|
||||
// Already saving
|
||||
return;
|
||||
}
|
||||
|
||||
toast.remove();
|
||||
try {
|
||||
if (await handleSave({force: true})) {
|
||||
// Already handled
|
||||
} else {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'One or more fields have errors, please doublecheck you filled all mandatory fields'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'Something went wrong while checking this URL, please try again'
|
||||
});
|
||||
}
|
||||
}}
|
||||
><Form
|
||||
marginBottom={false}
|
||||
marginTop
|
||||
>
|
||||
<URLTextField
|
||||
baseUrl=''
|
||||
hint={<>Need inspiration? <a className='text-green' href="https://www.ghost.org/explore" rel="noopener noreferrer" target='_blank'>Explore thousands of sites</a> to recommend</>}
|
||||
error={Boolean(errors.url)}
|
||||
hint={errors.url || <>Need inspiration? <a className='text-green' href="https://www.ghost.org/explore" rel="noopener noreferrer" target='_blank'>Explore thousands of sites</a> to recommend</>}
|
||||
placeholder='https://www.example.com'
|
||||
title='URL'
|
||||
onChange={openAddNewRecommendationModal}
|
||||
value={formState.url}
|
||||
onBlur={validate}
|
||||
onChange={u => updateForm((state) => {
|
||||
if (u.length && !u.startsWith('http://') && !u.startsWith('https://')) {
|
||||
u = 'https://' + u;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
url: u
|
||||
};
|
||||
})}
|
||||
onKeyDown={() => clearError?.('url')}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>;
|
||||
|
@ -1,45 +1,113 @@
|
||||
import AddRecommendationModal from './AddRecommendationModal';
|
||||
import Avatar from '../../../../admin-x-ds/global/Avatar';
|
||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {EditOrAddRecommendation, useAddRecommendation} from '../../../../api/recommendations';
|
||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
||||
import {toast} from 'react-hot-toast';
|
||||
|
||||
interface AddRecommendationModalProps {}
|
||||
interface AddRecommendationModalProps {
|
||||
recommendation: EditOrAddRecommendation,
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
const AddRecommendationModal: React.FC<AddRecommendationModalProps> = () => {
|
||||
const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({recommendation, animate}) => {
|
||||
const modal = useModal();
|
||||
const {updateRoute} = useRouting();
|
||||
const {mutateAsync: addRecommendation} = useAddRecommendation();
|
||||
|
||||
const {formState, updateForm, handleSave, saveState} = useForm({
|
||||
initialState: {
|
||||
...recommendation
|
||||
},
|
||||
onSave: async () => {
|
||||
await addRecommendation(formState);
|
||||
modal.remove();
|
||||
updateRoute('recommendations');
|
||||
},
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
return newErrors;
|
||||
}
|
||||
});
|
||||
|
||||
let okLabel = 'Add';
|
||||
|
||||
if (saveState === 'saving') {
|
||||
okLabel = 'Adding...';
|
||||
} else if (saveState === 'saved') {
|
||||
okLabel = 'Added';
|
||||
}
|
||||
|
||||
return <Modal
|
||||
afterClose={() => {
|
||||
// Closed without saving: reset route
|
||||
updateRoute('recommendations');
|
||||
}}
|
||||
cancelLabel='Back'
|
||||
animate={animate ?? true}
|
||||
cancelLabel={'Back'}
|
||||
dirty={true}
|
||||
okColor='black'
|
||||
okLabel='Add'
|
||||
okLabel={okLabel}
|
||||
size='sm'
|
||||
testId='add-recommendation-modal'
|
||||
title='Add recommendation'
|
||||
title={'Add recommendation'}
|
||||
onCancel={() => {
|
||||
if (saveState === 'saving') {
|
||||
// Already saving
|
||||
return;
|
||||
}
|
||||
// Switch modal without changing the route, but pass along any changes that were already made
|
||||
modal.remove();
|
||||
NiceModal.show(AddRecommendationModal, {
|
||||
animate: false,
|
||||
recommendation: {
|
||||
...formState
|
||||
}
|
||||
});
|
||||
}}
|
||||
onOk={async () => {
|
||||
if (saveState === 'saving') {
|
||||
// Already saving
|
||||
return;
|
||||
}
|
||||
|
||||
toast.remove();
|
||||
if (await handleSave({force: true})) {
|
||||
// Already handled
|
||||
} else {
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: 'One or more fields have errors, please doublecheck you filled all mandatory fields'
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
marginBottom={false}
|
||||
marginTop
|
||||
>
|
||||
<div className='mb-4 flex items-center gap-3 rounded-sm border border-grey-300 p-3'>
|
||||
<Avatar image='https://www.shesabeast.co/content/images/size/w256h256/2022/08/transparent-icon-black-copy-gray-bar.png' labelColor='white' />
|
||||
{(recommendation.favicon || recommendation.featured_image) && <Avatar image={recommendation.favicon ?? recommendation.featured_image!} labelColor='white' />}
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span className='mb-0.5 font-medium'>She‘s A Beast</span>
|
||||
<span className='text-xs leading-snug text-grey-700'>shesabeast.co</span>
|
||||
<span className='mb-0.5 font-medium'>{recommendation.title}</span>
|
||||
<span className='text-xs leading-snug text-grey-700'>{recommendation.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
clearBg={true}
|
||||
rows={3}
|
||||
title="Reason for recommending"
|
||||
value={formState.reason ?? ''}
|
||||
onChange={e => updateForm(state => ({...state, reason: e.target.value}))}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
export default NiceModal.create(AddRecommendationModal);
|
||||
export default NiceModal.create(AddRecommendationModalConfirm);
|
||||
|
@ -11,7 +11,10 @@ export type ErrorMessages = Record<string, string | undefined>
|
||||
export interface FormHook<State> {
|
||||
formState: State;
|
||||
saveState: SaveState;
|
||||
handleSave: () => Promise<boolean>;
|
||||
/**
|
||||
* Validate and save the state. Use the `force` option to save even when there are no changes made (e.g., initial state should be saveable)
|
||||
*/
|
||||
handleSave: (options?: {force?: boolean}) => Promise<boolean>;
|
||||
/**
|
||||
* Update the form state and mark the form as dirty. Should be used in input events
|
||||
*/
|
||||
@ -59,12 +62,12 @@ const useForm = <State>({initialState, onSave, onValidate}: {
|
||||
};
|
||||
|
||||
// function to save the changed settings via API
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (options: {force?: boolean} = {}) => {
|
||||
if (!validate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (saveState !== 'unsaved') {
|
||||
if (saveState !== 'unsaved' && !options.force) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -82,7 +82,7 @@ export const useFetchApi = () => {
|
||||
|
||||
const {apiRoot} = getGhostPaths();
|
||||
|
||||
const apiUrl = (path: string, searchParams: Record<string, string> = {}) => {
|
||||
export const apiUrl = (path: string, searchParams: Record<string, string> = {}) => {
|
||||
const url = new URL(`${apiRoot}${path}`, window.location.origin);
|
||||
url.search = new URLSearchParams(searchParams).toString();
|
||||
return url.toString();
|
||||
|
@ -203,6 +203,13 @@ class OEmbedService {
|
||||
gotOpts.retry = 0;
|
||||
}
|
||||
|
||||
const pickFn = (sizes, pickDefault) => {
|
||||
// Prioritize apple touch icon with sizes > 180
|
||||
const appleTouchIcon = sizes.find(item => item.rel.includes('apple') && item.sizes && item.size.width >= 180);
|
||||
const svgIcon = sizes.find(item => item.href.endsWith('svg'));
|
||||
return appleTouchIcon || svgIcon || pickDefault(sizes);
|
||||
};
|
||||
|
||||
const metascraper = require('metascraper')([
|
||||
require('metascraper-url')(),
|
||||
require('metascraper-title')(),
|
||||
@ -211,7 +218,8 @@ class OEmbedService {
|
||||
require('metascraper-publisher')(),
|
||||
require('metascraper-image')(),
|
||||
require('metascraper-logo-favicon')({
|
||||
gotOpts
|
||||
gotOpts,
|
||||
pickFn
|
||||
}),
|
||||
require('metascraper-logo')()
|
||||
]);
|
||||
|
@ -14,7 +14,7 @@ function validateString(object: any, key: string, {required = true} = {}): strin
|
||||
throw new errors.BadRequestError({message: `${key} must be an object`});
|
||||
}
|
||||
|
||||
if (object[key] !== undefined) {
|
||||
if (object[key] !== undefined && object[key] !== null) {
|
||||
if (typeof object[key] !== "string") {
|
||||
throw new errors.BadRequestError({message: `${key} must be a string`});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user