commit 87b9aa3cc952fc23d67f76e33c81db7f950afbac Author: Dym Sohin Date: Wed Oct 4 01:24:49 2023 +0200 re-init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07fd432 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*~ +._* +.DS_Store +node_modules +package-lock.json + +config.* +log.txt +output.txt +ssl +uploads +static/vendor diff --git a/app.js b/app.js new file mode 100644 index 0000000..f22b042 --- /dev/null +++ b/app.js @@ -0,0 +1,339 @@ +'use strict' + +const http = require( 'http' ) + , https = require( 'https' ) + , fs = require( 'fs' ) + , path = require( 'path' ) + , express = require( 'express' ) + , bodyParser = require( 'body-parser' ) + , formidable = require( 'formidable' ) + , compression = require( 'compression' ) + , minify = require( 'express-minify' ) + , morgan = require( 'morgan' ) + , range = require( 'range-2018.js' ) + +const DB = require( './lib/db.js' ) + , Photos = require( './lib/photos.js' ) + , Now = require( './lib/readable-timestamp.js' ) + , Config = require( './config.js' ) + +let app = express( ) + +app.all( '*', ( req, res, next ) => + { if( /:\/\/baidu\.com\//i.test( req.url ) ) + { return res.sendStatus( 403 ).end( ) } + + // if( ( 'http' == req.protocol ) + // || /^www\./i.test( req.hostname ) + // ) + // { return res.redirect( 301, + // [ 'https://' + // , req.hostname.replace( /^(www\.)+/i, '' ) + // , ':' + // , Config.ENV.SSLPORT + // , req.url + // ].join('') ) + // } + + next( ) + }) + + +app.use( bodyParser.json( ) ) + .use( bodyParser.urlencoded({ extended: false }) ) + .use( compression( ) ) + .use( minify( ) ) + .use( express.static( './static' ) ) + +if( Config.ENV.PRODUCTION ) + { app.use( morgan + ( 'combined' + , { skip: ( req, res ) => + /\.(jpg|png|svg|ico|css|js)$/i.test( req.url ) + , stream: fs.createWriteStream + ( path.join( __dirname, 'log.txt' ), { flags: 'a' } ) + } + ) ) + } + + +app.get( '/photos/:search', ( req, res ) => + { res.redirect( 302, '/photos/#'+req.params.search ) }) + + +app.post( '/api/photos/variant', async ( req, res ) => + { // create a Variant + if( !req.query + || !req.query.SKU + || !req.query.Variant + ) + { res.sendStatus( 400 ) + .end( 'not enough params to create a variant' ) + return false + } + + let { SKU, Variant, CopyOf } = req.query + Photos.createVariant({ SKU, Variant, CopyOf }) + + res.sendStatus( 200 ) + .end( ) + }) + + +app.put( '/api/photos/setDefaultVariant', ( req, res ) => + { if( !req.query + || !req.query.SKU + || !req.query.Variant + || !req.query.oldVarinatRenamedTo + ) + { res.sendStatus( 400 ) + .end( 'not enough params to set a default variant' + + '(PUT /api/photos/setDefaultVariant)' + ) + return false + } + + let { SKU, Variant, oldVarinatRenamedTo } = req.query + + // set Variant as Default + Photos.setDefaultVariant({ SKU, Variant, oldVarinatRenamedTo }) + + res.sendStatus( 200 ) + .end( ) + }) + +app.put( '/api/photos/renumber', async ( req, res ) => + { if( !req.query.SKU + || !req.query.Variant + || !req.query.OldNr + || !req.query.NewNr + ) + { res.sendStatus( 400 ) + .end( 'not enough params to renumber photos' + + '(PUT /api/photos/renumber)' + ) + return false + } + + // re-arraged the Photos + const { SKU, Variant, OldNr, NewNr } = req.query + const _success = await Photos.Renumber( + { SKU + , Variant + , OldNr + , NewNr + }) + + res.sendStatus( _success && 200 || 500 ) + .end( ) + }) + + +app.delete( '/api/photos/variant', async ( req, res ) => + { if( !req.query + || !req.query.SKU + || !req.query.Variant + ) + { res.sendStatus( 400 ) + .end( 'not enough parameters to delete variant' ) + return false + } + + const { SKU, Variant } = req.query + res.sendStatus( 200 ) + .end( await Photos.removeVariant({ SKU, Variant }) ) + }) + +app.delete( '/api/photos', async ( req, res ) => + { if( !req.query + || !req.query.SKU + || !req.query.Variant + || !req.query.Nr + ) + { res.sendStatus( 400 ) + .end( 'not enough query parameters to delete a photo' ) + return false + } + + const { SKU, Variant, Hash, Nr } = req.query + const _success = await Photos.removeOne( + { SKU + , Variant + , Nr + }) + + res.sendStatus( _success && 200 || 500 ) + .end( ) + }) + +app.post( '/api/photos', async ( req, res ) => + { if( !req.query + || !req.query.SKU + || !req.query.Variant + ) + { res.sendStatus( 400 ) + .end( 'not enough params to upload photo(s)' ) + return false + } + + let { SKU, Variant, Nr } = req.query + , UploadingMultiplePhotos = false + + if( 0 == Nr ) + { // uploading multiple photos + UploadingMultiplePhotos = true + + const CurrentPhotos = await Photos.getHashmap( + { SKU + , Variant + , arrNumbers : range( 1,10 ) + }) + + if( CurrentPhotos ) + { Object.keys( CurrentPhotos ).every( _Photo => + { if( !CurrentPhotos[ _Photo ] ) + { Nr = Number( _Photo ) + return false + } + return true + }) + } + else + { Nr = 1 } + } + else + { // replacing single photo + await Photos.deletePhotoFiles({ SKU, Nr }) + } + + let form = new formidable.IncomingForm( ) + form.uploadDir = path.join( __dirname, 'uploads' ) + form.encoding = 'binary' + + form.addListener( 'file', async ( name, file ) => + { if( UploadingMultiplePhotos + && ( 10 <= Nr ) + ) + { return false } + + console.info + ( Now( ) + , `recieving photo "${SKU}" /` + , `"${Variant}" / "${Nr}"` + ) + + Photos.Process + ({ FilePath : file.path + , SKU + , Variant + , Nr : Nr++ + }) + }) + + form.addListener( 'end', ( ) => + { res.sendStatus( 200 ) + .end( '' ) + }) + + form.parse( req, ( err, fields, files ) => + { if( err ) + { console.error( Now( ), { err, fields, files } ) } + }) + }) + + +app.put( '/api/photos/assignVariantToAccount', async ( req, res ) => + { // assigning Variant to Account + if( !req.query + || !req.query.SKU + || !req.query.Variant + || !req.query.Account + ) + { res.sendStatus( 400 ) + .end( 'not enough params to assigning Variant to Account ' + + '[POST /api/photos/assignVariantToAccount]' + ) + return false + } + + const { SKU, Variant, Account } = req.query + const _success = await Photos.assignVariantToAccount( + { SKU + , Variant + , Account + }) + + res.sendStatus( _success && 200 || 500 ) + .end( ) + }) + + +app.get( '/api/photos', async ( req, res ) => + { // get Photos_Ordered(since lastUpdated) table + // console.log('GET: /api/photos\nQUERY:', req.query ) + res.send( await Photos.get_Photos_Since( req.query.since ) ) + }) + + +app.get( '/api/photos/baseURL', async ( req, res ) => + { // get the httpURL of photos dir + res.end( Config.Paths.imgGlobal ) + }) + +app.get( '/api/photos/accounts', async ( req, res ) => + { // get all the Accounts for the photos + res.end( Config.Accounts.join( '\n' ) ) + }) + + +// http (for redirects only) +const httpURL = _ => `\n http://${ _ }:${ Config.ENV.PORT }` + +//console.log( Config, Config.ENV ) + +http.createServer( app ) + .listen( Config.ENV.PORT, Config.ENV.IP ) + +if( '::' == Config.ENV.IP ) + { console.info( Now( ), httpURL( '[::1]' ) ) } +else + { http.createServer( app ) + .listen( Config.ENV.PORT, Config.ENV.IP6 ) + + console.info + ( Now( ) + , httpURL( 'localhost' ) + , httpURL( Config.ENV.HOST ) + , httpURL( Config.ENV.IP ) + , httpURL( `[${ Config.ENV.IP6 }]` ) + ) + } + +// SSL +// const sslURL = _ => `\n https://${ _ }:${ Config.ENV.SSLPORT }` +// try +// { const SSLCerts = +// { cert: fs.readFileSync( path.join( __dirname, 'ssl', 'cert.pem' ) ) +// , key: fs.readFileSync( path.join( __dirname, 'ssl', 'key.pem' ) ) +// } + +// https.createServer( SSLCerts, app ) +// .listen( Config.ENV.SSLPORT, Config.ENV.IP ) + +// if( '::' == Config.ENV.IP ) +// { console.info( Now( ), sslURL( '[::1]' ) ) } +// else +// { https.createServer( SSLCerts, app ) +// .listen( Config.ENV.SSLPORT, Config.ENV.IP6 ) + +// console.info +// ( Now( ) +// , sslURL( 'localhost' ) +// , sslURL( Config.ENV.HOST ) +// , sslURL( Config.ENV.IP ) +// , sslURL( `[${ Config.ENV.IP6 }]` ) +// ) +// } +// } +// catch( err ) +// { console.warn( Now( ), 'no SSL:', err ) } diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000..7392c95 --- /dev/null +++ b/lib/db.js @@ -0,0 +1,72 @@ +'use strict' + +const fs = require( 'fs' ) + , path = require( 'path' ) + , URL = require( 'url' ).URL + , request = require( 'request' ) + + , Now = require( './readable-timestamp.js' ) + , Config = require( '../config.js' ) + +const req += ( http_method, sql_params ) => +new Promise( ( resolve, reject ) => + { let req_url = new URL( Config.SQL.BaseURL ) + + for( const k in sql_params ) + { req_url.searchParams.set( k, sql_params[ k ] ) } + + // console.log + // ({ url : req_url.toString() + // , method : http_method + // , auth : Config.SQL.auth + // }) + + request( { url: req_url.toString() + , method: http_method + , auth: Config.SQL.auth + } + , ( err, res, body ) => + { // console.log({ err, res, body }) + + if( err || !res ) + { reject( err ) + return false + } + if( 400 <= res.statusCode ) + { resolve( false ) } + + let _res = '' + + try + { _res = JSON.parse( body ) } + catch( err ) + { if( err ) + { console.error( Now(), '\n', JSON.stringify( + { http_method + , sql_params + , err + , _res + }, false, 2 ) ) + resolve( [] ) + } + } + resolve( _res ) + }) + + }).catch( err => console.error + ( Now() + , 'cant make request to mysql,' + , err + , http_method + , sql_params + ) ) + + +module.exports = exports = + { create: ( _ ) => req( 'POST' , _ ) + , read: ( _ ) => req( 'GET' , _ ) + , update: ( _ ) => req( 'PUT' , _ ) + , delete: ( _ ) => req( 'DELETE' , _ ) + , raw: ( _ ) => req( 'PATCH' , _ ) + } diff --git a/lib/get-id.js b/lib/get-id.js new file mode 100644 index 0000000..ff946b9 --- /dev/null +++ b/lib/get-id.js @@ -0,0 +1,6 @@ +'use strict' + +module.exports = exports = STR => + STR.replace( /\s+/g, '' ) + .replace( /[^\w\d\.,_-]/g, '-' ) + .toUpperCase() diff --git a/lib/photos.js b/lib/photos.js new file mode 100644 index 0000000..e158cf6 --- /dev/null +++ b/lib/photos.js @@ -0,0 +1,682 @@ +'use strict' + +const fs = require( 'fs' ) + , md5 = require( 'md5' ) + , path = require( 'path' ) + , gm = require( 'gm' ) + , url = require( 'url' ) + , range = require( 'range-2018.js' ) + + , DB = require( './db.js' ) + , GetID = require( './get-id.js' ) + , Now = require( './readable-timestamp.js' ) + , Config = require( '../config.js' ) + +const Sizes = +exports.Sizes = + { S : 200 + , N : 490 + , M : 800 + , L : 1600 + } + + +const SortAccounts = arrAccounts => + arrAccounts + .filter( a => a && a.trim( ) ) + .sort( ( a,b ) => b[ 0 ] == a[ 0 ] + ? 'i' == a[ 0xA ] + : 'r' == a[ 0 ] + ) + +const getHashFromPhotoURL = +exports.getHashFromPhotoURL = PhotoURL => + PhotoURL.match( /([^\/_]+)_?L\.jpg$/ )[ 1 ] + +const PhotoHashmapToSQL = +exports.PhotoHashmapToSQL = PhotoHashmap => + Object.keys( PhotoHashmap ) + .map( k => `Photo_${ k } = "${ PhotoHashmap[ k ] }"` ) + .join( ',' ) + +const SmallerSizes = +exports.SmallerSizes = + Object.keys( Sizes ) + .filter( k => 'L' != k ) + +const HashOrNr = +exports.HashOrNr = Hash => + ( Hash + && Hash.length + && ( Hash.length > 2 ) + ) + ? Hash + '_' + : Hash + +const getURL = +exports.getURL = ({ SKU, Hash, Size, EXT }) => + url.resolve + ( Config.Paths.imgGlobal + , path.join + ( './' + , GetID( SKU ) + , HashOrNr( Hash ) + Size + ( EXT || '.jpg' ) + ) + ) + +const getFilepath = +exports.getFilepath = ({ SKU, Hash, Size, EXT }) => + path.join + ( Config.Paths.imgLocal + , GetID( SKU ) + , HashOrNr( Hash ) + Size + ( EXT || '.jpg' ) + ) + +const Resize = +exports.Resize = ({ SKU, Hash }) => +new Promise( ( resolve, reject ) => + { SmallerSizes.forEach( ( Size, i, a ) => + { gm( getFilepath({ SKU, Hash, Size : 'L' }) ) + .resize( Sizes[ Size ], Sizes[ Size ] ) + .write( getFilepath({ SKU, Hash, Size }), err => + { if( err ) + { reject( err ) } + if( i == a.length - 1 ) + { resolve( true ) } + }) + }) + }).catch( err => console.error + ( Now( ) + , 'error resizing' + , '(Photos.Resize)' + , err + ) ) + +const Convert_toBW = path => +new Promise( ( resolve, reject ) => + { gm( path ) + .colorspace( 'GRAY' ) + .write( path, err => + { if( err ) + { console.error + ( Now( ) + , 'error converting #10 to BW' + , path + , err + ) + reject( false ) + } + resolve( true ) + }) + + }).catch( err => console.error + ( Now( ) + , 'error converting #10 to BW' + , '(Photos.Convert_toBW)' + , err + ) ) + +const Process = +exports.Process = ({ FilePath, SKU, Variant, Nr }) => +new Promise( async ( resolve, reject ) => + { if( 10 == Nr ) + { console.info( Now( ), 'converting to BW...\n' ) + await Convert_toBW( FilePath ) + } + + const Hash = md5( fs.readFileSync( FilePath ) ) + try + { fs.mkdirSync( path.join( Config.Paths.imgLocal, GetID( SKU ) ) ) } + catch( err ) + { console.info( 'dir', path.join( Config.Paths.imgLocal, GetID( SKU ) ), 'exist' ) } + fs.renameSync( FilePath, getFilepath({ SKU, Hash, Size : 'L' }) ) + + const Varinat_Exist = await DB.read( + { table : 'Photos' + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + + if( !Varinat_Exist || !Varinat_Exist.length ) + { await createVariant({ SKU, Variant }) } + + await DB.update( + { table : 'Photos' + , set : ` Photo_${ Nr } = "${ getURL({ SKU, Hash, Size : 'L' }) }" + , Need_update = TRUE + ` + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + + await Resize({ SKU, Hash }) + + resolve( true ) + + }).catch( err => console.error + ( Now( ) + , 'error processing' + , '(Photos.Process)' + , err + ) ) + +const Renumber = +exports.Renumber = ({ SKU, Variant, OldNr, NewNr }) => +new Promise( async ( resolve, reject ) => + { const OldHashmap = await getHashmap( + { SKU + , Variant + , arrNumbers : range( 1,10 ) + }) + const Photo_toShift = OldHashmap[ OldNr ] + + let newArr = Object + .keys( OldHashmap ) + .map( k => OldHashmap[ k ] ) + newArr.unshift( null ) + newArr.splice( OldNr, 1 ) + newArr.splice( NewNr, 0, Photo_toShift ) + + let NewHashmap = { } + range( 1,10 ).map( Nr => + { NewHashmap[ Nr ] = newArr[ Nr ] }) + + console.info + ( Now( ) + , 'Renumbering\n from :' + , OldHashmap + , 'to :' + , NewHashmap + ) + + const _res = await DB.update( + { table : 'Photos' + , set : PhotoHashmapToSQL( NewHashmap ) + + ', Need_update = TRUE' + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + + if( _res && _res.length ) + { resolve( true ) } + else + { reject( _res ) } + + }).catch( err => console.error + ( Now( ) + , 'error renumbering' + , '(Photos.Renumber)' + , err + ) ) + + +const URLoccurrences = +exports.URLoccurrences = ({ SKU, PhotoURL }) => +new Promise( async ( resolve, reject ) => + { const Photos_EQ_URL = range( 1,11 ) + .map( Nr => `Photo_${ Nr } = "${ PhotoURL }"` ) + .join( ' OR ' ) + + const _req = await DB.read( + { table : 'Photos' + , where : `( SKU = "${ SKU }" + AND ( ${ Photos_EQ_URL } ) + )` + }) + + if( typeof _req == 'object' ) + { let Occurrences = 0 + _req.forEach( V => + { for( Param in V ) + { Occurrences += ( V[ Param ] == PhotoURL ) } + }) + resolve( Occurrences ) + } + reject( false ) + + }).catch( err => console.error + ( Now( ) + , 'error getting finding occurrences of an URL' + , '(Photos.URLoccurrences)' + , err + ) ) + +const deletePhotoFiles = +exports.deletePhotoFiles = ({ SKU, Hash }) => +new Promise( async ( resolve, reject ) => + { if( !Hash ) + { console.error + ( Now( ) + , 'cant delete file, no Hash in params:' + , { SKU, Hash } + ) + resolve( false ) + return false + } + // check if filename exist anywhere else + if( 1 < ( await URLoccurrences( + { SKU + , PhotoURL : getURL({ SKU, Hash }) + }) + ) + ) + { resolve( false ) + return false + } + + console.log( 'removing file:', SKU + '/' + Hash ) + Object.keys( Sizes ).map + ( Size => fs.unlinkSync( getFilepath({ SKU, Hash, Size }) ) ) + + resolve( true ) + + }).catch( err => console.error + ( Now( ) + , 'error deleting files' + , '(Photos.deletePhotoFiles)' + , err + ) ) + +const removeOne = +exports.removeOne = ({ SKU, Variant, Nr }) => +new Promise( async ( resolve, reject ) => + { const HashMap_req = await getHashmap( + { SKU + , Variant + , arrNumbers : [ Nr ] + }) + + const PhotoURL = HashMap_req[ Nr ] + , Hash = getHashFromPhotoURL( PhotoURL ) + + console.log({ SKU, Variant, Nr, PhotoURL, Hash }) + + console.log + ( Now( ) + , 'removing photo' + , '(Photos.removeOne):\n' + , { SKU, Variant, Nr } + ) + + if( 10 === Number( Nr ) ) + { DB.update( + { table : 'Photos' + , set : `Photo_10 = ""` + , where : `( SKU = "${SKU}" + AND Variant = "${Variant}" + )` + }) + } + else + { const OldHashmap = await getHashmap( + { SKU + , Variant + , arrNumbers : range( 1,10 ).filter( _Nr => _Nr >= Nr ) + }) + + let NewHashmap = { } + Object.keys( OldHashmap ) + .filter( _Nr => OldHashmap[ _Nr ] ) + .forEach( _Nr => + { NewHashmap[ _Nr ] = + OldHashmap[ Number( _Nr ) + 1 ] || '' + }) + + await DB.update( + { table : 'Photos' + , set : PhotoHashmapToSQL( NewHashmap ) + , where : `( SKU = "${SKU}" + AND Variant = "${Variant}" + )` + }) + } + resolve( await deletePhotoFiles({ SKU, Hash }) ) + + }).catch( err => console.error + ( Now( ) + , 'error removing one' + , '(Photos.removeOne)' + , err + ) ) + +const getHashmap = +exports.getHashmap = ({ SKU, Variant, arrNumbers }) => +new Promise( async ( resolve, reject ) => + { const _req = await DB.read( + { table : 'Photos' + , columns : ( arrNumbers || range( 1,11 ) ) + .map( Nr => `Photo_${ Nr } AS "${ Nr }"` ) + .join( ',' ) + , where : `( SKU="${SKU}" + AND Variant="${Variant}" + )` + }) + + if( _req.length && typeof _req == 'object' ) + { resolve( _req[ 0 ] ) } + reject( false ) + + }).catch( err => console.error + ( Now( ) + , 'error getting hashmap' + , '(Photos.getHashmap)' + , err + ) ) + + +const setDefaultVariant = +exports.setDefaultVariant = async ({ SKU, Variant, oldVarinatRenamedTo }) => + { const _req = await DB.read( + { table : 'Photos' + , columns : 'SKU, Variant, Accounts' + , where : `SKU = "${ SKU }"` + }) + + if( !_req.length ) + { console.error + ( Now( ) + , 'no records of SKU' + , SKU + ) + return false + } + + let oldDefault = { Accounts :'' } + , newDefault = { Accounts :'' } + + _req.forEach( V => + { if( '_default' == V.Variant ) + { oldDefault = V } + if( Variant == V.Variant ) + { newDefault = V } + }) + + newDefault.Accounts = + [ ...newDefault.Accounts.split( /[\r\n]+/g ) + , ...oldDefault.Accounts.split( /[\r\n]+/g ) + ] + + newDefault.Accounts = SortAccounts( newDefault.Accounts ) + + if( oldDefault.SKU ) + { await DB.update( + { table : 'Photos' + , set : ` Variant = "${ oldVarinatRenamedTo }" + , Accounts = "" + ` + , where : `( SKU = "${ SKU }" + AND Variant = "_default" + )` + }) + } + + await DB.update( + { table : 'Photos' + , set : ` Variant = "_default" + , Accounts = "${ newDefault.Accounts.join('\n') }" + , Need_update = TRUE + ` + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + + console.log + ( Now( ) + , `variant "${ Variant }"` + , `of "${ SKU }" is now the default` + , '(Photos.setDefaultVariant)' + ) + + return true + } + + +const createVariant = +exports.createVariant = ({ SKU, Variant, CopyOf }) => +new Promise( async ( resolve, reject ) => + { const _test = await DB.read( + { table : 'Photos' + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + if( _test.length ) + { resolve( true ) + return true + } + + const _req = await DB.read( + { table : 'Photos' + , where : `( SKU = "${ SKU }" + AND Variant = "${ CopyOf || '_default' }" + )` + }) + + console.log( _req && _req.length && _req[ 0 ] + || 'err DB.read [Photos.createVariant]' + ) + + const Accounts + = '_default' == Variant + ? Config.Accounts.join( '\n' ) + : '' + + await DB.create( + { table : 'Photos' + , set : + ` SKU = "${ SKU }" + , Variant = "${ Variant }" + , Need_update = TRUE + , Accounts = "${ Accounts }" + ` + + ( _req && _req.length + ? `, Photo_10 = "${ _req[ 0 ].Photo_10 }"` + + ( CopyOf + ? ` + , Photo_1 = "${ _req[ 0 ].Photo_1 }" + , Photo_2 = "${ _req[ 0 ].Photo_2 }" + , Photo_3 = "${ _req[ 0 ].Photo_3 }" + , Photo_4 = "${ _req[ 0 ].Photo_4 }" + , Photo_5 = "${ _req[ 0 ].Photo_5 }" + , Photo_6 = "${ _req[ 0 ].Photo_6 }" + , Photo_7 = "${ _req[ 0 ].Photo_7 }" + , Photo_8 = "${ _req[ 0 ].Photo_8 }" + , Photo_9 = "${ _req[ 0 ].Photo_9 }" + ` + : '' ) + : '' ) + }) + + resolve( true ) + + }).catch( err => console.error + ( Now( ) + , 'error creating Variant' + , '(Photos.createVariant)' + , err + ) ) + + +const assignVariantToAccount = +exports.assignVariantToAccount = ({ SKU, Account, Variant }) => +new Promise( async ( resolve, reject ) => + { const AccountsBefore = await DB.read( + { table : 'Photos' + , columns : 'Variant, Accounts' + , where : `( SKU = "${ SKU }" + AND Accounts LIKE "%${ Account }%" + )` + }) + + if( AccountsBefore && AccountsBefore.length ) + { const PrevVariant = AccountsBefore[ 0 ].Variant + let arrAccountsToStay = AccountsBefore[ 0 ].Accounts.split(/[\r\n]+/g) + arrAccountsToStay.splice( arrAccountsToStay.indexOf(Account), 1) + + console.log({ PrevVariant, arrAccountsToStay }) + + await DB.update( + { table : 'Photos' + , set : `Accounts = "${ arrAccountsToStay.join('\n') }"` + , where : `( SKU = "${ SKU }" + AND Variant = "${ PrevVariant }" + )` + }) + } + else + { console.warn(Account, 'was unset before') } + + const AccountsInsertBefore = await DB.read( + { table : 'Photos' + , columns : 'Accounts' + , set : `Accounts = "${ Account }"` + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + + if( AccountsInsertBefore && AccountsInsertBefore.length ) + { let arrUpdatedAccountsInsert = + AccountsInsertBefore[ 0 ] + .Accounts + .split(/[\r\n]+/g) + + arrUpdatedAccountsInsert.push(Account) + arrUpdatedAccountsInsert = SortAccounts( arrUpdatedAccountsInsert ) + + DB.update( + { table : 'Photos' + , set : ` Accounts = "${ arrUpdatedAccountsInsert.join('\n') }" + , Need_update = TRUE + ` + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + + resolve( true ) + } + else + { reject( 'error getting AccountsInsertBefore' ) } + + }).catch( err => console.error + ( Now( ) + , 'cant assign Variant to Account' + , '(Photos.assignVariantToAccount)' + , err + ) ) + +const removeVariant = +exports.removeVariant = ({ SKU, Variant }) => +new Promise( async ( resolve, reject ) => + { const VariantToDelete = await DB.read( + { table : 'Photos' + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + if( !VariantToDelete.length ) + { resolve( 'variant does not exist' ) + return true + } + + if( VariantToDelete[ 0 ].Accounts.trim( ) ) + { const VariantsMigrateTo = await DB.read( + { table : 'Photos' + , where : `( SKU = "${ SKU }" + AND Variant != "${ Variant }" + )` + }) + if( VariantsMigrateTo.length ) + { let AccountsToMigrate = [...new Set + ( [ ...VariantToDelete[ 0 ].Accounts.split( /[\r\n]+/g ) + , ...VariantsMigrateTo[ 0 ].Accounts.split( /[\r\n]+/g ) + ] + )] + + console.log( + { VariantToDelete + , VariantsMigrateTo + , AccountsToMigrate + }) + + await DB.update( + { table : 'Photos' + , set : `Accounts = "${ AccountsToMigrate.join('\n') }"` + , where : `( SKU = "${ SKU }" + AND Variant = "${ VariantsMigrateTo[ 0 ].Variant }" + )` + }) + } + } + + DB.delete( + { table : 'Photos' + , where : `( SKU = "${ SKU }" + AND Variant = "${ Variant }" + )` + }) + + let Photos_toDelete = await getHashmap({ SKU, Variant }) + if( Photos_toDelete ) + { for( let i=1; i<11; i++ ) + { if( Photos_toDelete[ i ] ) + { await deletePhotoFiles( + { SKU + , Hash : Photos_toDelete[ i ] + }) + } + } + } + + resolve( true ) + + }).catch( err => console.error + ( Now( ) + , 'error removing variant' + , '(Photos.removeVariant)' + , err + ) ) + +const readTable = +exports.readTable = table => +new Promise( async ( resolve, reject ) => + resolve( await DB.read({ table }) ) + ).catch( err => console.error + ( Now( ) + , 'error removing variant' + , '(Photos.readTable)' + , err + ) ) + + +const get_Photos_Since = +exports.get_Photos_Since = ( since = 0 ) => +new Promise( async ( resolve, reject ) => + { const res = await DB.read( + { table : 'Photos_Ordered' + , where : ( 0 != since ) + && `( Updated > "${ since }" + AND Updated IS NOT NULL + )` + || '1' + , order_by : 'id' + }).catch( reject ) + + // console.log( 'get_Photos_Since:', since ) + // console.log( 'res:', res ) + resolve( res ) + + }).catch( err => console.error + ( Now( ) + , 'error removing variant' + , '(Photos.readTable)' + , err + ) ) + + +module.exports = exports diff --git a/lib/readable-timestamp.js b/lib/readable-timestamp.js new file mode 100644 index 0000000..69d0520 --- /dev/null +++ b/lib/readable-timestamp.js @@ -0,0 +1,10 @@ +'use strict' + +// readable timestamp +module.exports = exports = ( ) => + '\x1b[32m' + + ( new Date ) + .toJSON( ) + .slice( 0, 23 ) + .replace( 'T', ' ' ) + + '\x1b[0m' diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d790cb --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ "name" : "photo-manager" +, "description" : "Upload and Management of Photos (node.js, 2016)" +, "version" : "0.0.1" +, "author" : "Dym Sohin " +, "license" : "MIT" +, "main" : "app.js" +, "engines" : { "node" : ">=0.8.0" } +, "dependencies" : + { "body-parser" : "^1.18.3" + , "compression" : "^1.7.1" + , "express" : "^4.16.2" + , "express-minify" : "^0.2.0" + , "formidable" : "^1.1.1" + , "gm" : "^1.23.0" + , "md5" : "^2.2.1" + , "morgan" : "^1.9.0" + , "multer" : "^1.3.0" + , "range-2018.js": "0.0.4" + , "request" : "^2.87.0" + } +, "scripts" : + { "start" : "npm i --no-save && pm2 update && pm2 start app.js -i 1 --name 'photos' --no-daemon --watch --ignore-watch 'uploads static node_modules .git output.txt log.txt' --attach -l output.txt" + , "production" : "ENV=PRODUCTION pm2 start app.js -i 1 --name 'photos' --watch --ignore-watch 'uploads static node_modules .git output.txt log.txt' --attach -l output.txt" + , "dev" : "nodemon app.js --ignore static/" + } +} diff --git a/sample-config.js b/sample-config.js new file mode 100644 index 0000000..231e5e1 --- /dev/null +++ b/sample-config.js @@ -0,0 +1,40 @@ +'use strict' + +const os = require( 'os' ) + , path = require( 'path' ) + +const PRODUCTION = ( 'PRODUCTION' == process.env.ENV ) + || ( '' == os.hostname() ) + +module.exports = exports = + { ENV: + { PRODUCTION + , IP : process.env.IP + || ( PRODUCTION && '' ) + || '::' + , IP6 : process.env.IP6 + || ( PRODUCTION && '' ) + || '::' + , PORT : process.env.PORT || 8080 + , SSLPORT : process.env.SSLPORT || 4443 + , HOST : '' + } + + , Paths: + { imgGlobal : 'https://' + , imgLocal : '~/photos/' + , imgFTP : '~/ftp/' + } + + , SQL: + { BaseURL : 'http://' + , auth : + { user : 'user' + , pass : 'pass' + } + } + , Accounts : + [ 'account1' + , 'account2' + ] + } diff --git a/static/browserconfig.xml b/static/browserconfig.xml new file mode 100644 index 0000000..d251f13 --- /dev/null +++ b/static/browserconfig.xml @@ -0,0 +1,11 @@ + + + + + + + + #ffffff + + + diff --git a/static/icon.png b/static/icon.png new file mode 100644 index 0000000..9ff5ec1 Binary files /dev/null and b/static/icon.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f63f940 --- /dev/null +++ b/static/index.html @@ -0,0 +1,4 @@ + + +PHOTOS +

PHOTOS

diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..1108544 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,9 @@ +{ "name": "App", + "icons": + [ { "src": "icon.png" + , "sizes": "500x500" + , "type": "image\/png" + , "density": "4.0" + } + ] +} diff --git a/static/photos/index.html b/static/photos/index.html new file mode 100644 index 0000000..3601a97 --- /dev/null +++ b/static/photos/index.html @@ -0,0 +1,143 @@ + + +Photos + + + + + + +
+ + + + + + + + + + + + + + diff --git a/static/photos/script.js b/static/photos/script.js new file mode 100644 index 0000000..243e32a --- /dev/null +++ b/static/photos/script.js @@ -0,0 +1,1403 @@ +'use strict' + +// const { urlencoded } = require('body-parser') + +const d = document + , w = window + +let ProductTemplate = d.createElement( 'ProductTemplate' ) + , ThumbnailsRowTemplate = d.createElement( 'ThumbnailsRowTemplate' ) + , ThumbnailTemplate = d.createElement( 'ThumbnailTemplate' ) + , ProductTypesDatalist = d.getElementById( 'product_types' ) + , searchField = d.getElementById( 'search' ) + , searchForm = d.querySelector( 'nav form' ) + , btnShowAll = d.getElementById( 'ShowAll' ) + , btnShowWithoutPhotos = d.getElementById( 'ShowWithoutPhotos' ) + , chkShowOnlyAvaliable = d.getElementById( 'ShowOnlyAvaliable' ) + , txtCurrentCount = d.getElementById( 'CurrentCount' ) + , ProgressBarElement = d.querySelector( 'ProgressBarElement' ) + , Main = d.getElementById( 'photos' ) + , Moving_Thumbnail = null + , unloadedProducts = [] + , ProductTypes = new Set( ) + , ProgressBar + , ProgressBarTotal + , MaxChunk = 100 + , BaseURL = localStorage.getItem( 'BaseURL' ) || '' + , Accounts_Ordered = JSON.parse( localStorage.getItem( 'Accounts_Ordered' ) || '[ ]' ) + , Default_Accounts = JSON.parse( localStorage.getItem( 'Default_Accounts' ) || '{ }' ) + , _HardReload_in_3_2_ = false + , Products = Object.assign( { }, + ...Object.keys( localStorage ).map( SKU => + ( ![ 'lastUpdated', 'Accounts_Ordered', 'Default_Accounts', 'BaseURL' ].includes( SKU ) ) + && ({ [ SKU ]: JSON.parse( localStorage.getItem( SKU ) || '{ }' ) }) + || ({ }) + ) ) + +//console.log( Products ) + +const ceil_abs = N => Math.ceil( Math.abs( N ) ) +const range = ( cnt_or_start = 0, stop = null, step = 1 ) => + ( null === stop ) + && [...Array( cnt_or_start ).keys( )] + || [...Array( ceil_abs( ( stop - cnt_or_start ) / step ) )] + .map( ( _, i ) => cnt_or_start + i * step ) + + +function PopulateSearchWithTypes( ) + { [...ProductTypes] + .sort( ( a,b ) => a.localeCompare( b ) ) + .map( T => + { let O = document.createElement( 'option' ) + O.value = T + ProductTypesDatalist.appendChild( O ) + }) + } + +const getHashFromPhotoURL = PhotoURL => + PhotoURL.match( /([^\/_]+)_?L\.jpg$/ )[ 1 ] + +const unURIHash = ( Hash = w.location.hash ) => + decodeURIComponent( Hash ) + + +const Now = ( ) => + new Date( ).toISOString( ) + .slice( 0, 19 ) + .replace( 'T', ' ' ) + +const Product_Error = + { SKU: 'ERR-01-001' + , Title: 'Cant get Products from server' + , Variants: { _default: { 10:'', 1:'' } } + , Accounts: { } + } + +const DefaultRequestParams = + { ContentType: 'application/json' + , url: '/api/photos/' + , method: 'GET' + , body: '' + } + +const Search_onKeypress = e => + { if( ( typeof e.key == 'undefined' ) + || ( e.key == null ) + ) + { return false } + + const regexValidKeys = new RegExp + ( '^' + + '[µßáäåæëíïðñóöøúüþœœ\\w\\d]' + + '|Backspace|Delete|Clear|Cut|Paste|Undo|Redo' + + '$' + , 'i' + ) + + if( regexValidKeys.test( e.key ) ) + { showOnlySearched( ) } + } + +const setURL = path => + { let u = new URL( w.location.toString( ) ) + + u.pathname = '/photos/' + path + u.hash = '' + u.search = '' + d.title = path + history.pushState( { search: path }, path, u.toString( ) ) + } + +const AddClassFor600ms = ( _DOMnode, _Class ) => + { _DOMnode.classList.add( _Class ) + setTimeout( ( ) => { _DOMnode.classList.remove( _Class ) } + , 600 + ) + } + +const getPhotoURL = ({ SKU, Hash, Size }) => + ( SKU && Hash && Size ) + && ( BaseURL + + GetID( SKU ) + + '/' + + Hash + + ( Hash.length>2 ? '_' : '' ) + + Size + + '.jpg' + ) + || '' + + +const getSearchIndex = ( ...args ) => + [ ...args ] + .join( '' ) + .replace( /[^µßáäåæëíïðñóöøúüþœœ\w\d]/gi,'' ) + .toUpperCase( ) + +const setProgressBarTotal = Total => + { ProgressBarTotal = Total + ProgressBar = -1 + incrementProgressBar( ) + } + +function incrementProgressBar( ) + { ProgressBarElement.style.width = + ( ++ProgressBar * 100 / ProgressBarTotal ) + '%' + + if( ProgressBar == ProgressBarTotal ) + { Window_onScroll( ) } + } + +function collapseAll( ) + { setURL( searchField.value ) + d.querySelectorAll( 'ProductElement.targeted' ) + .forEach( P => P.classList.remove( 'targeted' ) ) + } + +function unhideAll( ) + { d.querySelectorAll( 'ProductElement.hidden' ) + .forEach( P => P.classList.remove( 'hidden' ) ) + } + +const UpdateCounter = Count => + { CurrentCount.textContent + = Count + || d.querySelectorAll( 'ProductElement:not(.hidden):not(.zero)' ) + .length + } + +const ShowOnlyAvaliable = e => + { const NonZero = Boolean( chkShowOnlyAvaliable.checked ) + d.querySelectorAll( 'ProductElement' ) + .forEach( P => + { if( NonZero && Products[ P.dataset.sku ].Amount == 0 ) + { P.classList.add('zero') } + else + { P.classList.remove('zero') } + }) + UpdateCounter( ) + } + +const ScrollTo = P => + { w.scroll( 0, P.offsetTop + P.offsetParent.offsetTop - 50 ) + Window_onScroll( ) + } + +const obj2URLSearchParams = obj => + { let u = new URLSearchParams( ) + for( const key in obj ) + { u.set( key, obj[ key ] ) } + return u.toString( ) + } + +const request = Params => +new Promise( ( resolve, reject ) => + { let { url, method, body, ContentType } = DefaultRequestParams + if( 'string' == typeof Params ) + { url = Params } + else + { if( 'string' == typeof Params.url ) + { url = Params.url } + else + { const { api, query } = Params + let u = new URL( w.location.toString( ) ) + u.pathname = '/api/photos/' + api + u.search = obj2URLSearchParams( query || {} ) + u.hash = '' + url = u.toString( ) + } + + method = Params.method || method + body = Params.body || body + ContentType = Params.ContentType || ContentType + } + + const headers = Params.headers + || { 'Content-Type' : ContentType } + + fetch( url + , [ 'GET','HEAD' ].includes( method ) + ? { headers, method } + : { headers, method, body } + ) + .then( res => + { if( !res || !res.ok ) + reject( res && res.status || url ) + if( res.statusCode >= 400 ) + resolve( false ) + + resolve( res.text( ) ) + }) + .catch( err => + { reject({ err, Params }) + console.error( 'rejected request::fetch', err ) + }) + + }).catch( err => console.error( 'cant [request]', err ) ) + + +const GetID = SKU => + SKU.replace( /\s+/g,'' ) + .replace( /[^\w\d\.\,_-]/g,'-' ) + .toUpperCase( ) + +const SendFiles = ({ Form, SKU, Variant, Nr }) => + { let u = new URL( w.location.toString( ) ) + u.pathname = '/api/photos' + u.search = obj2URLSearchParams({ SKU, Variant, Nr: Nr||'0' }) + Form.action = u.toString( ) + Form.submit( ) + } + +const ProcessPhotoFile = ({ PhotoFile, Nr }) => + { let canvas = d.createElement( 'canvas' ) + , ctx = canvas.getContext( '2d' ) + , reader = new FileReader( ) + , Hash + + return new Promise( ( resolve,reject ) => + { reader.onload = e => + { let img = new Image( ) + img.onload = ( ) => + { canvas.width = 200 + canvas.height = 200 + ctx.drawImage( img, 0, 0, 200, 200 ) + let imageData = ctx.getImageData(0, 0, 200, 200) + if( 10 == Nr ) + { let data = imageData.data + for( let i = 0; i < data.length; i += 4) + { data[ i ] // red + = data[ i+1 ] // green + = data[ i+2 ] // blue + = 0.34 * data[i] + 0.5 * data[i + 1] + 0.16 * data[i + 2] + } + ctx.putImageData( imageData, 0, 0 ) + } + resolve({ imgData: canvas.toDataURL( 'image/jpeg' ) + , imgWidth: img.naturalWidth + , imgHeight: img.naturalHeight + }) + } + img.src = e.target.result + } + try + { reader.readAsDataURL( PhotoFile ) } + catch( e ) + { reject( e ) } + } ).catch( err => console.error( 'cant [ProcessPhotoFile]', err )) + } + +const UploadSinglePhoto = async e => + { let Form = e.target.parentNode + , T = Form.parentNode.parentNode + , R = T.parentNode + , P = R.parentNode.parentNode + , PhotoFile = e.target.files[ 0 ] + , SKU = P.dataset.sku + , Nr = T.dataset.nr + , Variant = R.dataset.variant + , Hash = T.dataset.hash + + SendFiles({ Form, SKU, Variant, Nr }) + + T.dataset.src_big = getPhotoURL({ SKU, Hash, Size:'M' }) + + '?' + Date.now( ) + const { imgData, imgWidth, imgHeight } = await ProcessPhotoFile({ PhotoFile, Nr }) + T.dataset.src_small + = T.querySelector( 'img' ).src + = imgData + + if( 1600 != imgWidth + || 1600 != imgHeight + ) + { T.querySelector( 'WrongDimensions' ).innerText = imgWidth + 'x' + imgHeight } + + updateAccounts( P ) + } + +const UploadMultiplePhotos = async e => + { let Form = e.target.parentNode + , R = Form.parentNode.parentNode.parentNode.parentNode + , P = R.parentNode.parentNode + , SKU = P.dataset.sku + , Variant = R.dataset.variant + , Photos = e.target.files + , TT = R.querySelectorAll( 'Thumbnail' ) + , Nr = TT.length-1 + + SendFiles({ Form, SKU, Variant }) + + for( const i in Photos ) + { if( i != Number( i ) ) + { updateAccounts( P ) + return true + } + Nr = ( 9 == Nr ? 11 : Nr + 1 ) + + if( 10 < Nr ) + { return false } + + const Hash = ''// await getFilehash( Photos[i] ) + , PhotoFile = Photos[i] + + let T = createThumbnail( + { SKU + , Nr + , Hash + , Variant + , PrevUUID: SKU + ':' + ( TT[ Nr-1 ] ? Nr-1 : TT.length-1 ) + , NextUUID: SKU + ':' + ( Photos[ i+1 ] ? Nr+1 : 10 ) + }) + + T.dataset.src_big = getPhotoURL({ SKU, Hash, Size:'M' }) + const { imgData, imgWidth, imgHeight } = + await ProcessPhotoFile({ PhotoFile, Nr }) + + T.dataset.src_small + = T.querySelector( 'img' ).src + = imgData + + if( 1600 != imgWidth + || 1600 != imgHeight + ) + { T.querySelector( 'WrongDimensions' ).innerText = imgWidth + 'x' + imgHeight } + + R.appendChild( T ) + } + } + +const VariantSelector_onChange = e => + { let S = e.target || e + , R = S.parentNode.parentNode + , P = R.parentNode.parentNode + + const SKU = P.dataset.sku + , Account = R.dataset.account + , Variant = S.value + , Variants = Object.keys( Products[ SKU ].Variants ) + + Products[ SKU ].Accounts[ Account ] = Variant + + request({ api: 'assignVariantToAccount' + , method: 'PUT' + , query: { SKU, Account, Variant } + }) + + R.parentNode + .replaceChild( createAccount( + { SKU + , Account + , Variant + , Variants + , ProductElement: P + } ) + , R ) + } + +const updateAccounts = ProductElement => + { ProductElement.querySelectorAll( 'Variants ThumbnailsRow' ).forEach( V => + { ProductElement.querySelectorAll( 'Accounts ThumbnailsRow' ).forEach( A => + { if( V.dataset.variant != A.dataset.variant ) + { return false } + A.querySelector( 'select' ).value = A.dataset.variant + A.querySelectorAll( 'Thumbnail' ).forEach( T => A.removeChild( T ) ) + V.querySelectorAll( 'Thumbnail' ).forEach( T => + { let newT = A.appendChild( T.cloneNode( true )) + AddClassFor600ms( newT, 'updated' ) + }) + }) + }) + Window_onScroll( ) + } + +const openNewVariantDialog = e => + { let NewVariantDialog = e.target.parentNode.parentNode.parentNode + NewVariantDialog.querySelector( 'ConfirmationDialog' ) + .classList.toggle( 'hidden' ) + NewVariantDialog.querySelector( 'button' ) + .classList.toggle( 'hidden' ) + let VariantName = + NewVariantDialog.querySelector( `input[name='variant_name']` ) + VariantName.value = Now( ) + VariantName.select( ) + VariantName.focus( ) + } + +const abortNewVariant = e => + { let NewVariantDialog = e.target.parentNode.parentNode.parentNode + NewVariantDialog.querySelector( 'ConfirmationDialog' ) + .classList.toggle( 'hidden' ) + NewVariantDialog.querySelector( 'button' ) + .classList.toggle( 'hidden' ) + } + +const confirmNewVariant = e => + { let NewVariantDialog = e.target + && e.target.parentNode.parentNode.parentNode + || e.parentNode.parentNode + , P = NewVariantDialog.parentNode.parentNode + , VariantName = NewVariantDialog.querySelector( `input[name='variant_name']` ) + + NewVariantDialog.querySelector( 'ConfirmationDialog' ) + .classList.toggle( 'hidden' ) + NewVariantDialog.querySelector( 'button' ) + .classList.toggle( 'hidden' ) + + if( !VariantName.value.trim( ) ) + { VariantName.value = Now( ) } + + const SKU = P.dataset.sku + , Variant = VariantName.value + + Products[ SKU ].Variants[ Variant ] = { } + Products[ SKU ].Variants[ Variant ][ 10 ] = + Products[ SKU ].Variants[ '_default' ][ 10 ] || '' + + let V = P.querySelector( `Variants [data-variant='${Variant}']` ) + if( V ) + { ScrollTo( V ) + return false + } + + request({ api: 'variant' + , method: 'POST' + , query: { SKU, Variant } + }) + + let newV = createVariant( + { SKU + , Variant + , Photos: Products[ SKU ].Variants[ Variant ] + } ) + newV.querySelectorAll( 'Thumbnail' ).forEach( T => + { AddClassFor600ms( T, 'updated' ) } ) + + ScrollTo( P.querySelector( 'Variants' ).appendChild( newV )) + + P.querySelectorAll( 'Accounts select' ).forEach( S => + { let O = d.createElement( 'option' ) + O.textContent = Variant + S.appendChild( O ) + } ) + } + +const copyVariant = e => + { const R = e.target + && e.target.parentNode.parentNode + || e.parentNode.parentNode + , P = R.parentNode.parentNode + , SKU = P.dataset.sku + , CopyOf = R.dataset.variant + , Variant = 'Copy ' + Now( ) + + console.log({R,P, SKU, CopyOf, Variant}) + + Products[ SKU ].Variants[ Variant ] = Products[ SKU ].Variants[ CopyOf ] + + request({ api: 'variant' + , method: 'POST' + , query: { SKU, Variant, CopyOf } + }) + + let newV = createVariant( + { SKU + , Variant + , Photos: Products[ SKU ].Variants[ Variant ] + }) + + newV.querySelectorAll( 'Thumbnail' ).forEach( T => + { AddClassFor600ms( T, 'updated' ) } ) + + ScrollTo( P.querySelector( 'Variants' ).appendChild( newV )) + + P.querySelectorAll( 'Accounts select' ).forEach( S => + { let O = d.createElement( 'option' ) + O.textContent = Variant + S.appendChild( O ) + }) + } + + + +const toggleProduct = e => + { e.preventDefault( ) + + let P = e.target.parentNode.parentNode.parentNode + , SKU = P.dataset.sku + + if( P.classList.contains( 'targeted' ) ) + { collapseAll( ) + P.querySelectorAll( 'Thumbnail' ) + .forEach( T => T.classList.remove( 'updated' ) ) + } + else + { expandProduct( P ) } + return true + } + +const expandProduct = P => + { unzoomAll( ) + + P.classList.remove( 'hidden' ) + P.classList.add( 'targeted' ) + ScrollTo( P ) + + const SKU = P.dataset.sku + setURL( '!'+SKU ) + + if( P.querySelector( 'Accounts ThumbnailsRow' ) ) + { return true } + + let Product = Products[ SKU ] || { } + , Variants = [] + , NewVariant = P.querySelector( 'NewVariant' ) + , ConfirmNewVariant = NewVariant.querySelector( 'ConfirmNewVariant' ) + , AbortNewVariant = NewVariant.querySelector( 'AbortNewVariant' ) + , NewVariantName = NewVariant.querySelector( 'input[type=text]' ) + + NewVariant.querySelector( 'button' ).onclick = openNewVariantDialog + ConfirmNewVariant.onclick = confirmNewVariant + AbortNewVariant.onclick = abortNewVariant + NewVariantName.onkeypress = e => + { if( e.key == 'Escape' ) + { AbortNewVariant.click( ) + return false + } + else if( e.key == 'Enter' ) + { confirmNewVariant( ConfirmNewVariant ) } + } + + if( !Product.Variants ) + { Product.Variants = { _default: { } } } + + for( const Variant in Product.Variants ) + { Variants.push( Variant ) + if( Variant != '_default' ) + { P.querySelector( 'Variants' ).appendChild( + createVariant( + { SKU + , Variant + , Photos: Product.Variants[Variant] + }) + ) + } + } + + if( !Object.keys( Product.Accounts ).length ) + { Products[ SKU ].Accounts + = Product.Accounts + = Default_Accounts + } + + Accounts_Ordered.map( Account => + P.querySelector( 'Accounts' ).appendChild + ( createAccount( + { Account + , Variant: Product.Accounts[ Account ] || '_default' + , Variants + , ProductElement: P + , SKU + }) + ) + ) + + ScrollTo( P ) + } + +const Window_onScroll = e => + { const Window_Height = w.innerHeight + || d.documentElement.clientHeight + + d.querySelectorAll( 'ThumbnailsRow' ) + .forEach( ( R, i, a ) => + { let Rect = R.getBoundingClientRect( ) + if( ( 0 <= ( Rect.top + Rect.height ) ) + && ( Rect.top <= Window_Height ) + ) + { // if( ( i == a.length-1 ) + // && ( !d.querySelector( '.targeted' ) ) + // ) + // { showProducts( ) } + + if( ( R.querySelector( 'Thumbnail img' ).src + == R.querySelector( 'Thumbnail' ).dataset.src_small + ) + || ( R.querySelector( 'Thumbnail img' ).src + == R.querySelector( 'Thumbnail' ).dataset.src_big + ) + ) + { return false } + + R.querySelectorAll( 'Thumbnail:not(.zoomed)' ) + .forEach( T => T.querySelector( 'img' ).src = T.dataset.src_small ) + } + }) + } + +const Window_onResize = e => + { d.getElementById( 'ZoomedStyle' ).innerText = + `.zoomed + { top: ${ w.innerHeight/2 - 400 }px !important + ; left: ${ w.innerWidth/2 - 400 }px !important + ; position: fixed + } + `.replace( /\s\s+/g, '\n' ) + Window_onScroll( ) + } + +const Window_onKeyDown = e => + { let Zoomed = d.querySelector( 'Thumbnail.zoomed' ) + if( Zoomed ) + { if( e.key == 'Escape' ) + { unzoomAll( ) + return true + } + if( /^(k|n|ArrowRight)$/i.test( e.key )) + Zoomed.querySelector( 'Next' ).click( ) + if( /^(j|p|ArrowLeft)$/i.test( e.key )) + Zoomed.querySelector( 'Prev' ).click( ) + } + else + { if( e.key == 'Escape' ) + { collapseAll( ) + unhideAll( ) + showProducts( ) + } + if( ( e.key == 'R' && ( e.ctrlKey || e.metaKey ) ) + || ( ( e.which || e.keyCode ) == 116 ) + ) + { console.log( 'Hard Reload in 3.. 2.. ' ) + _HardReload_in_3_2_ = true + } + } + } + +const openThumbnailRemovalDialog = e => + { unzoomAll( ) + let RemovalButton = e.target + , ConfirmationDialog = RemovalButton.querySelector( 'ConfirmationDialog' ) + , Icon = RemovalButton.querySelector( 'Icon' ) + + if( !( ConfirmationDialog && Icon )) + { return false } + ConfirmationDialog.classList.remove( 'hidden' ) + Icon.classList.add( 'hidden' ) + // RemovalButton.removeEventListener( 'click', openThumbnailRemovalDialog ) + } + +const abortThumbnailRemoval = e => + { let RemovalDialog = e.target.parentNode.parentNode.parentNode + RemovalDialog.querySelector( 'Icon' ).classList.remove( 'hidden' ) + RemovalDialog.querySelector( 'ConfirmationDialog' ).classList.add( 'hidden' ) + // RemovalDialog.addEventListener( 'click', openThumbnailRemovalDialog ) + } + +const confirmThumbnailRemoval = e => + { unzoomAll( ) + let RemoveButton = e.target.parentNode.parentNode + , T = RemoveButton.parentNode + , R = T.parentNode + , P = R.parentNode.parentNode + + const SKU = P.dataset.sku + , Variant = R.dataset.variant + , Nr = T.dataset.nr + , Hash = T.dataset.hash + + console.log( 'removing photo:', SKU, Variant, Nr, Hash ) + request({ api: '' + , method: 'DELETE' + , query: { SKU, Variant, Nr } + }) + + if( 10 == Nr ) + { delete Products[ SKU ].Variants[ Variant ][ 10 ] } + else + { const newAmountOfRegularPhotos = Object + .keys( Products[ SKU ].Variants[ Variant ] ) + .filter( k => 10 > k ) + .length - 1 + + for( let _Nr = Nr; _Nr <= 9; _Nr++ ) + { if( _Nr <= newAmountOfRegularPhotos ) + { Products[ SKU ].Variants[ Variant ][ _Nr ] = + Products[ SKU ].Variants[ Variant ][ Number( _Nr ) + 1 ] + } + else + { delete Products[ SKU ].Variants[ Variant ][ _Nr ] } + } + } + + + let V = createVariant( + { SKU + , Variant + , Photos: Products[ SKU ].Variants[ Variant ] + }) + + R.parentNode.replaceChild( V, R ) + AddClassFor600ms( V, 'changed_numeration' ) + updateAccounts( P ) + } + +const openVariantRemovalDialog = e => + { unzoomAll( ) + let RemovalButton = e.target + , ConfirmationDialog = RemovalButton.querySelector( 'ConfirmationDialog' ) + , Icon = RemovalButton.querySelector( 'Icon' ) + + if( !( ConfirmationDialog && Icon )) + { return false } + ConfirmationDialog.classList.remove( 'hidden' ) + Icon.classList.add( 'hidden' ) + // RemovalButton.removeEventListener( 'click', openVariantRemovalDialog ) + } +const abortVariantRemoval = e => + { let RemovalDialog = e.target.parentNode.parentNode + RemovalDialog.querySelector( 'icon' ).classList.remove( 'hidden' ) + RemovalDialog.querySelector( 'ConfirmationDialog' ).classList.add( 'hidden' ) + // RemovalDialog.addEventListener( 'click', openVariantRemovalDialog ) + } +const confirmVariantRemoval = e => + { const R = e.target.parentNode.parentNode.parentNode.parentNode + , P = R.parentNode.parentNode + , Variant = R.dataset.variant + , SKU = P.dataset.sku + + console.log( 'removing', SKU, Variant ) + + request({ api: 'variant' + , method: 'DELETE' + , query: { SKU, Variant } + }) + + if( Products[ SKU ].Variants[ Variant ] ) + { delete Products[ SKU ].Variants[ Variant ] + localStorage.setItem( SKU, JSON.stringify( Products[ SKU ] ) ) + } + + // if( Variant == '_default' ) + // let chkNewDefault = P.querySelector('input[type=checkbox]:not(:checked)') + // if( chkNewDefault ) + // changeDefaultVariant( chkNewDefault ) + // } + // } + + R.parentNode.removeChild( R ) + } + +const unzoomAll = ( ) => + { d.querySelectorAll( 'Thumbnail.zoomed' ).forEach( z => + { //console.log(z) + z.querySelector( 'Prev' ).classList.add( 'hidden' ) + z.querySelector( 'Next' ).classList.add( 'hidden' ) + z.querySelector( 'Zoom i' ).classList.add( 'fa-search-plus' ) + z.querySelector( 'Zoom i' ).classList.remove( 'fa-search-minus' ) + z.classList.remove( 'zoomed' ) + z.querySelector( 'img' ).src = z.dataset.src_small + }) + } + +const ThumbnailZoomToggle = T => + { if( T.classList.contains( 'zoomed' ) ) + { unzoomAll( ) } + else + { ThumbnailZoomIn( T ) } + } + +const ThumbnailZoomIn = T => + { unzoomAll( ) + T.querySelector( 'Prev' ).classList.remove( 'hidden' ) + T.querySelector( 'Next' ).classList.remove( 'hidden' ) + T.querySelector( 'Zoom i' ).classList.remove( 'fa-search-plus' ) + T.querySelector( 'Zoom i' ).classList.add( 'fa-search-minus' ) + T.classList.add( 'zoomed' ) + T.querySelector( 'img' ).src = T.dataset.src_big + } + +const Moving_inProgress = Pos => + { Moving_Thumbnail.style.top = Pos.pageY+10 + 'px' + Moving_Thumbnail.style.left = Pos.pageX+5 + 'px' + Pos.preventDefault( ) + } + +const Moving_Stop = e => + { e.preventDefault( ) + d.removeEventListener( 'mousemove', Moving_inProgress ) + d.removeEventListener( 'mouseup', Moving_Stop ) + + if( Moving_Thumbnail != d.querySelector( 'Thumbnail.moving' ) ) + { return false } + + let R = Moving_Thumbnail.parentNode + , P = R.parentNode.parentNode + , SKU = P.dataset.sku + , Variant = R.dataset.variant + , OldNr = Moving_Thumbnail.dataset.nr + + R.querySelectorAll( 'MovePlaceholder' ) + .forEach( p => p.classList.add( 'hidden' ) ) + + R.classList.remove( 'has_moving' ) + Moving_Thumbnail.classList.remove( 'moving' ) + Moving_Thumbnail.style = '' + + let Place = d.querySelector( 'Thumbnail MovePlaceholder:hover' ) + if( !Place + || ( Place.parentNode.dataset.uuid + == Moving_Thumbnail.dataset.prevuuid + ) + ) + { return false } + + // rearrange photos + let NewNr = Number( Place.textContent ) + console.log( 'reordering photos', OldNr, '>>', NewNr ) + Place.parentNode.insertAdjacentElement( 'afterend', Moving_Thumbnail ) + AddClassFor600ms( Moving_Thumbnail.querySelector( 'img' ), 'updated' ) + + R.querySelectorAll( 'Thumbnail' ) + .forEach( ( T, i, a ) => + { const NrBefore = T.dataset.nr + , NrAfter = ( i<10 ? ( i==0 ? 10 : i ) : i+1 ) + T.dataset.nr + = T.querySelector( 'ThumbnailCaption' ).textContent + = NrAfter + T.dataset.uuid + = T.title + = T.dataset.sku + ':' + NrAfter + + T.dataset.prevuuid = T.dataset.sku + ':' + ( [ a.length, 10, ...range( 1,10 ) ][i] || i ) + T.dataset.nextuuid = T.dataset.sku + ':' + ( i==a.length-1 ? 10 : ( i<9 ? i+1 : i+2 ) ) + }) + + AddClassFor600ms( R, 'changed_numeration' ) + request({ api: 'renumber' + , method: 'PUT' + , query: { SKU, Variant, OldNr, NewNr } + }) + + updateAccounts( P ) + Moving_Thumbnail = null + } + +const Move = e => + { if( e.button == 0 ) + { Moving_Thumbnail = e.target.parentNode + Moving_Thumbnail.classList.add( 'moving' ) + Moving_Thumbnail.parentNode.classList.add( 'has_moving' ) + + let R = Moving_Thumbnail.parentNode + R.querySelectorAll( 'Thumbnail:not( .moving ) MovePlaceholder' ) + .forEach( ( MovePlaceholder, i, a ) => + { MovePlaceholder.textContent = i+1 + MovePlaceholder.classList.remove( 'hidden' ) + } ) + Moving_inProgress( e ) + d.addEventListener( 'mousemove', Moving_inProgress ) + d.addEventListener( 'mouseup', Moving_Stop ) + e.preventDefault( ) + } + } + +const Prev = e => + { const T = e.target.parentNode + , R = T.parentNode + , PrevT = R.querySelector( `[data-uuid='${T.dataset.prevuuid}']` ) + + if( PrevT ) + { ThumbnailZoomIn( PrevT ) } + } +const Next = e => + { const T = e.target.parentNode + , R = T.parentNode + , NextT = R.querySelector( `[data-uuid='${T.dataset.nextuuid}']` ) + + if( NextT ) + { ThumbnailZoomIn( NextT ) } + } + +const createThumbnail = ({ SKU, Nr, Hash, PrevUUID, NextUUID, Variant }) => + { let T = ThumbnailTemplate + .querySelector( 'Thumbnail' ) + .cloneNode( true ) + Hash = Hash || '' + + T.dataset.hash = Hash + T.dataset.src_big = getPhotoURL({ SKU, Hash, Nr, Size:'M' }) + T.dataset.src_small = getPhotoURL({ SKU, Hash, Nr, Size:'S' }) || '/placeholder.svg' + T.querySelector( 'img' ).src = '/placeholder.svg' + + T.querySelector( 'ThumbnailCaption' ).textContent = Nr + T.querySelector( 'Upload input' ).onchange = UploadSinglePhoto + T.querySelector( 'Remove' ).addEventListener( 'click', openThumbnailRemovalDialog ) + T.querySelector( 'Remove AbortRemoval' ).onclick = abortThumbnailRemoval + T.querySelector( 'Remove ConfirmRemoval' ).onclick = confirmThumbnailRemoval + T.querySelector( 'Move' ).onmousedown = Move + T.querySelector( 'Zoom' ).onclick = e => ThumbnailZoomToggle( e.target.parentNode ) + T.querySelector( 'Prev' ).onclick = Prev + T.querySelector( 'Next' ).onclick = Next + T.dataset.prevuuid = PrevUUID + T.dataset.nextuuid = NextUUID + + T.dataset.sku = SKU + T.dataset.nr = Nr + T.dataset.uuid + = T.title + = SKU+':'+Nr + + return T + } + +const changeDefaultVariant = e => + { const newDefaultCheckbox = e.target || e + , R = newDefaultCheckbox.parentNode.parentNode + , P = R.parentNode.parentNode + , SKU = P.dataset.sku + , NewDefaultVariant = R.dataset.variant + , oldDefaultRow = P.querySelector( `Variants ThumbnailsRow[data-variant='_default']` ) + , oldDefaultCheckbox = oldDefaultRow.querySelector( `input[type='checkbox']` ) + , oldVarinatRenamedTo = Now( ) + + oldDefaultRow.dataset.variant = oldVarinatRenamedTo + oldDefaultCheckbox.checked + = oldDefaultCheckbox.disabled + = false + newDefaultCheckbox.checked + = newDefaultCheckbox.disabled + = true + + oldDefaultRow.querySelector('RowTitle').textContent = oldVarinatRenamedTo + Products[ SKU ].Variants[ oldVarinatRenamedTo ] = + Products[ SKU ].Variants[ '_default' ] + + R.dataset.variant = '_default' + R.querySelector('RowTitle').textContent = '' + Products[ SKU ].Variants[ '_default' ] = + Products[ SKU ].Variants[ NewDefaultVariant ] + + delete Products[ SKU ].Variants[ NewDefaultVariant ] + + updateAccounts( P ) + request({ api: 'setDefaultVariant' + , method: 'PUT' + , query: { SKU, Variant: NewDefaultVariant, oldVarinatRenamedTo } + }) + + AddClassFor600ms( R, 'updated' ) + console.log( `set '${NewDefaultVariant}' as default of '${SKU}',` + , 'old default renamed to', oldVarinatRenamedTo + ) + } + +const createVariant = ({ SKU, Variant, Photos }) => + { let R = ThumbnailsRowTemplate + .querySelector( 'ThumbnailsRow' ) + .cloneNode( true ) + + let isDefault = R.querySelector( 'RowDescription input[type=checkbox]' ) + + isDefault.onchange = changeDefaultVariant + isDefault.checked = ( Variant == '_default' ) + isDefault.disabled = isDefault.checked + + R.querySelector( 'RowTitle' ).textContent = Variant.replace( '_default', '' ) + R.dataset.variant = Variant + + R.querySelector( 'input[type=file]' ).onchange = UploadMultiplePhotos + R.querySelector( 'RowDescription Remove' ).addEventListener( 'click', openVariantRemovalDialog ) + R.querySelector( 'RowDescription Remove AbortRemoval' ).onclick = abortVariantRemoval + R.querySelector( 'RowDescription Remove ConfirmRemoval' ).onclick = confirmVariantRemoval + R.querySelector( 'RowDescription Copy' ).onclick = copyVariant + + const Photo_Set = [ ...( new Set([ '10', ...Object.keys( Photos ).sort( ) ]) ) ] + Photo_Set.forEach( ( Nr, i, a ) => + { R.appendChild( createThumbnail( + { SKU + , Nr + , Hash: Photos[Nr] || '' + , Variant + , PrevUUID: SKU + ':' + ( a[i-1] ? a[i-1] : a[a.length-1] ) + , NextUUID: SKU + ':' + ( a[i+1] ? a[i+1] : a[0] ) + } ) ) + }) + + return R + } + +const createAccount = ({ Account, Variant, Variants, ProductElement, SKU }) => + { const VariantRowSelector = `Variants ThumbnailsRow[data-variant='${Variant}']` + , DefaultRowSelector = `Variants ThumbnailsRow` + + let R = ( ProductElement.querySelector( VariantRowSelector ) + || ProductElement.querySelector( DefaultRowSelector ) + ).cloneNode( true ) + + R.querySelector( 'RowDescription input[type=checkbox]' ) + .classList.add( 'hidden' ) + + R.dataset.account + = R.querySelector( 'RowTitle' ).textContent + = Account + + let S = R.querySelector( `select[name='VariantSelector']` ) + S.onchange = VariantSelector_onChange + Variants.forEach( V => + { let O = d.createElement( 'option' ) + O.textContent = V + O.selected = ( V == Variant ) + S.appendChild( O ) + } ) + + return R + } + +const createProduct = Product => + { if( d.querySelector( `ProductElement[id='${Product.SKU}']` ) ) + { return false } + + let P = ProductTemplate.querySelector( 'ProductElement' ) + .cloneNode( true ) + + P.dataset.searchindex = getSearchIndex( Product.SKU, Product.Title, Product.Type ) + P.querySelector( 'ProductAmount' ).textContent = Product.Amount + P.querySelector( 'ProductSKU a' ).onclick = toggleProduct + P.querySelector( 'ProductSKU a' ).href = '/photos/!'+Product.SKU + P.querySelector( 'ProductSKU a' ).textContent + = P.dataset.sku + = P.id + = Product.SKU + + P.querySelector( 'ProductTitle' ).innerHTML = + Product.Title.replace( /\[/, '
[' ) + + const Variant = '_default' + P.querySelector( 'Variants' ).appendChild( createVariant( + { SKU: Product.SKU + , Variant + , Photos: Product.Variants[ Variant ] + })) + + return P + } + +const ShowSKUs = Desired => +new Promise( (resolve, reject) => + { setProgressBarTotal( Desired.length ) // Desired.length >= MaxChunk + // && MaxChunk + // || Desired.length + + Desired.forEach( ( P, i, a ) => + { setTimeout( ( )=> + { unloadedProducts.splice( unloadedProducts.indexOf( P ), 1 ) + incrementProgressBar( ) + const newP = Main.appendChild( createProduct( Products[P] ) ) + // if( i==0 ) + // ScrollTo( newP ) + // } + if( i == 10 ) + { Window_onScroll( ) } + + if( i==a.length-1 ) + { resolve( true ) } + }, 1 ) + }) + }).catch( err => console.error( 'cant [ShowSKUs]', err ) ) + +const onSearch = async e => + { if( e ) e.preventDefault( ) + searchField.value = searchField.value.trim( ) + chkShowOnlyAvaliable.checked = false + UpdateCounter('') + setURL( searchField.value ) + + if( searchField.value === '*' ) + { // show all + unzoomAll( ) + collapseAll( ) + unhideAll( ) + showProducts( 1e10 ) + return true + } + if( searchField.value[0] === '-' ) + { // show w/o photos + unzoomAll( ) + collapseAll( ) + unhideAll( ) + const AbsentPhoto = Number( searchField.value + .replace(/^\-/, '') + .trim( ) + || 1 + ) + let Desired = [] + for( const SKU in Products ) + { const P = Products[ SKU ] + if( !P.Variants[ '_default' ][ AbsentPhoto ] ) + { Desired.push( SKU ) } + } + + d.querySelectorAll( 'ProductElement' ) + .forEach( P => + { if( !Desired.includes( P.dataset.sku ) ) + { P.classList.add( 'hidden' ) } + }) + + await ShowSKUs( Desired ) + + d.querySelectorAll( 'ProductElement' ) + .forEach( P => + { if( !Desired.includes( P.dataset.sku ) ) + { P.classList.add( 'hidden' ) } + }) + + UpdateCounter( ) + return true + } + + const SearchString = getSearchIndex( searchField.value ) + if( SearchString ) + { const Desired = unloadedProducts.filter( P => + getSearchIndex( P, Products[P].Title, Products[P].Type ) + .includes( SearchString ) + ) + if( Desired.length ) + { await ShowSKUs( Desired ) } + showOnlySearched({ expandSoloProduct: true }) + UpdateCounter( ) + } + else + { unhideAll( ) + collapseAll( ) + UpdateCounter( ) + } + } + +const showProducts = ( Amount = MaxChunk ) => + { setProgressBarTotal + ( ( unloadedProducts.length >= Amount ) + && Amount + || unloadedProducts.length + ) + + unloadedProducts.every( ( SKU, i, a ) => + { let P = createProduct( Products[ SKU ] ) + if( P ) + { setTimeout( ( )=> + { incrementProgressBar( ) + Main.appendChild( P ) + if( i>=Amount-1 || i==a.length-1 ) + { Window_onScroll( ) } + }, 1 ) + } + + if( i>=Amount-1 || i==a.length-1 ) + { unloadedProducts.splice( 0, Amount ) + return false + } + return true + } ) + } + +const showOnlySearched = ({ expandSoloProduct }) => + { searchField.value = searchField.value.trim( ) + + const SearchString = getSearchIndex( searchField.value ) + , SKU = searchField.value.replace( /^[!\?\*]+/, '' ) + , ForceSKU = ( searchField.value[0] == '!' ) + + if( ForceSKU && Products[ SKU ] ) + { d.querySelectorAll( `ProductElement:not([data-sku='${ SKU }'])` ) + .forEach( P => P.classList.add( 'hidden' ) ) + + let ShownProduct = d.querySelector( `ProductElement[data-sku='${ SKU }']` ) + ShownProduct.classList.remove( 'hidden' ) + expandProduct( ShownProduct ) + + return true + } + + if( !SearchString ) + { unhideAll( ) + Window_onScroll( ) + return false + } + + d.querySelectorAll( `ProductElement:not([data-searchindex*='${SearchString}'])` ) + .forEach( P => P.classList.add( 'hidden' ) ) + + let ShownProducts = + d.querySelectorAll( `ProductElement[data-searchindex*='${SearchString}']` ) + ShownProducts.forEach( P => P.classList.remove( 'hidden' ) ) + + if( expandSoloProduct && ShownProducts.length==1 ) + { expandProduct( ShownProducts[0] ) } + else if( ShownProducts.length > 1 ) + { collapseAll( ) + ScrollTo( ShownProducts[0] ) + } + + Window_onScroll( ) + } + +const loadData = ( ) => +new Promise( async ( resolve, reject ) => + { BaseURL = await request({ api: 'baseURL' }) + localStorage.setItem( 'BaseURL', BaseURL ) + console.log( 'BaseURL:', BaseURL ) + + Accounts_Ordered = ( await request({ api: 'accounts' }) ) + .split( /[\r\n]+/ ) + localStorage.setItem( 'Accounts_Ordered', JSON.stringify( Accounts_Ordered ) ) + console.log( 'Accounts:', JSON.stringify( Accounts_Ordered, false, 2 ) ) + + const Default_Accounts = Object.assign + ( { }, ...Accounts_Ordered.map( A => ({[ A ] : '_default' }) ) ) + localStorage.setItem( 'Default_Accounts', JSON.stringify( Default_Accounts, false, 2 ) ) + + let AllPhotos = [] + try + { console.log( 'getting Photos...' ) + AllPhotos = JSON.parse( await request( + { api: '' + , query: { since: localStorage.getItem( 'lastUpdated' ) || 0 } + }) ) + console.log + ( 'new rows in DB.Photos_Ordered ( since' + , localStorage.getItem( 'lastUpdated' ) || 0 + ,'):' + , AllPhotos.length + ) + } + catch( err ) + { console.error( err ) + Products = { 'ERR-01-001': Product_Error } + reject( false ) + return false + } + + localStorage.setItem( 'lastUpdated', Now( ) ) + console.log( 'lastUpdated:', localStorage.getItem( 'lastUpdated' ) ) + + + AllPhotos.forEach( P => + { const { SKU, Variant } = P + if( !Products[ SKU ] ) + { Products[ SKU ] = + { SKU + , Title: P.Title || '' + , Type: P.Type || '' + , Amount: P.Amount || 0 + , id: P.id + , Variants: { '_default': { } } + , Accounts: { } + } + + if( Products[ SKU ].Type.trim( ) + && !ProductTypes.has( Products[ SKU ].Type.trim( ) ) + ) + { ProductTypes.add( Products[ SKU ].Type.trim( ) ) } + } + + if( P.Accounts ) + { P.Accounts + .split(/[\r\n]+/) + .filter( A => A && A.trim( ) && A != 'undefined' ) + .forEach( Account => + { Products[ SKU ].Accounts[ Account ] = Variant }) + } + + if( Variant ) + { if( !Products[ SKU ].Variants[ Variant ] ) + { Products[ SKU ].Variants[ Variant ] = { } } + for( let Nr = 1; Nr < 11; Nr++ ) + { if( P[ Nr ] ) + { Products[ SKU ].Variants[ Variant ][ Nr ] + = getHashFromPhotoURL( P[ Nr ] ) + } + } + } + + }) + + for( let SKU in Products ) + { //console.log( SKU, Products[ SKU ] ) + if( SKU ) + { localStorage.setItem + ( SKU, JSON.stringify( Products[ SKU ] ) ) + } + } + + resolve( true ) + + }).catch( err => console.error( 'cant [loadData]', err ) ) + + +const init = async ( ) => + { w.onkeydown = Window_onKeyDown + w.onscroll = Window_onScroll + w.onresize = Window_onResize + Window_onResize( ) + + searchForm.onsubmit = onSearch + searchField.focus( ) + // searchField.onchange = Search_onKeypress + // searchField.onkeypress = Search_onKeypress + + btnShowAll.onclick = e => + { if( e ) e.preventDefault( ) + searchField.value = '*' + e.target.blur( ) + onSearch( ) + return false + } + btnShowWithoutPhotos.onclick = e => + { if( e ) e.preventDefault( ) + searchField.value = '-1' + e.target.blur( ) + onSearch( ) + return false + } + chkShowOnlyAvaliable.onchange = ShowOnlyAvaliable + + + ProductTemplate.innerHTML = + d.getElementById( 'ProductTemplate' ).innerHTML + ThumbnailsRowTemplate.innerHTML = + d.getElementById( 'ThumbnailsRowTemplate' ).innerHTML + ThumbnailTemplate.innerHTML = + d.getElementById( 'ThumbnailTemplate' ).innerHTML + + if( !localStorage.getItem( 'lastUpdated' ) + || !Object.keys( Products ).length + ) + { await loadData( ) } + else + { loadData( ) } + + PopulateSearchWithTypes( ) + + unloadedProducts = Object.keys( Products ) + unloadedProducts.sort( (a,b) => Products[a].id - Products[b].id ) + // console.log( 'active Products:', unloadedProducts ) + + let locHash = unURIHash( ) + if( w.location.search || locHash ) + { let u = new URLSearchParams( w.location.search ) + MaxChunk = Number( u.get( 'max' ) || 100 ) + let s = locHash + searchField.value = s.toUpperCase( ) + onSearch( ) + } + else + { showProducts( MaxChunk ) } + } + + +w.addEventListener( 'DOMContentLoaded', init( ), true ) +w.addEventListener( 'beforeunload', e => + { + if( _HardReload_in_3_2_ ) + { console.warn( 'CLEAR!!' ) + localStorage.clear( ) + } + }) diff --git a/static/photos/style.css b/static/photos/style.css new file mode 100644 index 0000000..6d876b5 --- /dev/null +++ b/static/photos/style.css @@ -0,0 +1,590 @@ +:root + { --cloud-blue: #0080dd + ; --border-blue: #0af + ; --super-light-gray: #f8f8f8 + ; --cancel-red: #c00 + ; --confirm-green: #4b0 + } + +Thumbnail + { background: url('placeholder.svg') 50% 50% no-repeat + ; background-size: 100% 100% + ; margin: 0 1rem 1rem 0 + ; width: 5rem + ; height: 5rem + ; position: relative + ; display: block + ; float: left + } + + Thumbnail img + { width: 5rem + ; height: 5rem + ; display: inline-block + ; position: absolute + ; border: 0.2rem solid lightgray + ; background: lightgray + } + + Thumbnail[data-nr='10'] img + { border-color: var(--border-blue) } + + Thumbnail ThumbnailCaption + { display: inline-block + ; position: absolute + ; background: lightgray + ; font-weight: bold + ; padding: 0.15rem 0.3rem + ; bottom: -0.4rem + ; right: 0.35rem + ; font-size: 0.5rem + ; color: #555 + ; border-radius: 0.6rem + } + + Thumbnail .button + { display: none + ; position: absolute + ; font-size: 1.1rem + ; padding: 0 0.35rem 0.2rem 0.35rem + ; margin: 0.25rem + } + + Thumbnail:hover .button + { display: inline-block } + + Thumbnail .button .fa + { text-shadow: 1.5pt 1.5pt 1pt white + , -1.5pt -1.5pt 1pt white + , -1.5pt 1.5pt 1pt white + , 1.5pt -1.5pt 1pt white + } + + Thumbnail .button .fa + , .button Icon + { pointer-events: none } + + Thumbnail .arrow + { + ; margin: -1.5rem + ; position: absolute + ; top: 50% + ; display: none + } + +Upload + { position: relative + } + + Upload .fa + { color: var(--cloud-blue) + } + + Upload input[type='file'] + { top: 0 + ; left: 0 + ; position: absolute + ; display: block + ; width: 2.1rem + ; height: 2.1rem + ; overflow: hidden + ; cursor: pointer + } + +Copy .fa + { color: var(--confirm-green) + ; font-size: 0.66em + } +Remove .fa + { color: var(--cancel-red) } + +AbortRemoval .fa + { color: #333 } + +Move .fa + { color: #333 } + +Thumbnail[data-nr='10'] Move + { display: none !important } + +Zoom .fa + { color: #333 } + +Thumbnail.moving + { position: absolute !important + ; pointer-events: none + ; z-index: 1 + ; margin-left: 0 !important + } + +MovePlaceholder + { border: 0.25rem solid white + ; cursor: move + ; background: lightgray + ; margin-top: -1rem + ; margin-left: 1rem + ; height: 7rem + ; width: 3rem + ; float: right + ; color: gray + ; text-align: center + ; line-height: 6.5rem + ; font-size: 2rem + } + + MovePlaceholder:hover + { background: #8f0 } + +.has_moving Thumbnail + { margin-right: 3rem } + +.has_moving Thumbnail MovePlaceholder + { margin-right: -3rem } + +.has_moving Thumbnail .button + { display: none } + +@keyframes flash_border_red + { from { border-color: lightgray } + to { border-color: #c44 } + } + +.failed + { border-width: 0.25rem + ; animation-duration: 0.3s + ; animation-name: flash_border_red + ; animation-direction: alternate + ; animation-iteration-count: 2 + } + +@keyframes flash_border_orange + { from { border-color: lightgray } + to { border-color: #f52 } + } + +.removed + { animation-duration: 0.3s + ; animation-name: flash_border_orange + ; animation-direction: alternate + ; animation-iteration-count: 2 + } + +@keyframes flash_border_green + { from { border-color: lightgray } + to { border-color: #8f0 } + } + +.updated + { animation-duration: 0.3s + ; animation-name: flash_border_green + ; animation-direction: alternate + ; animation-iteration-count: 2 + } + +@keyframes flash_background_green + { from { background-color: lightgray } + to { background-color: #8f0 } + } + +.changed_numeration Thumbnail:not([data-nr='10']) ThumbnailCaption + { animation-duration: 0.3s + ; animation-name: flash_background_green + ; animation-direction: alternate + ; animation-iteration-count: 2 + } + +ProductElement + { display: inline-block + ; width: 100% + ; min-height: 7rem + ; clear: both + ; float: none + ; padding: 0.5rem + } + + ProductElement ProductDescription + { float: left + ; padding: 0 1rem + ; width: 10rem + ; display: block + } + + ProductDescription ProductTitle + { font-size: 0.6rem + ; display: block + ; text-overflow: hidden + ; clear: both + } + + ProductDescription ProductAmount + { color: gray + ; display: block + } + +ThumbnailsRow + { display: block + ; margin-left: 10rem + ; min-height: 5rem + } + + ThumbnailsRow RowDescription + { font-size: 0.75rem + ; clear: both + ; text-align: right + ; display: inline-block + ; min-width: 6rem + ; margin-left: -7rem + ; float: left + ; position: relative + } + +RowDescription > Remove + { display: inline-block + ; clear: none + ; position: absolute + } + +RowDescription > Copy + { display: inline-block + ; left: 2rem + ; position: absolute + } + +RowDescription RowTitle + { width: 8rem + ; display: block + ; margin-left: -2rem + ; clear: both + } + +RowDescription input[type='checkbox'] + { display: inline-block + ; width: auto + ; float: right + } + +AddPhotos + { display: block + ; position: relative + ; clear: both + ; width: 1.5rem + ; height: 1.5rem + ; float: right + } + + AddPhotos .fa + { color: gray } + + AddPhotos:hover .fa + { color: #0080dd } + + +Remove ConfirmationDialog + { position: relative + ; width: 3rem + ; height: 1.45rem + ; display: inline-block + ; top: 0.15rem + ; right: -1.85rem + ; background: lightgray + ; border-radius: 1rem + ; z-index: 2 + ; box-shadow: 1.5pt 1.5pt 1pt red + , -1.5pt -1.5pt 1pt red + , -1.5pt 1.5pt 1pt red + , 1.5pt -1.5pt 1pt red + } + +Remove ConfirmationDialog .button + { margin: 0 + ; display: inline-block + ; float: left + ; height: 1.5rem + ; width: 1.5rem + ; font-size: 1rem + ; line-height: 1.4rem + ; position: relative + } + +Remove ConfirmationDialog .button .fa + { text-shadow: none } + +Remove AbortRemoval:hover:after + { content: '' + ; display: inline-block + ; position: absolute + ; top: 0.1rem + ; left: 0.1rem + ; height: 1.25rem + ; width: 1.25rem + ; border-radius: 1rem + ; background: white + ; opacity: 0.5 + } + +Remove ConfirmRemoval:hover:after + { display: inline-block + ; position: absolute + ; content: '' + ; top: 0.1rem + ; left: 0.1rem + ; height: 1.25rem + ; width: 1.25rem + ; border-radius: 1rem + ; background: red + ; opacity: 0.25 + } + +.button +, select + { cursor: pointer } + +.top { top: 0 } +.left { left: 0 } +.right { right: 0 } +.bottom { bottom: 0 } + +select +, button +, input:not([type='file']) + { border: 0.1rem solid lightgray + ; font-size: 0.75rem + ; width: 6rem + ; height: 1.5rem + ; -webkit-outline: 0 + ; -moz-outline: 0 + ; outline: 0 + ; outline-width: 0 + ; display: block + ; padding: 0.15rem + } + +hr + { border-top: 1rem solid var(--super-light-gray) + ; padding-top: 1rem + ; clear: both + ; display: block + ; width: 20rem + } + +ProductTitle hr + { border: 0 + ; padding: 0 + ; width: auto + } + +NewVariant + { float: left + ; position: relative + ; left: 9rem + ; width: 13.95rem + } + +NewVariant ConfirmationDialog input[name='variant_name'] + { width: 10.95rem /* visual bug: if set to 11 seems TOO wide */ + ; float: left + ; margin-right: 0.75rem + } + +NewVariant button + { width: 1.5rem } + +NewVariant ConfirmationDialog + { position: relative } + +NewVariant AbortNewVariant + { float: right } + +AbortNewVariant .fa + { color: var(--cancel-red) } + +ConfirmNewVariant + { float: left } + +ConfirmNewVariant .fa + { color: var(--confirm-green) } + +.hidden +, .zero +, ProductElement:not(.targeted) Upload +, ProductElement:not(.targeted) Move +, ProductElement:not(.targeted) Remove +, ProductElement:not(.targeted) > hr +, ProductElement:not(.targeted) ThumbnailsRow:not(:first-of-type) +, ProductElement:not(.targeted) Accounts +, ProductElement:not(.targeted) NewVariant +, ProductElement:not(.targeted) RowDescription +, Variants select +, Accounts .button +, Accounts AddPhotos +, ThumbnailsRow Thumbnail:first-of-type Remove + { display: none !important + } + +ProductElement.targeted + { border-top: 2rem solid var(--super-light-gray) + ; border-bottom: 2rem solid var(--super-light-gray) + ; padding-top: 3rem + ; padding-bottom: 3rem + ; margin-bottom: 4rem + } + +ProductElement.targeted:last-of-type + { margin-bottom: -0.4rem } + +ProductElement:first-of-type + { margin-top: 0 } + +form + { display: inline-block + ; left: 0 + } + +Thumbnail.zoomed + { position: fixed + ; top: 2rem + ; left: 2rem + ; z-index: 1 + ; width: 800px + ; height: 800px + } + + Thumbnail.zoomed .button + { display: none } + + Thumbnail.zoomed Zoom + , Thumbnail.zoomed .arrow + { display: inline-block !important + ; z-index: 3 + } + + Thumbnail.zoomed img + { width: 800px + ; height: 800px + } + +ProgressBarElement + { display: block + ; width: 0 + ; height: 2pt + ; margin-top: -2pt + ; background: var(--border-blue) + ; opacity: 0.25 + ; pointer-events: none + ; transition-duration: 0.25s + ; transition-timing-function: ease-out + } + +.transparent + { filter: alpha(opacity=0) + ; opacity: 0 + } + +main + { margin-top: 2rem } + +nav + { position: fixed + ; z-index: 10 + ; display: inline-block + ; top: 0 + ; left: 0 + ; height: 2rem + ; min-width: 10rem + ; width: 100% + ; background: lightgray + ; box-shadow: 0 2pt 5pt lightgray + } + +nav form + { display: block + ; min-width: 10rem + ; width: 100% + } + +nav input + { font-size: 1.3rem !important + ; height: 2rem !important + ; float: left + } +nav input[type='search'] + { display: block + ; background: var(--super-light-gray) !important + ; border-color: var(--super-light-gray) !important + ; min-width: 10rem + ; width: 50% !important + } + +nav input[type='checkbox'] + { display: none } + +nav input[type='checkbox'] + label + { float: right + ; background: var(--super-light-gray) + ; border: 0 + ; min-width: 5rem + ; display: block + ; padding: 0.25rem 0.5rem + ; height: 2rem + ; float: left + } +nav input[type='checkbox']:checked + label + { background: #ad0 + } + +nav input[type='submit'] + { background: var(--super-light-gray) !important + ; width: 2rem + ; text-align: center + ; border: 0 + ; line-height: 1.3rem + ; font-size: 1rem + } + +nav span + { float: left + ; border: 0 + ; min-width: 3rem + ; display: block + ; padding: 0.25rem 0.5rem + ; height: 2rem + ; float: left + ; text-align: center + } + +nav button + { display: block + ; background: var(--super-light-gray) !important + ; float: right + ; width: 2rem + ; height: 2rem + ; text-align: center + ; border: 0 + } + +nav button .fa + { font-size: 1rem + ; line-height: 1.5rem + } + +RowDescription Remove ConfirmationDialog + { right: auto + ; left: -0.45rem + } + +RowDescription Remove ConfirmationDialog .button i + { left: -0.35rem + ; position: relative + } + +Thumbnail WrongDimensions + { background: var(--cancel-red) + ; color: white + ; position: absolute + ; display: block + ; z-index: 20 + ; text-align: center + ; width: 5rem + ; top: -1.15rem + ; font-size: 0.75rem + } diff --git a/static/placeholder.svg b/static/placeholder.svg new file mode 100644 index 0000000..b1a059d --- /dev/null +++ b/static/placeholder.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/reset.css b/static/reset.css new file mode 100644 index 0000000..c0fe605 --- /dev/null +++ b/static/reset.css @@ -0,0 +1,37 @@ +@font-face + { font-family: 'PT Sans Caption' + ; font-style: normal + ; font-weight: 400 + ; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC + , U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000 + ; src: local( 'PT Sans Caption' ) + , local( 'PTSans-Caption' ) + , url( 'https://fonts.gstatic.com/s/ptsanscaption/v9/OXYTDOzBcXU8MTNBvBHeSebG2bpC97vgFIKC7TC5f4U.eot' ) + format( 'embedded-opentype' ) + , url( 'https://fonts.gstatic.com/s/ptsanscaption/v9/OXYTDOzBcXU8MTNBvBHeSVu3pQpJXC1E_Hw7zMp8vJM.woff2' ) + format( 'woff2' ) + , url( 'https://fonts.gstatic.com/s/ptsanscaption/v9/OXYTDOzBcXU8MTNBvBHeSQRW432DtwGNER78eOJ0i0s.woff' ) + format( 'woff' ) + , url( 'https://fonts.gstatic.com/s/ptsanscaption/v9/OXYTDOzBcXU8MTNBvBHeSVQX5OToqP8MI8UfeMWsWEY.ttf' ) + format( 'truetype' ) + , url( 'https://fonts.gstatic.com/l/font?kit=OXYTDOzBcXU8MTNBvBHeSUGdxaNEzIe9U2myqkmfmAQ&skey=3b791ca205af4a6f&v=v9#PTSansCaption' ) + format( 'svg' ) + } + +* { line-height: 1.5em + ; padding: 0 + ; margin: 0 + ; background: none + ; color: black + ; border: none + ; text-decoration: none + ; list-style: none + ; box-sizing: border-box + ; font: normal 16pt 'PT Sans Caption', sans-serif + } + +nav + { list-style: none } + +::-webkit-file-upload-button + { cursor: pointer } diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..70c2374 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /