Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save mihaisavezi/8535c1d076aed366225d413d0cbf87e6 to your computer and use it in GitHub Desktop.
Save mihaisavezi/8535c1d076aed366225d413d0cbf87e6 to your computer and use it in GitHub Desktop.
Gallery app with Finite State Machines (xstate)
<div id="app"></div>
const { Machine } = xstate;
const galleryMachine = Machine({
initial: 'start',
states: {
start: {
on: {
SEARCH: 'loading'
}
},
loading: {
onEntry: ['search'],
on: {
SEARCH_SUCCESS: {
gallery: {
actions: ['updateItems']
}
},
SEARCH_FAILURE: 'error',
CANCEL_SEARCH: 'gallery'
}
},
error: {
on: {
SEARCH: 'loading'
}
},
gallery: {
on: {
SEARCH: 'loading',
SELECT_PHOTO: 'photo'
}
},
photo: {
onEntry: ['setPhoto'],
on: {
EXIT_PHOTO: 'gallery'
}
}
}
});
class App extends React.Component {
constructor() {
super();
this.state = {
gallery: galleryMachine.initialState,
query: '',
items: []
};
}
command(action, event) {
switch (action) {
case 'search':
// execute the search command
this.search(event.query);
break;
case 'updateItems':
if (event.items) {
// update the state with the found items
return { items: event.items };
}
break;
case 'setPhoto':
if (event.item) {
return { photo: event.item }
}
default:
break;
}
}
transition(event) {
const currentGalleryState = this.state.gallery;
const nextGalleryState =
galleryMachine.transition(currentGalleryState, event.type);
if (nextGalleryState.actions) {
const nextState = nextGalleryState.actions
.reduce((state, action) => this.command(action, event) || state, undefined);
this.setState({
gallery: nextGalleryState.value,
...nextState
});
}
}
handleSubmit(e) {
e.persist();
e.preventDefault();
this.transition({ type: 'SEARCH', query: this.state.query });
}
search(query) {
const encodedQuery = encodeURIComponent(query);
setTimeout(() => {
fetchJsonp(
`https://api.flickr.com/services/feeds/photos_public.gne?lang=en-us&format=json&tags=${encodedQuery}`,
{ jsonpCallback: 'jsoncallback' })
.then(res => res.json())
.then(data => {
this.transition({ type: 'SEARCH_SUCCESS', items: data.items });
})
.catch(error => {
this.transition({ type: 'SEARCH_FAILURE' });
});
}, 1000);
}
handleChangeQuery(value) {
this.setState({ query: value })
}
renderForm(state) {
const searchText = {
loading: 'Searching...',
error: 'Try search again',
start: 'Search'
}[state] || 'Search';
return (
<form className="ui-form" onSubmit={e => this.handleSubmit(e)}>
<input
type="search"
className="ui-input"
value={this.state.query}
onChange={e => this.handleChangeQuery(e.target.value)}
placeholder="Search Flickr for photos..."
disabled={state === 'loading'}
/>
<div className="ui-buttons">
<button
className="ui-button"
disabled={state === 'loading'}>
{searchText}
</button>
{state === 'loading' &&
<button
className="ui-button"
type="button"
onClick={() => this.transition({ type: 'CANCEL_SEARCH' })}>
Cancel
</button>
}
</div>
</form>
);
}
renderGallery(state) {
return (
<section className="ui-items" data-state={state}>
{state === 'error'
? <span className="ui-error">Uh oh, search failed.</span>
: this.state.items.map((item, i) =>
<img
src={item.media.m}
className="ui-item"
style={{'--i': i}}
key={item.link}
onClick={() => this.transition({
type: 'SELECT_PHOTO', item
})}
/>
)
}
</section>
);
}
renderPhoto(state) {
if (state !== 'photo') return;
return (
<section
className="ui-photo-detail"
onClick={() => this.transition({ type: 'EXIT_PHOTO' })}>
<img src={this.state.photo.media.m} className="ui-photo"/>
</section>
)
}
render() {
const galleryState = this.state.gallery;
return (
<div className="ui-app" data-state={galleryState}>
{this.renderForm(galleryState)}
{this.renderGallery(galleryState)}
{this.renderPhoto(galleryState)}
</div>
);
}
}
ReactDOM.render(<App />, document.querySelector('#app'));
<script src="https://unpkg.com/react@16.0.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16.0.0/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/fetch-jsonp@1.1.3/build/fetch-jsonp.js"></script>
<script src="https://unpkg.com/xstate@3.0.1/dist/xstate.js"></script>
*, *:before, *:after {
position: relative;
box-sizing: border-box;
}
html, body {
height: 100%;
width: 100%;
padding: 0;
margin: 0;
}
body {
display: flex;
justify-content: center;
align-items: center;
background-color: #FAFAFA;
}
@mixin shadow($color: rgba(black, 0.1)) {
box-shadow: 0 .2rem 1rem $color;
}
* {
transition: all .3s cubic-bezier(.2, 0, .4, 1);
}
.ui-app {
display: flex;
flex-direction: column;
justify-content: flex-start;
width: 40rem;
max-width: 90vw;
height: calc(100vh - 2rem);
&[data-state="start"] {
justify-content: center;
}
&[data-state="loading"] {
.ui-item {
opacity: .5;
}
}
&[data-state="photo"] {
* {
opacity: 0.3;
}
.ui-photo-detail, .ui-photo-detail * {
opacity: 1;
}
.ui-items {
pointer-events: none;
}
}
&:after {
content: 'current state: ' attr(data-state);
position: absolute;
bottom: .5rem;
color: white;
background-color: rgba(black, 0.4);
font-size: 1rem;
padding: .5rem 1rem;
border-radius: 1rem;
left: 50%;
transform: translateX(-50%);
text-shadow: 0 0 .1rem black;
pointer-events: none;
}
}
.ui-form {
margin-bottom: 1rem;
}
.ui-input {
@include shadow();
display: block;
-webkit-appearance: none;
appearance: none;
width: 100%;
border: none;
font-size: 2rem;
height: 3rem;
margin-bottom: 1rem;
padding: 0 1rem;
&::-webkit-input-placeholder {
color: #CDCDCD;
}
&:focus {
outline: none;
}
}
.ui-buttons {
text-align: center;
}
.ui-button {
@include shadow();
display: inline-block;
-webkit-appearance: none;
appearance: none;
border: none;
background-color: #EB7452;
color: white;
height: 3rem;
padding: 0 3rem;
border-radius: 3rem;
margin: 0 1rem;
&[disabled] {
opacity: 0.5;
}
&[type="button"] {
background-color: #555;
}
}
.ui-items {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
flex-shrink: 1;
overflow-y: scroll;
margin: 0 -.25rem;
&:hover > .ui-item {
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
.ui-item {
display: block;
height: 10rem;
width: auto;
flex-shrink: 0;
flex-grow: 0;
margin: .25rem;
animation: item .5s calc(var(--i, 0) * .05s) cubic-bezier(.5, 0, .2, 1) both;
background-color: #EEE;
@keyframes item {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
}
.ui-photo-detail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.ui-photo {
height: auto;
width: auto;
min-height: 50vh;
min-width: 50vw;
max-height: 100%;
max-width: 100%;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment