diff --git a/architecture.md b/architecture.md new file mode 100644 index 00000000..9997bc7f --- /dev/null +++ b/architecture.md @@ -0,0 +1,51 @@ +# Technical Architecture + +Netlify CMS is a React Application, using Redux for state management with immutable data structures (immutable.js). + +## State shape / reducers +**Auth:** Keeps track of the logged state and the current user. + +**Config:** Holds the environment configuration (backend type, available collections & fields). + +**Collections** List of available collections, its fields and metadata information. + +**Entries:** Entries for each field. + +**EntryDraft:** Reused for each entry that is edited or created. It holds the entry's temporary data util it's persisted on the backend. + +**Medias:** Keeps references to all media files uploaded by the user during the current session. + +## Selectors: +Selectors are functions defined within reducers used to compute derived data from the Redux store. The available selectors are: + +**selectEntry:** Selects a single entry, given the collection and a slug. + +**selectEntries:** Selects all entries for a given collection. + +**getMedia:** Selects a single MediaProxy object for the given URI: + +## Value Objects: +**MediaProxy:** MediaProxy is a Value Object that holds information regarding a media file (such as an image, for example), whether it's persisted online or hold locally in cache. + +For files persisted online, the MediaProxy only keeps information about it's URI. For local files, the MediaProxy will keep a reference to the actual File object while generating the expected final URIs and on-demand blobs for local preview. + +The MediaProxy object can be used directly inside a media tag (such as ``), as it will always return something that can be used by the media tag to render correctly (either the URI for the online file or a single-use blob). + +## Components structure and Workflows +Components are separated into two main categories: Container components and presentational components. + + +### Entry Editing: +For either updating an existing entry or creating a new one, the `EntryEditor` is used and the flow is the same: +- When mounted, the `EntryPage` container component dispatches the `createDraft` action, setting the `entryDraft` state to a blank state (in case of a new entry) or to a copy of the selected entry (in case of an edit). +- The `EntryPage` will also render widgets for each field type in the given entry. +- Widgets are used for editing entry fields. There are different widgets for different field types, and they are always defined in a pair containing a `control` and a `preview` components. The control component is responsible for presenting the user with the appropriate interface for manipulating the current field value, while the preview component is responsible for displaying value with the appropriate styling. + +#### Widget components implementation: +The control component receives 3 callbacks as props: onChange, onAddMedia & onRemoveMedia. + - onChange (Required): Should be called when the users changes the current value. It will ultimately end up updating the EntryDraft object in the Redux Store, thus updating the preview component. + - onAddMedia & onRemoveMedia (optionals): If the field accepts file uploads for media (images, for example), these callbacks should be invoked with a `MediaProxy` value object. `onAddMedia` will get the current media stored in the Redux state tree while `onRemoveMedia` will remove it. MediaProxy objects are stored in the `Medias` object and referenced in the `EntryDraft` object on the state tree. + +Both control and preview widgets receive a `getMedia` selector via props. Displaying the media (or its uri) for the user should always be done via `getMedia`, as it returns a MediaProxy that can return the correct value for both medias already persisted on server and cached media not yet uploaded. + +The actual persistence of the content and medias inserted into the control component are delegated to the backend implementation. The backend will be called with the updated values and a a list of mediaProxy objects for each field of the entry, and should return a promise that can resolve into the persisted entry object and the list of the persisted media URIs. diff --git a/src/actions/config.js b/src/actions/config.js index 51f743cd..c0148f3e 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -1,6 +1,7 @@ import yaml from 'js-yaml'; import { currentBackend } from '../backends/backend'; import { authenticate } from '../actions/auth'; +import * as MediaProxy from '../valueObjects/MediaProxy'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; @@ -27,9 +28,17 @@ export function configFailed(err) { }; } +export function configDidLoad(config) { + return (dispatch) => { + MediaProxy.setConfig(config); + dispatch(configLoaded(config)); + }; +} + + export function loadConfig(config) { if (window.CMS_CONFIG) { - return configLoaded(window.CMS_CONFIG); + return configDidLoad(window.CMS_CONFIG); } return (dispatch, getState) => { dispatch(configLoading()); @@ -40,7 +49,7 @@ export function loadConfig(config) { } response.text().then(parseConfig).then((config) => { - dispatch(configLoaded(config)); + dispatch(configDidLoad(config)); const backend = currentBackend(config); const user = backend && backend.currentUser(); user && dispatch(authenticate(user)); diff --git a/src/actions/entries.js b/src/actions/entries.js index dffcecc8..329c7318 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -1,5 +1,8 @@ import { currentBackend } from '../backends/backend'; +/* + * Contant Declarations + */ export const ENTRY_REQUEST = 'ENTRY_REQUEST'; export const ENTRY_SUCCESS = 'ENTRY_SUCCESS'; export const ENTRY_FAILURE = 'ENTRY_FAILURE'; @@ -8,7 +11,20 @@ export const ENTRIES_REQUEST = 'ENTRIES_REQUEST'; export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; -export function entryLoading(collection, slug) { +export const DRAFT_CREATE = 'DRAFT_CREATE'; +export const DRAFT_DISCARD = 'DRAFT_DISCARD'; +export const DRAFT_CHANGE = 'DRAFT_CHANGE'; + + +export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; +export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; +export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; + + +/* + * Simple Action Creators (Internal) + */ +function entryLoading(collection, slug) { return { type: ENTRY_REQUEST, payload: { @@ -18,7 +34,7 @@ export function entryLoading(collection, slug) { }; } -export function entryLoaded(collection, entry) { +function entryLoaded(collection, entry) { return { type: ENTRY_SUCCESS, payload: { @@ -28,7 +44,16 @@ export function entryLoaded(collection, entry) { }; } -export function entriesLoaded(collection, entries, pagination) { +function entriesLoading(collection) { + return { + type: ENTRIES_REQUEST, + payload: { + collection: collection.get('name') + } + }; +} + +function entriesLoaded(collection, entries, pagination) { return { type: ENTRIES_SUCCESS, payload: { @@ -39,16 +64,7 @@ export function entriesLoaded(collection, entries, pagination) { }; } -export function entriesLoading(collection) { - return { - type: ENTRIES_REQUEST, - payload: { - collection: collection.get('name') - } - }; -} - -export function entriesFailed(collection, error) { +function entriesFailed(collection, error) { return { type: ENTRIES_FAILURE, error: 'Failed to load entries', @@ -57,6 +73,60 @@ export function entriesFailed(collection, error) { }; } +function entryPersisting(collection, entry) { + return { + type: ENTRY_PERSIST_REQUEST, + payload: { + collection: collection, + entry: entry + } + }; +} + +function entryPersisted(persistedEntry, persistedMediaFiles) { + return { + type: ENTRY_PERSIST_SUCCESS, + payload: { + persistedEntry: persistedEntry, + persistedMediaFiles: persistedMediaFiles + } + }; +} + +function entryPersistFail(collection, entry, error) { + return { + type: ENTRIES_FAILURE, + error: 'Failed to persist entry', + payload: error.toString() + }; +} + +/* + * Exported simple Action Creators + */ +export function createDraft(entry) { + return { + type: DRAFT_CREATE, + payload: entry + }; +} + +export function discardDraft() { + return { + type: DRAFT_DISCARD + }; +} + +export function changeDraft(entry) { + return { + type: DRAFT_CHANGE, + payload: entry + }; +} + +/* + * Exported Thunk Action Creators + */ export function loadEntry(collection, slug) { return (dispatch, getState) => { const state = getState(); @@ -75,7 +145,23 @@ export function loadEntries(collection) { const backend = currentBackend(state.config); dispatch(entriesLoading(collection)); - backend.entries(collection) - .then((response) => dispatch(entriesLoaded(collection, response.entries, response.pagination))) + backend.entries(collection).then( + (response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)), + (error) => dispatch(entriesFailed(collection, error)) + ); + }; +} + +export function persist(collection, entry, mediaFiles) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + dispatch(entryPersisting(collection, entry)); + backend.persist(collection, entry, mediaFiles).then( + ({persistedEntry, persistedMediaFiles}) => { + dispatch(entryPersisted(persistedEntry, persistedMediaFiles)); + }, + (error) => dispatch(entryPersistFail(collection, entry, error)) + ); }; } diff --git a/src/actions/media.js b/src/actions/media.js new file mode 100644 index 00000000..88f822ff --- /dev/null +++ b/src/actions/media.js @@ -0,0 +1,10 @@ +export const ADD_MEDIA = 'ADD_MEDIA'; +export const REMOVE_MEDIA = 'REMOVE_MEDIA'; + +export function addMedia(mediaProxy) { + return {type: ADD_MEDIA, payload: mediaProxy}; +} + +export function removeMedia(uri) { + return {type: REMOVE_MEDIA, payload: uri}; +} diff --git a/src/backends/backend.js b/src/backends/backend.js index 72dba0c6..760c7845 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -66,6 +66,26 @@ class Backend { return entry; }; } + + persist(collection, entryDraft) { + const entryData = entryDraft.getIn(['entry', 'data']).toObject(); + const entryObj = { + path: entryDraft.getIn(['entry', 'path']), + slug: entryDraft.getIn(['entry', 'slug']), + raw: this.entryToRaw(collection, entryData) + }; + return this.implementation.persist(collection, entryObj, entryDraft.get('mediaFiles').toJS()).then( + (response) => ({ + persistedEntry: this.entryWithFormat(collection)(response.persistedEntry), + persistedMediaFiles:response.persistedMediaFiles + }) + ); + } + + entryToRaw(collection, entry) { + const format = resolveFormat(collection, entry); + return format && format.toFile(entry); + } } export function resolveBackend(config) { diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index 3f4ed4bd..2afc719b 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -47,4 +47,11 @@ export default class TestRepo { response.entries.filter((entry) => entry.slug === slug)[0] )); } + + persist(collection, entry, mediaFiles = []) { + const folder = collection.get('folder'); + const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1); + window.repoFiles[folder][fileName]['content'] = entry.raw; + return Promise.resolve({persistedEntry:entry, persistedMediaFiles:[]}); + } } diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js index 203bc581..18d4fae5 100644 --- a/src/components/ControlPane.js +++ b/src/components/ControlPane.js @@ -3,13 +3,16 @@ import Widgets from './Widgets'; export default class ControlPane extends React.Component { controlFor(field) { - const { entry } = this.props; + const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; const widget = Widgets[field.get('widget')] || Widgets._unknown; return React.createElement(widget.Control, { key: field.get('name'), field: field, value: entry.getIn(['data', field.get('name')]), - onChange: (value) => this.props.onChange(entry.setIn(['data', field.get('name')], value)) + onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)), + onAddMedia: onAddMedia, + onRemoveMedia: onRemoveMedia, + getMedia: getMedia }); } diff --git a/src/components/EntryEditor.js b/src/components/EntryEditor.js index 488b5cb3..5c344364 100644 --- a/src/components/EntryEditor.js +++ b/src/components/EntryEditor.js @@ -3,30 +3,28 @@ import ControlPane from './ControlPane'; import PreviewPane from './PreviewPane'; export default class EntryEditor extends React.Component { - constructor(props) { - super(props); - this.state = {entry: props.entry}; - this.handleChange = this.handleChange.bind(this); - } - - handleChange(entry) { - this.setState({entry: entry}); - } render() { - const { collection, entry } = this.props; - + const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props; return

Entry in {collection.get('label')}

{entry && entry.get('title')}

- +
- +
+
; } } diff --git a/src/components/PreviewPane.js b/src/components/PreviewPane.js index baca6c86..bce60c92 100644 --- a/src/components/PreviewPane.js +++ b/src/components/PreviewPane.js @@ -3,12 +3,13 @@ import Widgets from './Widgets'; export default class PreviewPane extends React.Component { previewFor(field) { - const { entry } = this.props; + const { entry, getMedia } = this.props; const widget = Widgets[field.get('widget')] || Widgets._unknown; return React.createElement(widget.Preview, { key: field.get('name'), field: field, - value: entry.getIn(['data', field.get('name')]) + value: entry.getIn(['data', field.get('name')]), + getMedia: getMedia, }); } diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index 68d9c884..dbf01c67 100644 --- a/src/components/Widgets/ImageControl.js +++ b/src/components/Widgets/ImageControl.js @@ -1,14 +1,13 @@ import React from 'react'; +import { truncateMiddle } from '../../lib/textHelper'; +import MediaProxy from '../../valueObjects/MediaProxy'; + +const MAX_DISPLAY_LENGTH = 50; export default class ImageControl extends React.Component { constructor(props) { super(props); - this.state = { - currentImage: props.value - }; - - this.revokeCurrentImage = this.revokeCurrentImage.bind(this); this.handleChange = this.handleChange.bind(this); this.handleFileInputRef = this.handleFileInputRef.bind(this); this.handleClick = this.handleClick.bind(this); @@ -17,16 +16,6 @@ export default class ImageControl extends React.Component { this.renderImageName = this.renderImageName.bind(this); } - componentWillUnmount() { - this.revokeCurrentImage(); - } - - revokeCurrentImage() { - if (this.state.currentImage instanceof File) { - window.URL.revokeObjectURL(this.state.currentImage); - } - } - handleFileInputRef(el) { this._fileInput = el; } @@ -48,7 +37,7 @@ export default class ImageControl extends React.Component { handleChange(e) { e.stopPropagation(); e.preventDefault(); - this.revokeCurrentImage(); + const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files; const files = [...fileList]; const imageType = /^image\//; @@ -60,28 +49,25 @@ export default class ImageControl extends React.Component { } }); + this.props.onRemoveMedia(this.props.value); if (file) { - // Custom toString function on file, so it can be used on regular image fields - file.toString = function() { - return window.URL.createObjectURL(file); - }; + const mediaProxy = new MediaProxy(file.name, file); + this.props.onAddMedia(mediaProxy); + this.props.onChange(mediaProxy.uri); + } else { + this.props.onChange(null); } - this.props.onChange(file); - this.setState({currentImage: file}); } renderImageName() { - if (!this.state.currentImage) return null; - - if (this.state.currentImage instanceof File) { - return this.state.currentImage.name; - } else if (typeof this.state.currentImage === 'string') { - const fileName = this.state.currentImage; - return fileName.substring(fileName.lastIndexOf('/') + 1); + if (!this.props.value) return null; + if (this.value instanceof MediaProxy) { + return truncateMiddle(this.props.value.uri, MAX_DISPLAY_LENGTH); + } else { + return truncateMiddle(this.props.value, MAX_DISPLAY_LENGTH); } - return null; } render() { diff --git a/src/components/Widgets/ImagePreview.js b/src/components/Widgets/ImagePreview.js index 06a23cda..8e0a857e 100644 --- a/src/components/Widgets/ImagePreview.js +++ b/src/components/Widgets/ImagePreview.js @@ -6,7 +6,7 @@ export default class ImagePreview extends React.Component { } render() { - const { value } = this.props; - return value ? : null; + const { value, getMedia } = this.props; + return value ? : null; } } diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index f8e0072f..2311b8d3 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -2,7 +2,7 @@ import React from 'react'; import { Link } from 'react-router'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; -import { selectEntries } from '../reducers/entries'; +import { selectEntries } from '../reducers'; import EntryListing from '../components/EntryListing'; class DashboardPage extends React.Component { diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index 8abfdf18..aff9eb35 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -1,38 +1,83 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Map } from 'immutable'; -import { loadEntry } from '../actions/entries'; -import { selectEntry } from '../reducers/entries'; +import { + loadEntry, + createDraft, + discardDraft, + changeDraft, + persist +} from '../actions/entries'; +import { addMedia, removeMedia } from '../actions/media'; +import { selectEntry, getMedia } from '../reducers'; import EntryEditor from '../components/EntryEditor'; class EntryPage extends React.Component { constructor(props) { super(props); - this.props.dispatch(loadEntry(props.collection, props.slug)); + this.props.loadEntry(props.collection, props.slug); + this.handlePersist = this.handlePersist.bind(this); + } + + componentDidMount() { + if (this.props.entry) { + this.props.createDraft(this.props.entry); + } + } + + componentWillReceiveProps(nextProps) { + if (this.props.entry !== nextProps.entry && !nextProps.entry.get('isFetching')) { + this.props.createDraft(nextProps.entry); + } + } + + componentWillUnmount() { + this.props.discardDraft(); + } + + handlePersist() { + this.props.persist(this.props.collection, this.props.entryDraft); } render() { - const { entry, collection } = this.props; - if (entry == null || entry.get('isFetching')) { + const { + entry, entryDraft, boundGetMedia, collection, changeDraft, addMedia, removeMedia + } = this.props; + + if (entry == null || entryDraft.get('entry') == undefined || entry.get('isFetching')) { return
Loading...
; } - return ( ); } } function mapStateToProps(state, ownProps) { - const { collections } = state; + const { collections, entryDraft } = state; const collection = collections.get(ownProps.params.name); const slug = ownProps.params.slug; const entry = selectEntry(state, collection.get('name'), slug); - - return {collection, collections, slug, entry}; + const boundGetMedia = getMedia.bind(null, state); + return {collection, collections, entryDraft, boundGetMedia, slug, entry}; } -export default connect(mapStateToProps)(EntryPage); +export default connect( + mapStateToProps, + { + changeDraft, + addMedia, + removeMedia, + loadEntry, + createDraft, + discardDraft, + persist + } +)(EntryPage); diff --git a/src/formats/yaml.js b/src/formats/yaml.js index c0384e4b..944f3e9e 100644 --- a/src/formats/yaml.js +++ b/src/formats/yaml.js @@ -1,5 +1,6 @@ import yaml from 'js-yaml'; import moment from 'moment'; +import MediaProxy from '../valueObjects/MediaProxy'; const MomentType = new yaml.Type('date', { kind: 'scalar', @@ -14,9 +15,23 @@ const MomentType = new yaml.Type('date', { } }); +const ImageType = new yaml.Type('image', { + kind: 'scalar', + instanceOf: MediaProxy, + represent: function(value) { + return `${value.uri}`; + }, + resolve: function(value) { + if (value === null) return false; + if (value instanceof MediaProxy) return true; + return false; + } +}); + + const OutputSchema = new yaml.Schema({ include: yaml.DEFAULT_SAFE_SCHEMA.include, - implicit: [MomentType].concat(yaml.DEFAULT_SAFE_SCHEMA.implicit), + implicit: [MomentType, ImageType].concat(yaml.DEFAULT_SAFE_SCHEMA.implicit), explicit: yaml.DEFAULT_SAFE_SCHEMA.explicit }); diff --git a/src/index.js b/src/index.js index 7f0ba47c..dd7e27f6 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,8 @@ import 'file?name=index.html!../example/index.html'; const store = configureStore(); +window.store = store; + const el = document.createElement('div'); document.body.appendChild(el); diff --git a/src/lib/textHelper.js b/src/lib/textHelper.js new file mode 100644 index 00000000..39ffd31a --- /dev/null +++ b/src/lib/textHelper.js @@ -0,0 +1,6 @@ +export function truncateMiddle(string = '', size) { + if (string.length <= size) { + return string; + } + return string.substring(0, size / 2) + '\u2026' + string.substring(string.length - size / 2 + 1, string.length); +} diff --git a/src/reducers/auth.js b/src/reducers/auth.js index 845ec634..69fbdbff 100644 --- a/src/reducers/auth.js +++ b/src/reducers/auth.js @@ -1,7 +1,7 @@ import Immutable from 'immutable'; import { AUTH_REQUEST, AUTH_SUCCESS, AUTH_FAILURE } from '../actions/auth'; -export function auth(state = null, action) { +const auth = (state = null, action) => { switch (action.type) { case AUTH_REQUEST: return Immutable.Map({isFetching: true}); @@ -13,4 +13,6 @@ export function auth(state = null, action) { default: return state; } -} +}; + +export default auth; diff --git a/src/reducers/collections.js b/src/reducers/collections.js index 48c24002..ca6e61ce 100644 --- a/src/reducers/collections.js +++ b/src/reducers/collections.js @@ -1,7 +1,7 @@ import { OrderedMap, fromJS } from 'immutable'; import { CONFIG_SUCCESS } from '../actions/config'; -export function collections(state = null, action) { +const collections = (state = null, action) => { switch (action.type) { case CONFIG_SUCCESS: const collections = action.payload && action.payload.collections; @@ -14,3 +14,5 @@ export function collections(state = null, action) { return state; } } + +export default collections; diff --git a/src/reducers/config.js b/src/reducers/config.js index c343001a..135f2af1 100644 --- a/src/reducers/config.js +++ b/src/reducers/config.js @@ -1,7 +1,7 @@ import Immutable from 'immutable'; import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE } from '../actions/config'; -export function config(state = null, action) { +const config = (state = null, action) => { switch (action.type) { case CONFIG_REQUEST: return Immutable.Map({isFetching: true}); @@ -12,4 +12,6 @@ export function config(state = null, action) { default: return state; } -} +}; + +export default config; diff --git a/src/reducers/entries.js b/src/reducers/entries.js index 6c54c39e..ae77ea05 100644 --- a/src/reducers/entries.js +++ b/src/reducers/entries.js @@ -3,7 +3,7 @@ import { ENTRY_REQUEST, ENTRY_SUCCESS, ENTRIES_REQUEST, ENTRIES_SUCCESS } from '../actions/entries'; -export function entries(state = Map({entities: Map(), pages: Map()}), action) { +const entries = (state = Map({entities: Map(), pages: Map()}), action) => { switch (action.type) { case ENTRY_REQUEST: return state.setIn(['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'], true); @@ -28,13 +28,15 @@ export function entries(state = Map({entities: Map(), pages: Map()}), action) { default: return state; } -} +}; -export function selectEntry(state, collection, slug) { - return state.entries.getIn(['entities', `${collection}.${slug}`]); -} +export const selectEntry = (state, collection, slug) => ( + state.getIn(['entities', `${collection}.${slug}`]) +); -export function selectEntries(state, collection) { - const slugs = state.entries.getIn(['pages', collection, 'ids']); +export const selectEntries = (state, collection) => { + const slugs = state.getIn(['pages', collection, 'ids']); return slugs && slugs.map((slug) => selectEntry(state, collection, slug)); -} +}; + +export default entries; diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js new file mode 100644 index 00000000..67c999ff --- /dev/null +++ b/src/reducers/entryDraft.js @@ -0,0 +1,34 @@ +import { Map, List } from 'immutable'; +import { DRAFT_CREATE, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries'; +import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media'; + +const initialState = Map({entry: Map(), mediaFiles: List()}); + +const entryDraft = (state = Map(), action) => { + switch (action.type) { + case DRAFT_CREATE: + if (!action.payload) { + // New entry + return initialState; + } + // Existing Entry + return state.withMutations((state) => { + state.set('entry', action.payload); + state.set('mediaFiles', List()); + }); + case DRAFT_DISCARD: + return initialState; + case DRAFT_CHANGE: + return state.set('entry', action.payload); + + case ADD_MEDIA: + return state.update('mediaFiles', (list) => list.push(action.payload.uri)); + case REMOVE_MEDIA: + return state.update('mediaFiles', (list) => list.filterNot((uri) => uri === action.payload)); + + default: + return state; + } +}; + +export default entryDraft; diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 00000000..29a7cdf3 --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,27 @@ +import auth from './auth'; +import config from './config'; +import entries, * as fromEntries from './entries'; +import entryDraft from './entryDraft'; +import collections from './collections'; +import medias, * as fromMedias from './medias'; + +const reducers = { + auth, + config, + collections, + entries, + entryDraft, + medias +}; + +export default reducers; + +export const selectEntry = (state, collection, slug) => + fromEntries.selectEntry(state.entries, collection, slug); + + +export const selectEntries = (state, collection) => + fromEntries.selectEntries(state.entries, collection); + +export const getMedia = (state, uri) => + fromMedias.getMedia(state.medias, uri); diff --git a/src/reducers/medias.js b/src/reducers/medias.js new file mode 100644 index 00000000..e80d24dc --- /dev/null +++ b/src/reducers/medias.js @@ -0,0 +1,31 @@ +import { Map } from 'immutable'; +import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media'; +import { ENTRY_PERSIST_SUCCESS } from '../actions/entries'; +import MediaProxy from '../valueObjects/MediaProxy'; + +const medias = (state = Map(), action) => { + switch (action.type) { + case ADD_MEDIA: + return state.set(action.payload.uri, action.payload); + case REMOVE_MEDIA: + return state.delete(action.payload); + case ENTRY_PERSIST_SUCCESS: + return state.map((media, uri) => { + if (action.payload.persistedMediaFiles.indexOf(uri) > -1) media.uploaded = true; + return media; + }); + + default: + return state; + } +}; + +export default medias; + +export const getMedia = (state, uri) => { + if (state.has(uri)) { + return state.get(uri); + } else { + return new MediaProxy(uri, null, true); + } +}; diff --git a/src/store/configureStore.js b/src/store/configureStore.js index d87b77a1..3107dba0 100644 --- a/src/store/configureStore.js +++ b/src/store/configureStore.js @@ -2,16 +2,10 @@ import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; import thunkMiddleware from 'redux-thunk'; import { browserHistory } from 'react-router'; import { syncHistory, routeReducer } from 'react-router-redux'; -import { auth } from '../reducers/auth'; -import { config } from '../reducers/config'; -import { entries } from '../reducers/entries'; -import { collections } from '../reducers/collections'; +import reducers from '../reducers'; const reducer = combineReducers({ - auth, - config, - collections, - entries, + ...reducers, router: routeReducer }); diff --git a/src/valueObjects/MediaProxy.js b/src/valueObjects/MediaProxy.js new file mode 100644 index 00000000..13e6742c --- /dev/null +++ b/src/valueObjects/MediaProxy.js @@ -0,0 +1,14 @@ +let config; +export const setConfig = (configObj) => { + config = configObj; +}; + +export default function MediaProxy(value, file, uploaded = false) { + this.value = value; + this.file = file; + this.uploaded = uploaded; + this.uri = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; + this.toString = function() { + return this.uploaded ? this.uri : window.URL.createObjectURL(this.file, {oneTimeOnly: true}); + }; +}