Revert "Extracted Unsplash Selector from AdminX (#19838)" (#19844)

no issue

- caused a styling regression, making changes then will merge again.
This commit is contained in:
Ronald Langeveld 2024-03-12 17:35:09 +02:00 committed by GitHub
parent 55791a8c64
commit 36f11a65a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1290 additions and 39 deletions

View File

@ -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",

View File

@ -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<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 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:
</ImageUpload>
{
showUnsplash && unsplashConfig && unsplashEnabled && (
<UnsplashSelector
unsplashProviderConfig={unsplashConfig}
<UnsplashSearchModal
unsplashConf={{
defaultHeaders: unsplashConfig
}}
onClose={() => {
setShowUnsplash(false);
}}

View File

@ -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<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

@ -0,0 +1,68 @@
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

@ -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;
};

View File

@ -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<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

@ -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<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

@ -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'
}
];

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 176 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 226 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 216 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 283 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 197 B

View File

@ -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();
}
}
}

View File

@ -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<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

@ -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<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

@ -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<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

@ -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<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

@ -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<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

@ -0,0 +1,31 @@
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

@ -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);
});
});
});

View File

@ -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);
});
});

View File

@ -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']
}
}
});

View File

@ -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"