feature (image): EXIF viewer on photos

This commit is contained in:
Mickael KERJEAN
2018-05-30 21:19:03 +10:00
parent aebca52060
commit b177a97d27
13 changed files with 334 additions and 43 deletions

103
client/assets/img/info.svg Normal file
View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 100 100"
style="enable-background:new 0 0 100 100;"
xml:space="preserve"
sodipodi:docname="info.svg"
inkscape:version="0.92.2 2405546, 2018-03-11"><metadata
id="metadata43"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs41" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1894"
inkscape:window-height="1027"
id="namedview39"
showgrid="false"
inkscape:zoom="2.36"
inkscape:cx="-78.38983"
inkscape:cy="50"
inkscape:window-x="10"
inkscape:window-y="37"
inkscape:window-maximized="0"
inkscape:current-layer="Capa_1" />
<g
id="g6"
style="fill:#f2f2f2;fill-opacity:1"
transform="matrix(0.882,0,0,0.882,5.9,5.9)">
<g
id="g4"
style="fill:#f2f2f2;fill-opacity:1">
<path
style="fill:#f2f2f2;fill-opacity:1"
d="m 62.162,0 c 6.696,0 10.043,4.567 10.043,9.789 0,6.522 -5.814,12.555 -13.391,12.555 -6.344,0 -10.045,-3.752 -9.869,-9.947 C 48.945,7.176 53.35,0 62.162,0 Z M 41.543,100 c -5.287,0 -9.164,-3.262 -5.463,-17.615 l 6.07,-25.457 c 1.057,-4.077 1.23,-5.707 0,-5.707 -1.588,0 -8.451,2.816 -12.51,5.59 L 27,52.406 C 39.863,41.48 54.662,35.072 61.004,35.072 c 5.285,0 6.168,6.361 3.525,16.148 L 57.58,77.98 c -1.234,4.729 -0.703,6.359 0.527,6.359 1.586,0 6.787,-1.963 11.896,-6.041 L 73,82.377 C 60.488,95.1 46.83,100 41.543,100 Z"
id="path2"
inkscape:connector-curvature="0" />
</g>
</g>
<g
id="g8">
</g>
<g
id="g10">
</g>
<g
id="g12">
</g>
<g
id="g14">
</g>
<g
id="g16">
</g>
<g
id="g18">
</g>
<g
id="g20">
</g>
<g
id="g22">
</g>
<g
id="g24">
</g>
<g
id="g26">
</g>
<g
id="g28">
</g>
<g
id="g30">
</g>
<g
id="g32">
</g>
<g
id="g34">
</g>
<g
id="g36">
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -2,38 +2,53 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Input, Button, Modal, NgIf } from './';
import { alert } from '../helpers/'
import "./alert.scss";
export class Alert extends React.Component {
export class AlertModal extends React.Component {
constructor(props){
super(props);
this.state = {
appear: false,
value: null,
fns: {}
};
this.onSubmit = this.onSubmit.bind(this);
}
componentDidMount(){
alert.subscribe((Component, okCallback) => {
this.setState({
appear: true,
value: Component,
fns: {ok: okCallback || function(){}}
});
});
}
onSubmit(e){
e && e.preventDefault && e.preventDefault();
this.props.onConfirm && this.props.onConfirm();
this.state.fns.ok();
this.setState({appear: false});
}
onEscapeKeyPress(e){
if(e.keyCode === 27 && this.state.fns){ this.onSubmit(); }
}
render() {
return (
<Modal isActive={this.props.appear} onQuit={this.onSubmit.bind(this)}>
<Modal isActive={this.state.appear} onQuit={this.onSubmit.bind(this)}>
<div className="component_alert">
<p className="modal-message">
{this.props.message}
</p>
<form onSubmit={this.onSubmit.bind(this)}>
<div className="buttons">
<Button type="submit" theme="secondary" onClick={this.onSubmit.bind(this)}>OK</Button>
</div>
</form>
<div className="modal-message">
{this.state.value}
</div>
<div className="buttons">
<Button type="submit" theme="secondary" onClick={this.onSubmit}>OK</Button>
</div>
</div>
</Modal>
);
}
}
Alert.propTypes = {
appear: PropTypes.bool.isRequired,
message: PropTypes.string.isRequired,
onConfirm: PropTypes.func
};

View File

@ -33,6 +33,7 @@ import img_grid from '../assets/img/grid.svg';
import img_list from '../assets/img/list.svg';
import img_sort from '../assets/img/sort.svg';
import img_check from '../assets/img/check.svg';
import img_info from '../assets/img/info.svg';
export const Icon = (props) => {
if(props.name === null) return null;
@ -97,6 +98,8 @@ export const Icon = (props) => {
img = img_sort;
}else if(props.name === 'check'){
img = img_check;
}else if(props.name === 'info'){
img = img_info;
}else{
throw('unknown icon');
}

View File

@ -13,8 +13,8 @@ export { Uploader } from './uploader';
export { Bundle } from './bundle';
export { Modal } from './modal';
export { ModalPrompt } from './prompt';
export { AlertModal } from './alert';
export { Notification } from './notification';
export { Alert } from './alert';
export { Audio } from './audio';
export { Video } from './video';
export { Dropdown, DropdownButton, DropdownList, DropdownItem } from './dropdown';

View File

@ -10,8 +10,8 @@
> div{
background: white;
width: 65%;
max-width: 300px;
width: 80%;
max-width: 310px;
padding: 40px 20px 0 20px;
border-radius: 2px;
}

View File

@ -53,12 +53,12 @@ export class ModalPrompt extends React.Component {
{this.state.text}
</p>
<form onSubmit={this.onSubmit}>
<Input autoFocus={true} value={this.state.value} type={this.state.type} autoComplete="new-password" onChange={(e) => this.setState({value: e.target.value})} />
<div className="modal-error-message">{this.state.error}&nbsp;</div>
<div className="buttons">
<Button type="button" onClick={this.onCancel}>CANCEL</Button>
<Button type="submit" theme="secondary" onClick={this.onSubmit}>OK</Button>
</div>
<Input autoFocus={true} value={this.state.value} type={this.state.type} autoComplete="new-password" onChange={(e) => this.setState({value: e.target.value})} />
<div className="modal-error-message">{this.state.error}&nbsp;</div>
<div className="buttons">
<Button type="button" onClick={this.onCancel}>CANCEL</Button>
<Button type="submit" theme="secondary" onClick={this.onSubmit}>OK</Button>
</div>
</form>
</div>
</Modal>

15
client/helpers/alert.js Normal file
View File

@ -0,0 +1,15 @@
const Alert = function (){
let fn = null;
return {
now: function(Component, okCallback){
if(!fn){ return window.setTimeout(() => this.now(Component, okCallback), 50); }
fn(Component, okCallback);
},
subscribe: function(_fn){
fn = _fn;
}
};
};
export const alert = new Alert();

View File

@ -9,6 +9,7 @@ export { memory } from './memory';
export { prepare } from './navigate';
export { invalidate, http_get, http_post, http_delete } from './ajax';
export { prompt } from './prompt';
export { alert } from './alert';
export { notify } from './notify';
export { gid } from './random';
export { leftPad } from './common';

View File

@ -1,19 +1,151 @@
import React from 'react';
import EXIF from 'exif-js';
import path from 'path';
import { MenuBar } from './menubar';
import { Icon, NgIf } from '../../components/';
import { alert } from '../../helpers/';
import './imageviewer.scss';
export const ImageViewer = (props) => {
const image_url = (size) => {
return props.data+"&meta=true&size="+parseInt((window.innerWidth - 40)*size);
};
return (
<div style={{height: '100%'}}>
<MenuBar title={props.filename} download={props.data} />
<div style={{textAlign: 'center', background: '#525659', height: 'calc(100% - 34px)', overflow: 'hidden', padding: '20px', boxSizing: 'border-box'}}>
<img src={image_url(1)}
srcSet={image_url(1)+", "+image_url(3/2)+" 1.5x, "+image_url(2)+" 2x"}
style={{maxHeight: '100%', maxWidth: '100%', minHeight: '100px', background: '#f1f1f1', boxShadow: 'rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px, rgba(0, 0, 0, 0.2) 0px 2px 4px -1px'}} />
</div>
</div>
);
export class ImageViewer extends React.Component{
constructor(props){
super(props);
this.state = {
info: null
};
}
openInfo(){
alert.now(<Metadata el={this.refs.$img}/>);
}
render(){
const image_url = (size) => {
return this.props.data+"&meta=true&size="+parseInt((window.innerWidth - 40)*size);
};
const isJpeg = (filename) => {
const ext = path.extname(filename).toLowerCase().substring(1);
return ext === "jpg" || ext === "jpeg";
};
return (
<div style={{height: '100%'}}>
<MenuBar title={this.props.filename} download={this.props.data}>
<NgIf type="inline" cond={isJpeg(this.props.filename)}>
<Icon name="info" onClick={this.openInfo.bind(this)} />
</NgIf>
</MenuBar>
<div style={{textAlign: 'center', background: '#525659', height: 'calc(100% - 34px)', overflow: 'hidden', padding: '20px', boxSizing: 'border-box'}}>
<img
ref="$img"
src={image_url(1)}
srcSet={image_url(1)+", "+image_url(3/2)+" 1.5x, "+image_url(2)+" 2x"}
style={{maxHeight: '100%', maxWidth: '100%', minHeight: '100px', background: '#f1f1f1', boxShadow: 'rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px, rgba(0, 0, 0, 0.2) 0px 2px 4px -1px'}} />
</div>
</div>
);
}
}
class Metadata extends React.Component {
constructor(props){
super(props);
this.state = {
date: null,
location: null,
iso: null,
aperture: null,
shutter: null,
model: null,
maker: null
};
}
componentDidMount(){
let self = this;
EXIF.getData(this.props.el, function(){
const metadata = EXIF.getAllTags(this);
window.metadata = metadata;
self.setState({
date: metadata['DateTime'] || metadata['DateTimeDigitized'] || metadata['DateTimeOriginal'] || metadata['GPSDateStamp'],
location: metadata['GPSLatitude'] && metadata['GPSLongitude'] && [
[ metadata['GPSLatitude'][0], metadata['GPSLatitude'][1], metadata['GPSLatitude'][2], metadata["GPSLatitudeRef"]],
[ metadata['GPSLongitude'][0], metadata['GPSLongitude'][1], metadata['GPSLongitude'][2], metadata["GPSLongitudeRef"]]
],
maker: metadata['Make'],
model: metadata['Model'],
focal: metadata['FocalLength'],
aperture: metadata['FNumber'],
shutter: metadata['ExposureTime'],
iso: metadata['ISOSpeedRatings']
});
});
}
render(){
const display_camera = (model, focal) => {
if(!model && !focal) return "-";
if(!focal) return model;
return model+" ("+parseInt(focal)+"mm)";
};
const display_settings = (aperture, shutter, iso) => {
if(!aperture || !shutter || !iso) return "-";
return "f/"+parseInt(aperture*10)/10+" "+speed(shutter)+" ISO"+iso;
function speed(n){
if(n > 60) return (parseInt(n) / 60).toString()+"m";
if(n >= 1) return parseInt(n).toString()+"s";
return "1/"+parseInt(nearestPow2(1/n)).toString()+"s";
}
function nearestPow2(n){
const refs = [1,2,3,4,5,6,8,10,13,15,20,25,30,40,45,50,60,80,90,100,125,160,180,200,250,320,350,400,500,640,750,800,1000,1250,1500,1600,2000,2500,3000,3200,4000,5000,6000,6400,8000,12000,16000,32000,50000];
for(let i=0, l=refs.length; i<l; i++){
if(refs[i] <= n) continue;
return refs[i] - n < refs[i-1] - n ? refs[i] : refs[i-1];
}
return n;
}
};
const display_date = (_date) => {
if(!_date) return "-";
let date = _date.substring(0,10).replace(/:/g, "/");
let time = _date.substring(11,16).replace(":", "h");
if(Intl && Intl.DateTimeFormat){
date = Intl.DateTimeFormat().format(new Date(date));
}
let text = date;
if(time) text += " "+time;
return text;
};
const display_location = (location) => {
if(!location || location.length !== 2) return '-';
let text = location[0][0]+"°"+location[0][1]+"'"+location[0][2]+"''"+location[0][3];
text += " ";
text += location[1][0]+"°"+location[1][1]+"'"+location[1][2]+"''"+location[1][3];
return text;
};
return (
<div className="component_metadata">
<div>
<span className="label no-select">Date: </span>
<span className="value">{display_date(this.state.date)}</span>
</div>
<div>
<span className="label no-select">Location: </span>
<span className="value small"><a href={"https://www.google.com/maps/search/?api=1&query="+display_location(this.state.location)}>{display_location(this.state.location)}</a></span>
</div>
<div>
<span className="label no-select">Settings: </span>
<span className="value">{display_settings(this.state.aperture, this.state.shutter, this.state.iso)}</span>
</div>
<div>
<span className="label no-select">Camera: </span>
<span className="value">{display_camera(this.state.model,this.state.focal)}</span>
</div>
</div>
);
}
}

View File

@ -0,0 +1,21 @@
.component_metadata{
.label{
display: inline-block;
width: 70px;
font-size: 0.95em;
text-align: right;
padding-right: 10px;
color: var(--color);
vertical-align: top;
}
.value{
display: inline-block;
width: calc(100% - 80px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
letter-spacing: -0.5px;
&.small{font-size: 0.95em; letter-spacing: -1px;}
}
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { BrowserRouter, Route, IndexRoute, Switch } from 'react-router-dom';
import { NotFoundPage, ConnectPage, HomePage, LogoutPage, FilesPage, ViewerPage } from './pages/';
import { Bundle, URL_HOME, URL_FILES, URL_VIEWER, URL_LOGIN, URL_LOGOUT } from './helpers/';
import { ModalPrompt, Notification, Audio, Video } from './components/';
import { ModalPrompt, AlertModal, Notification, Audio, Video } from './components/';
export default class AppRouter extends React.Component {
render() {
@ -19,6 +19,7 @@ export default class AppRouter extends React.Component {
</Switch>
</BrowserRouter>
<ModalPrompt />
<AlertModal />
<Notification />
</div>
);

View File

@ -58,6 +58,7 @@
"css-loader": "^0.28.10",
"dropbox": "^2.5.3",
"ejs": "^2.5.6",
"exif-js": "^2.3.0",
"html-loader": "^0.4.5",
"html-webpack-plugin": "^2.28.0",
"http-server": "^0.9.0",

View File

@ -59,7 +59,7 @@ app.get('/cat', function(req, res){
let endpoint = config.transcoder.url;
if(req.query.size){
endpoint += "?size="+req.query.size;
endpoint += "?size="+req.query.size+"&meta="+(req.query.meta === "true" ? "true": "false");
}
const post_request = request({
method: "POST",
@ -68,7 +68,6 @@ app.get('/cat', function(req, res){
});
return form.pipe(post_request)
.on('error', (err) => {
console.log(err);
res.status(500).end();
})
.pipe(res);