mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-23 03:42:27 +03:00
no issue - caused a styling regression, making changes then will merge again.
This commit is contained in:
parent
55791a8c64
commit
36f11a65a0
@ -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",
|
||||
|
@ -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;
|
@ -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);
|
||||
}}
|
||||
|
192
apps/admin-x-settings/src/unsplash/UnsplashSearchModal.tsx
Normal file
192
apps/admin-x-settings/src/unsplash/UnsplashSearchModal.tsx
Normal 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;
|
68
apps/admin-x-settings/src/unsplash/UnsplashService.ts
Normal file
68
apps/admin-x-settings/src/unsplash/UnsplashService.ts
Normal 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();
|
||||
}
|
||||
}
|
80
apps/admin-x-settings/src/unsplash/UnsplashTypes.ts
Normal file
80
apps/admin-x-settings/src/unsplash/UnsplashTypes.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
161
apps/admin-x-settings/src/unsplash/api/UnsplashProvider.ts
Normal file
161
apps/admin-x-settings/src/unsplash/api/UnsplashProvider.ts
Normal 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;
|
||||
}
|
||||
}
|
142
apps/admin-x-settings/src/unsplash/api/unsplashFixtures.ts
Normal file
142
apps/admin-x-settings/src/unsplash/api/unsplashFixtures.ts
Normal 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'
|
||||
}
|
||||
];
|
@ -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 |
3
apps/admin-x-settings/src/unsplash/assets/kg-close.svg
Normal file
3
apps/admin-x-settings/src/unsplash/assets/kg-close.svg
Normal 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 |
@ -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 |
3
apps/admin-x-settings/src/unsplash/assets/kg-search.svg
Normal file
3
apps/admin-x-settings/src/unsplash/assets/kg-search.svg
Normal 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 |
@ -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 |
55
apps/admin-x-settings/src/unsplash/masonry/MasonryService.ts
Normal file
55
apps/admin-x-settings/src/unsplash/masonry/MasonryService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
37
apps/admin-x-settings/src/unsplash/photo/PhotoUseCase.ts
Normal file
37
apps/admin-x-settings/src/unsplash/photo/PhotoUseCase.ts
Normal 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();
|
||||
}
|
||||
}
|
37
apps/admin-x-settings/src/unsplash/ui/UnsplashButton.tsx
Normal file
37
apps/admin-x-settings/src/unsplash/ui/UnsplashButton.tsx
Normal 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;
|
149
apps/admin-x-settings/src/unsplash/ui/UnsplashGallery.tsx
Normal file
149
apps/admin-x-settings/src/unsplash/ui/UnsplashGallery.tsx
Normal 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;
|
85
apps/admin-x-settings/src/unsplash/ui/UnsplashImage.tsx
Normal file
85
apps/admin-x-settings/src/unsplash/ui/UnsplashImage.tsx
Normal 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&utm_medium=referral&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&utm_medium=referral&utm_campaign=api-credit&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;
|
42
apps/admin-x-settings/src/unsplash/ui/UnsplashSelector.tsx
Normal file
42
apps/admin-x-settings/src/unsplash/ui/UnsplashSelector.tsx
Normal 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;
|
31
apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx
Normal file
31
apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx
Normal 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;
|
56
apps/admin-x-settings/test/unit/unsplash/Masonry.test.ts
Normal file
56
apps/admin-x-settings/test/unit/unsplash/Masonry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
36
yarn.lock
36
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"
|
||||
|
Loading…
Reference in New Issue
Block a user