From 36f11a65a0f478500f7f19d66a3eb2a7b95cf66b Mon Sep 17 00:00:00 2001 From: Ronald Langeveld Date: Tue, 12 Mar 2024 17:35:09 +0200 Subject: [PATCH] Revert "Extracted Unsplash Selector from AdminX (#19838)" (#19844) no issue - caused a styling regression, making changes then will merge again. --- apps/admin-x-settings/package.json | 1 - .../components/selectors/UnsplashSelector.tsx | 24 --- .../site/designAndBranding/BrandSettings.tsx | 8 +- .../src/unsplash/UnsplashSearchModal.tsx | 192 ++++++++++++++++++ .../src/unsplash/UnsplashService.ts | 68 +++++++ .../src/unsplash/UnsplashTypes.ts | 80 ++++++++ .../unsplash/api/InMemoryUnsplashProvider.ts | 54 +++++ .../src/unsplash/api/UnsplashProvider.ts | 161 +++++++++++++++ .../src/unsplash/api/unsplashFixtures.ts | 142 +++++++++++++ .../unsplash/assets/kg-card-type-unsplash.svg | 3 + .../src/unsplash/assets/kg-close.svg | 3 + .../src/unsplash/assets/kg-download.svg | 3 + .../src/unsplash/assets/kg-search.svg | 3 + .../src/unsplash/assets/kg-unsplash-heart.svg | 3 + .../src/unsplash/masonry/MasonryService.ts | 55 +++++ .../src/unsplash/photo/PhotoUseCase.ts | 37 ++++ .../src/{utils => unsplash}/portal.tsx | 0 .../src/unsplash/ui/UnsplashButton.tsx | 37 ++++ .../src/unsplash/ui/UnsplashGallery.tsx | 149 ++++++++++++++ .../src/unsplash/ui/UnsplashImage.tsx | 85 ++++++++ .../src/unsplash/ui/UnsplashSelector.tsx | 42 ++++ .../src/unsplash/ui/UnsplashZoomed.tsx | 31 +++ .../test/unit/unsplash/Masonry.test.ts | 56 +++++ .../unit/unsplash/UnsplashService.test.ts | 53 +++++ apps/admin-x-settings/vite.config.mjs | 3 - yarn.lock | 36 +++- 26 files changed, 1290 insertions(+), 39 deletions(-) delete mode 100644 apps/admin-x-settings/src/components/selectors/UnsplashSelector.tsx create mode 100644 apps/admin-x-settings/src/unsplash/UnsplashSearchModal.tsx create mode 100644 apps/admin-x-settings/src/unsplash/UnsplashService.ts create mode 100644 apps/admin-x-settings/src/unsplash/UnsplashTypes.ts create mode 100644 apps/admin-x-settings/src/unsplash/api/InMemoryUnsplashProvider.ts create mode 100644 apps/admin-x-settings/src/unsplash/api/UnsplashProvider.ts create mode 100644 apps/admin-x-settings/src/unsplash/api/unsplashFixtures.ts create mode 100644 apps/admin-x-settings/src/unsplash/assets/kg-card-type-unsplash.svg create mode 100644 apps/admin-x-settings/src/unsplash/assets/kg-close.svg create mode 100644 apps/admin-x-settings/src/unsplash/assets/kg-download.svg create mode 100644 apps/admin-x-settings/src/unsplash/assets/kg-search.svg create mode 100644 apps/admin-x-settings/src/unsplash/assets/kg-unsplash-heart.svg create mode 100644 apps/admin-x-settings/src/unsplash/masonry/MasonryService.ts create mode 100644 apps/admin-x-settings/src/unsplash/photo/PhotoUseCase.ts rename apps/admin-x-settings/src/{utils => unsplash}/portal.tsx (100%) create mode 100644 apps/admin-x-settings/src/unsplash/ui/UnsplashButton.tsx create mode 100644 apps/admin-x-settings/src/unsplash/ui/UnsplashGallery.tsx create mode 100644 apps/admin-x-settings/src/unsplash/ui/UnsplashImage.tsx create mode 100644 apps/admin-x-settings/src/unsplash/ui/UnsplashSelector.tsx create mode 100644 apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx create mode 100644 apps/admin-x-settings/test/unit/unsplash/Masonry.test.ts create mode 100644 apps/admin-x-settings/test/unit/unsplash/UnsplashService.test.ts diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index 1914d70e21..5f0dbef6be 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -39,7 +39,6 @@ "dependencies": { "@codemirror/lang-html": "^6.4.5", "@tryghost/color-utils": "0.2.0", - "@tryghost/kg-unsplash-selector": "^0.1.8", "@tryghost/limit-service": "^1.2.10", "@tryghost/nql": "0.12.1", "@tryghost/timezone-data": "0.4.1", diff --git a/apps/admin-x-settings/src/components/selectors/UnsplashSelector.tsx b/apps/admin-x-settings/src/components/selectors/UnsplashSelector.tsx deleted file mode 100644 index 2c030ce6f5..0000000000 --- a/apps/admin-x-settings/src/components/selectors/UnsplashSelector.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import '@tryghost/kg-unsplash-selector/dist/style.css'; // required to load the unsplash styles -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 = ({unsplashProviderConfig, onClose, onImageInsert}) => { - return ( - - - - ); -}; - -export default UnsplashSelector; diff --git a/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx b/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx index 05a9a735f9..2be76a8b76 100644 --- a/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx +++ b/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx @@ -1,5 +1,5 @@ import React, {useRef, useState} from 'react'; -import UnsplashSelector from '../../../selectors/UnsplashSelector'; +import UnsplashSearchModal from '../../../../unsplash/UnsplashSearchModal'; 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,8 +144,10 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: { showUnsplash && unsplashConfig && unsplashEnabled && ( - { setShowUnsplash(false); }} diff --git a/apps/admin-x-settings/src/unsplash/UnsplashSearchModal.tsx b/apps/admin-x-settings/src/unsplash/UnsplashSearchModal.tsx new file mode 100644 index 0000000000..86c00216c1 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/UnsplashSearchModal.tsx @@ -0,0 +1,192 @@ +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 = ({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(null); + const [scrollPos, setScrollPos] = useState(0); + const [lastScrollPos, setLastScrollPos] = useState(0); + const [isLoading, setIsLoading] = useState(UnsplashLib.searchIsRunning() || true); + const initLoadRef = useRef(false); + const [searchTerm, setSearchTerm] = useState(''); + const [zoomedImg, setZoomedImg] = useState(null); + const [dataset, setDataset] = useState([]); + + 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) => { + 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 ( + + + + + + ); +}; + +export default UnsplashSearchModal; diff --git a/apps/admin-x-settings/src/unsplash/UnsplashService.ts b/apps/admin-x-settings/src/unsplash/UnsplashService.ts new file mode 100644 index 0000000000..2bc0941563 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/UnsplashService.ts @@ -0,0 +1,68 @@ +import MasonryService from './masonry/MasonryService'; +import {Photo} from './UnsplashTypes'; +import {PhotoUseCases} from './photo/PhotoUseCase'; + +export interface IUnsplashService { + loadNew(): Promise; + layoutPhotos(): void; + getColumns(): Photo[][] | [] | null; + updateSearch(term: string): Promise; + loadNextPage(): Promise; + 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(); + } +} diff --git a/apps/admin-x-settings/src/unsplash/UnsplashTypes.ts b/apps/admin-x-settings/src/unsplash/UnsplashTypes.ts new file mode 100644 index 0000000000..3c78aa223f --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/UnsplashTypes.ts @@ -0,0 +1,80 @@ +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; +}; diff --git a/apps/admin-x-settings/src/unsplash/api/InMemoryUnsplashProvider.ts b/apps/admin-x-settings/src/unsplash/api/InMemoryUnsplashProvider.ts new file mode 100644 index 0000000000..c58b530539 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/api/InMemoryUnsplashProvider.ts @@ -0,0 +1,54 @@ +// 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 { + 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 { + 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 { + 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; + }; + } +} diff --git a/apps/admin-x-settings/src/unsplash/api/UnsplashProvider.ts b/apps/admin-x-settings/src/unsplash/api/UnsplashProvider.ts new file mode 100644 index 0000000000..7050e62943 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/api/UnsplashProvider.ts @@ -0,0 +1,161 @@ +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 { + 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 { + const url = `${this.API_URL}/photos?per_page=30`; + const request = await this.makeRequest(url); + return request as Photo[]; + } + + public async fetchNextPage(): Promise { + 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 { + 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 { + if (photo.links.download_location) { + await this.makeRequest(photo.links.download_location); + } + } + + private async checkStatus(response: Response): Promise { + if (response.status >= 200 && response.status < 300) { + return response; + } + + let errorText = ''; + let responseTextPromise: Promise; // or Promise 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; + } +} diff --git a/apps/admin-x-settings/src/unsplash/api/unsplashFixtures.ts b/apps/admin-x-settings/src/unsplash/api/unsplashFixtures.ts new file mode 100644 index 0000000000..3a56b8ed6b --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/api/unsplashFixtures.ts @@ -0,0 +1,142 @@ +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' + } +]; diff --git a/apps/admin-x-settings/src/unsplash/assets/kg-card-type-unsplash.svg b/apps/admin-x-settings/src/unsplash/assets/kg-card-type-unsplash.svg new file mode 100644 index 0000000000..805b50f3e5 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/assets/kg-card-type-unsplash.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/admin-x-settings/src/unsplash/assets/kg-close.svg b/apps/admin-x-settings/src/unsplash/assets/kg-close.svg new file mode 100644 index 0000000000..30bce27c3b --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/assets/kg-close.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/admin-x-settings/src/unsplash/assets/kg-download.svg b/apps/admin-x-settings/src/unsplash/assets/kg-download.svg new file mode 100644 index 0000000000..2d1c72bfa4 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/assets/kg-download.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/admin-x-settings/src/unsplash/assets/kg-search.svg b/apps/admin-x-settings/src/unsplash/assets/kg-search.svg new file mode 100644 index 0000000000..dd56d96d42 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/assets/kg-search.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/admin-x-settings/src/unsplash/assets/kg-unsplash-heart.svg b/apps/admin-x-settings/src/unsplash/assets/kg-unsplash-heart.svg new file mode 100644 index 0000000000..1b31419ccb --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/assets/kg-unsplash-heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/admin-x-settings/src/unsplash/masonry/MasonryService.ts b/apps/admin-x-settings/src/unsplash/masonry/MasonryService.ts new file mode 100644 index 0000000000..6a624cf983 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/masonry/MasonryService.ts @@ -0,0 +1,55 @@ +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(); + } + } +} diff --git a/apps/admin-x-settings/src/unsplash/photo/PhotoUseCase.ts b/apps/admin-x-settings/src/unsplash/photo/PhotoUseCase.ts new file mode 100644 index 0000000000..d46cd8e131 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/photo/PhotoUseCase.ts @@ -0,0 +1,37 @@ +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 { + return await this._provider.fetchPhotos(); + } + + async searchPhotos(term: string): Promise { + return await this._provider.searchPhotos(term); + } + + async triggerDownload(photo: Photo): Promise { + this._provider.triggerDownload(photo); + } + + async fetchNextPage(): Promise { + let request = await this._provider.fetchNextPage(); + + if (request) { + return request; + } + + return null; + } + + searchIsRunning(): boolean { + return this._provider.searchIsRunning(); + } +} diff --git a/apps/admin-x-settings/src/utils/portal.tsx b/apps/admin-x-settings/src/unsplash/portal.tsx similarity index 100% rename from apps/admin-x-settings/src/utils/portal.tsx rename to apps/admin-x-settings/src/unsplash/portal.tsx diff --git a/apps/admin-x-settings/src/unsplash/ui/UnsplashButton.tsx b/apps/admin-x-settings/src/unsplash/ui/UnsplashButton.tsx new file mode 100644 index 0000000000..3fb835663d --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/ui/UnsplashButton.tsx @@ -0,0 +1,37 @@ +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 { + icon?: ButtonIconType; + label?: string; +} + +const BUTTON_ICONS: Record>>> = { + heart: UnsplashHeartIcon, + download: DownloadIcon +}; + +const UnsplashButton: React.FC = ({icon, label, ...props}) => { + let Icon = null; + if (icon) { + Icon = BUTTON_ICONS[icon]; + } + + return ( + e.stopPropagation()} + {...props} + > + {icon && Icon && } + {label && {label}} + + ); +}; + +export default UnsplashButton; diff --git a/apps/admin-x-settings/src/unsplash/ui/UnsplashGallery.tsx b/apps/admin-x-settings/src/unsplash/ui/UnsplashGallery.tsx new file mode 100644 index 0000000000..e162358094 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/ui/UnsplashGallery.tsx @@ -0,0 +1,149 @@ +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; + isLoading?: boolean; + zoomed?: Photo | null; +} + +interface UnsplashGalleryProps extends GalleryLayoutProps { + error?: string | null; + dataset?: Photo[][] | []; + selectImg?: any; + insertImage?: any; +} + +const UnsplashGalleryLoading: React.FC = () => { + return ( +
+
+
+ ); +}; + +export const MasonryColumn: React.FC = (props) => { + return ( +
+ {props.children} +
+ ); +}; + +const UnsplashGalleryColumns: React.FC = (props) => { + if (!props?.columns) { + return null; + } + + return ( + props?.columns.map((array, index) => ( + // eslint-disable-next-line react/no-array-index-key + + { + array.map((payload: Photo) => ( + + )) + } + + )) + ); +}; + +const GalleryLayout: React.FC = (props) => { + return ( +
+
+ {props.children} + {props?.isLoading && } +
+
+ ); +}; + +const UnsplashGallery: React.FC = ({zoomed, + error, + galleryRef, + isLoading, + dataset, + selectImg, + insertImage}) => { + if (zoomed) { + return ( + + + + ); + } + + if (error) { + return ( + +
+

Error

+

{error}

+
+
+ ); + } + + return ( + + + + ); +}; + +export default UnsplashGallery; diff --git a/apps/admin-x-settings/src/unsplash/ui/UnsplashImage.tsx b/apps/admin-x-settings/src/unsplash/ui/UnsplashImage.tsx new file mode 100644 index 0000000000..824ba883b0 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/ui/UnsplashImage.tsx @@ -0,0 +1,85 @@ +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 = ({payload, srcUrl, links, likes, user, alt, urls, height, width, zoomed, insertImage, selectImg}) => { + const handleClick = (e: MouseEvent) => { + e.stopPropagation(); + selectImg(zoomed ? null : payload); + }; + + return ( +
+ {alt} +
+
+ + +
+
+
+ author +
{user.name}
+
+ { + e.stopPropagation(); + insertImage({ + src: urls.regular.replace(/&w=1080/, '&w=2000'), + caption: `Photo by ${user.name} / Unsplash`, + height: height, + width: width, + alt: alt, + links: links + }); + }} /> +
+
+
+ ); +}; + +export default UnsplashImage; diff --git a/apps/admin-x-settings/src/unsplash/ui/UnsplashSelector.tsx b/apps/admin-x-settings/src/unsplash/ui/UnsplashSelector.tsx new file mode 100644 index 0000000000..6873de96dc --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/ui/UnsplashSelector.tsx @@ -0,0 +1,42 @@ +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) => void; + children: ReactNode; +} + +const UnsplashSelector: FunctionComponent = ({closeModal, handleSearch, children}) => { + return ( + <> +
+
+ +
+
+

+ + Unsplash +

+
+ + +
+
+ {children} +
+
+ + ); +}; + +export default UnsplashSelector; diff --git a/apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx b/apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx new file mode 100644 index 0000000000..88860d3b18 --- /dev/null +++ b/apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx @@ -0,0 +1,31 @@ +import UnsplashImage, {UnsplashImageProps} from './UnsplashImage'; +import {FC} from 'react'; +import {Photo} from '../UnsplashTypes'; + +interface UnsplashZoomedProps extends Omit { + zoomed: Photo | null; + selectImg: (photo: Photo | null) => void; +} + +const UnsplashZoomed: FC = ({payload, insertImage, selectImg, zoomed}) => { + return ( +
selectImg(null)}> + +
+ ); +}; + +export default UnsplashZoomed; diff --git a/apps/admin-x-settings/test/unit/unsplash/Masonry.test.ts b/apps/admin-x-settings/test/unit/unsplash/Masonry.test.ts new file mode 100644 index 0000000000..e42ecafe94 --- /dev/null +++ b/apps/admin-x-settings/test/unit/unsplash/Masonry.test.ts @@ -0,0 +1,56 @@ +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); + }); + }); +}); diff --git a/apps/admin-x-settings/test/unit/unsplash/UnsplashService.test.ts b/apps/admin-x-settings/test/unit/unsplash/UnsplashService.test.ts new file mode 100644 index 0000000000..35ae259827 --- /dev/null +++ b/apps/admin-x-settings/test/unit/unsplash/UnsplashService.test.ts @@ -0,0 +1,53 @@ +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); + }); +}); diff --git a/apps/admin-x-settings/vite.config.mjs b/apps/admin-x-settings/vite.config.mjs index 79df33d790..27795efe4e 100644 --- a/apps/admin-x-settings/vite.config.mjs +++ b/apps/admin-x-settings/vite.config.mjs @@ -20,9 +20,6 @@ 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'] } } }); diff --git a/yarn.lock b/yarn.lock index 0753ad2672..9f324ce122 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7169,11 +7169,6 @@ dependencies: "@tryghost/kg-clean-basic-html" "^4.0.1" -"@tryghost/kg-unsplash-selector@^0.1.8": - version "0.1.8" - resolved "https://registry.yarnpkg.com/@tryghost/kg-unsplash-selector/-/kg-unsplash-selector-0.1.8.tgz#ef82ca1e2017f4d822d3e6358cb4ccf80a1ea269" - integrity sha512-ymyf4gwAASOyyvyw3ANP3/YnDB7jp4jgS5CdT/hM8BIg3xMZ6808blV+sOA21fgv72Jwaxs6pYAsHRQvIPXu9g== - "@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" @@ -28336,7 +28331,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@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": 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== @@ -28354,6 +28349,15 @@ 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" @@ -28447,7 +28451,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@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm: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== @@ -28475,6 +28479,13 @@ 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" @@ -30964,7 +30975,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@^7.0.0: +"wrap-ansi-cjs@npm: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== @@ -30982,6 +30993,15 @@ 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"