mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-11-03 04:50:14 +08:00
feature (image): EXIF viewer on photos
This commit is contained in:
103
client/assets/img/info.svg
Normal file
103
client/assets/img/info.svg
Normal 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 |
@ -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
|
||||
};
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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} </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} </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
15
client/helpers/alert.js
Normal 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();
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
21
client/pages/viewerpage/imageviewer.scss
Normal file
21
client/pages/viewerpage/imageviewer.scss
Normal 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;}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user