1
Fork 0

Refactor scanner into multiple files

This commit is contained in:
viktorstrate 2019-07-21 22:51:51 +02:00
parent 7a54fc9bc9
commit df0f2e9140
12 changed files with 501 additions and 414 deletions

View File

@ -1,398 +0,0 @@
import fs from 'fs-extra'
import path from 'path'
import { resolve as pathResolve, basename as pathBasename } from 'path'
import { PubSub } from 'apollo-server'
import uuid from 'uuid'
import { exiftool } from 'exiftool-vendored'
import sharp from 'sharp'
import readChunk from 'read-chunk'
import imageType from 'image-type'
import { promisify } from 'util'
import config from './config'
const imageSize = promisify(require('image-size'))
export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS'
const isImage = async path => {
const buffer = await readChunk(path, 0, 12)
const type = imageType(buffer)
return type != null
}
export const isRawImage = async path => {
const buffer = await readChunk(path, 0, 12)
const { ext } = imageType(buffer)
const rawTypes = ['cr2', 'arw', 'crw', 'dng']
return rawTypes.includes(ext)
}
class PhotoScanner {
constructor(driver) {
this.driver = driver
this.isRunning = false
this.pubsub = new PubSub()
this.scanAll = this.scanAll.bind(this)
this.scanAlbum = this.scanAlbum.bind(this)
this.scanUser = this.scanUser.bind(this)
this.processImage = this.processImage.bind(this)
this.imagesToProgress = 0
this.finishedImages = 0
}
async scanAll() {
this.isRunning = true
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: 0,
finished: false,
error: false,
errorMessage: '',
},
})
let session = this.driver.session()
let allUserScans = []
session.run('MATCH (u:User) return u').subscribe({
onNext: record => {
const user = record.toObject().u.properties
console.log('USER', user)
if (!user.rootPath) {
console.log(`User ${user.username}, has no root path, skipping`)
return
}
console.log(`Scanning ${user.username}...`)
allUserScans.push(this.scanUser(user))
},
onCompleted: () => {
session.close()
this.isRunning = false
Promise.all(allUserScans)
.then(() => {
console.log(
`Done scanning ${this.finishedImages} of ${this.imagesToProgress}`
)
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: 100,
finished: true,
error: false,
errorMessage: '',
},
})
})
.catch(error => {
console.log('SYNC ERROR', JSON.stringify(error))
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: 0,
finished: false,
error: true,
errorMessage: error.message,
},
})
})
},
onError: error => {
console.error(error)
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: 0,
finished: false,
error: true,
errorMessage: error.message,
},
})
},
})
}
async scanUser(user) {
console.log('Scanning path', user.rootPath)
const driver = this.driver
const scanAlbum = this.scanAlbum
let foundAlbumIds = []
async function scanPath(path, parentAlbum) {
const list = fs.readdirSync(path)
let foundImage = false
let newAlbums = []
for (const item of list) {
const itemPath = pathResolve(path, item)
// console.log(`Scanning item ${itemPath}...`)
const stat = fs.statSync(itemPath)
if (stat.isDirectory()) {
const session = driver.session()
let nextParentAlbum = null
const findAlbumResult = await session.run(
'MATCH (a:Album { path: {path} }) RETURN a',
{
path: itemPath,
}
)
session.close()
if (findAlbumResult.records.length != 0) {
const album = findAlbumResult.records[0].toObject().a.properties
console.log('Found existing album', album.title)
foundAlbumIds.push(album.id)
nextParentAlbum = album.id
scanAlbum(album)
continue
}
const {
foundImage: imagesInDirectory,
newAlbums: childAlbums,
} = await scanPath(itemPath, nextParentAlbum)
if (imagesInDirectory) {
console.log(`Found new album at ${itemPath}`)
const session = driver.session()
console.log('Adding album')
const albumId = uuid()
const albumResult = await session.run(
`MATCH (u:User { id: {userId} })
CREATE (a:Album { id: {id}, title: {title}, path: {path} })
CREATE (u)-[own:OWNS]->(a)
RETURN a`,
{
id: albumId,
userId: user.id,
title: item,
path: itemPath,
}
)
foundAlbumIds.push(albumId)
newAlbums.push(albumId)
const album = albumResult.records[0].toObject().a.properties
if (parentAlbum) {
console.log('Linking parent album for', album.title)
await session.run(
`MATCH (parent:Album { id: {parentId} })
MATCH (child:Album { id: {childId} })
CREATE (parent)-[:SUBALBUM]->(child)`,
{
childId: albumId,
parentId: parentAlbum,
}
)
}
console.log(`Linking ${childAlbums.length} child albums`)
for (let childAlbum of childAlbums) {
await session.run(
`MATCH (parent:Album { id: {parentId} })
MATCH (child:Album { id: {childId} })
CREATE (parent)-[:SUBALBUM]->(child)`,
{
parentId: albumId,
childId: childAlbum,
}
)
}
scanAlbum(album)
session.close()
}
continue
}
if (!foundImage && (await isImage(itemPath))) {
foundImage = true
}
}
return { foundImage, newAlbums }
}
await scanPath(user.rootPath)
console.log('Found album ids', foundAlbumIds)
const session = this.driver.session()
const userAlbumsResult = await session.run(
'MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album)-[:CONTAINS]->(p:Photo) WHERE NOT a.id IN {foundAlbums} DETACH DELETE a, p RETURN a',
{ userId: user.id, foundAlbums: foundAlbumIds }
)
console.log(
`Deleted ${userAlbumsResult.records.length} albums from ${user.username} that was not found locally`
)
session.close()
console.log('User scan complete')
}
async scanAlbum(album) {
const { title, path, id } = album
console.log('Scanning album', title)
const list = fs.readdirSync(path)
for (const item of list) {
const itemPath = pathResolve(path, item)
if (await isImage(itemPath)) {
const session = this.driver.session()
this.imagesToProgress++
const photoResult = await session.run(
`MATCH (p:Photo {path: {imgPath} })<--(a:Album {id: {albumId}}) RETURN p`,
{
imgPath: itemPath,
albumId: id,
}
)
if (photoResult.records.length != 0) {
console.log(`Photo already exists ${item}`)
const id = photoResult.records[0].get('p').properties.id
const thumbnailPath = pathResolve(
config.cachePath,
id,
'thumbnail.jpg'
)
if (!(await fs.exists(thumbnailPath))) {
this.processImage(id)
} else {
this.finishedImages++
}
} else {
console.log(`Found new image at ${itemPath}`)
const imageId = uuid()
await session.run(
`MATCH (a:Album { id: {albumId} })
CREATE (p:Photo {id: {id}, path: {path}, title: {title} })
CREATE (a)-[:CONTAINS]->(p)`,
{
id: imageId,
path: itemPath,
title: item,
albumId: id,
}
)
this.processImage(imageId)
}
}
}
}
async processImage(id) {
const session = this.driver.session()
const result = await session.run(`MATCH (p:Photo { id: {id} }) RETURN p`, {
id,
})
await session.run(
`MATCH (p:Photo { id: {id} })-[rel]->(url:PhotoURL) DELETE url, rel`,
{ id }
)
console.log('PROCESS IMAGE RESULT', result)
const photo = result.records[0].get('p').properties
console.log('Processing photo', photo.path)
const imagePath = path.resolve(config.cachePath, 'images', id)
await fs.remove(imagePath)
await fs.mkdirp(imagePath)
let originalPath = photo.path
if (await isRawImage(photo.path)) {
console.log('Processing RAW image')
const extractedPath = path.resolve(imagePath, 'extracted.jpg')
await exiftool.extractPreview(photo.path, extractedPath)
originalPath = extractedPath
}
// Resize image
const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg')
await sharp(originalPath)
.jpeg({ quality: 80 })
.resize(1440, 1080, { fit: 'inside', withoutEnlargement: true })
.toFile(thumbnailPath)
const { width: originalWidth, height: originalHeight } = await imageSize(
originalPath
)
const { width: thumbnailWidth, height: thumbnailHeight } = await imageSize(
thumbnailPath
)
await session.run(
`MATCH (p:Photo { id: {id} })
CREATE (thumbnail:PhotoURL { url: {thumbnailUrl}, width: {thumbnailWidth}, height: {thumbnailHeight} })
CREATE (original:PhotoURL { url: {originalUrl}, width: {originalWidth}, height: {originalHeight} })
CREATE (p)-[:THUMBNAIL_URL]->(thumbnail)
CREATE (p)-[:ORIGINAL_URL]->(original)
`,
{
id,
thumbnailUrl: `/images/${id}/${path.basename(thumbnailPath)}`,
thumbnailWidth,
thumbnailHeight,
originalUrl: `/images/${id}/${path.basename(originalPath)}`,
originalWidth,
originalHeight,
}
)
session.close()
console.log('Processing done')
this.finishedImages++
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: this.finishedImages / this.imagesToProgress,
finished: false,
error: false,
errorMessage: '',
},
})
}
}
export default PhotoScanner

View File

@ -8,7 +8,7 @@ import { v1 as neo4j } from 'neo4j-driver'
import { makeAugmentedSchema } from 'neo4j-graphql-js' import { makeAugmentedSchema } from 'neo4j-graphql-js'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import http from 'http' import http from 'http'
import PhotoScanner from './Scanner' import PhotoScanner from './scanner/Scanner'
import _ from 'lodash' import _ from 'lodash'
import config from './config' import config from './config'
@ -124,8 +124,6 @@ server.applyMiddleware({ app, graphPath })
app.use('/images/:id/:image', async function(req, res) { app.use('/images/:id/:image', async function(req, res) {
const { id, image } = req.params const { id, image } = req.params
console.log('image', image)
let user = null let user = null
try { try {

View File

@ -1,4 +1,4 @@
import { EVENT_SCANNER_PROGRESS } from '../scanner' import { EVENT_SCANNER_PROGRESS } from '../scanner/Scanner'
const Mutation = { const Mutation = {
async scanAll(root, args, ctx, info) { async scanAll(root, args, ctx, info) {

114
api/src/scanner/Scanner.js Normal file
View File

@ -0,0 +1,114 @@
import fs from 'fs-extra'
import path from 'path'
import { resolve as pathResolve, basename as pathBasename } from 'path'
import { PubSub } from 'apollo-server'
import uuid from 'uuid'
import { exiftool } from 'exiftool-vendored'
import sharp from 'sharp'
import { isImage, isRawImage } from './utils'
import { promisify } from 'util'
import config from '../config'
import _scanUser from './scanUser'
import _scanAlbum from './scanAlbum'
import _processImage from './processImage'
import _scanAll from './scanAll'
export const EVENT_SCANNER_PROGRESS = 'SCANNER_PROGRESS'
class PhotoScanner {
constructor(driver) {
this.driver = driver
this.isRunning = false
this.pubsub = new PubSub()
this.processImage = this.processImage.bind(this)
this.scanAlbum = this.scanAlbum.bind(this)
this.scanUser = this.scanUser.bind(this)
this.scanAll = this.scanAll.bind(this)
this.imagesToProgress = 0
this.finishedImages = 0
this.addImageToProgress = () => {
this.imagesToProgress++
}
this.addFinishedImage = () => {
this.finishedImages++
}
}
async scanUser(user) {
await _scanUser({ driver: this.driver, scanAlbum: this.scanAlbum }, user)
}
async scanAlbum(album) {
await _scanAlbum(
{
driver: this.driver,
addImageToProgress: this.addImageToProgress,
addFinishedImage: this.addFinishedImage,
processImage: this.processImage,
},
album
)
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: this.finishedImages / this.imagesToProgress,
finished: false,
error: false,
errorMessage: '',
},
})
}
async processImage(id) {
await _processImage(
{
driver: this.driver,
addFinishedImage: this.addFinishedImage,
},
id
)
}
async scanAll() {
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: 0,
finished: false,
error: false,
errorMessage: '',
},
})
try {
await _scanAll({ driver: this.driver, scanUser: this.scanUser })
} catch (error) {
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: 0,
finished: false,
error: true,
errorMessage: error.message,
},
})
throw error
}
console.log(
`Done scanning ${this.finishedImages} of ${this.imagesToProgress}`
)
this.pubsub.publish(EVENT_SCANNER_PROGRESS, {
scannerStatusUpdate: {
progress: 100,
finished: true,
error: false,
errorMessage: '',
},
})
}
}
export default PhotoScanner

View File

@ -0,0 +1,79 @@
import fs from 'fs-extra'
import path from 'path'
import { exiftool } from 'exiftool-vendored'
import sharp from 'sharp'
import { isRawImage, imageSize } from './utils'
import config from '../config'
export default async function processImage({ driver, addFinishedImage }, id) {
const session = driver.session()
const result = await session.run(`MATCH (p:Photo { id: {id} }) RETURN p`, {
id,
})
await session.run(
`MATCH (p:Photo { id: {id} })-[rel]->(url:PhotoURL) DELETE url, rel`,
{ id }
)
const photo = result.records[0].get('p').properties
console.log('Processing photo', photo.path)
const imagePath = path.resolve(config.cachePath, 'images', id)
await fs.remove(imagePath)
await fs.mkdirp(imagePath)
let originalPath = photo.path
if (await isRawImage(photo.path)) {
// console.log('Processing RAW image')
const extractedPath = path.resolve(imagePath, 'extracted.jpg')
await exiftool.extractPreview(photo.path, extractedPath)
originalPath = extractedPath
}
// Resize image
const thumbnailPath = path.resolve(imagePath, 'thumbnail.jpg')
await sharp(originalPath)
.jpeg({ quality: 80 })
.resize(1440, 1080, { fit: 'inside', withoutEnlargement: true })
.toFile(thumbnailPath)
try {
const { width: originalWidth, height: originalHeight } = await imageSize(
originalPath
)
const { width: thumbnailWidth, height: thumbnailHeight } = await imageSize(
thumbnailPath
)
await session.run(
`MATCH (p:Photo { id: {id} })
CREATE (thumbnail:PhotoURL { url: {thumbnailUrl}, width: {thumbnailWidth}, height: {thumbnailHeight} })
CREATE (original:PhotoURL { url: {originalUrl}, width: {originalWidth}, height: {originalHeight} })
CREATE (p)-[:THUMBNAIL_URL]->(thumbnail)
CREATE (p)-[:ORIGINAL_URL]->(original)
`,
{
id,
thumbnailUrl: `/images/${id}/${path.basename(thumbnailPath)}`,
thumbnailWidth,
thumbnailHeight,
originalUrl: `/images/${id}/${path.basename(originalPath)}`,
originalWidth,
originalHeight,
}
)
} catch (e) {
console.log('Create photo url failed', e)
}
session.close()
addFinishedImage()
}

View File

@ -0,0 +1,71 @@
import fs from 'fs-extra'
import path from 'path'
import uuid from 'uuid'
import { isImage } from './utils'
import config from '../config'
export default async function scanAlbum(
{ driver, addImageToProgress, addFinishedImage, processImage },
album
) {
const { title, path: albumPath, id } = album
console.log('Scanning album', title)
const list = await fs.readdir(albumPath)
let processingImagePromises = []
for (const item of list) {
const itemPath = path.resolve(albumPath, item)
if (await isImage(itemPath)) {
const session = driver.session()
addImageToProgress()
const photoResult = await session.run(
`MATCH (p:Photo {path: {imgPath} })<--(a:Album {id: {albumId}}) RETURN p`,
{
imgPath: itemPath,
albumId: id,
}
)
if (photoResult.records.length != 0) {
// console.log(`Photo already exists ${item}`)
const id = photoResult.records[0].get('p').properties.id
const thumbnailPath = path.resolve(
config.cachePath,
id,
'thumbnail.jpg'
)
if (!(await fs.exists(thumbnailPath))) {
processingImagePromises.push(processImage(id))
} else {
addFinishedImage()
}
} else {
console.log(`Found new image at ${itemPath}`)
const imageId = uuid()
await session.run(
`MATCH (a:Album { id: {albumId} })
CREATE (p:Photo {id: {id}, path: {path}, title: {title} })
CREATE (a)-[:CONTAINS]->(p)`,
{
id: imageId,
path: itemPath,
title: item,
albumId: id,
}
)
processingImagePromises.push(processImage(imageId))
}
}
}
await Promise.all(processingImagePromises)
console.log('Done processing album', album.title)
}

View File

@ -0,0 +1,38 @@
export default function scanAll({ driver, scanUser }) {
return new Promise((resolve, reject) => {
let session = driver.session()
let allUserScans = []
session.run('MATCH (u:User) return u').subscribe({
onNext: record => {
const user = record.toObject().u.properties
if (!user.rootPath) {
console.log(`User ${user.username}, has no root path, skipping`)
return
}
allUserScans.push(
scanUser(user).catch(reason => {
console.log(
`User scan exception for user ${user.username} ${reason}`
)
reject(reason)
})
)
},
onCompleted: () => {
session.close()
Promise.all(allUserScans).then(() => {
resolve()
})
},
onError: error => {
session.close()
reject(error)
},
})
})
}

144
api/src/scanner/scanUser.js Normal file
View File

@ -0,0 +1,144 @@
import fs from 'fs-extra'
import { resolve as pathResolve } from 'path'
import uuid from 'uuid'
import { isImage } from './utils'
export default async function scanUser({ driver, scanAlbum }, user) {
console.log('Scanning user', user.username, 'at', user.path)
let foundAlbumIds = []
let albumScanPromises = []
async function scanPath(path, parentAlbum) {
console.log('SCAN PATH', path)
const list = fs.readdirSync(path)
let foundImage = false
let newAlbums = []
for (const item of list) {
const itemPath = pathResolve(path, item)
// console.log(`Scanning item ${itemPath}...`)
let stat = null
try {
stat = await fs.stat(itemPath)
} catch {
console.log('ERROR reading file stat for item:', itemPath)
}
if (stat && stat.isDirectory()) {
const session = driver.session()
let nextParentAlbum = null
const findAlbumResult = await session.run(
'MATCH (a:Album { path: {path} }) RETURN a',
{
path: itemPath,
}
)
session.close()
const {
foundImage: imagesInDirectory,
newAlbums: childAlbums,
} = await scanPath(itemPath, nextParentAlbum)
if (findAlbumResult.records.length > 0) {
const album = findAlbumResult.records[0].toObject().a.properties
console.log('Found existing album', album.title)
nextParentAlbum = album.id
foundAlbumIds.push(album.id)
albumScanPromises.push(scanAlbum(album))
continue
}
if (imagesInDirectory) {
console.log(`Found new album at ${itemPath}`)
const session = driver.session()
console.log('Adding album')
const albumId = uuid()
const albumResult = await session.run(
`MATCH (u:User { id: {userId} })
CREATE (a:Album { id: {id}, title: {title}, path: {path} })
CREATE (u)-[own:OWNS]->(a)
RETURN a`,
{
id: albumId,
userId: user.id,
title: item,
path: itemPath,
}
)
newAlbums.push(albumId)
const album = albumResult.records[0].toObject().a.properties
if (parentAlbum) {
console.log('Linking parent album for', album.title)
await session.run(
`MATCH (parent:Album { id: {parentId} })
MATCH (child:Album { id: {childId} })
MERGE (parent)-[:SUBALBUM]->(child)`,
{
childId: albumId,
parentId: parentAlbum,
}
)
}
console.log(`Linking ${childAlbums.length} child albums`)
for (let childAlbum of childAlbums) {
await session.run(
`MATCH (parent:Album { id: {parentId} })
MATCH (child:Album { id: {childId} })
CREATE (parent)-[:SUBALBUM]->(child)`,
{
parentId: albumId,
childId: childAlbum,
}
)
}
foundAlbumIds.push(album.id)
albumScanPromises.push(scanAlbum(album))
session.close()
}
continue
}
if (!foundImage && (await isImage(itemPath))) {
foundImage = true
}
}
return { foundImage, newAlbums }
}
await scanPath(user.rootPath)
console.log('Found album ids', foundAlbumIds)
const session = driver.session()
const userAlbumsResult = await session.run(
'MATCH (u:User { id: {userId} })-[:OWNS]->(a:Album)-[:CONTAINS]->(p:Photo) WHERE NOT a.id IN {foundAlbums} DETACH DELETE a, p RETURN a',
{ userId: user.id, foundAlbums: foundAlbumIds }
)
console.log(
`Deleted ${userAlbumsResult.records.length} albums from ${user.username} that was not found locally`
)
session.close()
await Promise.all(albumScanPromises)
console.log('User scan complete')
}

33
api/src/scanner/utils.js Normal file
View File

@ -0,0 +1,33 @@
import fs from 'fs-extra'
import readChunk from 'read-chunk'
import imageType from 'image-type'
import { promisify } from 'util'
export const isImage = async path => {
if ((await fs.stat(path)).isDirectory()) {
return false
}
try {
const buffer = await readChunk(path, 0, 12)
const type = imageType(buffer)
return type != null
} catch (e) {
throw new Error(`isImage error at ${path}: ${JSON.stringify(e)}`)
}
}
export const isRawImage = async path => {
try {
const buffer = await readChunk(path, 0, 12)
const { ext } = imageType(buffer)
const rawTypes = ['cr2', 'arw', 'crw', 'dng']
return rawTypes.includes(ext)
} catch (e) {
throw new Error(`isRawImage error at ${path}: ${JSON.stringify(e)}`)
}
}
export const imageSize = promisify(require('image-size'))

View File

@ -4,6 +4,7 @@ import { Query } from 'react-apollo'
import Layout from '../../Layout' import Layout from '../../Layout'
import AlbumSidebar from './AlbumSidebar' import AlbumSidebar from './AlbumSidebar'
import PhotoGallery from '../../PhotoGallery' import PhotoGallery from '../../PhotoGallery'
import AlbumGallery from '../AllAlbumsPage/AlbumGallery'
const albumQuery = gql` const albumQuery = gql`
query albumQuery($id: ID) { query albumQuery($id: ID) {
@ -85,15 +86,31 @@ class AlbumPage extends Component {
{({ loading, error, data }) => { {({ loading, error, data }) => {
if (error) return <div>Error</div> if (error) return <div>Error</div>
let subAlbumElement = null
if (data.album) { if (data.album) {
this.photoAmount = data.album.photos.length this.photoAmount = data.album.photos.length
if (data.album.subAlbums.length > 0) {
subAlbumElement = (
<AlbumGallery
loading={loading}
error={error}
albums={data.album.subAlbums}
/>
)
}
} }
return ( return (
<div> <div>
<h1>{data.album && data.album.title}</h1>
{subAlbumElement}
{data.album && data.album.subAlbums.length > 0 && (
<h2>Images</h2>
)}
<PhotoGallery <PhotoGallery
loading={loading} loading={loading}
title={data.album && data.album.title}
photos={data.album && data.album.photos} photos={data.album && data.album.photos}
activeIndex={this.state.activeImage} activeIndex={this.state.activeImage}
onSelectImage={index => { onSelectImage={index => {

View File

@ -4,10 +4,8 @@ import { Loader } from 'semantic-ui-react'
import { AlbumBox } from './AlbumBox' import { AlbumBox } from './AlbumBox'
const Container = styled.div` const Container = styled.div`
margin: -10px; margin: 20px -10px;
margin-top: 20px;
position: relative; position: relative;
min-height: 500px;
` `
const AlbumGallery = ({ loading, error, albums }) => { const AlbumGallery = ({ loading, error, albums }) => {

View File

@ -60,13 +60,7 @@ const PhotoFiller = styled.div`
flex-grow: 999999; flex-grow: 999999;
` `
const PhotoGallery = ({ const PhotoGallery = ({ activeIndex = -1, photos, loading, onSelectImage }) => {
activeIndex = -1,
photos,
loading,
title,
onSelectImage,
}) => {
let photoElements = null let photoElements = null
if (photos) { if (photos) {
photoElements = photos.map((photo, index) => { photoElements = photos.map((photo, index) => {
@ -96,7 +90,6 @@ const PhotoGallery = ({
return ( return (
<div> <div>
<h1>{title}</h1>
<Gallery> <Gallery>
<Loader active={loading}>Loading images</Loader> <Loader active={loading}>Loading images</Loader>
{photoElements} {photoElements}