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
|
updated_at: string|null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EditOrAddRecommendation = Omit<Recommendation, 'id'|'created_at'|'updated_at'> & {id?: string};
|
||||||
|
|
||||||
export interface RecommendationResponseType {
|
export interface RecommendationResponseType {
|
||||||
meta?: Meta
|
meta?: Meta
|
||||||
recommendations: Recommendation[]
|
recommendations: Recommendation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecommendationEditResponseType extends RecommendationResponseType {
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecommendationDeleteResponseType {}
|
export interface RecommendationDeleteResponseType {}
|
||||||
|
|
||||||
const dataType = 'RecommendationResponseType';
|
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 Form from '../../../../admin-x-ds/global/form/Form';
|
||||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
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 React from 'react';
|
||||||
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
|
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
|
||||||
|
import useForm from '../../../../hooks/useForm';
|
||||||
import useRouting from '../../../../hooks/useRouting';
|
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 {updateRoute} = useRouting();
|
||||||
|
const {query: queryOembed} = useGetOembed();
|
||||||
|
|
||||||
const openAddNewRecommendationModal = () => {
|
const {formState, updateForm, handleSave, errors, validate, saveState, clearError} = useForm({
|
||||||
updateRoute('recommendations/add-confirm');
|
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
|
return <Modal
|
||||||
afterClose={() => {
|
afterClose={() => {
|
||||||
|
// Closed without saving: reset route
|
||||||
updateRoute('recommendations');
|
updateRoute('recommendations');
|
||||||
}}
|
}}
|
||||||
|
animate={animate ?? true}
|
||||||
okColor='black'
|
okColor='black'
|
||||||
okLabel='Next'
|
okLabel={okLabel}
|
||||||
size='sm'
|
size='sm'
|
||||||
testId='add-recommendation-modal'
|
testId='add-recommendation-modal'
|
||||||
title='Add recommendation'
|
title='Add recommendation'
|
||||||
>
|
onOk={async () => {
|
||||||
<Form
|
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}
|
marginBottom={false}
|
||||||
marginTop
|
marginTop
|
||||||
>
|
>
|
||||||
<URLTextField
|
<URLTextField
|
||||||
baseUrl=''
|
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'
|
placeholder='https://www.example.com'
|
||||||
title='URL'
|
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>
|
</Form>
|
||||||
</Modal>;
|
</Modal>;
|
||||||
|
@ -1,45 +1,113 @@
|
|||||||
|
import AddRecommendationModal from './AddRecommendationModal';
|
||||||
import Avatar from '../../../../admin-x-ds/global/Avatar';
|
import Avatar from '../../../../admin-x-ds/global/Avatar';
|
||||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
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 React from 'react';
|
||||||
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
|
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
|
||||||
|
import useForm from '../../../../hooks/useForm';
|
||||||
import useRouting from '../../../../hooks/useRouting';
|
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 {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
|
return <Modal
|
||||||
afterClose={() => {
|
afterClose={() => {
|
||||||
|
// Closed without saving: reset route
|
||||||
updateRoute('recommendations');
|
updateRoute('recommendations');
|
||||||
}}
|
}}
|
||||||
cancelLabel='Back'
|
animate={animate ?? true}
|
||||||
|
cancelLabel={'Back'}
|
||||||
|
dirty={true}
|
||||||
okColor='black'
|
okColor='black'
|
||||||
okLabel='Add'
|
okLabel={okLabel}
|
||||||
size='sm'
|
size='sm'
|
||||||
testId='add-recommendation-modal'
|
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
|
<Form
|
||||||
marginBottom={false}
|
marginBottom={false}
|
||||||
marginTop
|
marginTop
|
||||||
>
|
>
|
||||||
<div className='mb-4 flex items-center gap-3 rounded-sm border border-grey-300 p-3'>
|
<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`}>
|
<div className={`flex grow flex-col`}>
|
||||||
<span className='mb-0.5 font-medium'>She‘s A Beast</span>
|
<span className='mb-0.5 font-medium'>{recommendation.title}</span>
|
||||||
<span className='text-xs leading-snug text-grey-700'>shesabeast.co</span>
|
<span className='text-xs leading-snug text-grey-700'>{recommendation.url}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TextArea
|
<TextArea
|
||||||
clearBg={true}
|
clearBg={true}
|
||||||
rows={3}
|
rows={3}
|
||||||
title="Reason for recommending"
|
title="Reason for recommending"
|
||||||
|
value={formState.reason ?? ''}
|
||||||
|
onChange={e => updateForm(state => ({...state, reason: e.target.value}))}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>;
|
</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> {
|
export interface FormHook<State> {
|
||||||
formState: State;
|
formState: State;
|
||||||
saveState: SaveState;
|
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
|
* 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
|
// function to save the changed settings via API
|
||||||
const handleSave = async () => {
|
const handleSave = async (options: {force?: boolean} = {}) => {
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saveState !== 'unsaved') {
|
if (saveState !== 'unsaved' && !options.force) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ export const useFetchApi = () => {
|
|||||||
|
|
||||||
const {apiRoot} = getGhostPaths();
|
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);
|
const url = new URL(`${apiRoot}${path}`, window.location.origin);
|
||||||
url.search = new URLSearchParams(searchParams).toString();
|
url.search = new URLSearchParams(searchParams).toString();
|
||||||
return url.toString();
|
return url.toString();
|
||||||
|
@ -203,6 +203,13 @@ class OEmbedService {
|
|||||||
gotOpts.retry = 0;
|
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')([
|
const metascraper = require('metascraper')([
|
||||||
require('metascraper-url')(),
|
require('metascraper-url')(),
|
||||||
require('metascraper-title')(),
|
require('metascraper-title')(),
|
||||||
@ -211,7 +218,8 @@ class OEmbedService {
|
|||||||
require('metascraper-publisher')(),
|
require('metascraper-publisher')(),
|
||||||
require('metascraper-image')(),
|
require('metascraper-image')(),
|
||||||
require('metascraper-logo-favicon')({
|
require('metascraper-logo-favicon')({
|
||||||
gotOpts
|
gotOpts,
|
||||||
|
pickFn
|
||||||
}),
|
}),
|
||||||
require('metascraper-logo')()
|
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`});
|
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") {
|
if (typeof object[key] !== "string") {
|
||||||
throw new errors.BadRequestError({message: `${key} must be a string`});
|
throw new errors.BadRequestError({message: `${key} must be a string`});
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user