326 lines
9.0 KiB
TypeScript
326 lines
9.0 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
HashRouter as Router,
|
|
Switch,
|
|
Route,
|
|
useParams
|
|
} from "react-router-dom";
|
|
import 'animate.css';
|
|
|
|
import Random from './rand';
|
|
import './App.css';
|
|
|
|
type Config = {
|
|
sleepTimeSeconds: number;
|
|
showUI: boolean;
|
|
|
|
}
|
|
|
|
let CONFIG: Config;
|
|
if (process.env.NODE_ENV === 'production') {
|
|
CONFIG = {
|
|
sleepTimeSeconds: 5 * 60,
|
|
showUI: false,
|
|
}
|
|
} else {
|
|
CONFIG = {
|
|
sleepTimeSeconds: 10,
|
|
showUI: true,
|
|
}
|
|
}
|
|
|
|
const IMAGE_CHUNK = 256;
|
|
const roundup = (v: number, mod: number) => {
|
|
let r = v % mod;
|
|
if (r === 0) {
|
|
return v;
|
|
} else {
|
|
return v + (mod - r);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Shuffles array in place. ES6 version
|
|
* From https://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array
|
|
* @param {Array} a items An array containing the items.
|
|
*/
|
|
function shuffle(a: Array<MediaItem>) {
|
|
let rng = new Random(new Date().getDay());
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = Math.floor(rng.nextFloat() * (i + 1));
|
|
[a[i], a[j]] = [a[j], a[i]];
|
|
}
|
|
return a;
|
|
}
|
|
|
|
class Slide {
|
|
// One or two items. For example if display is landscape we'll try to fit
|
|
// two portrait images and only one landscape.
|
|
items: Array<MediaItem>;
|
|
nextSlide?: Slide;
|
|
prevSlide?: Slide;
|
|
constructor(items: Array<MediaItem>) {
|
|
this.items = items;
|
|
}
|
|
prefetchImages() {
|
|
console.log(`prefetchImages, I have ${this.imageUrls.length} images`);
|
|
this.imageUrls.map(url => new Image().src = url);
|
|
}
|
|
get imageUrls(): Array<string> {
|
|
let w = window.innerWidth * window.devicePixelRatio;
|
|
let h = window.innerHeight * window.devicePixelRatio;
|
|
let ratio = w/h;
|
|
if (ratio > 1) {
|
|
// Landscape image
|
|
w = roundup(w, IMAGE_CHUNK);
|
|
h = Math.round(w/ratio);
|
|
} else {
|
|
// Portrait image
|
|
h = roundup(h, IMAGE_CHUNK);
|
|
w = Math.round(h/ratio);
|
|
}
|
|
//console.log(`Window size ${window.innerWidth}x${window.innerHeight} with a devicePixelRatio of ${window.devicePixelRatio} for a total size of ${w}x${h}`);
|
|
return this.items.map(img => `/api/image/${img.id}?w=${w}&h=${h}`);
|
|
}
|
|
render() {
|
|
const imgs = this.imageUrls.map(url => {
|
|
let style: React.CSSProperties = {
|
|
height: '100%',
|
|
width: '100%',
|
|
backgroundColor: 'black',
|
|
backgroundImage: `url(${url})`,
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundPosition: 'center center',
|
|
backgroundSize: 'cover',
|
|
};
|
|
return <div key={url} style={style}></div>;
|
|
});
|
|
// TODO(wathiede): make sure the style handles multiple items.
|
|
return <div style={{
|
|
height: '100%',
|
|
width: '100%',
|
|
}}>{imgs}</div>;
|
|
}
|
|
};
|
|
|
|
type MediaMetadata = {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
type MediaItem = {
|
|
id: string;
|
|
mediaMetadata: MediaMetadata;
|
|
filename: string;
|
|
};
|
|
type AlbumProps = {
|
|
album: string;
|
|
showUI: boolean;
|
|
sleepTimeSeconds: number;
|
|
};
|
|
type AlbumState = {
|
|
error: any;
|
|
mediaItems: Array<MediaItem> | null;
|
|
curSlide?: Slide,
|
|
showUI: boolean;
|
|
timerID: any | null;
|
|
};
|
|
class Album extends React.Component<AlbumProps, AlbumState> {
|
|
state: AlbumState = {
|
|
error: null,
|
|
mediaItems: null,
|
|
showUI: this.props.showUI,
|
|
timerID: null,
|
|
};
|
|
componentDidMount() {
|
|
this.loadAlbum()
|
|
}
|
|
loadAlbum() {
|
|
let {album} = this.props;
|
|
fetch(process.env.PUBLIC_URL + `/api/album/${album}`)
|
|
.then(res => res.json())
|
|
.then(
|
|
(mediaItems: Array<MediaItem>) => {
|
|
let w = window.innerWidth * window.devicePixelRatio;
|
|
let h = window.innerHeight * window.devicePixelRatio;
|
|
let ratio = w/h;
|
|
let landscapes = mediaItems.filter((mi) => {
|
|
let md = mi.mediaMetadata;
|
|
let ratio = md.width/md.height;
|
|
return ratio > 1;
|
|
});
|
|
|
|
let portraits = mediaItems.filter((mi) => {
|
|
let md = mi.mediaMetadata;
|
|
let ratio = md.width/md.height;
|
|
return ratio <= 1;
|
|
});
|
|
|
|
console.log(`${landscapes.length} landscape photos`);
|
|
console.log(`${portraits.length} portraits photos`);
|
|
let photos;
|
|
if (ratio > 1) {
|
|
console.log('display in landscape mode');
|
|
photos = landscapes;
|
|
} else {
|
|
console.log('display in portrait mode');
|
|
photos = portraits;
|
|
}
|
|
photos = shuffle(photos);
|
|
let slides = photos.map((p)=>{
|
|
return new Slide([p]);
|
|
});
|
|
let numSlides = slides.length;
|
|
slides.forEach((p, idx)=>{
|
|
let nextIdx = (idx+1)%numSlides;
|
|
let prevIdx = (numSlides+idx-1)%numSlides;
|
|
p.nextSlide = slides[nextIdx];
|
|
p.prevSlide = slides[prevIdx];
|
|
})
|
|
|
|
this.setState({curSlide: slides[0]});
|
|
let {sleepTimeSeconds} = this.props;
|
|
let timerID = setInterval(()=>{
|
|
let {curSlide} = this.state;
|
|
this.setState({curSlide: curSlide?.nextSlide})
|
|
console.log('timer fired');
|
|
}, sleepTimeSeconds*1000);
|
|
this.setState({timerID});
|
|
},
|
|
(error) => this.setState({error}),
|
|
);
|
|
}
|
|
componentWillUnmount() {
|
|
let {timerID} = this.state;
|
|
clearInterval(timerID);
|
|
}
|
|
nextPhoto() {
|
|
}
|
|
render() {
|
|
// TODO(wathiede): fade transition.
|
|
// TODO(wathiede): pair-up portrait orientation images.
|
|
let {curSlide, error, showUI} = this.state;
|
|
if (error !== null) {
|
|
return <h2>Error: {JSON.stringify(error)}</h2>;
|
|
} else if (curSlide) {
|
|
let nextSlide = curSlide?.nextSlide;
|
|
let prevSlide = curSlide?.prevSlide;
|
|
let prefetchStyle: React.CSSProperties = {
|
|
backgroundColor: 'rgba(127, 127, 127, 0.5)',
|
|
backgroundPosition: 'center center',
|
|
bottom: 0,
|
|
height: '25%',
|
|
position: 'absolute',
|
|
width: '25%',
|
|
};
|
|
let leftPrefetchStyle: React.CSSProperties = {
|
|
left: 0,
|
|
...prefetchStyle
|
|
};
|
|
let rightPrefetchStyle: React.CSSProperties = {
|
|
right: 0,
|
|
...prefetchStyle
|
|
};
|
|
let ui;
|
|
if (showUI) {
|
|
ui = <div id="ui">
|
|
<div
|
|
style={leftPrefetchStyle}
|
|
onClick={(e)=>{
|
|
e.stopPropagation();
|
|
this.setState({curSlide: curSlide?.prevSlide})
|
|
}}>{ prevSlide?.render() }</div>
|
|
{/* TODO(wathiede): make this work with multiple items. */}
|
|
<div className="meta">{curSlide?.items[0].filename}</div>
|
|
<div
|
|
style={rightPrefetchStyle}
|
|
onClick={(e)=>{
|
|
e.stopPropagation();
|
|
this.setState({curSlide: curSlide?.nextSlide})
|
|
}}>{ nextSlide?.render() }</div>
|
|
</div>;
|
|
}
|
|
nextSlide?.prefetchImages();
|
|
return <div id="slide" onClick={(e)=>{
|
|
e.stopPropagation();
|
|
this.setState({showUI: !showUI})
|
|
}}>
|
|
{ curSlide?.render() }
|
|
{ ui }
|
|
</div>;
|
|
} else {
|
|
return <h2>Loading...</h2>;
|
|
}
|
|
}
|
|
}
|
|
|
|
type AlbumIndexProps = {
|
|
};
|
|
type AlbumIndexState = {
|
|
error: any | null,
|
|
albums: Array<any> | null,
|
|
};
|
|
class AlbumIndex extends React.Component<AlbumIndexProps, AlbumIndexState> {
|
|
state: AlbumIndexState = {
|
|
error: null,
|
|
albums: null,
|
|
}
|
|
componentDidMount() {
|
|
fetch(process.env.PUBLIC_URL + "/api/albums")
|
|
.then(res => res.json())
|
|
.then(
|
|
(result) => this.setState({albums: result}),
|
|
(error) => this.setState({error}),
|
|
);
|
|
}
|
|
render() {
|
|
let {error, albums} = this.state;
|
|
if (error !== null) {
|
|
return <h2>Error: {JSON.stringify(error)}</h2>;
|
|
} else if (albums !== null) {
|
|
return albums.map((a) => {
|
|
let img = <img src="https://via.placeholder.com/256x128" className="mr-3" alt="unset"/>;
|
|
if (a.coverPhotoMediaItemId !== undefined) {
|
|
img = <img src={ `/api/image/${a.coverPhotoMediaItemId}?w=256&h=256` } className="mr-3" alt={ a.title }/>
|
|
}
|
|
|
|
let figure = <figure key={ a.id } className="figure">
|
|
{img}
|
|
<figcaption className="figure-caption">{ a.title || "No title" } - { a.mediaItemsCount || 0 } photos </figcaption>
|
|
</figure>;
|
|
return <a key={ a.id } href={ '#' + a.id }>
|
|
{ figure }
|
|
</a>
|
|
});
|
|
} else {
|
|
return <h2>Loading...</h2>;
|
|
}
|
|
}
|
|
}
|
|
|
|
type AlbumRouteProps = { sleepTimeSeconds: number, showUI: boolean };
|
|
const AlbumRoute = ({sleepTimeSeconds, showUI}: AlbumRouteProps) => {
|
|
// We can use the `useParams` hook here to access
|
|
// the dynamic pieces of the URL.
|
|
let { albumId } = useParams();
|
|
albumId = albumId || '';
|
|
return <Album album={albumId} showUI={showUI} sleepTimeSeconds={sleepTimeSeconds} />;
|
|
}
|
|
|
|
const App = () => {
|
|
let {showUI, sleepTimeSeconds} = CONFIG;
|
|
return <Router>
|
|
<Switch>
|
|
<Route exact path="/">
|
|
<div className="container">
|
|
<AlbumIndex />
|
|
</div>
|
|
</Route>
|
|
<Route exact path="/:albumId">
|
|
<AlbumRoute showUI={showUI} sleepTimeSeconds={sleepTimeSeconds} />
|
|
</Route>
|
|
</Switch>
|
|
</Router>
|
|
}
|
|
|
|
export default App;
|