This commit is contained in:
Dym Sohin 2023-10-04 01:24:49 +02:00
commit 87b9aa3cc9
18 changed files with 3392 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
*~
._*
.DS_Store
node_modules
package-lock.json
config.*
log.txt
output.txt
ssl
uploads
static/vendor

339
app.js Normal file
View File

@ -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( '<script>window.close( )</script>' )
})
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 ) }

72
lib/db.js Normal file
View File

@ -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' , _ )
}

6
lib/get-id.js Normal file
View File

@ -0,0 +1,6 @@
'use strict'
module.exports = exports = STR =>
STR.replace( /\s+/g, '' )
.replace( /[^\w\d\.,_-]/g, '-' )
.toUpperCase()

682
lib/photos.js Normal file
View File

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

10
lib/readable-timestamp.js Normal file
View File

@ -0,0 +1,10 @@
'use strict'
// readable timestamp
module.exports = exports = ( ) =>
'\x1b[32m'
+ ( new Date )
.toJSON( )
.slice( 0, 23 )
.replace( 'T', ' ' )
+ '\x1b[0m'

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{ "name" : "photo-manager"
, "description" : "Upload and Management of Photos (node.js, 2016)"
, "version" : "0.0.1"
, "author" : "Dym Sohin <re@dym.sh>"
, "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/"
}
}

40
sample-config.js Normal file
View File

@ -0,0 +1,40 @@
'use strict'
const os = require( 'os' )
, path = require( 'path' )
const PRODUCTION = ( 'PRODUCTION' == process.env.ENV )
|| ( '<DOMAIN.TLD>' == os.hostname() )
module.exports = exports =
{ ENV:
{ PRODUCTION
, IP : process.env.IP
|| ( PRODUCTION && '<IP.4.ADDR>' )
|| '::'
, IP6 : process.env.IP6
|| ( PRODUCTION && '<IP:6:ADDR>' )
|| '::'
, PORT : process.env.PORT || 8080
, SSLPORT : process.env.SSLPORT || 4443
, HOST : '<DOMAIN.TLD>'
}
, Paths:
{ imgGlobal : 'https://<PHOTOS.URL>'
, imgLocal : '~/photos/'
, imgFTP : '~/ftp/'
}
, SQL:
{ BaseURL : 'http://<DB-API.URL>'
, auth :
{ user : 'user'
, pass : 'pass'
}
}
, Accounts :
[ 'account1'
, 'account2'
]
}

11
static/browserconfig.xml Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/icon.png"/>
<square150x150logo src="/icon.png"/>
<square310x310logo src="/icon.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

4
static/index.html Normal file
View File

@ -0,0 +1,4 @@
<!DOCTYPE html>
<meta charset='utf-8'>
<title>PHOTOS</title>
<h1>PHOTOS</h1>

9
static/manifest.json Normal file
View File

@ -0,0 +1,9 @@
{ "name": "App",
"icons":
[ { "src": "icon.png"
, "sizes": "500x500"
, "type": "image\/png"
, "density": "4.0"
}
]
}

143
static/photos/index.html Normal file
View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<meta charset='utf-8'>
<title>Photos</title>
<style id='ZoomedStyle'></style>
<iframe name='uploading' id='uploading' style='display:none'></iframe>
<nav>
<form id='searchForm' name='searchForm' action='#'>
<input type='search' id='search' name='search' placeholder='search..' tabindex='1' list='product_types'>
<input type='submit' id='searchButton' name='searchButton' value='🔎'>
<input type='checkbox' id='ShowOnlyAvaliable' name='ShowOnlyAvaliable'>
<label for='ShowOnlyAvaliable'>ShowOnlyAvaliable</label>
<span id='CurrentCount'></span>
<button id='ShowAll' name='ShowAll' title='Show All'>
<i class='fa fa-globe'></i>
</button>
<button id='ShowWithoutPhotos' name='ShowWithoutPhotos' title='No Photos'>
<i class='fa fa-question'></i>
</button>
<ProgressBarElement></ProgressBarElement>
</form>
</nav>
<main id='photos'></main>
<datalist id='product_types'></datalist>
<template id='ProductTemplate'>
<ProductElement>
<ProductDescription>
<NewVariant title='Add Variant..'>
<button class='button' id='add_new_variant'>
<i class='fa fa-plus' aria-hidden='true'></i>
</button>
<ConfirmationDialog class='hidden'>
<input type='text' name='variant_name' placeholder='Variant name'>
<ConfirmNewVariant title='Add Variant' class='button top'>
<i class='fa fa-plus' aria-hidden='true'></i>
</ConfirmNewVariant>
<AbortNewVariant title='Abort' class='button'>
<i class='fa fa-times' aria-hidden='true'></i>
</AbortNewVariant>
</ConfirmationDialog>
</NewVariant>
<ProductSKU>
<a href='#'></a>
</ProductSKU>
<ProductTitle></ProductTitle>
<ProductAmount></ProductAmount>
</ProductDescription>
<Variants></Variants>
<hr>
<Accounts></Accounts>
</ProductElement>
</template>
<template id='ThumbnailsRowTemplate'>
<ThumbnailsRow>
<RowDescription>
<Copy title='Copy Variant' class='button top'>
<Icon>
<i class='fa fa-copy' aria-hidden='true'></i>
</Icon>
</button>
</Copy>
<Remove title='Remove Variant..' class='button top left'>
<Icon>
<i class='fa fa-times' aria-hidden='true'></i>
</Icon>
<ConfirmationDialog class='hidden'>
<AbortRemoval title='Abort' class='button top left'>
<i class='fa fa-times' aria-hidden='true'></i>
</AbortRemoval>
<ConfirmRemoval title='Remove' class='button top right'>
<i class='fa fa-times' aria-hidden='true'></i>
</ConfirmRemoval>
</ConfirmationDialog>
</Remove>
<AddPhotos title='Add Photos'>
<Upload class='button top left'>
<i class='fa fa-cloud-upload' aria-hidden='true'></i>
<form target='uploading' enctype='multipart/form-data' method='post' onsubmit='void 0; return false'>
<input name='files[]' type='file' class='transparent' accept='image/*' multiple>
</form>
</Upload>
</AddPhotos>
<input type='checkbox' name='is_default' title='Set as Default'>
<RowTitle></RowTitle>
<select name='VariantSelector'></select>
</RowDescription>
</ThumbnailsRow>
</template>
<template id='ThumbnailTemplate'>
<Thumbnail>
<Prev title='Prev' class='hidden button arrow left'>
<i class='fa fa-arrow-left' aria-hidden='true'></i>
</Prev>
<WrongDimensions></WrongDimensions>
<img src='../placeholder.svg'>
<ThumbnailCaption></ThumbnailCaption>
<Upload title='Upload' class='button top left'>
<i class='fa fa-cloud-upload' aria-hidden='true'></i>
<form target='uploading' enctype='multipart/form-data' method='post' onsubmit='void 0; return false'>
<input name='files[]' class='top left transparent' type='file' accept='image/*'>
</form>
</Upload>
<Remove title='Remove..' class='button top right'>
<Icon>
<i class='fa fa-times' aria-hidden='true'></i>
</Icon>
<ConfirmationDialog class='hidden'>
<AbortRemoval title='Abort' class='button top left'>
<i class='fa fa-times' aria-hidden='true'></i>
</AbortRemoval>
<ConfirmRemoval title='Remove' class='button top right'>
<i class='fa fa-times' aria-hidden='true'></i>
</ConfirmRemoval>
</ConfirmationDialog>
</Remove>
<Move title='Move' class='button bottom left'>
<i class='fa fa-arrows' aria-hidden='true'></i>
</Move>
<Zoom title='Zoom' class='button bottom right'>
<i class='fa fa-search-plus' aria-hidden='true'></i>
</Zoom>
<Next title='Next' class='hidden button arrow right'>
<i class='fa fa-arrow-right' aria-hidden='true'></i>
</Next>
<MovePlaceholder title='' class='hidden'></MovePlaceholder>
</Thumbnail>
</template>
<link rel='stylesheet' href='../vendor/fa.css'>
<link rel='stylesheet' href='../reset.css'>
<link rel='stylesheet' href='style.css'>
<script async defer src='script.js'></script>

1403
static/photos/script.js Normal file

File diff suppressed because it is too large Load Diff

590
static/photos/style.css Normal file
View File

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

6
static/placeholder.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.53383 200.16929" height="38.042" width="38.042">
<g fill="none" stroke="gray" stroke-opacity=".3">
<path style="isolation:auto;mix-blend-mode:normal" color="#000" overflow="visible" stroke-width="10" d="M-24.318 195.17h190.17V5h-190.17z"/>
<path stroke-width="3.577" d="M36.756 134.095h68.021v-68.02h-68.02z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 392 B

37
static/reset.css Normal file
View File

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

2
static/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /