Extracted Unsplash Selector from AdminX (#19849)

no issue

- Adds the unsplash selector as a standalone typescript package inside
the Koenig monorepo.
- Currently we have 3 versions of the Unsplash Selector. One in
Koenig-Lexical, one in AdminX and the original Ember version.
- We can now start phasing out the application coupled version of the
selector and replace it with the reusable version.
- We can now import it via npm to any React application.
- This commit removes the Unsplash components from AdminX and imports it
instead.

This is the second commit for this as the previous commit broke styles
due to normalise styles leaking into the Ember app. Disabling preflight
(https://github.com/TryGhost/Koenig/pull/1169) in Tailwind fixed it.
This commit is contained in:
Ronald Langeveld 2024-03-12 22:40:33 +02:00 committed by GitHub
parent 9203eea673
commit 19da5c6af4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 216 additions and 1294 deletions

View File

@ -39,6 +39,7 @@
"dependencies": {
"@codemirror/lang-html": "^6.4.5",
"@tryghost/color-utils": "0.2.0",
"@tryghost/kg-unsplash-selector": "^0.1.11",
"@tryghost/limit-service": "^1.2.10",
"@tryghost/nql": "0.12.1",
"@tryghost/timezone-data": "0.4.1",

View File

@ -0,0 +1,24 @@
import '@tryghost/kg-unsplash-selector/dist/style.css';
import Portal from '../../utils/portal';
import React from 'react';
import {DefaultHeaderTypes, PhotoType, UnsplashSearchModal} from '@tryghost/kg-unsplash-selector';
type UnsplashSelectorModalProps = {
onClose: () => void;
onImageInsert: (image: PhotoType) => void;
unsplashProviderConfig: DefaultHeaderTypes | null;
};
const UnsplashSelector : React.FC<UnsplashSelectorModalProps> = ({unsplashProviderConfig, onClose, onImageInsert}) => {
return (
<Portal classNames='admin-x-settings'>
<UnsplashSearchModal
unsplashProviderConfig={unsplashProviderConfig}
onClose={onClose}
onImageInsert={onImageInsert}
/>
</Portal>
);
};
export default UnsplashSelector;

View File

@ -1,5 +1,5 @@
import React, {useRef, useState} from 'react';
import UnsplashSearchModal from '../../../../unsplash/UnsplashSearchModal';
import UnsplashSelector from '../../../selectors/UnsplashSelector';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {ColorPickerField, Heading, Hint, ImageUpload, SettingGroupContent, TextField, debounce} from '@tryghost/admin-x-design-system';
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
@ -144,10 +144,8 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
</ImageUpload>
{
showUnsplash && unsplashConfig && unsplashEnabled && (
<UnsplashSearchModal
unsplashConf={{
defaultHeaders: unsplashConfig
}}
<UnsplashSelector
unsplashProviderConfig={unsplashConfig}
onClose={() => {
setShowUnsplash(false);
}}

View File

@ -1,192 +0,0 @@
import MasonryService from './masonry/MasonryService';
import Portal from './portal';
import React, {useMemo, useRef, useState} from 'react';
import UnsplashGallery from './ui/UnsplashGallery';
import UnsplashSelector from './ui/UnsplashSelector';
import {DefaultHeaderTypes, Photo} from './UnsplashTypes';
import {PhotoUseCases} from './photo/PhotoUseCase';
import {UnsplashProvider} from './api/UnsplashProvider';
import {UnsplashService} from './UnsplashService';
interface UnsplashModalProps {
onClose: () => void;
onImageInsert: (image: Photo) => void;
unsplashConf: {
defaultHeaders: DefaultHeaderTypes;
};
}
const UnsplashSearchModal : React.FC<UnsplashModalProps> = ({onClose, onImageInsert, unsplashConf}) => {
const unsplashRepo = useMemo(() => new UnsplashProvider(unsplashConf.defaultHeaders), [unsplashConf.defaultHeaders]);
const photoUseCase = useMemo(() => new PhotoUseCases(unsplashRepo), [unsplashRepo]);
const masonryService = useMemo(() => new MasonryService(3), []);
const UnsplashLib = useMemo(() => new UnsplashService(photoUseCase, masonryService), [photoUseCase, masonryService]);
const galleryRef = useRef<HTMLDivElement | null>(null);
const [scrollPos, setScrollPos] = useState<number>(0);
const [lastScrollPos, setLastScrollPos] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(UnsplashLib.searchIsRunning() || true);
const initLoadRef = useRef<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>('');
const [zoomedImg, setZoomedImg] = useState<Photo | null>(null);
const [dataset, setDataset] = useState<Photo[][] | []>([]);
React.useEffect(() => {
if (galleryRef.current && zoomedImg === null && lastScrollPos !== 0) {
galleryRef.current.scrollTop = lastScrollPos;
setLastScrollPos(0);
}
}, [zoomedImg, scrollPos, lastScrollPos]);
React.useEffect(() => {
const handleKeyDown = (e:KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
React.useEffect(() => {
const ref = galleryRef.current;
if (!zoomedImg) {
if (ref) {
ref.addEventListener('scroll', () => {
setScrollPos(ref.scrollTop);
});
}
// unmount
return () => {
if (ref) {
ref.removeEventListener('scroll', () => {
setScrollPos(ref.scrollTop);
});
}
};
}
}, [galleryRef, zoomedImg]);
const loadInitPhotos = React.useCallback(async () => {
if (initLoadRef.current === false || searchTerm.length === 0) {
setDataset([]);
UnsplashLib.clearPhotos();
await UnsplashLib.loadNew();
const columns = UnsplashLib.getColumns();
setDataset(columns || []);
if (galleryRef.current && galleryRef.current.scrollTop !== 0) {
galleryRef.current.scrollTop = 0;
}
setIsLoading(false);
}
}, [UnsplashLib, searchTerm]);
const handleSearch = async (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
if (query.length > 2) {
setZoomedImg(null);
setSearchTerm(query);
}
if (query.length === 0) {
setSearchTerm('');
initLoadRef.current = false;
await loadInitPhotos();
}
};
const search = React.useCallback(async () => {
if (searchTerm) {
setIsLoading(true);
setDataset([]);
UnsplashLib.clearPhotos();
await UnsplashLib.updateSearch(searchTerm);
const columns = UnsplashLib.getColumns();
if (columns) {
setDataset(columns);
}
if (galleryRef.current && galleryRef.current.scrollTop !== 0) {
galleryRef.current.scrollTop = 0;
}
setIsLoading(false);
}
}, [searchTerm, UnsplashLib]);
React.useEffect(() => {
const timeoutId = setTimeout(async () => {
if (searchTerm.length > 2) {
await search();
} else {
await loadInitPhotos();
}
}, 300);
return () => {
initLoadRef.current = true;
clearTimeout(timeoutId);
};
}, [searchTerm, search, loadInitPhotos]);
const loadMorePhotos = React.useCallback(async () => {
setIsLoading(true);
await UnsplashLib.loadNextPage();
const columns = UnsplashLib.getColumns();
setDataset(columns || []);
setIsLoading(false);
}, [UnsplashLib]);
React.useEffect(() => {
const ref = galleryRef.current;
if (ref) {
const handleScroll = async () => {
if (zoomedImg === null && ref.scrollTop + ref.clientHeight >= ref.scrollHeight - 1000) {
await loadMorePhotos();
}
};
ref.addEventListener('scroll', handleScroll);
return () => {
ref.removeEventListener('scroll', handleScroll);
};
}
}, [galleryRef, loadMorePhotos, zoomedImg]);
const selectImg = (payload:Photo) => {
if (payload) {
setZoomedImg(payload);
setLastScrollPos(scrollPos);
}
if (payload === null) {
setZoomedImg(null);
if (galleryRef.current) {
galleryRef.current.scrollTop = lastScrollPos;
}
}
};
async function insertImage(image:Photo) {
if (image.src) {
UnsplashLib.triggerDownload(image);
onImageInsert(image);
}
}
return (
<Portal classNames='admin-x-settings'>
<UnsplashSelector
closeModal={onClose}
handleSearch={handleSearch}
>
<UnsplashGallery
dataset={dataset}
error={null}
galleryRef={galleryRef}
insertImage={insertImage}
isLoading={isLoading}
selectImg={selectImg}
zoomed={zoomedImg}
/>
</UnsplashSelector>
</Portal>
);
};
export default UnsplashSearchModal;

View File

@ -1,68 +0,0 @@
import MasonryService from './masonry/MasonryService';
import {Photo} from './UnsplashTypes';
import {PhotoUseCases} from './photo/PhotoUseCase';
export interface IUnsplashService {
loadNew(): Promise<void>;
layoutPhotos(): void;
getColumns(): Photo[][] | [] | null;
updateSearch(term: string): Promise<void>;
loadNextPage(): Promise<void>;
clearPhotos(): void;
triggerDownload(photo: Photo): void;
photos: Photo[];
searchIsRunning(): boolean;
}
export class UnsplashService implements IUnsplashService {
private photoUseCases: PhotoUseCases;
private masonryService: MasonryService;
public photos: Photo[] = [];
constructor(photoUseCases: PhotoUseCases, masonryService: MasonryService) {
this.photoUseCases = photoUseCases;
this.masonryService = masonryService;
}
async loadNew() {
let images = await this.photoUseCases.fetchPhotos();
this.photos = images;
await this.layoutPhotos();
}
async layoutPhotos() {
this.masonryService.reset();
this.photos.forEach((photo) => {
photo.ratio = photo.height / photo.width;
this.masonryService.addPhotoToColumns(photo);
});
}
getColumns() {
return this.masonryService.getColumns();
}
async updateSearch(term: string) {
let results = await this.photoUseCases.searchPhotos(term);
this.photos = results;
this.layoutPhotos();
}
async loadNextPage() {
const newPhotos = await this.photoUseCases.fetchNextPage() || [];
this.photos = [...this.photos, ...newPhotos];
this.layoutPhotos();
}
clearPhotos() {
this.photos = [];
}
triggerDownload(photo: Photo) {
this.photoUseCases.triggerDownload(photo);
}
searchIsRunning() {
return this.photoUseCases.searchIsRunning();
}
}

View File

@ -1,80 +0,0 @@
export type URLS = {
raw: string;
full: string;
regular: string;
small: string;
thumb: string;
};
export type Links = {
self: string;
html: string;
download: string;
download_location: string;
};
export type ProfileImage = {
small: string;
medium: string;
large: string;
};
export type User = {
id: string;
updated_at: string;
username: string;
name: string;
first_name: string;
last_name: string;
twitter_username: string;
portfolio_url: string;
bio: string;
location: string;
links: Links;
profile_image: ProfileImage;
instagram_username: string;
total_collections: number;
total_likes: number;
total_photos: number;
accepted_tos: boolean;
for_hire: boolean;
social: {
instagram_username: string;
portfolio_url: string;
twitter_username: string;
paypal_email: null | string;
};
};
export type Photo = {
id: string;
slug: string;
created_at: string;
updated_at: string;
promoted_at: string | null; // Nullable
width: number;
height: number;
color: string;
blur_hash: string;
description: null | string; // Nullable
alt_description: string;
breadcrumbs: []; // You could make this more specific
urls: URLS;
links: Links;
likes: number;
liked_by_user: boolean;
current_user_collections: []; // You could make this more specific
sponsorship: null | []; // Nullable
topic_submissions: []; // You could make this more specific
user: User;
ratio: number;
src? : string;
};
export type DefaultHeaderTypes = {
Authorization: string;
'Accept-Version': string;
'Content-Type': string;
'App-Pragma': string;
'X-Unsplash-Cache': boolean;
};

View File

@ -1,54 +0,0 @@
// for testing purposes
import {Photo} from '../UnsplashTypes';
import {fixturePhotos} from './unsplashFixtures';
export class InMemoryUnsplashProvider {
photos: Photo[] = fixturePhotos;
PAGINATION: { [key: string]: string } = {};
REQUEST_IS_RUNNING: boolean = false;
SEARCH_IS_RUNNING: boolean = false;
LAST_REQUEST_URL: string = '';
ERROR: string | null = null;
IS_LOADING: boolean = false;
currentPage: number = 1;
public async fetchPhotos(): Promise<Photo[]> {
this.IS_LOADING = true;
const start = (this.currentPage - 1) * 30;
const end = this.currentPage * 30;
this.currentPage += 1;
this.IS_LOADING = false;
return this.photos.slice(start, end);
}
public async fetchNextPage(): Promise<Photo[] | null> {
if (this.REQUEST_IS_RUNNING || this.SEARCH_IS_RUNNING) {
return null;
}
const photos = await this.fetchPhotos();
return photos.length > 0 ? photos : null;
}
public async searchPhotos(term: string): Promise<Photo[]> {
this.SEARCH_IS_RUNNING = true;
const filteredPhotos = this.photos.filter(photo => photo.description?.includes(term) || photo.alt_description?.includes(term)
);
this.SEARCH_IS_RUNNING = false;
return filteredPhotos;
}
searchIsRunning(): boolean {
return this.SEARCH_IS_RUNNING;
}
triggerDownload(photo: Photo): void {
() => {
photo;
};
}
}

View File

@ -1,161 +0,0 @@
import {DefaultHeaderTypes, Photo} from '../UnsplashTypes';
export class UnsplashProvider {
API_URL: string = 'https://api.unsplash.com';
HEADERS: DefaultHeaderTypes;
ERROR: string | null = null;
PAGINATION: { [key: string]: string } = {};
REQUEST_IS_RUNNING: boolean = false;
SEARCH_IS_RUNNING: boolean = false;
LAST_REQUEST_URL: string = '';
IS_LOADING: boolean = false;
constructor(HEADERS: DefaultHeaderTypes) {
this.HEADERS = HEADERS;
}
private async makeRequest(url: string): Promise<Photo[] | {results: Photo[]} | null> {
if (this.REQUEST_IS_RUNNING) {
return null;
}
this.LAST_REQUEST_URL = url;
const options = {
method: 'GET',
headers: this.HEADERS as unknown as HeadersInit
};
try {
this.REQUEST_IS_RUNNING = true;
this.IS_LOADING = true;
const response = await fetch(url, options);
const checkedResponse = await this.checkStatus(response);
this.extractPagination(checkedResponse);
const jsonResponse = await checkedResponse.json();
if ('results' in jsonResponse) {
return jsonResponse.results;
} else {
return jsonResponse;
}
} catch (error) {
this.ERROR = error as string;
return null;
} finally {
this.REQUEST_IS_RUNNING = false;
this.IS_LOADING = false;
}
}
private extractPagination(response: Response): Response {
let linkRegex = new RegExp('<(.*)>; rel="(.*)"');
let links = [];
let pagination : { [key: string]: string } = {};
for (let entry of response.headers.entries()) {
if (entry[0] === 'link') {
links.push(entry[1]);
}
}
if (links) {
links.toString().split(',').forEach((link) => {
if (link){
let linkParts = linkRegex.exec(link);
if (linkParts) {
pagination[linkParts[2]] = linkParts[1];
}
}
});
}
this.PAGINATION = pagination;
return response;
}
public async fetchPhotos(): Promise<Photo[]> {
const url = `${this.API_URL}/photos?per_page=30`;
const request = await this.makeRequest(url);
return request as Photo[];
}
public async fetchNextPage(): Promise<Photo[] | null> {
if (this.REQUEST_IS_RUNNING) {
return null;
}
if (this.SEARCH_IS_RUNNING) {
return null;
}
if (this.PAGINATION.next) {
const url = `${this.PAGINATION.next}`;
const response = await this.makeRequest(url);
if (response) {
return response as Photo[];
}
}
return null;
}
public async searchPhotos(term: string): Promise<Photo[]> {
const url = `${this.API_URL}/search/photos?query=${term}&per_page=30`;
const request = await this.makeRequest(url);
if (request) {
return request as Photo[];
}
return [];
}
public async triggerDownload(photo: Photo): Promise<void> {
if (photo.links.download_location) {
await this.makeRequest(photo.links.download_location);
}
}
private async checkStatus(response: Response): Promise<Response> {
if (response.status >= 200 && response.status < 300) {
return response;
}
let errorText = '';
let responseTextPromise: Promise<string>; // or Promise<string> if you know the type
const contentType = response.headers.get('content-type');
if (contentType === 'application/json') {
responseTextPromise = response.json().then(json => (json).errors[0]); // or cast to a specific type if you know it
} else if (contentType === 'text/xml') {
responseTextPromise = response.text();
} else {
throw new Error('Unsupported content type');
}
return responseTextPromise.then((responseText: string) => { // you can type responseText based on what you expect
if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
// we've hit the rate limit on the API
errorText = 'Unsplash API rate limit reached, please try again later.';
}
errorText = errorText || responseText || `Error ${response.status}: Uh-oh! Trouble reaching the Unsplash API`;
// set error text for display in UI
this.ERROR = errorText;
// throw error to prevent further processing
let error = new Error(errorText) as Error; // or create a custom Error class
throw error;
});
}
searchIsRunning(): boolean {
return this.SEARCH_IS_RUNNING;
}
}

View File

@ -1,142 +0,0 @@
import {Photo} from '../UnsplashTypes';
export const fixturePhotos: Photo[] = [
{
id: '1',
slug: 'photo1',
created_at: '2021-01-01',
updated_at: '2021-01-02',
promoted_at: null,
width: 1080,
height: 720,
color: '#ffffff',
blur_hash: 'abc123',
description: 'A nice photo',
alt_description: 'alt1',
breadcrumbs: [],
urls: {
raw: 'http://example.com/raw1',
full: 'http://example.com/full1',
regular: 'http://example.com/regular1',
small: 'http://example.com/small1',
thumb: 'http://example.com/thumb1'
},
links: {
self: 'http://example.com/self1',
html: 'http://example.com/html1',
download: 'http://example.com/download1',
download_location: 'http://example.com/download_location1'
},
likes: 100,
liked_by_user: true,
current_user_collections: [],
sponsorship: null,
topic_submissions: [],
user: {
id: 'user1',
updated_at: '2021-01-01',
username: 'user1',
name: 'User One',
first_name: 'User',
last_name: 'One',
twitter_username: 'user1_twitter',
portfolio_url: 'http://portfolio1.com',
bio: 'Bio1',
location: 'Location1',
links: {
self: 'http://example.com/self1',
html: 'http://example.com/html1',
download: 'http://example.com/download1',
download_location: 'http://example.com/download_location1'
},
profile_image: {
small: 'http://small1.com',
medium: 'http://medium1.com',
large: 'http://large1.com'
},
instagram_username: 'insta1',
total_collections: 10,
total_likes: 100,
total_photos: 1000,
accepted_tos: true,
for_hire: false,
social: {
instagram_username: 'insta1',
portfolio_url: 'http://portfolio1.com',
twitter_username: 'user1_twitter',
paypal_email: null
}
},
ratio: 1.5,
src: 'http://src1.com'
},
{
id: '2',
slug: 'photo1',
created_at: '2021-01-01',
updated_at: '2021-01-02',
promoted_at: null,
width: 1080,
height: 720,
color: '#ffffff',
blur_hash: 'abc123',
description: 'hello world',
alt_description: 'alt1',
breadcrumbs: [],
urls: {
raw: 'http://example.com/raw1',
full: 'http://example.com/full1',
regular: 'http://example.com/regular1',
small: 'http://example.com/small1',
thumb: 'http://example.com/thumb1'
},
links: {
self: 'http://example.com/self1',
html: 'http://example.com/html1',
download: 'http://example.com/download1',
download_location: 'http://example.com/download_location1'
},
likes: 100,
liked_by_user: true,
current_user_collections: [],
sponsorship: null,
topic_submissions: [],
user: {
id: 'user1',
updated_at: '2021-01-01',
username: 'user1',
name: 'User One',
first_name: 'User',
last_name: 'One',
twitter_username: 'user1_twitter',
portfolio_url: 'http://portfolio1.com',
bio: 'Bio1',
location: 'Location1',
links: {
self: 'http://example.com/self1',
html: 'http://example.com/html1',
download: 'http://example.com/download1',
download_location: 'http://example.com/download_location1'
},
profile_image: {
small: 'http://small1.com',
medium: 'http://medium1.com',
large: 'http://large1.com'
},
instagram_username: 'insta1',
total_collections: 10,
total_likes: 100,
total_photos: 1000,
accepted_tos: true,
for_hire: false,
social: {
instagram_username: 'insta1',
portfolio_url: 'http://portfolio1.com',
twitter_username: 'user1_twitter',
paypal_email: null
}
},
ratio: 1.5,
src: 'http://src1.com'
}
];

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.43 122.41">
<path d="M83.86 54.15v34.13H38.57V54.15H0v68.26h122.43V54.15H83.86zM38.57 0h45.3v34.13h-45.3z"/>
</svg>

Before

Width:  |  Height:  |  Size: 176 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M.75 23.249l22.5-22.5M23.25 23.249L.75.749"/>
</svg>

Before

Width:  |  Height:  |  Size: 226 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M20 5.5l-8 8-8-8m-3.5 13h23" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 216 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" stroke-width="1.5" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M1.472 13.357a9.063 9.063 0 1 0 16.682-7.09 9.063 9.063 0 1 0-16.682 7.09Zm14.749 2.863 7.029 7.03"/>
</svg>

Before

Width:  |  Height:  |  Size: 283 B

View File

@ -1,3 +0,0 @@
<svg viewBox="0 0 32 32">
<path d="M17.4 29c-.8.8-2 .8-2.8 0L2.3 16.2C-.8 13.1-.8 8 2.3 4.8c3.1-3.1 8.2-3.1 11.3 0L16 7.6l2.3-2.8c3.1-3.1 8.2-3.1 11.3 0 3.1 3.1 3.1 8.2 0 11.4L17.4 29z"/>
</svg>

Before

Width:  |  Height:  |  Size: 197 B

View File

@ -1,55 +0,0 @@
import {Photo} from '../UnsplashTypes';
export default class MasonryService {
public columnCount: number;
public columns: Photo[][] | [] = [];
public columnHeights: number[] | null;
constructor(columnCount: number = 3) {
this.columnCount = columnCount;
this.columns = [[]];
this.columnHeights = null;
}
reset(): void {
let columns: Photo[][] = [];
let columnHeights: number[] = [];
for (let i = 0; i < this.columnCount; i += 1) {
columns[i] = [];
columnHeights[i] = 0;
}
this.columns = columns;
this.columnHeights = columnHeights;
}
addColumns(): void {
for (let i = 0; i < this.columnCount; i++) {
(this.columns as Photo[][]).push([]);
this.columnHeights!.push(0);
}
}
addPhotoToColumns(photo: Photo): void {
if (!this.columns) {
this.reset();
}
let min = Math.min(...this.columnHeights!);
let columnIndex = this.columnHeights!.indexOf(min);
this.columnHeights![columnIndex] += 300 * photo.ratio;
this.columns![columnIndex].push(photo);
}
getColumns(): Photo[][] | null {
return this.columns;
}
changeColumnCount(newColumnCount: number): void {
if (newColumnCount !== this.columnCount) {
this.columnCount = newColumnCount;
this.reset();
}
}
}

View File

@ -1,37 +0,0 @@
import {InMemoryUnsplashProvider} from '../api/InMemoryUnsplashProvider';
import {Photo} from '../UnsplashTypes';
import {UnsplashProvider} from '../api/UnsplashProvider';
export class PhotoUseCases {
private _provider: UnsplashProvider | InMemoryUnsplashProvider; // InMemoryUnsplashProvider is for testing purposes
constructor(provider: UnsplashProvider | InMemoryUnsplashProvider) {
this._provider = provider;
}
async fetchPhotos(): Promise<Photo[]> {
return await this._provider.fetchPhotos();
}
async searchPhotos(term: string): Promise<Photo[]> {
return await this._provider.searchPhotos(term);
}
async triggerDownload(photo: Photo): Promise<void> {
this._provider.triggerDownload(photo);
}
async fetchNextPage(): Promise<Photo[] | null> {
let request = await this._provider.fetchNextPage();
if (request) {
return request;
}
return null;
}
searchIsRunning(): boolean {
return this._provider.searchIsRunning();
}
}

View File

@ -1,37 +0,0 @@
import React, {HTMLProps} from 'react';
import {ReactComponent as DownloadIcon} from '../assets/kg-download.svg';
import {ReactComponent as UnsplashHeartIcon} from '../assets/kg-unsplash-heart.svg';
// Define the available icon types
type ButtonIconType = 'heart' | 'download';
// Define the props type
interface UnsplashButtonProps extends HTMLProps<HTMLAnchorElement> {
icon?: ButtonIconType;
label?: string;
}
const BUTTON_ICONS: Record<ButtonIconType, React.ComponentType<Partial<React.SVGProps<SVGSVGElement>>>> = {
heart: UnsplashHeartIcon,
download: DownloadIcon
};
const UnsplashButton: React.FC<UnsplashButtonProps> = ({icon, label, ...props}) => {
let Icon = null;
if (icon) {
Icon = BUTTON_ICONS[icon];
}
return (
<a
className="flex h-8 shrink-0 cursor-pointer items-center rounded-md bg-white px-3 py-2 font-sans text-sm font-medium leading-6 text-grey-700 opacity-90 transition-all ease-in-out hover:opacity-100"
onClick={e => e.stopPropagation()}
{...props}
>
{icon && Icon && <Icon className={`h-4 w-4 ${icon === 'heart' ? 'fill-red' : ''} stroke-[3px] ${label && 'mr-1'}`} />}
{label && <span>{label}</span>}
</a>
);
};
export default UnsplashButton;

View File

@ -1,149 +0,0 @@
import React, {ReactNode, RefObject} from 'react';
import UnsplashImage from './UnsplashImage';
import UnsplashZoomed from './UnsplashZoomed';
import {Photo} from '../UnsplashTypes';
interface MasonryColumnProps {
children: ReactNode;
}
interface UnsplashGalleryColumnsProps {
columns?: Photo[][] | [];
insertImage?: any;
selectImg?: any;
zoomed?: Photo | null;
}
interface GalleryLayoutProps {
children?: ReactNode;
galleryRef: RefObject<HTMLDivElement>;
isLoading?: boolean;
zoomed?: Photo | null;
}
interface UnsplashGalleryProps extends GalleryLayoutProps {
error?: string | null;
dataset?: Photo[][] | [];
selectImg?: any;
insertImage?: any;
}
const UnsplashGalleryLoading: React.FC = () => {
return (
<div className="absolute inset-y-0 left-0 flex w-full items-center justify-center overflow-hidden pb-[8vh]" data-kg-loader>
<div className="relative inline-block h-[50px] w-[50px] animate-spin rounded-full border border-black/10 before:z-10 before:mt-[7px] before:block before:h-[7px] before:w-[7px] before:rounded-full before:bg-grey-800"></div>
</div>
);
};
export const MasonryColumn: React.FC<MasonryColumnProps> = (props) => {
return (
<div className="mr-6 flex grow basis-0 flex-col justify-start last-of-type:mr-0">
{props.children}
</div>
);
};
const UnsplashGalleryColumns: React.FC<UnsplashGalleryColumnsProps> = (props) => {
if (!props?.columns) {
return null;
}
return (
props?.columns.map((array, index) => (
// eslint-disable-next-line react/no-array-index-key
<MasonryColumn key={index}>
{
array.map((payload: Photo) => (
<UnsplashImage
key={payload.id}
alt={payload.alt_description}
height={payload.height}
insertImage={props?.insertImage}
likes={payload.likes}
links={payload.links}
payload={payload}
selectImg={props?.selectImg}
srcUrl={payload.urls.regular}
urls={payload.urls}
user={payload.user}
width={payload.width}
zoomed={props?.zoomed || null}
/>
))
}
</MasonryColumn>
))
);
};
const GalleryLayout: React.FC<GalleryLayoutProps> = (props) => {
return (
<div className="relative h-full overflow-hidden" data-kg-unsplash-gallery>
<div ref={props.galleryRef} className={`flex h-full w-full justify-center overflow-auto px-20 ${props?.zoomed ? 'pb-10' : ''}`} data-kg-unsplash-gallery-scrollref>
{props.children}
{props?.isLoading && <UnsplashGalleryLoading />}
</div>
</div>
);
};
const UnsplashGallery: React.FC<UnsplashGalleryProps> = ({zoomed,
error,
galleryRef,
isLoading,
dataset,
selectImg,
insertImage}) => {
if (zoomed) {
return (
<GalleryLayout
galleryRef={galleryRef}
zoomed={zoomed}>
<UnsplashZoomed
alt={zoomed.alt_description}
height={zoomed.height}
insertImage={insertImage}
likes={zoomed.likes}
links={zoomed.links}
payload={zoomed}
selectImg={selectImg}
srcUrl={zoomed.urls.regular}
urls={zoomed.urls}
user={zoomed.user}
width={zoomed.width}
zoomed={zoomed}
/>
</GalleryLayout>
);
}
if (error) {
return (
<GalleryLayout
galleryRef={galleryRef}
zoomed={zoomed}>
<div className="flex h-full flex-col items-center justify-center">
<h1 className="mb-4 text-2xl font-bold">Error</h1>
<p className="text-lg font-medium">{error}</p>
</div>
</GalleryLayout>
);
}
return (
<GalleryLayout
galleryRef={galleryRef}
isLoading={isLoading}
zoomed={zoomed}>
<UnsplashGalleryColumns
columns={dataset}
insertImage={insertImage}
selectImg={selectImg}
zoomed={zoomed}
/>
</GalleryLayout>
);
};
export default UnsplashGallery;

View File

@ -1,85 +0,0 @@
import UnsplashButton from './UnsplashButton';
import {FC, MouseEvent} from 'react';
import {Links, Photo, User} from '../UnsplashTypes';
export interface UnsplashImageProps {
payload: Photo;
srcUrl: string;
links: Links;
likes: number;
user: User;
alt: string;
urls: { regular: string };
height: number;
width: number;
zoomed: Photo | null;
insertImage: (options: {
src: string,
caption: string,
height: number,
width: number,
alt: string,
links: Links
}) => void;
selectImg: (payload: Photo | null) => void;
}
const UnsplashImage: FC<UnsplashImageProps> = ({payload, srcUrl, links, likes, user, alt, urls, height, width, zoomed, insertImage, selectImg}) => {
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
selectImg(zoomed ? null : payload);
};
return (
<div
className={`relative mb-6 block bg-grey-100 ${zoomed ? 'h-full w-[max-content] cursor-zoom-out' : 'w-full cursor-zoom-in'}`}
data-kg-unsplash-gallery-item
onClick={handleClick}>
<img
alt={alt}
className={`${zoomed ? 'h-full w-auto object-contain' : 'block h-auto'}`}
height={height}
loading='lazy'
src={srcUrl}
width={width}
data-kg-unsplash-gallery-img
/>
<div className="absolute inset-0 flex flex-col justify-between bg-gradient-to-b from-black/5 via-black/5 to-black/30 p-5 opacity-0 transition-all ease-in-out hover:opacity-100">
<div className="flex items-center justify-end gap-3">
<UnsplashButton
data-kg-button="unsplash-like"
href={`${links.html}/?utm_source=ghost&amp;utm_medium=referral&amp;utm_campaign=api-credit`}
icon="heart"
label={likes.toString()}
rel="noopener noreferrer"
target="_blank"
/>
<UnsplashButton
data-kg-button="unsplash-download"
href={`${links.download}/?utm_source=ghost&amp;utm_medium=referral&amp;utm_campaign=api-credit&amp;force=true`}
icon="download"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<img alt="author" className="mr-2 h-8 w-8 rounded-full" src={user.profile_image.medium} />
<div className="mr-2 truncate font-sans text-sm font-medium text-white">{user.name}</div>
</div>
<UnsplashButton label="Insert image" data-kg-unsplash-insert-button onClick={(e) => {
e.stopPropagation();
insertImage({
src: urls.regular.replace(/&w=1080/, '&w=2000'),
caption: `<span>Photo by <a href="${user.links.html}">${user.name}</a> / <a href="https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit">Unsplash</a></span>`,
height: height,
width: width,
alt: alt,
links: links
});
}} />
</div>
</div>
</div>
);
};
export default UnsplashImage;

View File

@ -1,42 +0,0 @@
import {ChangeEvent, FunctionComponent, ReactNode} from 'react';
import {ReactComponent as CloseIcon} from '../assets/kg-close.svg';
import {ReactComponent as SearchIcon} from '../assets/kg-search.svg';
import {ReactComponent as UnsplashIcon} from '../assets/kg-card-type-unsplash.svg';
interface UnsplashSelectorProps {
closeModal: () => void;
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
children: ReactNode;
}
const UnsplashSelector: FunctionComponent<UnsplashSelectorProps> = ({closeModal, handleSearch, children}) => {
return (
<>
<div className="fixed inset-0 z-40 h-[100vh] bg-black opacity-60"></div>
<div className="not-kg-prose fixed inset-8 z-50 overflow-hidden rounded bg-white shadow-xl" data-kg-modal="unsplash">
<button className="absolute right-6 top-6 cursor-pointer" type="button">
<CloseIcon
className="h-4 w-4 stroke-2 text-grey-400"
data-kg-modal-close-button
onClick={() => closeModal()}
/>
</button>
<div className="flex h-full flex-col">
<header className="flex shrink-0 items-center justify-between px-20 py-10">
<h1 className="flex items-center gap-2 font-sans text-3xl font-bold text-black">
<UnsplashIcon className="mb-1" />
Unsplash
</h1>
<div className="relative w-full max-w-sm">
<SearchIcon className="absolute left-4 top-1/2 h-4 w-4 -translate-y-2 text-grey-700" />
<input className="h-10 w-full rounded-full border border-solid border-grey-300 pl-10 pr-8 font-sans text-md font-normal text-black focus:border-grey-400 focus-visible:outline-none" placeholder="Search free high-resolution photos" autoFocus data-kg-unsplash-search onChange={handleSearch} />
</div>
</header>
{children}
</div>
</div>
</>
);
};
export default UnsplashSelector;

View File

@ -1,31 +0,0 @@
import UnsplashImage, {UnsplashImageProps} from './UnsplashImage';
import {FC} from 'react';
import {Photo} from '../UnsplashTypes';
interface UnsplashZoomedProps extends Omit<UnsplashImageProps, 'zoomed'> {
zoomed: Photo | null;
selectImg: (photo: Photo | null) => void;
}
const UnsplashZoomed: FC<UnsplashZoomedProps> = ({payload, insertImage, selectImg, zoomed}) => {
return (
<div className="flex h-full grow basis-0 justify-center" data-kg-unsplash-zoomed onClick={() => selectImg(null)}>
<UnsplashImage
alt={payload.alt_description}
height={payload.height}
insertImage={insertImage}
likes={payload.likes}
links={payload.links}
payload={payload}
selectImg={selectImg}
srcUrl={payload.urls.regular}
urls={payload.urls}
user={payload.user}
width={payload.width}
zoomed={zoomed}
/>
</div>
);
};
export default UnsplashZoomed;

View File

@ -1,56 +0,0 @@
import MasonryService from '../../../src/unsplash/masonry/MasonryService';
import {Photo} from '../../../src/unsplash/UnsplashTypes';
import {fixturePhotos} from '../../../src/unsplash/api/unsplashFixtures';
describe('MasonryService', () => {
let service: MasonryService;
let mockPhotos: Photo[];
beforeEach(() => {
service = new MasonryService(3);
mockPhotos = fixturePhotos;
});
it('should initialize with default column count', () => {
expect(service.columnCount).toEqual(3);
});
describe('reset', () => {
it('should reset columns and columnHeights', () => {
service.reset();
expect(service.columns.length).toEqual(3);
expect(service.columnHeights!.length).toEqual(3);
});
});
describe('addPhotoToColumns', () => {
it('should add photo to columns with the minimum height)', () => {
service.reset();
service.addPhotoToColumns(mockPhotos[0]);
expect(service.columns![0]).toContain(mockPhotos[0]);
});
});
describe('getColumns', () => {
it('should return the columns', () => {
service.reset();
const columns = service.getColumns();
expect(columns).toEqual(service.columns);
});
});
describe('changeColumnCount', () => {
it('should change the column count and reset', () => {
service.changeColumnCount(4);
expect(service.columnCount).toEqual(4);
expect(service.columns.length).toEqual(4);
expect(service.columnHeights!.length).toEqual(4);
});
it('should not reset if the column count is not changed', () => {
const prevColumns = service.getColumns();
service.changeColumnCount(3);
expect(service.getColumns()).toEqual(prevColumns);
});
});
});

View File

@ -1,53 +0,0 @@
import MasonryService from '../../../src/unsplash/masonry/MasonryService';
import {IUnsplashService, UnsplashService} from '../../../src/unsplash/UnsplashService';
import {InMemoryUnsplashProvider} from '../../../src/unsplash/api/InMemoryUnsplashProvider';
import {PhotoUseCases} from '../../../src/unsplash/photo/PhotoUseCase';
import {fixturePhotos} from '../../../src/unsplash/api/unsplashFixtures';
describe('UnsplashService', () => {
let unsplashService: IUnsplashService;
let UnsplashProvider: InMemoryUnsplashProvider;
let masonryService: MasonryService;
let photoUseCases: PhotoUseCases;
beforeEach(() => {
UnsplashProvider = new InMemoryUnsplashProvider();
masonryService = new MasonryService(3);
photoUseCases = new PhotoUseCases(UnsplashProvider);
unsplashService = new UnsplashService(photoUseCases, masonryService);
});
it('can load new photos', async function () {
await unsplashService.loadNew();
const photos = unsplashService.photos;
expect(photos).toEqual(fixturePhotos);
});
it('set up new columns of 3', async function () {
await unsplashService.loadNew();
const columns = unsplashService.getColumns();
if (columns) {
expect(columns.length).toBe(3);
}
});
it('can search for photos', async function () {
await unsplashService.updateSearch('cat');
const photos = unsplashService.photos;
expect(photos.length).toBe(0);
await unsplashService.updateSearch('photo');
const photos2 = unsplashService.photos;
expect(photos2.length).toBe(1);
});
it('can check if search is running', async function () {
const isRunning = unsplashService.searchIsRunning();
expect(isRunning).toBe(false);
});
it('can load next page', async function () {
await unsplashService.loadNextPage();
const photos = unsplashService.photos;
expect(photos.length).toBe(2);
});
});

View File

@ -20,6 +20,9 @@ export default (function viteConfig() {
// @TODO: Remove this when @tryghost/nql is updated
mingo: resolve(__dirname, '../../node_modules/mingo/dist/mingo.js')
}
},
optimizeDeps: {
include: ['@tryghost/kg-unsplash-selector']
}
}
});

217
yarn.lock
View File

@ -2242,6 +2242,14 @@
"@elastic/transport" "^8.3.4"
tslib "^2.4.0"
"@elastic/elasticsearch@8.12.2":
version "8.12.2"
resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-8.12.2.tgz#7a241f739a509cc59faee85f79a4c9e9e5ba9128"
integrity sha512-04NvH3LIgcv1Uwguorfw2WwzC9Lhfsqs9f0L6uq6MrCw0lqe/HOQ6E8vJ6EkHAA15iEfbhtxOtenbZVVcE+mAQ==
dependencies:
"@elastic/transport" "^8.4.1"
tslib "^2.4.0"
"@elastic/transport@^8.3.4":
version "8.4.0"
resolved "https://registry.npmjs.org/@elastic/transport/-/transport-8.4.0.tgz#e1ec05f7a2857162c161e2c97008f9b21301a673"
@ -2254,6 +2262,18 @@
tslib "^2.4.0"
undici "^5.22.1"
"@elastic/transport@^8.4.1":
version "8.4.1"
resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-8.4.1.tgz#f98c5a5e2156bcb3f01170b4aca7e7de4d8b61b8"
integrity sha512-/SXVuVnuU5b4dq8OFY4izG+dmGla185PcoqgK6+AJMpmOeY1QYVNbWtCwvSvoAANN5D/wV+EBU8+x7Vf9EphbA==
dependencies:
debug "^4.3.4"
hpagent "^1.0.0"
ms "^2.1.3"
secure-json-parse "^2.4.0"
tslib "^2.4.0"
undici "^5.22.1"
"@ember-data/adapter@3.24.0":
version "3.24.0"
resolved "https://registry.yarnpkg.com/@ember-data/adapter/-/adapter-3.24.0.tgz#995c19bc6fb95c94cbb83b8c3c7bc08253346cba"
@ -6947,6 +6967,14 @@
"@tryghost/root-utils" "^0.3.25"
debug "^4.3.1"
"@tryghost/debug@^0.1.28":
version "0.1.28"
resolved "https://registry.yarnpkg.com/@tryghost/debug/-/debug-0.1.28.tgz#498ef3450aa654ebb15a47553c2478a33164c0e6"
integrity sha512-iZKKlDDcZZa77GCgZ+o/Vp5oz520SOOpKCnoapgKGkFLRFT/0/D54jw/KY2pHGTFBXrcrE8kqTulgeuMNP+ABA==
dependencies:
"@tryghost/root-utils" "^0.3.26"
debug "^4.3.1"
"@tryghost/elasticsearch@^3.0.15":
version "3.0.15"
resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.15.tgz#d4be60b79155d95de063e17ea90ff0151a0a35d9"
@ -6956,6 +6984,15 @@
"@tryghost/debug" "^0.1.26"
split2 "4.2.0"
"@tryghost/elasticsearch@^3.0.16":
version "3.0.17"
resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.17.tgz#408e8ba7ce35c9357f6814bf0fd9b88cb56c2ebb"
integrity sha512-4uYnFJQ0QDNleko1J26E0byWnHrEBZzd3S1WVTbCztlC14KQweZxmfou3fc5JmcT/GNiyXd5Pgx+bLMtVi017g==
dependencies:
"@elastic/elasticsearch" "8.12.2"
"@tryghost/debug" "^0.1.28"
split2 "4.2.0"
"@tryghost/email-mock-receiver@0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@tryghost/email-mock-receiver/-/email-mock-receiver-0.3.2.tgz#abd8086935a95a996b6c5c803478a9f81dcae19a"
@ -6977,7 +7014,24 @@
focus-trap "^6.7.2"
postcss-preset-env "^7.3.1"
"@tryghost/errors@1.2.26", "@tryghost/errors@1.3.0", "@tryghost/errors@1.3.1", "@tryghost/errors@^1.2.26", "@tryghost/errors@^1.2.27", "@tryghost/errors@^1.2.3":
"@tryghost/errors@1.2.26":
version "1.2.26"
resolved "https://registry.yarnpkg.com/@tryghost/errors/-/errors-1.2.26.tgz#0d0503a51e681998421548fbddbdd7376384c457"
integrity sha512-s/eynvVUiAhHP0HB7CPQs7qH7Pm1quJ2iUMTCuH7HV8LqiGoQFNc21/5R4lRv+2Jt3yf69UPCs/6G+kAgZipNw==
dependencies:
"@stdlib/utils-copy" "^0.0.7"
lodash "^4.17.21"
uuid "^9.0.0"
"@tryghost/errors@1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@tryghost/errors/-/errors-1.3.0.tgz#273beb4c91bd7eb8a44b2e4154e57cc5321ad9dc"
integrity sha512-XI3Gw+6Mbua7FiCdNMY+0bVQ0pP6YDY5UpIAd9YIb1PWtISmiqEA2St1bvBk08Blfks3+lFPigh/YUxjuTKjRw==
dependencies:
"@stdlib/utils-copy" "^0.0.7"
uuid "^9.0.0"
"@tryghost/errors@1.3.1", "@tryghost/errors@^1.2.26", "@tryghost/errors@^1.2.27", "@tryghost/errors@^1.2.3", "@tryghost/errors@^1.3.1":
version "1.3.1"
resolved "https://registry.npmjs.org/@tryghost/errors/-/errors-1.3.1.tgz#32a00c5e5293c46e54d03a66da871ac34b2ab35c"
integrity sha512-iZqT0vZ3NVZNq9o1HYxW00k1mcUAC+t5OLiI8O29/uQwAfy7NemY+Cabl9mWoIwgvBmw7l0Z8pHTcXMo1c+xMw==
@ -7023,6 +7077,14 @@
"@tryghost/errors" "^1.2.26"
"@tryghost/request" "^1.0.0"
"@tryghost/http-stream@^0.1.27":
version "0.1.28"
resolved "https://registry.yarnpkg.com/@tryghost/http-stream/-/http-stream-0.1.28.tgz#ae38ef2e113e23b582d194fc76c5196a54171a11"
integrity sha512-E1TyZE5e121TPjMgJvlSPTcxJl3Xdq78kugmx2deLxADa5W/vuchidZ6yYQQzxCXPciQFUciVYT8B5tggZb+Xw==
dependencies:
"@tryghost/errors" "^1.3.1"
"@tryghost/request" "^1.0.3"
"@tryghost/image-transform@1.2.11":
version "1.2.11"
resolved "https://registry.yarnpkg.com/@tryghost/image-transform/-/image-transform-1.2.11.tgz#82463d97f8747db6db70165a04e824eed6791fee"
@ -7169,6 +7231,11 @@
dependencies:
"@tryghost/kg-clean-basic-html" "^4.0.1"
"@tryghost/kg-unsplash-selector@^0.1.11":
version "0.1.11"
resolved "https://registry.yarnpkg.com/@tryghost/kg-unsplash-selector/-/kg-unsplash-selector-0.1.11.tgz#d5fe973c01217fa5854a528a7b74e6d008b25309"
integrity sha512-5guN8Z8vpF2zCvfziKqFvQN/LQGuV2/ZKz+JiDk6dVBOwHZ/flitMDmnlodCNYT141yQE9x9DCrZAC216koHqw==
"@tryghost/kg-utils@^1.0.24":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@tryghost/kg-utils/-/kg-utils-1.0.24.tgz#4ef358ef803272cbe257993b9f79ea0a6b432077"
@ -7190,7 +7257,24 @@
lodash "^4.17.21"
luxon "^1.26.0"
"@tryghost/logging@2.4.10", "@tryghost/logging@2.4.8", "@tryghost/logging@^2.4.7":
"@tryghost/logging@2.4.10":
version "2.4.10"
resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-2.4.10.tgz#2e5b56c53364be330c1e6f2ffa33e3c30b7bac8e"
integrity sha512-l356vLSQmszY14y7ef5YxY4CZ3418NXn5+LvFdlweeTRk0ilWx1mVUoXi8IlVh90rIVbemv+pXi1dusJB6peQA==
dependencies:
"@tryghost/bunyan-rotating-filestream" "^0.0.7"
"@tryghost/elasticsearch" "^3.0.16"
"@tryghost/http-stream" "^0.1.27"
"@tryghost/pretty-stream" "^0.1.21"
"@tryghost/root-utils" "^0.3.25"
bunyan "^1.8.15"
bunyan-loggly "^1.4.2"
fs-extra "^11.0.0"
gelf-stream "^1.1.1"
json-stringify-safe "^5.0.1"
lodash "^4.17.21"
"@tryghost/logging@2.4.8", "@tryghost/logging@^2.4.7":
version "2.4.8"
resolved "https://registry.yarnpkg.com/@tryghost/logging/-/logging-2.4.8.tgz#a9e9abdbec823f0c6a009aa2f6847ce454b35475"
integrity sha512-/pIeTcw9jpqWJ5/VyUn5sa3rsUxUHortykB4oYd5vKr16KgnrVOuGPCg4JqmdGfz2zrkgKaGd9cAsTNE++0Deg==
@ -7305,6 +7389,15 @@
moment "^2.29.1"
prettyjson "^1.2.5"
"@tryghost/pretty-stream@^0.1.21":
version "0.1.22"
resolved "https://registry.yarnpkg.com/@tryghost/pretty-stream/-/pretty-stream-0.1.22.tgz#856a6b8eb3bd17b2fdc5ca668ac27bdd2f55b04b"
integrity sha512-97/JRI7rmdQIG6zwPzux58Kfc/UJfdvKiJgYgH7+CuNQqdl0Zy2+X0wlnRnYjck7tj781/rhGTEGGZg6PHZbaw==
dependencies:
lodash "^4.17.21"
moment "^2.29.1"
prettyjson "^1.2.5"
"@tryghost/promise@0.3.4":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@tryghost/promise/-/promise-0.3.4.tgz#b5437eb14a3d06e7d32f277e10975ff77061e16e"
@ -7327,6 +7420,18 @@
got "13.0.0"
lodash "^4.17.21"
"@tryghost/request@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@tryghost/request/-/request-1.0.3.tgz#73f7417bd4eb382133ea1eaa092ff14d070c45aa"
integrity sha512-ruHs3omvxTYGIm87gGJSRx0r64y4mBWV52d0wiwgOeCyyYL2WDYQ1dTgHWZbSjl8YHc2p0lc8gkPPxBZ+9ZnUA==
dependencies:
"@tryghost/errors" "^1.3.1"
"@tryghost/validator" "^0.2.9"
"@tryghost/version" "^0.1.26"
cacheable-lookup "7.0.0"
got "13.0.0"
lodash "^4.17.21"
"@tryghost/root-utils@0.3.24":
version "0.3.24"
resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.24.tgz#91653fbadc882fb8510844f163a2231c87f30fab"
@ -7343,6 +7448,14 @@
caller "^1.0.1"
find-root "^1.1.0"
"@tryghost/root-utils@^0.3.26":
version "0.3.26"
resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.26.tgz#26e626706b67dddd13f59812826a806c45722122"
integrity sha512-akbI+mmnU6mlwbVAEy8Ay1MYbMEocKEVP+QHbYCKk3d++2TM2lmSZm6CFIP5+10NeFiP2Em6jSaGXBpaalR9VQ==
dependencies:
caller "^1.0.1"
find-root "^1.1.0"
"@tryghost/server@^0.1.37":
version "0.1.37"
resolved "https://registry.yarnpkg.com/@tryghost/server/-/server-0.1.37.tgz#04ee5671b19a4a5be05e361e293d47eb9c6c2482"
@ -7382,6 +7495,13 @@
dependencies:
lodash.template "^4.5.0"
"@tryghost/tpl@^0.1.28":
version "0.1.28"
resolved "https://registry.yarnpkg.com/@tryghost/tpl/-/tpl-0.1.28.tgz#c1453eedf33da7010b1c556f2e4d92f656351fd9"
integrity sha512-z8DBIDntaJQMHEmp/ZhCpPjc5TXIsu7ZdnOVbAVK2YnzhLcjDl/JPpmt2FXLV3VBo7VM1XBT9nptiYd2kFnZFg==
dependencies:
lodash.template "^4.5.0"
"@tryghost/url-utils@4.4.6", "@tryghost/url-utils@^4.0.0":
version "4.4.6"
resolved "https://registry.yarnpkg.com/@tryghost/url-utils/-/url-utils-4.4.6.tgz#4938e55fcc11620fd17c64346249d420f6f97129"
@ -7406,6 +7526,17 @@
moment-timezone "^0.5.23"
validator "7.2.0"
"@tryghost/validator@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.9.tgz#6b33d884d96e0bca20a750d9dd1ed534a0efbab6"
integrity sha512-7EBFiXUGhU6ReuryAnqh5BM0Fa918NSEN3UR2dqrgk861W/pwofmx8r179jVKBNA0cSYot/DVj5bWlUV25cWvQ==
dependencies:
"@tryghost/errors" "^1.3.1"
"@tryghost/tpl" "^0.1.28"
lodash "^4.17.21"
moment-timezone "^0.5.23"
validator "7.2.0"
"@tryghost/version@0.1.24", "@tryghost/version@^0.1.24":
version "0.1.24"
resolved "https://registry.yarnpkg.com/@tryghost/version/-/version-0.1.24.tgz#eb8bc345929ba8f67c3f36757bd91c12f701a5f5"
@ -7414,6 +7545,14 @@
"@tryghost/root-utils" "^0.3.24"
semver "^7.3.5"
"@tryghost/version@^0.1.26":
version "0.1.26"
resolved "https://registry.yarnpkg.com/@tryghost/version/-/version-0.1.26.tgz#00829961bfef66b0aae01f6b866068df63859236"
integrity sha512-um1GihMBOs+1+p6tPGgIOHGlPeYyj0w+JxRHSIstPyywMmuM+kH3LUrHt3N3hb7zzgWfehM0L81k28MCGTqV5Q==
dependencies:
"@tryghost/root-utils" "^0.3.26"
semver "^7.3.5"
"@tryghost/webhook-mock-receiver@0.2.8":
version "0.2.8"
resolved "https://registry.yarnpkg.com/@tryghost/webhook-mock-receiver/-/webhook-mock-receiver-0.2.8.tgz#1cb3bb5de667f597f2eaa25aff3e9e572212cafa"
@ -22815,18 +22954,57 @@ module-details-from-path@^1.0.3:
resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b"
integrity sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==
moment-timezone@0.5.23, moment-timezone@0.5.34, moment-timezone@^0.5.23, moment-timezone@^0.5.31, moment-timezone@^0.5.33:
moment-timezone@0.5.23, moment-timezone@^0.5.23:
version "0.5.23"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.23.tgz#7cbb00db2c14c71b19303cb47b0fb0a6d8651463"
integrity sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==
dependencies:
moment ">= 2.9.0"
moment@2.24.0, moment@2.27.0, moment@2.29.1, moment@2.29.3, moment@2.29.4, "moment@>= 2.9.0", moment@^2.10.2, moment@^2.18.1, moment@^2.19.3, moment@^2.27.0, moment@^2.29.1:
moment-timezone@0.5.34:
version "0.5.34"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c"
integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg==
dependencies:
moment ">= 2.9.0"
moment-timezone@^0.5.31, moment-timezone@^0.5.33:
version "0.5.45"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.45.tgz#cb685acd56bac10e69d93c536366eb65aa6bcf5c"
integrity sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==
dependencies:
moment "^2.29.4"
moment@2.24.0, "moment@>= 2.9.0", moment@^2.10.2, moment@^2.18.1, moment@^2.19.3:
version "2.24.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
moment@2.27.0:
version "2.27.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d"
integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==
moment@2.29.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
moment@2.29.3:
version "2.29.3"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.3.tgz#edd47411c322413999f7a5940d526de183c031f3"
integrity sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==
moment@2.29.4:
version "2.29.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
moment@^2.27.0, moment@^2.29.1, moment@^2.29.4:
version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
moo@^0.5.0, moo@^0.5.1:
version "0.5.2"
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"
@ -28331,7 +28509,7 @@ string-template@~0.2.1:
resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
integrity sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==
"string-width-cjs@npm:string-width@^4.2.0":
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -28349,15 +28527,6 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
@ -28451,7 +28620,7 @@ stringify-entities@^2.0.0:
is-decimal "^1.0.2"
is-hexadecimal "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -28479,13 +28648,6 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
@ -30975,7 +31137,7 @@ workerpool@^6.0.2, workerpool@^6.0.3, workerpool@^6.1.5, workerpool@^6.4.0:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544"
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -30993,15 +31155,6 @@ wrap-ansi@^6.0.1:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"