├── .editorconfig ├── .gitignore ├── README.md ├── logo.png ├── package.json ├── public ├── _redirects ├── connect.png ├── created.png ├── favicon.ico ├── github.svg ├── google-slide.png ├── google-slides.png ├── google.png ├── index.html ├── manifest.json ├── producthunt.svg ├── robots.txt ├── select.png └── zeplin.png ├── src ├── App.js ├── components │ ├── ConnectCard │ │ ├── ConnectCard.js │ │ └── index.js │ ├── Connected │ │ ├── Connected.js │ │ └── index.js │ ├── CreateSection │ │ ├── CreateSection.js │ │ └── index.js │ ├── Footer │ │ ├── Footer.js │ │ └── index.js │ ├── GoogleConnectCard │ │ ├── GoogleConnectCard.js │ │ └── index.js │ ├── Header │ │ ├── Header.js │ │ └── index.js │ ├── HomeCard │ │ ├── HomeCard.js │ │ └── index.js │ ├── IntegrationImage │ │ ├── IntegrationImage.js │ │ ├── LovePath │ │ │ ├── LovePath.jsx │ │ │ └── index.js │ │ └── index.js │ ├── PrivateRoute.js │ ├── ProjectCombobox │ │ ├── ProjectCombobox.jsx │ │ └── index.js │ └── ZeplinConnectCard │ │ ├── ZeplinConnectCard.js │ │ └── index.js ├── constants │ └── index.js ├── index.css ├── index.js ├── layouts │ └── Main.jsx ├── pages │ ├── Connect.js │ ├── Create.js │ └── Home.js ├── providers │ ├── AuthProvider.js │ └── StoreProvider │ │ ├── StoreProvider.jsx │ │ ├── actions.js │ │ ├── index.js │ │ ├── reducer.js │ │ ├── selectors.js │ │ └── types.js ├── services │ ├── google.js │ └── zeplin.js ├── setupTests.js └── utils │ ├── http.js │ ├── image.js │ └── image.test.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .env* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Zeplin Slides 3 |

4 | 5 |

6 | Create presentations in Google Slides from Zeplin projects 7 |

8 | 9 |

10 | 11 | Zeplin Slides 12 | 13 |

14 | 15 | 16 | 17 | ## Development 18 | 19 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app) and [Material UI](https://material-ui.com/). 20 | 21 | ### APIs 22 | 23 | This project uses [Zeplin API](https://docs.zeplin.dev) to fetch Zeplin projects and screens and [Google Slides API](https://developers.google.com/slides) to create presentations in Google Slides. 24 | 25 | ### Available Scripts 26 | 27 | In the project directory, you can run: 28 | 29 | #### `yarn start` 30 | 31 | Runs the app in the development mode.
32 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 33 | 34 | The page will reload if you make edits.
35 | You will also see any lint errors in the console. 36 | 37 | #### `yarn test` 38 | 39 | Launches the test runner in the interactive watch mode.
40 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 41 | 42 | #### `yarn build` 43 | 44 | Builds the app for production to the `build` folder.
45 | It correctly bundles React in production mode and optimizes the build for the best performance. 46 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zeplin-slides", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "react-scripts start", 6 | "build": "react-scripts build", 7 | "test": "react-scripts test", 8 | "eject": "react-scripts eject" 9 | }, 10 | "dependencies": { 11 | "@material-ui/core": "^4.9.9", 12 | "@material-ui/icons": "^4.9.1", 13 | "@material-ui/lab": "^4.0.0-alpha.48", 14 | "@testing-library/jest-dom": "^4.2.4", 15 | "@testing-library/react": "^9.3.2", 16 | "@testing-library/user-event": "^7.1.2", 17 | "moment": "^2.24.0", 18 | "react": "^16.13.1", 19 | "react-dom": "^16.13.1", 20 | "react-router-dom": "^5.1.2", 21 | "react-scripts": "3.4.1", 22 | "typeface-roboto": "^0.0.75" 23 | }, 24 | "eslintConfig": { 25 | "extends": "react-app", 26 | "globals": { 27 | "gapi": true 28 | } 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/connect.png -------------------------------------------------------------------------------- /public/created.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/created.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/favicon.ico -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/google-slide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/google-slide.png -------------------------------------------------------------------------------- /public/google-slides.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/google-slides.png -------------------------------------------------------------------------------- /public/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/google.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Zeplin Slides 28 | 29 | 30 | 31 | 35 | 44 | 45 | 46 | 47 |
48 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Zeplin Google Slides", 3 | "name": "Create presentations in Google Slides from Zeplin projects", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "zeplin.png", 12 | "type": "image/png", 13 | "sizes": "256x256" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /public/producthunt.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/select.png -------------------------------------------------------------------------------- /public/zeplin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/zeplin.png -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; 3 | 4 | import { AuthProvider } from "./providers/AuthProvider"; 5 | import { StoreProvider } from "./providers/StoreProvider"; 6 | 7 | import Connect from "./pages/Connect"; 8 | import Create from "./pages/Create"; 9 | import Home from "./pages/Home"; 10 | 11 | import PrivateRoute from "./components/PrivateRoute"; 12 | 13 | function App() { 14 | return ( 15 | 16 | 17 | 18 | 19 | } /> 20 | } /> 21 | } /> 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /src/components/ConnectCard/ConnectCard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Box, 5 | Card, 6 | CardContent, 7 | Typography, 8 | Button, 9 | CircularProgress, 10 | } from "@material-ui/core"; 11 | 12 | export default function ConnectCard({ 13 | accountName, 14 | accountEmail, 15 | description, 16 | onConnect, 17 | onDisconnect, 18 | isConnected, 19 | buttonIcon, 20 | authenticating, 21 | }) { 22 | let buttonText = "Connect"; 23 | if (isConnected) { 24 | buttonText = "Disconnect"; 25 | } else if (authenticating) { 26 | buttonText = "Connecting…"; 27 | } 28 | 29 | const buttonStartIcon = authenticating ? ( 30 | 31 | ) : ( 32 | buttonIcon 33 | ); 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | {isConnected ? "Connected to" : "Connect"} {accountName} account 41 | 42 | 43 | {isConnected ? ( 44 | 45 | Connected to account {accountEmail}. 46 | 47 | ) : ( 48 | description 49 | )} 50 | 51 | 52 | 53 | 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ConnectCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./ConnectCard"; 2 | -------------------------------------------------------------------------------- /src/components/Connected/Connected.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Avatar, Chip, Box } from "@material-ui/core"; 3 | 4 | export default function Connected() { 5 | return ( 6 | 7 | } 9 | label="Zeplin" 10 | onDelete={() => console.log()} 11 | /> 12 | 15 | } 16 | label="Google" 17 | onDelete={() => console.log()} 18 | /> 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Connected/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Connected" 2 | -------------------------------------------------------------------------------- /src/components/CreateSection/CreateSection.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function CreateSection() { 4 | return
create
; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/CreateSection/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./CreateSection"; 2 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Link } from "@material-ui/core"; 4 | 5 | export default function Footer() { 6 | return ( 7 | 8 | 13 | Contact 14 | 15 | 22 | GitHub 23 | 24 | 31 | Privacy Policy 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Footer/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Footer"; 2 | -------------------------------------------------------------------------------- /src/components/GoogleConnectCard/GoogleConnectCard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useAuth } from "../../providers/AuthProvider"; 4 | import { 5 | onSignInClick, 6 | onSignOutClick, 7 | } from "../../services/google"; 8 | 9 | import ConnectCard from "../ConnectCard"; 10 | 11 | export default function GoogleConnectCard() { 12 | const { isGoogleConnected, googleUser, setGoogleUser, isAuthenticatingGoogle } = useAuth(); 13 | 14 | const onDisconnect = () => { 15 | onSignOutClick(); 16 | 17 | setGoogleUser(null); 18 | }; 19 | 20 | return ( 21 | } 29 | authenticating={isAuthenticatingGoogle} 30 | /> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/GoogleConnectCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./GoogleConnectCard"; 2 | -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Link } from "@material-ui/core"; 4 | 5 | import IntegrationImage from "../IntegrationImage"; 6 | 7 | export default function Header() { 8 | return ( 9 | 10 | 11 | 17 | mertkahyaoglu/zeplin-google-slides 22 | 23 | {/* 28 | Product Hunt 29 | 30 | */} 31 | 32 | 33 | 34 |

Zeplin Slides

35 |

Create presentations in Google Slides from Zeplin projects

36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Header"; 2 | -------------------------------------------------------------------------------- /src/components/HomeCard/HomeCard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Typography, Box, Paper } from "@material-ui/core"; 4 | 5 | export default function HomeCard({ title, image, reverse, disableImageBorder }) { 6 | return ( 7 | 8 | 9 | 17 | 18 | {title} 19 | 20 | 24 | {image} 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/HomeCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./HomeCard"; 2 | -------------------------------------------------------------------------------- /src/components/IntegrationImage/IntegrationImage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box } from "@material-ui/core"; 4 | 5 | import LovePath from "./LovePath"; 6 | 7 | function IntegrationImage() { 8 | return ( 9 | 10 | Zeplin 15 | 16 | Google Slides 21 | 22 | ); 23 | } 24 | 25 | export default IntegrationImage; 26 | -------------------------------------------------------------------------------- /src/components/IntegrationImage/LovePath/LovePath.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box } from "@material-ui/core"; 4 | 5 | function LovePath() { 6 | return ( 7 | 8 | 9 | 10 | 17 | 18 | 19 | 20 | 21 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export default LovePath; 37 | -------------------------------------------------------------------------------- /src/components/IntegrationImage/LovePath/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./LovePath"; 2 | -------------------------------------------------------------------------------- /src/components/IntegrationImage/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./IntegrationImage"; 2 | -------------------------------------------------------------------------------- /src/components/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect } from "react-router-dom"; 3 | 4 | import { useAuth } from "../providers/AuthProvider"; 5 | 6 | import CircularProgress from "@material-ui/core/CircularProgress"; 7 | import { Box } from "@material-ui/core"; 8 | 9 | export default function PrivateRoute({ children, ...rest }) { 10 | const { isAuthenticatingGoogle, isAllConnected } = useAuth(); 11 | 12 | if (isAuthenticatingGoogle) { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | return ( 21 | 24 | isAllConnected ? ( 25 | children 26 | ) : ( 27 | 33 | ) 34 | } 35 | /> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/ProjectCombobox/ProjectCombobox.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import TextField from "@material-ui/core/TextField"; 4 | import Autocomplete from "@material-ui/lab/Autocomplete"; 5 | import CircularProgress from "@material-ui/core/CircularProgress"; 6 | 7 | import { fetchProjects } from "../../services/zeplin"; 8 | 9 | export default function ProjectCombobox({ onProjectSelect }) { 10 | const [open, setOpen] = useState(false); 11 | const [options, setOptions] = useState(); 12 | const loading = open && !options; 13 | 14 | const onOpen = async () => { 15 | setOpen(true); 16 | 17 | if (options) { 18 | return; 19 | } 20 | 21 | const projects = await fetchProjects(); 22 | 23 | setOptions( 24 | projects.map((project) => ({ 25 | name: project.name, 26 | value: project, 27 | })) 28 | ); 29 | }; 30 | 31 | const onClose = () => { 32 | setOpen(false); 33 | }; 34 | 35 | const onChange = (event, value) => { 36 | if (value) { 37 | onProjectSelect(value.value); 38 | } 39 | }; 40 | 41 | return ( 42 | option.value === value.value} 48 | getOptionLabel={(option) => option.name} 49 | options={options || []} 50 | loading={loading} 51 | noOptionsText="No projects in this account" 52 | renderInput={(params) => ( 53 | 61 | {loading ? ( 62 | 63 | ) : null} 64 | {params.InputProps.endAdornment} 65 | 66 | ), 67 | }} 68 | /> 69 | )} 70 | /> 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/ProjectCombobox/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./ProjectCombobox"; 2 | -------------------------------------------------------------------------------- /src/components/ZeplinConnectCard/ZeplinConnectCard.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useLocation, useHistory } from "react-router-dom"; 3 | 4 | import { useStore } from "../../providers/StoreProvider"; 5 | import { useAuth } from "../../providers/AuthProvider"; 6 | import { 7 | fetchAccessToken, 8 | authorize as authorizeZeplin, 9 | } from "../../services/zeplin"; 10 | 11 | import ConnectCard from "../ConnectCard"; 12 | 13 | function ZeplinConnectCard() { 14 | const { search } = useLocation(); 15 | const history = useHistory(); 16 | const { actions, selectors } = useStore(); 17 | const { isZeplinConnected, connectZeplin, disconnectZeplin } = useAuth(); 18 | const [authenticating, setAuthenticating] = useState(false); 19 | 20 | const code = new URLSearchParams(search).get("code"); 21 | const zeplinUser = selectors.zeplinUser(); 22 | 23 | useEffect(() => { 24 | async function getZeplinToken(code) { 25 | try { 26 | setAuthenticating(true); 27 | const { access_token } = await fetchAccessToken(code); 28 | 29 | if (access_token) { 30 | connectZeplin(access_token); 31 | 32 | setAuthenticating(false); 33 | 34 | history.replace("/"); 35 | } 36 | } catch { 37 | // noop 38 | } 39 | } 40 | 41 | if (code) { 42 | getZeplinToken(code); 43 | } 44 | }, []); 45 | 46 | useEffect(() => { 47 | if (isZeplinConnected && !zeplinUser) { 48 | actions.getZeplinUser(); 49 | } 50 | }, [isZeplinConnected, zeplinUser]); 51 | 52 | return ( 53 | } 61 | authenticating={authenticating} 62 | /> 63 | ); 64 | } 65 | 66 | export default ZeplinConnectCard; 67 | -------------------------------------------------------------------------------- /src/components/ZeplinConnectCard/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./ZeplinConnectCard"; 2 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const ZEPLIN_TOKEN_STORAGE_KEY = "zeplinToken"; 2 | export const APP_URL = process.env.REACT_APP_APP_URL; 3 | export const GOOGLE_CLIENT_ID = process.env.REACT_APP_GOOGLE_CLIENT_ID; 4 | export const GOOGLE_API_KEY = process.env.REACT_APP_API_KEY; 5 | export const ZEPLIN_CLIENT_ID = process.env.REACT_APP_ZEPLIN_CLIENT_ID; 6 | export const ZEPLIN_CLIENT_SECRET = process.env.REACT_APP_ZEPLIN_CLIENT_SECRET; 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: #f5f5f5; 9 | } 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import App from "./App"; 5 | 6 | import "./index.css"; 7 | 8 | import "typeface-roboto"; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById("root") 15 | ); 16 | -------------------------------------------------------------------------------- /src/layouts/Main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Container } from "@material-ui/core"; 4 | 5 | import Header from "../components/Header"; 6 | 7 | function Main({ children, maxWidth = "sm" }) { 8 | return ( 9 | 10 |
11 | 12 | {children} 13 | 14 | ); 15 | } 16 | 17 | export default Main; 18 | -------------------------------------------------------------------------------- /src/pages/Connect.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | 4 | import { Button } from "@material-ui/core"; 5 | import CreateIcon from "@material-ui/icons/Slideshow"; 6 | 7 | import { useAuth } from "../providers/AuthProvider"; 8 | 9 | import Main from "../layouts/Main"; 10 | 11 | import ZeplinConnectCard from "../components/ZeplinConnectCard"; 12 | import GoogleConnectCard from "../components/GoogleConnectCard"; 13 | 14 | function Connect() { 15 | const history = useHistory(); 16 | const { isAllConnected } = useAuth(); 17 | 18 | const onCreate = () => { 19 | history.push("/create"); 20 | }; 21 | 22 | return ( 23 |
24 | 25 | 26 | 27 | {isAllConnected && ( 28 | 39 | )} 40 |
41 | ); 42 | } 43 | 44 | export default Connect; 45 | -------------------------------------------------------------------------------- /src/pages/Create.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | 4 | import ArrowBack from "@material-ui/icons/ArrowBack"; 5 | import { green } from "@material-ui/core/colors"; 6 | import { Alert, AlertTitle } from "@material-ui/lab"; 7 | import { 8 | Paper, 9 | Box, 10 | Link, 11 | Typography, 12 | Button, 13 | CircularProgress, 14 | makeStyles, 15 | } from "@material-ui/core"; 16 | 17 | import Main from "../layouts/Main"; 18 | import ProjectCombobox from "../components/ProjectCombobox"; 19 | 20 | import { createPresentationFromProject } from "../services/google"; 21 | 22 | const useStyles = makeStyles((theme) => ({ 23 | buttonSuccess: { 24 | backgroundColor: green[500], 25 | "&:hover": { 26 | backgroundColor: green[700], 27 | }, 28 | color: "white", 29 | }, 30 | })); 31 | 32 | export default function Create() { 33 | const classes = useStyles(); 34 | const history = useHistory(); 35 | const [selectedProject, setSelectedProject] = useState(); 36 | const [creating, setCreating] = useState(); 37 | const [error, setError] = useState(); 38 | const [createdPresentation, setCreatedPresentation] = useState(); 39 | 40 | const onBack = (e) => { 41 | e.preventDefault(); 42 | 43 | history.replace("/"); 44 | }; 45 | 46 | const onCreate = async () => { 47 | setCreating(true); 48 | 49 | const response = await createPresentationFromProject(selectedProject); 50 | 51 | if (response.error) { 52 | setError(response.error); 53 | setCreating(false); 54 | return; 55 | } 56 | 57 | setCreatedPresentation({ 58 | url: response, 59 | project: selectedProject, 60 | }); 61 | setError(null); 62 | setCreating(false); 63 | }; 64 | 65 | return ( 66 |
67 | 68 | 69 | 70 | 71 | 72 | Create a presentation from a Zeplin project 73 | 74 | 75 | 76 | 77 | 78 | 79 | 93 | 94 | 95 | 96 | 97 | 98 | {!creating && createdPresentation && ( 99 | 100 | 101 | 102 | Your presentation is created successfully! 103 | 104 | 105 | {createdPresentation.project.name} is created 106 | under your Google Slides account. Click below to check it out! 107 | 108 | 109 | 110 | 122 | 123 | 124 | 125 | )} 126 | 127 | {error && ( 128 | 129 | Error occurred! 130 | {error.message} 131 | 132 | )} 133 | 134 | 135 | 140 | 141 | Change accounts 142 | 143 | 144 |
145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /src/pages/Home.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | 4 | import { Typography, Box, Button } from "@material-ui/core"; 5 | 6 | import Main from "../layouts/Main"; 7 | import HomeCard from "../components/HomeCard"; 8 | import Footer from "../components/Footer"; 9 | 10 | export default function Home() { 11 | const history = useHistory(); 12 | 13 | const onClick = () => { 14 | history.push("/"); 15 | }; 16 | 17 | return ( 18 |
19 | 20 | 27 | Zeplin Slides allows you to create Google Slide 28 | presentations from screens of your Zeplin projects in 3 simple steps. 29 | 30 | 31 | } 34 | /> 35 | 36 | } 39 | reverse 40 | /> 41 | 42 | } 45 | /> 46 | 47 | } 50 | reverse 51 | disableImageBorder 52 | /> 53 | 54 | 55 | 65 | 66 | 67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/providers/AuthProvider.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { onClientLoad } from "../services/google"; 3 | import { ZEPLIN_TOKEN_STORAGE_KEY } from "../constants"; 4 | 5 | const AuthContext = React.createContext(); 6 | 7 | const storedZeplinToken = localStorage.getItem(ZEPLIN_TOKEN_STORAGE_KEY); 8 | 9 | function AuthProvider({ children }) { 10 | const [isAuthenticatingGoogle, setGoogleAuthenticating] = useState(true); 11 | const [zeplinToken, setZeplinToken] = useState(storedZeplinToken); 12 | const [googleUser, setGoogleUser] = useState(false); 13 | 14 | useEffect(() => { 15 | onClientLoad(user => { 16 | setGoogleUser(user); 17 | 18 | setGoogleAuthenticating(false); 19 | }); 20 | }, []); 21 | 22 | const connectZeplin = (token) => { 23 | localStorage.setItem(ZEPLIN_TOKEN_STORAGE_KEY, token); 24 | 25 | setZeplinToken(token); 26 | }; 27 | 28 | const disconnectZeplin = () => { 29 | localStorage.removeItem(ZEPLIN_TOKEN_STORAGE_KEY); 30 | 31 | setZeplinToken(null); 32 | }; 33 | 34 | const isZeplinConnected = !!zeplinToken; 35 | const isGoogleConnected = !!googleUser; 36 | 37 | const isAllConnected = isZeplinConnected && !!isGoogleConnected; 38 | 39 | const zeplinContextValues = { 40 | zeplinToken, 41 | connectZeplin, 42 | disconnectZeplin, 43 | isZeplinConnected, 44 | }; 45 | 46 | const googleContextValues = { 47 | googleUser, 48 | setGoogleUser, 49 | isGoogleConnected, 50 | isAuthenticatingGoogle, 51 | }; 52 | 53 | return ( 54 | 57 | {children} 58 | 59 | ); 60 | } 61 | 62 | const useAuth = () => React.useContext(AuthContext); 63 | 64 | export { AuthProvider, useAuth }; 65 | -------------------------------------------------------------------------------- /src/providers/StoreProvider/StoreProvider.jsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useReducer } from "react"; 2 | 3 | import reducer from "./reducer"; 4 | import { getActions } from "./actions"; 5 | import { getSelectors } from "./selectors"; 6 | 7 | const initialState = { 8 | zeplinUser: null 9 | }; 10 | 11 | const StateContext = createContext(); 12 | 13 | const StoreProvider = ({ children }) => { 14 | const [state, dispatch] = useReducer(reducer, initialState); 15 | 16 | const selectors = getSelectors(state); 17 | const actions = getActions(dispatch); 18 | 19 | return ( 20 | 24 | ); 25 | }; 26 | 27 | const useStore = () => useContext(StateContext); 28 | 29 | export { StoreProvider, useStore }; 30 | -------------------------------------------------------------------------------- /src/providers/StoreProvider/actions.js: -------------------------------------------------------------------------------- 1 | import ACTION_TYPES from "./types"; 2 | 3 | import * as zeplinService from "../../services/zeplin"; 4 | 5 | const getActions = dispatch => ({ 6 | async getZeplinUser() { 7 | const zeplinUser = await zeplinService.fetchCurrentUser(); 8 | 9 | dispatch({ 10 | type: ACTION_TYPES.GET_ZEPLIN_USER, 11 | zeplinUser 12 | }); 13 | }, 14 | }); 15 | 16 | export { getActions }; 17 | -------------------------------------------------------------------------------- /src/providers/StoreProvider/index.js: -------------------------------------------------------------------------------- 1 | import { StoreProvider, useStore } from "./StoreProvider"; 2 | 3 | export { StoreProvider, useStore }; 4 | -------------------------------------------------------------------------------- /src/providers/StoreProvider/reducer.js: -------------------------------------------------------------------------------- 1 | import ACTION_TYPES from "./types"; 2 | 3 | const reducers = { 4 | loadZeplinUser(state, { zeplinUser }) { 5 | return { 6 | ...state, 7 | zeplinUser 8 | }; 9 | }, 10 | }; 11 | 12 | const reducer = (state, action) => { 13 | switch (action.type) { 14 | case ACTION_TYPES.GET_ZEPLIN_USER: 15 | return reducers.loadZeplinUser(state, action); 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | export default reducer; 22 | -------------------------------------------------------------------------------- /src/providers/StoreProvider/selectors.js: -------------------------------------------------------------------------------- 1 | const getSelectors = state => ({ 2 | zeplinUser() { 3 | return state.zeplinUser; 4 | }, 5 | }); 6 | 7 | export { getSelectors }; 8 | -------------------------------------------------------------------------------- /src/providers/StoreProvider/types.js: -------------------------------------------------------------------------------- 1 | export default { 2 | GET_ZEPLIN_USER: "getZeplinUser", 3 | }; 4 | -------------------------------------------------------------------------------- /src/services/google.js: -------------------------------------------------------------------------------- 1 | import * as moment from "moment"; 2 | 3 | import { APP_URL, GOOGLE_API_KEY, GOOGLE_CLIENT_ID } from "../constants"; 4 | import { fetchProjectScreensGroupedBySection } from "./zeplin"; 5 | import { getScreenImageProperties } from "../utils/image"; 6 | 7 | const DISCOVERY_DOCS = [ 8 | "https://slides.googleapis.com/$discovery/rest?version=v1", 9 | ]; 10 | const SCOPES = "https://www.googleapis.com/auth/presentations"; 11 | 12 | const gapi = window.gapi; 13 | 14 | export function onClientLoad(onUpdateUserProfile) { 15 | gapi.load("client:auth2", () => init(onUpdateUserProfile)); 16 | } 17 | 18 | function getCurrentUserProfile(user) { 19 | if (user.isSignedIn()) { 20 | return { 21 | email: user.getBasicProfile().getEmail(), 22 | }; 23 | } 24 | 25 | return null; 26 | } 27 | 28 | function init(onUpdateUserProfile) { 29 | gapi.client 30 | .init({ 31 | apiKey: GOOGLE_API_KEY, 32 | clientId: GOOGLE_CLIENT_ID, 33 | discoveryDocs: DISCOVERY_DOCS, 34 | scope: SCOPES, 35 | }) 36 | .then( 37 | () => { 38 | const currentGoogleUser = gapi.auth2.getAuthInstance().currentUser; 39 | const currentUser = currentGoogleUser.get(); 40 | const profile = getCurrentUserProfile(currentUser); 41 | onUpdateUserProfile(profile); 42 | 43 | currentGoogleUser.listen((currentUser) => { 44 | const profile = getCurrentUserProfile(currentUser); 45 | onUpdateUserProfile(profile); 46 | }); 47 | }, 48 | (error) => { 49 | console.log(JSON.stringify(error, null, 2)); 50 | } 51 | ); 52 | } 53 | 54 | export function onSignInClick() { 55 | gapi.auth2.getAuthInstance().signIn(); 56 | } 57 | 58 | export function onSignOutClick() { 59 | gapi.auth2.getAuthInstance().signOut(); 60 | } 61 | 62 | function getPresentationUrl(presentationId) { 63 | return `https://docs.google.com/presentation/d/${presentationId}`; 64 | } 65 | 66 | function generateSlideRequests({ slideId, title, subtitle, layouts }) { 67 | const titleLayout = layouts.find( 68 | (layout) => layout.layoutProperties.name === "TITLE" 69 | ); 70 | 71 | if (!titleLayout) { 72 | return []; // TODO: return text box shape if layout does not exist 73 | } 74 | 75 | const pageTitleId = `page_title_id_${slideId}`; 76 | const pageSubtitleId = `page_subtitle_id_${slideId}`; 77 | const pageSlideNumberId = `page_slide_number_id_${slideId}`; 78 | 79 | return [ 80 | { 81 | createSlide: { 82 | objectId: slideId, 83 | slideLayoutReference: { 84 | predefinedLayout: "TITLE", 85 | }, 86 | placeholderIdMappings: [ 87 | { 88 | layoutPlaceholder: { 89 | type: "CENTERED_TITLE", 90 | }, 91 | objectId: pageTitleId, 92 | }, 93 | { 94 | layoutPlaceholder: { 95 | type: "SUBTITLE", 96 | }, 97 | objectId: pageSubtitleId, 98 | }, 99 | { 100 | layoutPlaceholder: { 101 | type: "SLIDE_NUMBER", 102 | }, 103 | objectId: pageSlideNumberId, 104 | }, 105 | ], 106 | }, 107 | }, 108 | { 109 | insertText: { 110 | objectId: pageTitleId, 111 | text: title, 112 | }, 113 | }, 114 | { 115 | insertText: { 116 | objectId: pageSubtitleId, 117 | text: subtitle, 118 | }, 119 | }, 120 | ]; 121 | } 122 | 123 | function generateScreenRequests({ screen, pageSize }) { 124 | const { 125 | id: screenId, 126 | name: screenName, 127 | image: { 128 | original_url: screenUrl, 129 | width: screenWidth, 130 | height: screenHeight, 131 | }, 132 | } = screen; 133 | 134 | const { 135 | width: { magnitude: pageWidth, unit: pageDimensionsUnit }, 136 | height: { magnitude: pageHeight }, 137 | } = pageSize; 138 | 139 | const pageId = `page_id_${screenId}`; 140 | const titleId = `${pageId}_title`; 141 | const imageId = `${pageId}_image`; 142 | return [ 143 | // Add a slide for screen 144 | { 145 | createSlide: { 146 | objectId: pageId, 147 | }, 148 | }, 149 | // Add screen image 150 | { 151 | createImage: { 152 | objectId: imageId, 153 | url: screenUrl, 154 | elementProperties: getScreenImageProperties({ 155 | pageId, 156 | width: screenWidth, 157 | height: screenHeight, 158 | pageSize, 159 | }), 160 | }, 161 | }, 162 | // Add screen name 163 | { 164 | createShape: { 165 | objectId: titleId, 166 | shapeType: "TEXT_BOX", 167 | elementProperties: { 168 | pageObjectId: pageId, 169 | size: { 170 | height: { 171 | magnitude: pageHeight / 12, 172 | unit: pageDimensionsUnit, 173 | }, 174 | width: { 175 | magnitude: pageWidth, 176 | unit: pageDimensionsUnit, 177 | }, 178 | }, 179 | }, 180 | }, 181 | }, 182 | { 183 | insertText: { 184 | objectId: titleId, 185 | text: screenName, 186 | }, 187 | }, 188 | ]; 189 | } 190 | 191 | function generateSectionRequests({ section, index, pageSize, layouts }) { 192 | const { 193 | id: sectionId, 194 | name: sectionName, 195 | description: sectionDescription, 196 | screens: sectionScreens, 197 | } = section; 198 | 199 | let sectionRequests = []; 200 | 201 | // If screens do not have a section, do not add title page slide for them 202 | if (section.id !== "default") { 203 | sectionRequests = generateSlideRequests({ 204 | slideId: sectionId, 205 | title: sectionName, 206 | subtitle: sectionDescription, 207 | layouts, 208 | }); 209 | } 210 | 211 | const screenRequests = sectionScreens.map((screen) => 212 | generateScreenRequests({ screen, pageSize }) 213 | ); 214 | 215 | return sectionRequests.concat(screenRequests); 216 | } 217 | 218 | function generateLastSlideRequests({ 219 | slideId, 220 | title, 221 | subtitle, 222 | layouts, 223 | pageSize, 224 | }) { 225 | const requests = generateSlideRequests({ slideId, title, subtitle, layouts }); 226 | 227 | const { 228 | width: { magnitude: pageWidth, unit: pageDimensionsUnit }, 229 | height: { magnitude: pageHeight }, 230 | } = pageSize; 231 | 232 | const textId = `${slideId}_text`; 233 | return requests.concat([ 234 | // Add screen name 235 | { 236 | createShape: { 237 | objectId: textId, 238 | shapeType: "TEXT_BOX", 239 | elementProperties: { 240 | pageObjectId: slideId, 241 | size: { 242 | height: { 243 | magnitude: pageHeight / 12, 244 | unit: pageDimensionsUnit, 245 | }, 246 | width: { 247 | magnitude: pageWidth, 248 | unit: pageDimensionsUnit, 249 | }, 250 | }, 251 | transform: { 252 | scaleX: 1, 253 | scaleY: 1, 254 | translateY: (pageHeight / 12) * 11, 255 | unit: pageDimensionsUnit, 256 | }, 257 | }, 258 | }, 259 | }, 260 | { 261 | insertText: { 262 | objectId: textId, 263 | text: "Crafted with Zeplin Slides", 264 | }, 265 | }, 266 | { 267 | updateParagraphStyle: { 268 | objectId: textId, 269 | textRange: { type: "ALL" }, 270 | style: { alignment: "CENTER" }, 271 | fields: "alignment", 272 | }, 273 | }, 274 | { 275 | updateTextStyle: { 276 | objectId: textId, 277 | textRange: { 278 | type: "FIXED_RANGE", 279 | startIndex: 13, 280 | endIndex: 27, 281 | }, 282 | style: { 283 | link: { 284 | url: APP_URL, 285 | }, 286 | }, 287 | fields: "link", 288 | }, 289 | }, 290 | ]); 291 | } 292 | 293 | export async function createPresentationFromProject(project) { 294 | const { 295 | id: projectId, 296 | name: projectName, 297 | updated: projectUpdateTimestamp, 298 | } = project; 299 | 300 | try { 301 | // Create presentation 302 | const createResponse = await gapi.client.slides.presentations.create({ 303 | title: projectName, 304 | }); 305 | 306 | console.log("Presentation created: ", createResponse.result); 307 | 308 | const { 309 | result: { 310 | presentationId, 311 | pageSize, 312 | layouts, 313 | slides: [{ objectId: firstSlideId } = {}], 314 | }, 315 | } = createResponse; 316 | 317 | // Delete first slide 318 | const deleteRequest = { 319 | deleteObject: { 320 | objectId: firstSlideId, 321 | }, 322 | }; 323 | 324 | // Generate first slide for project 325 | const projectSlideRequests = generateSlideRequests({ 326 | slideId: projectId, 327 | title: projectName, 328 | subtitle: moment(projectUpdateTimestamp * 1000).format("ll"), 329 | layouts, 330 | }); 331 | 332 | // Generate section title pages & section screens slides 333 | const projectSections = await fetchProjectScreensGroupedBySection( 334 | projectId 335 | ); 336 | const sectionsSlideRequests = projectSections.flatMap((section, index) => 337 | generateSectionRequests({ section, index, pageSize, layouts }) 338 | ); 339 | 340 | // Generate last slide 341 | const lastSlideRequests = generateLastSlideRequests({ 342 | slideId: "last_slide", 343 | title: "~Fin~", 344 | layouts, 345 | pageSize, 346 | }); 347 | 348 | const requests = [ 349 | firstSlideId ? deleteRequest : null, 350 | projectSlideRequests, 351 | sectionsSlideRequests, 352 | lastSlideRequests, 353 | ].flat(); 354 | 355 | const updateResponse = await gapi.client.slides.presentations.batchUpdate({ 356 | presentationId, 357 | requests, 358 | }); 359 | 360 | console.log("Updated presentation:", updateResponse.result); 361 | 362 | return getPresentationUrl(presentationId); 363 | } catch (errorResponse) { 364 | return errorResponse.result; 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /src/services/zeplin.js: -------------------------------------------------------------------------------- 1 | import http, { handleResponse } from "../utils/http"; 2 | import { APP_URL, ZEPLIN_CLIENT_ID, ZEPLIN_CLIENT_SECRET } from "../constants"; 3 | 4 | const ZEPLIN_API_URL = "https://api.zeplin.dev/v1"; 5 | 6 | export function authorize() { 7 | window.location = `${ZEPLIN_API_URL}/oauth/authorize?client_id=${ZEPLIN_CLIENT_ID}&redirect_uri=${APP_URL}&response_type=code`; 8 | } 9 | 10 | export function fetchAccessToken(code) { 11 | const params = { 12 | grant_type: "authorization_code", 13 | client_id: ZEPLIN_CLIENT_ID, 14 | client_secret: ZEPLIN_CLIENT_SECRET, 15 | redirect_uri: APP_URL, 16 | code, 17 | }; 18 | 19 | return http 20 | .post(`${ZEPLIN_API_URL}/oauth/token`, params) 21 | .then(handleResponse); 22 | } 23 | 24 | export function fetchCurrentUser() { 25 | return http.get(`${ZEPLIN_API_URL}/users/me`).then(handleResponse); 26 | } 27 | 28 | export async function fetchProjects() { 29 | return http.get(`${ZEPLIN_API_URL}/projects?limit=100`).then(handleResponse); 30 | } 31 | 32 | function fetchProjectScreens(pid) { 33 | return http 34 | .get(`${ZEPLIN_API_URL}/projects/${pid}/screens?sort=section`) 35 | .then(handleResponse); 36 | } 37 | 38 | function fetchProjectScreenSections(pid) { 39 | return http 40 | .get(`${ZEPLIN_API_URL}/projects/${pid}/screen_sections`) 41 | .then(handleResponse); 42 | } 43 | 44 | const DEFAULT_SECTION = { 45 | id: "default", 46 | }; 47 | 48 | export async function fetchProjectScreensGroupedBySection(pid) { 49 | return Promise.all([ 50 | fetchProjectScreens(pid), 51 | fetchProjectScreenSections(pid), 52 | ]) 53 | .then(([screens, sections]) => 54 | [DEFAULT_SECTION].concat(sections).map((section) => ({ 55 | ...section, 56 | screens: screens.filter((screen) => { 57 | if (screen.section) { 58 | return screen.section.id === section.id; 59 | } 60 | 61 | return section.id === "default"; 62 | }), 63 | })) 64 | ) 65 | .catch((e) => { 66 | console.log(e); 67 | return []; 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/utils/http.js: -------------------------------------------------------------------------------- 1 | import { ZEPLIN_TOKEN_STORAGE_KEY } from "../constants"; 2 | 3 | function getHeaders() { 4 | const headers = new Headers(); 5 | headers.set("Content-Type", "application/json"); 6 | 7 | const zeplinToken = localStorage.getItem("zeplinToken"); 8 | if (zeplinToken) { 9 | headers.set("Authorization", `Bearer ${zeplinToken}`); 10 | } 11 | 12 | return headers; 13 | } 14 | 15 | export function handleResponse(response) { 16 | return response.json().then((resJson) => { 17 | if (response.ok) { 18 | return resJson; 19 | } 20 | 21 | if ( 22 | resJson.message === "token_expired" || 23 | resJson.message === "invalid_token" 24 | ) { 25 | localStorage.removeItem(ZEPLIN_TOKEN_STORAGE_KEY); 26 | 27 | window.location = "/"; 28 | } 29 | 30 | // TODO: show modal for errors 31 | throw new Error(resJson.message); 32 | }); 33 | } 34 | 35 | const http = { 36 | /** 37 | * Sends a get request with Zeplin headers. 38 | * @param {String} url 39 | * @param {Object} options additional fetch options 40 | */ 41 | get(url, options = {}) { 42 | return fetch(url, { headers: getHeaders(), ...options }); 43 | }, 44 | 45 | /** 46 | * Sends a post request with Zeplin headers. 47 | * @param {String} url 48 | * @param {Object} body 49 | */ 50 | post(url, body) { 51 | const args = { 52 | headers: getHeaders(), 53 | method: "POST", 54 | }; 55 | 56 | if (body) { 57 | Object.assign(args, { body: JSON.stringify(body) }); 58 | } 59 | 60 | return fetch(url, args); 61 | }, 62 | 63 | /** 64 | * Sends a put request with Zeplin headers 65 | * @param {String} url 66 | * @param {Object} body 67 | */ 68 | put(url, body) { 69 | const args = { 70 | headers: getHeaders(), 71 | method: "PUT", 72 | }; 73 | 74 | if (body) { 75 | Object.assign(args, { body: JSON.stringify(body) }); 76 | } 77 | 78 | return fetch(url, args); 79 | }, 80 | 81 | /** 82 | * Sends a delete request with Zeplin headers 83 | * @param {String} url 84 | * @param {Object} body 85 | */ 86 | delete(url, body) { 87 | const args = { 88 | headers: getHeaders(), 89 | method: "DELETE", 90 | }; 91 | 92 | if (body) { 93 | Object.assign(args, { body: JSON.stringify(body) }); 94 | } 95 | 96 | return fetch(url, args); 97 | }, 98 | }; 99 | 100 | export default http; 101 | -------------------------------------------------------------------------------- /src/utils/image.js: -------------------------------------------------------------------------------- 1 | function getAspectRatio(width, height) { 2 | return width / height; 3 | } 4 | 5 | function getDimensionsGivenWidth(width, ratio) { 6 | return { 7 | width, 8 | height: Math.round(width / ratio), 9 | }; 10 | } 11 | 12 | function getDimensionsGivenHeight(height, ratio) { 13 | return { 14 | width: Math.round(height * ratio), 15 | height, 16 | }; 17 | } 18 | 19 | export function getFittedDimensions({ width, height, pageWidth, pageHeight }) { 20 | const aspectRatio = getAspectRatio(width, height); 21 | const pageAspectRatio = getAspectRatio(pageWidth, pageHeight); 22 | 23 | if (pageAspectRatio > aspectRatio) { 24 | return getDimensionsGivenHeight(pageHeight, aspectRatio); 25 | } 26 | 27 | return getDimensionsGivenWidth(pageWidth, aspectRatio); 28 | } 29 | 30 | function getTranslateValueToCenter(value, pageValue) { 31 | return Math.round((pageValue - value) / 2); 32 | } 33 | 34 | export function getTranslateValuesForFittedDimensions({ 35 | width, 36 | height, 37 | pageWidth, 38 | pageHeight, 39 | }) { 40 | const aspectRatio = getAspectRatio(width, height); 41 | const pageAspectRatio = getAspectRatio(pageWidth, pageHeight); 42 | 43 | if (pageAspectRatio > aspectRatio) { 44 | return { 45 | translateX: getTranslateValueToCenter(width, pageWidth), 46 | }; 47 | } 48 | 49 | return { 50 | translateY: getTranslateValueToCenter(height, pageHeight), 51 | }; 52 | } 53 | 54 | export function getScreenImageProperties({ pageId, width, height, pageSize }) { 55 | const { 56 | width: { magnitude: pageWidth, unit }, 57 | height: { magnitude: pageHeight }, 58 | } = pageSize; 59 | 60 | const fittedDimensions = getFittedDimensions({ 61 | width, 62 | height, 63 | pageWidth, 64 | pageHeight, 65 | }); 66 | const translateValues = getTranslateValuesForFittedDimensions({ 67 | ...fittedDimensions, 68 | pageWidth, 69 | pageHeight, 70 | }); 71 | 72 | return { 73 | pageObjectId: pageId, 74 | size: { 75 | height: { 76 | magnitude: fittedDimensions.height, 77 | unit, 78 | }, 79 | width: { 80 | magnitude: fittedDimensions.width, 81 | unit, 82 | }, 83 | }, 84 | transform: { 85 | scaleX: 1, 86 | scaleY: 1, 87 | ...translateValues, 88 | unit, 89 | }, 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/image.test.js: -------------------------------------------------------------------------------- 1 | import { getFittedDimensions, getTranslateValuesForFittedDimensions } from "./image"; 2 | 3 | describe("Image: getFittedDimensions", () => { 4 | it("returns dimensions that fit to page given small image", () => { 5 | const width = 300; 6 | const height = 100; 7 | const pageWidth = 600; 8 | const pageHeight = 400; 9 | 10 | const expected = { 11 | width: 600, 12 | height: 200, 13 | }; 14 | 15 | const actual = getFittedDimensions({ 16 | width, 17 | height, 18 | pageWidth, 19 | pageHeight, 20 | }); 21 | 22 | expect(actual).toEqual(expected); 23 | }); 24 | 25 | it("returns dimensions that fit to page given overflowing image", () => { 26 | const width = 900; 27 | const height = 720; 28 | const pageWidth = 600; 29 | const pageHeight = 400; 30 | 31 | const expected = { 32 | width: 500, 33 | height: 400, 34 | }; 35 | 36 | const actual = getFittedDimensions({ 37 | width, 38 | height, 39 | pageWidth, 40 | pageHeight, 41 | }); 42 | 43 | expect(actual).toEqual(expected); 44 | }); 45 | 46 | it("returns dimensions that fit to page given image with <1 aspect ratio", () => { 47 | const width = 400; 48 | const height = 500; 49 | const pageWidth = 600; 50 | const pageHeight = 400; 51 | 52 | const expected = { 53 | width: 320, 54 | height: 400, 55 | }; 56 | 57 | const actual = getFittedDimensions({ 58 | width, 59 | height, 60 | pageWidth, 61 | pageHeight, 62 | }); 63 | 64 | expect(actual).toEqual(expected); 65 | }); 66 | 67 | it("returns dimensions that fit to page given overflowing image with >1 aspect ratio", () => { 68 | const width = 500; 69 | const height = 900; 70 | const pageWidth = 600; 71 | const pageHeight = 400; 72 | 73 | const expected = { 74 | width: 222, 75 | height: 400, 76 | }; 77 | 78 | const actual = getFittedDimensions({ 79 | width, 80 | height, 81 | pageWidth, 82 | pageHeight, 83 | }); 84 | 85 | expect(actual).toEqual(expected); 86 | }); 87 | }); 88 | 89 | describe("Image: getTranslateValuesForFittedDimensions", () => { 90 | it("returns translate values given small image", () => { 91 | const width = 300; 92 | const height = 100; 93 | const pageWidth = 600; 94 | const pageHeight = 400; 95 | 96 | const fittedDimensions = getFittedDimensions({ 97 | width, 98 | height, 99 | pageWidth, 100 | pageHeight, 101 | }); 102 | 103 | const expected = { 104 | translateY: 100, 105 | }; 106 | 107 | const actual = getTranslateValuesForFittedDimensions({ 108 | ...fittedDimensions, 109 | pageWidth, 110 | pageHeight, 111 | }); 112 | 113 | expect(actual).toEqual(expected); 114 | }); 115 | }); 116 | 117 | describe("Image: getTranslateValuesForFittedDimensions", () => { 118 | it("returns translate values given big image with <1 aspect ratio", () => { 119 | const width = 400; 120 | const height = 1600; 121 | const pageWidth = 600; 122 | const pageHeight = 400; 123 | 124 | const fittedDimensions = getFittedDimensions({ 125 | width, 126 | height, 127 | pageWidth, 128 | pageHeight, 129 | }); 130 | 131 | const expected = { 132 | translateX: 250, 133 | }; 134 | 135 | const actual = getTranslateValuesForFittedDimensions({ 136 | ...fittedDimensions, 137 | pageWidth, 138 | pageHeight, 139 | }); 140 | 141 | expect(actual).toEqual(expected); 142 | }); 143 | }); 144 | --------------------------------------------------------------------------------