From 3286ba1794ba7b0b27ffbe8a7528ea5b3157e36b Mon Sep 17 00:00:00 2001 From: Robin Pyon Date: Fri, 15 Nov 2019 15:24:53 +0000 Subject: [PATCH 001/514] Initial commit --- .babelrc.js | 12 + .d.ts | 3 + .eslintrc.js | 43 + .gitignore | 17 + .prettierrc | 6 + README.md | 87 + package.json | 73 + sanity.json | 17 + src/app.tsx | 58 + src/components/Browser/Browser.tsx | 178 + src/components/Dialog/Conflicts.tsx | 83 + src/components/Dialog/Dialog.tsx | 74 + src/components/Dialog/Refs.tsx | 79 + src/components/Dialogs/Dialogs.tsx | 30 + src/components/Footer/Footer.tsx | 146 + src/components/Header/Header.tsx | 147 + src/components/Item/Card.tsx | 147 + src/components/Item/Table.tsx | 176 + .../ResponsiveBox/ResponsiveBox.tsx | 33 + src/components/Snackbars/Snackbars.tsx | 25 + src/components/View/Card.tsx | 43 + src/components/View/Table.tsx | 85 + .../ViewportObserver/ViewportObserver.tsx | 38 + src/config.ts | 55 + src/contexts/AssetBrowserDispatchContext.tsx | 67 + src/contexts/AssetBrowserStateContext.tsx | 51 + src/helpers/withRedux.tsx | 40 + src/hooks/useKeyPress.ts | 38 + src/hooks/useOnScreen.ts | 34 + src/index.ts | 10 + src/modules/assets/index.ts | 378 ++ src/modules/assets/types.ts | 95 + src/modules/dialog/index.ts | 139 + src/modules/dialog/types.ts | 33 + src/modules/index.ts | 26 + src/modules/snackbars/index.tsx | 166 + src/modules/snackbars/types.ts | 29 + src/modules/types.ts | 3 + src/styled/Box.tsx | 40 + src/styled/Checkbox.tsx | 48 + src/styled/IconButton.tsx | 18 + src/styled/Image.tsx | 31 + src/styled/Row.tsx | 64 + src/styled/theme.ts | 44 + src/types/index.ts | 108 + src/util/imageDprUrl.ts | 11 + tsconfig.json | 22 + yarn.lock | 3624 +++++++++++++++++ 48 files changed, 6774 insertions(+) create mode 100644 .babelrc.js create mode 100644 .d.ts create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 package.json create mode 100644 sanity.json create mode 100644 src/app.tsx create mode 100644 src/components/Browser/Browser.tsx create mode 100644 src/components/Dialog/Conflicts.tsx create mode 100644 src/components/Dialog/Dialog.tsx create mode 100644 src/components/Dialog/Refs.tsx create mode 100644 src/components/Dialogs/Dialogs.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Item/Card.tsx create mode 100644 src/components/Item/Table.tsx create mode 100644 src/components/ResponsiveBox/ResponsiveBox.tsx create mode 100644 src/components/Snackbars/Snackbars.tsx create mode 100644 src/components/View/Card.tsx create mode 100644 src/components/View/Table.tsx create mode 100644 src/components/ViewportObserver/ViewportObserver.tsx create mode 100644 src/config.ts create mode 100644 src/contexts/AssetBrowserDispatchContext.tsx create mode 100644 src/contexts/AssetBrowserStateContext.tsx create mode 100644 src/helpers/withRedux.tsx create mode 100644 src/hooks/useKeyPress.ts create mode 100644 src/hooks/useOnScreen.ts create mode 100644 src/index.ts create mode 100644 src/modules/assets/index.ts create mode 100644 src/modules/assets/types.ts create mode 100644 src/modules/dialog/index.ts create mode 100644 src/modules/dialog/types.ts create mode 100644 src/modules/index.ts create mode 100644 src/modules/snackbars/index.tsx create mode 100644 src/modules/snackbars/types.ts create mode 100644 src/modules/types.ts create mode 100644 src/styled/Box.tsx create mode 100644 src/styled/Checkbox.tsx create mode 100644 src/styled/IconButton.tsx create mode 100644 src/styled/Image.tsx create mode 100644 src/styled/Row.tsx create mode 100644 src/styled/theme.ts create mode 100644 src/types/index.ts create mode 100644 src/util/imageDprUrl.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.babelrc.js b/.babelrc.js new file mode 100644 index 000000000..3470b633e --- /dev/null +++ b/.babelrc.js @@ -0,0 +1,12 @@ +module.exports = { + presets: ['@babel/preset-typescript', '@babel/preset-react'], + plugins: [ + '@babel/plugin-proposal-object-rest-spread', + [ + 'babel-plugin-styled-components', + { + ssr: false + } + ] + ] +} diff --git a/.d.ts b/.d.ts new file mode 100644 index 000000000..f144e9761 --- /dev/null +++ b/.d.ts @@ -0,0 +1,3 @@ +declare module 'part:@sanity/*' +declare module 'part:sanity-plugin-media/*' +declare module 'use-deep-compare-effect' diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..26a522e77 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,43 @@ +module.exports = { + env: { + browser: true, + node: false + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:prettier/recommended', + 'plugin:react/recommended', + 'prettier/@typescript-eslint' + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true + }, + project: './tsconfig.json' + }, + rules: { + '@typescript-eslint/explicit-function-return-type': 0, + 'import/no-unresolved': ['error', {ignore: ['^react$', '.*:.*']}], + 'no-unused-vars': [ + 'error', + { + ignoreRestSiblings: true + } + ] + }, + settings: { + 'import/ignore': ['.*node_modules.*', '.*:.*'], + 'import/resolver': { + node: { + paths: ['src'], + extensions: ['.js', '.jsx', '.ts', '.tsx'] + } + } + }, + plugins: [] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..9ec428542 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Compiled files +lib + +# Dependency directories +/node_modules + +# Logs +logs +*.log +npm-debug.log* +yarn-error.log* + +# Mac OS finder cache files +.DS_Store + +# VS Code +.vscode \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..ad1e3081f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "printWidth": 100, + "bracketSpacing": false, + "singleQuote": true +} diff --git a/README.md b/README.md new file mode 100644 index 000000000..09b0fc93e --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Sanity Media + +Alternative media management for [Sanity](https://www.sanity.io/). + +⚠ This plugin is currently in alpha. Use at your own risk! ⚠ + +![example](https://user-images.githubusercontent.com/209129/69345186-a013b680-0c68-11ea-9aae-0425c54bbe86.jpg) + +## Background + +This plugin provides a dedicated media browser for managing images within Sanity. + +Out of the box, it provides a link in the menu where you can view all dataset images from anywhere within your studio. + +It can also be used as a [custom asset source](https://www.sanity.io/docs/custom-asset-sources) for image fields. + +## Features + +- Easily access media from the menu +- View file metadata +- Select and delete multiple assets +- Grid / table views +- Display currently selected assets +- Basic filename / date sorting +- Display unused assets +- Integration with Sanity's snackbar notifications + +When `sanity-plugin-media` is accessed via a custom asset source, you'll have the option to insert assets as well as view the currently selected image for that field. + +## Install + +In your Sanity project folder: + +```sh +sanity install media +``` + +This will add the Media button to your studio menu. If this is all you're after – that's all you need to do! + +### Enabling it as a global custom asset source + +This plugin exposes `part:sanity-plugin-media/asset-source` as a part you can import when defining custom asset sources. + +In `sanity.json`, add the following snippet the `parts` array: + +```json +{ + "implements": "part:@sanity/form-builder/input/image/asset-sources", + "path": "./parts/assetSources.js" +} +``` + +`./parts/assetSources.js`: + +```js +import MediaAssetSource from 'part:sanity-plugin-media/asset-source' + +export default [MediaAssetSource] +``` + +Now clicking 'select' on every image field will invoke `sanity-plugin-media`. + +Read more about Sanity's [custom asset sources](https://www.sanity.io/docs/custom-asset-sources). + +## Good to know + +- Batch deleting assets invokes multiple API requests - this is because [Sanity's transactions are atomic](https://www.sanity.io/docs/transactions). In other words, deleting 10 selected assets will use 10 API requests. + +## Known issues + +- GROQ queries for total `sanity.imageAssets` count is really, really slow. + +## Roadmap + +- Filter images by the current document +- More keyboard shortcuts +- Delete confirmation dialog +- Display current document info +- Image uploads +- Multiple selection / insertion +- More detailed metadata views +- Folder management +- Fix typings across the board, consider using `typesafe-actions` + +## Contributing + +Contributions, issues and feature requests are welcome! diff --git a/package.json b/package.json new file mode 100644 index 000000000..a80405d8d --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "sanity-plugin-media", + "version": "0.1.0", + "license": "MIT", + "author": { + "name": "Robin Pyon", + "email": "robin@robinpyon.com", + "url": "https://robinpyon.com" + }, + "keywords": [ + "sanity", + "cms", + "headless", + "realtime", + "content", + "sanity-plugin", + "asset", + "browser" + ], + "files": [ + "lib/", + "sanity.json" + ], + "scripts": { + "prepare": "npm run build", + "build": "tsc" + }, + "dependencies": { + "@sanity/components": "0.144.2", + "@types/pluralize": "0.0.29", + "date-fns": "2.7.0", + "filesize": "6.0.1", + "idx": "2.5.6", + "immer": "5.0.0", + "pluralize": "8.0.0", + "react-icons": "3.8.0", + "react-redux": "7.1.3", + "redux": "4.0.4", + "redux-observable": "1.2.0", + "rxjs": "6.5.3", + "styled-components": "5.0.0-rc.2", + "styled-system": "5.1.2", + "typesafe-actions": "5.1.0", + "use-deep-compare-effect": "1.3.0" + }, + "devDependencies": { + "@babel/cli": "7.7.0", + "@babel/core": "7.7.2", + "@babel/plugin-proposal-object-rest-spread": "7.6.2", + "@babel/preset-react": "7.7.0", + "@babel/preset-typescript": "7.7.2", + "@types/react": "16.9.11", + "@types/react-redux": "7.1.5", + "@types/styled-components": "4.4.0", + "@types/styled-system": "5.1.3", + "@types/styled-system__css": "5.0.4", + "@typescript-eslint/eslint-plugin": "2.7.0", + "@typescript-eslint/parser": "2.7.0", + "babel-eslint": "10.0.3", + "babel-plugin-styled-components": "1.10.6", + "eslint": "6.6.0", + "eslint-config-prettier": "6.6.0", + "eslint-plugin-import": "2.18.2", + "eslint-plugin-prettier": "3.1.1", + "eslint-plugin-react": "7.16.0", + "prettier": "1.19.1", + "redux-devtools-extension": "2.13.8", + "typescript": "3.7.2" + }, + "peerDependencies": { + "react": "^16.8" + } +} diff --git a/sanity.json b/sanity.json new file mode 100644 index 000000000..c69637657 --- /dev/null +++ b/sanity.json @@ -0,0 +1,17 @@ +{ + "parts": [ + { + "name": "part:sanity-plugin-media/asset-source", + "implements": "part:@sanity/form-builder/input/image/asset-source", + "path": "index.js" + }, + { + "implements": "part:@sanity/base/tool", + "path": "index.js" + } + ], + "paths": { + "source": "./src", + "compiled": "./lib" + } +} diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 000000000..13f137882 --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import styled, {ThemeProvider, css} from 'styled-components' + +import theme from './styled/theme' +import {AssetBrowserDispatchProvider} from './contexts/AssetBrowserDispatchContext' +import {AssetBrowserStateProvider} from './contexts/AssetBrowserStateContext' +import withRedux from './helpers/withRedux' +import Browser from './components/Browser/Browser' +import Dialogs from './components/Dialogs/Dialogs' +import Snackbars from './components/Snackbars/Snackbars' +import useKeyPress from './hooks/useKeyPress' +import Box from './styled/Box' +import {Asset} from './types' + +type Props = { + onClose: () => void + onSelect: () => void + selectedAssets: Asset[] +} + +type ContainerProps = { + fullscreen?: boolean +} + +const Container = styled(Box)` + ${props => + props.fullscreen && + css` + z-index: 1000; + position: fixed; + top: 0; + left: 0; + `} +` + +const AssetBrowser = (props: Props) => { + const {onClose, onSelect, selectedAssets} = props + + // Close on escape key press + useKeyPress('Escape', onClose) + + // TODO: preload selectedAssets in redux store rather than prop drilling + return ( + + + + + + + + + + + + ) +} + +export default withRedux(AssetBrowser) diff --git a/src/components/Browser/Browser.tsx b/src/components/Browser/Browser.tsx new file mode 100644 index 000000000..7befd8cd5 --- /dev/null +++ b/src/components/Browser/Browser.tsx @@ -0,0 +1,178 @@ +import produce from 'immer' +import React, {useRef, useState} from 'react' +import useDeepCompareEffect from 'use-deep-compare-effect' +import Spinner from 'part:@sanity/components/loading/spinner' + +import {useAssetBrowserActions} from '../../contexts/AssetBrowserDispatchContext' +import {useAssetBrowserState} from '../../contexts/AssetBrowserStateContext' +import {ORDERS, VIEWS, getFilters} from '../../config' +import Box from '../../styled/Box' +import {BrowserOptions, Filter, Asset} from '../../types' +import Footer from '../Footer/Footer' +import Header from '../Header/Header' +import CardView from '../View/Card' +import TableView from '../View/Table' +import ViewportObserver from '../ViewportObserver/ViewportObserver' + +const PER_PAGE = 20 + +type Props = { + // TODO: use correct type + document?: any + onClose?: () => void + selectedAssets?: Asset[] +} + +const Browser = (props: Props) => { + const {document: currentDocument, onClose, selectedAssets} = props + + const filters: Filter[] = getFilters(currentDocument) + + const viewRef = useRef(null) + + const {onFetch} = useAssetBrowserActions() + const {fetching, items, totalCount} = useAssetBrowserState() + const [browserOptions, setBrowserOptions] = useState({ + filter: filters[0], + order: ORDERS[0], + pageIndex: 0, + replaceOnFetch: false, + view: VIEWS[0] + }) + + const hasFetchedOnce = totalCount >= 0 + + const fetchPage = (index: number, replace: boolean) => { + const {filter, order} = browserOptions + + const start = index * PER_PAGE + const end = start + PER_PAGE + + const sort = `order(${order.value})` + const selector = `[${start}...${end}]` + + // Can be null when operating on pristine / unsaved drafts + const currentDocumentId = currentDocument && currentDocument._id + + onFetch({ + filter: filter.value, + ...(currentDocumentId ? {params: {documentId: currentDocumentId}} : {}), + projections: `{ + _id, + _updatedAt, + extension, + metadata { + dimensions, + isOpaque, + }, + originalFilename, + size, + url + }`, + replace, + selector, + sort + }) + } + + const scrollToTop = () => { + const viewEl = viewRef && viewRef.current + if (viewEl) { + viewEl.scrollTo(0, 0) + } + } + + // Fetch items on mount and when options have changed + useDeepCompareEffect(() => { + const {pageIndex, replaceOnFetch} = browserOptions + + fetchPage(pageIndex, replaceOnFetch) + + // Scroll to top when replacing items + if (replaceOnFetch) { + scrollToTop() + } + }, [browserOptions]) + + const hasMore = (browserOptions.pageIndex + 1) * PER_PAGE < totalCount + + const handleFetchNextPage = () => { + setBrowserOptions( + produce(draft => { + draft.pageIndex += 1 + draft.replaceOnFetch = false + }) + ) + } + + const handleUpdateBrowserOptions = (field: string, value: Record) => { + setBrowserOptions( + produce(draft => { + draft[field] = value + draft.pageIndex = 0 + draft.replaceOnFetch = true + }) + ) + } + + return ( + + {/* Header */} +
+ + {/* Items */} + + {/* View: Grid */} + {browserOptions.view?.value === 'grid' && ( + + + + )} + + {/* View: Table */} + {browserOptions.view?.value === 'table' && ( + + )} + + {/* Viewport observer */} + {hasFetchedOnce && !fetching && ( + { + if (hasMore) { + handleFetchNextPage() + } + }} + /> + )} + + + {/* Footer */} +