├── .babelrc ├── .gitignore ├── README.md ├── docs ├── assets │ ├── 0561a1b4570e0b0f698a.svg │ ├── 338423adce5122fd2099.jpg │ ├── 7ce11e5e1362b744f1f9.gif │ ├── 808c73b93fdda891df55.svg │ ├── a702a17ee4b6bd539917.svg │ ├── a8894c2cefa638205ab7.svg │ ├── c0ff8094396e7af26be7.svg │ ├── c224fe7fa3e2ba2e9d02.svg │ ├── d8e8fa85b03962d9136d.gif │ ├── e64416594c1bbc37b794.svg │ ├── ffe0d370df3c41b0deff.svg │ └── icon.png ├── favicon.ico ├── index.40b76d5722404f6b1821.bundle.js ├── index.40b76d5722404f6b1821.bundle.js.LICENSE.txt └── index.html ├── package-lock.json ├── package.json ├── screenshots ├── 1.png ├── 2.png └── 3.png ├── src ├── App.jsx ├── App.scss ├── assets │ ├── arrow-left.svg │ ├── arrow-right.svg │ ├── burger.svg │ ├── error.jpg │ ├── expand.svg │ ├── favicon.ico │ ├── icon.png │ ├── loading.gif │ ├── shrink.svg │ ├── splash.gif │ ├── x.svg │ ├── zoom-in.svg │ └── zoom-out.svg ├── components │ ├── Burger │ │ ├── Burger.jsx │ │ └── Burger.scss │ ├── QuickNav │ │ ├── QuickNav.jsx │ │ └── QuickNav.scss │ ├── Sidebar │ │ ├── Sidebar.jsx │ │ └── Sidebar.scss │ ├── SidebarFile │ │ ├── SidebarFile.jsx │ │ └── SidebarFile.scss │ ├── SidebarFilelist │ │ ├── SidebarFilelist.jsx │ │ └── SidebarFilelist.scss │ ├── SidebarUploader │ │ ├── SidebarUploader.jsx │ │ └── SidebarUploader.scss │ ├── Toolbar │ │ ├── Toolbar.jsx │ │ └── Toolbar.scss │ └── Viewer │ │ ├── Viewer.jsx │ │ └── Viewer.scss ├── index.html ├── index.js ├── index.scss └── variables.scss ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "useBuiltIns": "usage", 8 | "corejs": 3.6 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Splash screen gif](./src/assets/splash.gif) 2 | 3 | # **Ame** 4 | 5 | A browser-based .cbz reader 6 | → https://lijandrew.github.io/ame-reader/ 7 | 8 | ## **Table of Contents** 9 | 10 | [How to use](#how-to-use) 11 | [Latest changes](#latest-changes) 12 | [Plans](#plans) 13 | [Screenshots](#screenshots) 14 | 15 | ## **How to use** 16 | 17 | - Use the **Upload** button or drag 'n drop files into the web app. 18 | - Supported file extensions: _.cbz_, _.cbr_, _.zip_, _.rar_, _.7z_, _.7zip_, _.jpg_, _.jpeg_, _.png_, _.gif_ 19 | - Uploaded files will appear in the left sidebar. Click one to display its contents in the viewer 20 | - The sidebar has controls for zoom, margin, and previous/next file navigation 21 | - There are also "quick navigation" buttons at the top and bottom of the viewer for convenience 22 | - Click the hamburger menu to toggle the sidebar 23 | 24 | ## **Latest changes** 25 | 26 | - Loading and error splash images 27 | - Fixed file list scrolling 28 | - Greatly improved usability for narrow devices 29 | - Buttons now gray out when they are supposed to 30 | - Drag 'n drop file upload improvements 31 | - Production build 32 | 33 | ## **Plans** 34 | 35 | - PWA app ("installable" on Edge & Chrome) 36 | - File loading bar 37 | - ElectronJS desktop app 38 | 39 | ## **Screenshots** 40 | 41 | ![Demo screenshot 1](./screenshots/1.png) 42 | 43 | ![Demo screenshot 2](./screenshots/2.png) 44 | 45 | ![Demo screenshot 3](./screenshots/3.png) 46 | -------------------------------------------------------------------------------- /docs/assets/0561a1b4570e0b0f698a.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/assets/338423adce5122fd2099.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/docs/assets/338423adce5122fd2099.jpg -------------------------------------------------------------------------------- /docs/assets/7ce11e5e1362b744f1f9.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/docs/assets/7ce11e5e1362b744f1f9.gif -------------------------------------------------------------------------------- /docs/assets/808c73b93fdda891df55.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/a702a17ee4b6bd539917.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/assets/a8894c2cefa638205ab7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/c0ff8094396e7af26be7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/c224fe7fa3e2ba2e9d02.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/d8e8fa85b03962d9136d.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/docs/assets/d8e8fa85b03962d9136d.gif -------------------------------------------------------------------------------- /docs/assets/e64416594c1bbc37b794.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/ffe0d370df3c41b0deff.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/docs/assets/icon.png -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/docs/favicon.ico -------------------------------------------------------------------------------- /docs/index.40b76d5722404f6b1821.bundle.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | 9 | JSZip v3.7.0 - A JavaScript class for generating and reading zip files 10 | 11 | 12 | (c) 2009-2016 Stuart Knightley 13 | Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/master/LICENSE.markdown. 14 | 15 | JSZip uses the library pako released under the MIT license : 16 | https://github.com/nodeca/pako/blob/master/LICENSE 17 | */ 18 | 19 | /** @license React v0.20.2 20 | * scheduler.production.min.js 21 | * 22 | * Copyright (c) Facebook, Inc. and its affiliates. 23 | * 24 | * This source code is licensed under the MIT license found in the 25 | * LICENSE file in the root directory of this source tree. 26 | */ 27 | 28 | /** @license React v17.0.2 29 | * react-dom.production.min.js 30 | * 31 | * Copyright (c) Facebook, Inc. and its affiliates. 32 | * 33 | * This source code is licensed under the MIT license found in the 34 | * LICENSE file in the root directory of this source tree. 35 | */ 36 | 37 | /** @license React v17.0.2 38 | * react.production.min.js 39 | * 40 | * Copyright (c) Facebook, Inc. and its affiliates. 41 | * 42 | * This source code is licensed under the MIT license found in the 43 | * LICENSE file in the root directory of this source tree. 44 | */ 45 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Ame
-------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ame", 3 | "version": "1.0.0", 4 | "description": "Browser-based manga & comic reader", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack serve --open --config ./webpack.dev.js", 8 | "build-dev": "webpack --config ./webpack.dev.js", 9 | "build": "webpack --config ./webpack.prod.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/lijandrew/ame-reader.git" 14 | }, 15 | "author": "Andrew Li", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/lijandrew/ame-reader/issues" 19 | }, 20 | "homepage": "https://github.com/lijandrew/ame-reader#readme", 21 | "devDependencies": { 22 | "@babel/core": "^7.14.8", 23 | "@babel/preset-env": "^7.14.8", 24 | "@babel/preset-react": "^7.14.5", 25 | "babel-loader": "^8.2.2", 26 | "clean-webpack-plugin": "^4.0.0-alpha.0", 27 | "copy-webpack-plugin": "^9.0.1", 28 | "core-js": "^3.16.0", 29 | "css-loader": "^6.2.0", 30 | "html-webpack-plugin": "^5.3.2", 31 | "mini-css-extract-plugin": "^2.1.0", 32 | "node-sass": "^6.0.1", 33 | "sass-loader": "^12.1.0", 34 | "style-loader": "^3.2.1", 35 | "webpack": "^5.47.1", 36 | "webpack-cli": "^4.7.2", 37 | "webpack-dev-server": "^3.11.2", 38 | "webpack-merge": "^5.8.0", 39 | "workbox-webpack-plugin": "^6.2.4" 40 | }, 41 | "dependencies": { 42 | "jszip": "^3.7.0", 43 | "react": "^17.0.2", 44 | "react-dom": "^17.0.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/screenshots/1.png -------------------------------------------------------------------------------- /screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/screenshots/2.png -------------------------------------------------------------------------------- /screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/screenshots/3.png -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Sidebar from "./components/Sidebar/Sidebar.jsx"; 4 | import Viewer from "./components/Viewer/Viewer.jsx"; 5 | 6 | import "./App.scss"; 7 | 8 | export default class App extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | files: [], 13 | viewerFile: null, 14 | zoom: 80, 15 | margin: 5, 16 | }; 17 | 18 | this.zoomConstant = 10; 19 | this.marginConstant = 10; 20 | this.maxZoom = 100; 21 | this.minZoom = 10; 22 | this.maxMargin = 100; 23 | this.minMargin = 0; 24 | 25 | // For Sidebar toggling to work even though Burger menu is child of Viewer for CSS reasons 26 | this.sidebarRef = React.createRef(); 27 | } 28 | 29 | /** 30 | * Adds files to this.state.files 31 | * TODO: Deal with duplicates 32 | * @param {File[]} files Array of Files to be added to state variable 33 | * @param {Function} callback Callback function (used by SidebarUploader to call revealUploadedFile) 34 | */ 35 | addFiles = (files, callback) => { 36 | this.setState( 37 | (state) => ({ 38 | files: [...state.files, ...files], 39 | }), 40 | () => { 41 | if (callback) callback(); 42 | } 43 | ); 44 | }; 45 | 46 | /** 47 | * Deletes passed File from the file list 48 | * @param {File} file File to be deleted from file list 49 | */ 50 | deleteFile = (file) => { 51 | this.setState((state) => { 52 | let filesCopy = [...state.files]; 53 | filesCopy.splice(filesCopy.indexOf(file), 1); 54 | return { 55 | files: filesCopy, 56 | }; 57 | }); 58 | }; 59 | 60 | /** 61 | * Sets which file Viewer should display 62 | * Passed all the way down to SidebarFile to use in its onclick 63 | * @param {File} file The file to display in Viewer 64 | */ 65 | setViewerFile = (file) => { 66 | this.setState({ 67 | viewerFile: file, 68 | }); 69 | }; 70 | 71 | /** 72 | * Sets viewerFile to the next file in the array 73 | */ 74 | nextViewerFile = () => { 75 | let currentIndex = this.state.files.indexOf(this.state.viewerFile); 76 | if (currentIndex === -1) { 77 | return; 78 | } 79 | if (currentIndex < this.state.files.length - 1) { 80 | this.setViewerFile(this.state.files[currentIndex + 1]); 81 | } 82 | // If out of bounds, do nothing 83 | }; 84 | 85 | /** 86 | * Sets viewerFile to the previous file in the array 87 | */ 88 | prevViewerFile = () => { 89 | let currentIndex = this.state.files.indexOf(this.state.viewerFile); 90 | if (currentIndex === -1) { 91 | return; 92 | } 93 | if (currentIndex > 0) { 94 | this.setViewerFile(this.state.files[currentIndex - 1]); 95 | } 96 | // If out of bounds, do nothing 97 | }; 98 | 99 | setZoom = (newZoom) => { 100 | this.setState({ 101 | zoom: newZoom, 102 | }); 103 | }; 104 | 105 | setMargin = (newMargin) => { 106 | this.setState({ 107 | margin: newMargin, 108 | }); 109 | }; 110 | 111 | increaseZoom = () => { 112 | this.setZoom(Math.min(this.maxZoom, this.state.zoom + this.zoomConstant)); 113 | }; 114 | 115 | decreaseZoom = () => { 116 | console.log("decreaseZoom"); 117 | this.setZoom(Math.max(this.minZoom, this.state.zoom - this.zoomConstant)); 118 | }; 119 | 120 | increaseMargin = () => { 121 | this.setMargin( 122 | Math.min(this.maxMargin, this.state.margin + this.marginConstant) 123 | ); 124 | }; 125 | 126 | decreaseMargin = () => { 127 | console.log("decreaseMargin"); 128 | this.setMargin( 129 | Math.max(this.minMargin, this.state.margin - this.marginConstant) 130 | ); 131 | }; 132 | 133 | toggleSidebar = () => { 134 | this.sidebarRef.current.toggleSidebar(); 135 | }; 136 | 137 | render() { 138 | return ( 139 |
140 | 161 | 170 |
171 | ); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | // Application styling 2 | // For non app related styles (i.e. CSS resets, global background color), see index.scss 3 | 4 | .App { 5 | display: flex; 6 | } 7 | 8 | // To be applied to the wrapper
around the svg , not to the itself 9 | .svg-button { 10 | cursor: pointer; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | padding: 15px 10px; 15 | user-select: none; 16 | } 17 | 18 | .svg-button img { 19 | user-select: none; 20 | } 21 | 22 | .svg-button.disabled { 23 | filter: grayscale(100%) brightness(50%); 24 | pointer-events: none; 25 | } 26 | 27 | @media (hover: hover) and (pointer: fine) { 28 | .svg-button:hover img { 29 | filter: brightness(150%); 30 | } 31 | } 32 | 33 | @media only screen and (max-width: 350px) { 34 | .svg-button { 35 | padding: 15px 7px; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/assets/arrow-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/arrow-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/burger.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/src/assets/error.jpg -------------------------------------------------------------------------------- /src/assets/expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/src/assets/icon.png -------------------------------------------------------------------------------- /src/assets/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/src/assets/loading.gif -------------------------------------------------------------------------------- /src/assets/shrink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/splash.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lijandrew/ame-reader/1303068369df31e4de5e461aa77604acfd4182f6/src/assets/splash.gif -------------------------------------------------------------------------------- /src/assets/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/zoom-in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/zoom-out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Burger/Burger.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "./Burger.scss"; 4 | 5 | export default class Burger extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | render() { 11 | return ( 12 |
13 | Hamburger menu icon 20 |
21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Burger/Burger.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .Burger { 4 | position: absolute; 5 | left: 10px; 6 | top: 10px; 7 | padding: 20px; 8 | border: none; 9 | background-color: $dark; 10 | border-radius: 0; 11 | cursor: pointer; 12 | user-select: none; 13 | img { 14 | transition: transform 200ms ease-out; 15 | } 16 | } 17 | 18 | @media (hover: hover) and (pointer: fine) { 19 | .Burger:hover { 20 | background-color: $primary; 21 | img { 22 | filter: brightness(0); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/QuickNav/QuickNav.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "./QuickNav.scss"; 4 | 5 | export default class QuickNav extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | canPrevViewerFile = () => { 11 | return ( 12 | this.props.viewerFile && 13 | this.props.files.indexOf(this.props.viewerFile) !== 0 14 | ); 15 | }; 16 | 17 | canNextViewerFile = () => { 18 | return ( 19 | this.props.viewerFile && 20 | this.props.files.indexOf(this.props.viewerFile) !== 21 | this.props.files.length - 1 22 | ); 23 | }; 24 | 25 | render() { 26 | return ( 27 |
28 |
32 | Left arrow 38 |
39 | 40 |
{this.props.filename.split(".")[0]}
41 | 42 |
46 | Right arrow 52 |
53 |
54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/QuickNav/QuickNav.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .QuickNav { 4 | display: flex; 5 | flex-direction: row; 6 | width: 100%; 7 | justify-content: center; 8 | align-items: center; 9 | font-family: $font-family; 10 | color: $primary; 11 | background-color: $darker; 12 | 13 | // Ensures Burger menu doesn't cover QuickNav's previous file button 14 | // when the open file has a very long name 15 | margin: 80px 0; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SidebarFilelist from "../SidebarFilelist/SidebarFilelist"; 3 | import SidebarUploader from "../SidebarUploader/SidebarUploader"; 4 | import Toolbar from "../Toolbar/Toolbar.jsx"; 5 | import Burger from "../Burger/Burger.jsx"; 6 | 7 | import "./Sidebar.scss"; 8 | 9 | export default class Sidebar extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | isVisible: true, 14 | }; 15 | this.sidebarFilelistRef = React.createRef(); 16 | this.toolbarRef = React.createRef(); 17 | this.sidebarUploaderRef = React.createRef(); 18 | } 19 | 20 | /** 21 | * Opens Sidebar and scrolls Filelist down to bottom to show newly-uploaded files. 22 | */ 23 | revealUploadedFiles = () => { 24 | this.setState( 25 | { 26 | isVisible: true, 27 | }, 28 | () => { 29 | this.sidebarFilelistRef.current.scrollToBottom(); 30 | } 31 | ); 32 | }; 33 | 34 | toggleSidebar = () => { 35 | this.setState((state) => ({ 36 | isVisible: !state.isVisible, 37 | })); 38 | }; 39 | 40 | render() { 41 | return ( 42 |
43 |
44 | 45 |
46 |
49 | 54 | 71 | 78 |
79 |
80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | // TODO: limit overflow-y scrollbar to Filelist only (Upload button should not scroll) 4 | 5 | .Sidebar { 6 | z-index: 2; 7 | position: relative; 8 | display: flex; 9 | flex-direction: row-reverse; 10 | height: 100%; 11 | max-height: 100%; 12 | 13 | // -84px to ensure there is always room for the Burger 14 | // Required b/c Burger is position: absolute 15 | max-width: calc(100% - 85px); 16 | } 17 | 18 | .Sidebar-Burger-wrapper { 19 | position: relative; 20 | } 21 | 22 | .Sidebar-content { 23 | background-color: $dark; 24 | 25 | // Make bottom vertical child scrollable 26 | display: flex; 27 | flex-direction: column; 28 | 29 | overflow-x: hidden; 30 | } 31 | 32 | .Sidebar-content.hidden { 33 | width: 0; 34 | } 35 | 36 | @media only screen and (max-width: 700px) { 37 | .Sidebar { 38 | position: fixed; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/SidebarFile/SidebarFile.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "./SidebarFile.scss"; 4 | 5 | export default class SidebarFile extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | active: false, 10 | }; 11 | this.elemRef = React.createRef(); 12 | } 13 | 14 | componentDidUpdate(prevProps) { 15 | if (this.props.viewerFile !== prevProps.viewerFile) { 16 | if (this.props.viewerFile === this.props.file) { 17 | this.setState({ 18 | active: true, 19 | }); 20 | } else { 21 | this.setState({ 22 | active: false, 23 | }); 24 | } 25 | } 26 | } 27 | 28 | handleClick = (e) => { 29 | this.props.setViewerFile(this.props.file); 30 | }; 31 | 32 | handleAuxClick = (e) => { 33 | if (e.button === 1) { 34 | this.handleDelete(e); 35 | } 36 | }; 37 | 38 | handleDelete = (e) => { 39 | e.stopPropagation(); 40 | this.props.setViewerFile(); // Reset Viewer by calling without giving a File 41 | this.props.deleteFile(this.props.file); 42 | }; 43 | 44 | render() { 45 | return ( 46 |
51 | {this.props.file.name} 52 |
53 | 54 |
55 |
56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/SidebarFile/SidebarFile.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .SidebarFile { 4 | padding: 10px; 5 | background-color: $dark; 6 | color: $primary; 7 | font-family: $font-family; 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: space-between; 11 | user-select: none; 12 | cursor: pointer; 13 | transition: background-color 200ms ease-out; 14 | &:not(:nth-child(1)) { 15 | border-top: 1px solid $primary; 16 | } 17 | white-space: pre-line; 18 | // white-space: pre-wrap; 19 | } 20 | 21 | .delete { 22 | display: flex; 23 | cursor: pointer; 24 | margin-left: 20px; 25 | img { 26 | transition: transform 200ms ease-out; 27 | width: 15px; 28 | } 29 | } 30 | 31 | .SidebarFile.active { 32 | background-color: $primary; 33 | color: $darker; 34 | .delete img { 35 | filter: grayscale(100%) brightness(0); 36 | } 37 | } 38 | 39 | @media (hover: hover) and (pointer: fine) { 40 | .SidebarFile:hover { 41 | background-color: $primary; 42 | color: $darker; 43 | .delete img { 44 | filter: grayscale(100%) brightness(0); 45 | } 46 | } 47 | .delete:hover img { 48 | transform: rotate(90deg); 49 | } 50 | } -------------------------------------------------------------------------------- /src/components/SidebarFilelist/SidebarFilelist.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import SidebarFile from "../SidebarFile/SidebarFile.jsx"; 4 | 5 | import "./SidebarFilelist.scss"; 6 | 7 | export default class SidebarFilelist extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.sidebarFilelistRef = React.createRef(); 11 | } 12 | 13 | scrollToBottom = () => { 14 | console.log("scrollToBottom called"); 15 | this.sidebarFilelistRef.current.scrollTop = 16 | this.sidebarFilelistRef.current.scrollHeight; 17 | }; 18 | 19 | getFileElems = () => { 20 | let fileElems = []; 21 | for (let i = 0; i < this.props.files.length; i++) { 22 | fileElems.push( 23 | 30 | ); 31 | } 32 | return fileElems; 33 | }; 34 | 35 | render() { 36 | return ( 37 |
38 | {this.getFileElems()} 39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/SidebarFilelist/SidebarFilelist.scss: -------------------------------------------------------------------------------- 1 | .SidebarFilelist { 2 | // Make bottom vertical child scrollable 3 | overflow-y: auto; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/SidebarUploader/SidebarUploader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "./SidebarUploader.scss"; 4 | 5 | export default class SidebarUploader extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.pickerInput = null; 9 | this.pickerButton = null; 10 | this.dropper = null; 11 | this.pickerInputRef = React.createRef(); 12 | this.pickerButtonRef = React.createRef(); 13 | this.dropperRef = React.createRef(); 14 | } 15 | 16 | componentDidMount() { 17 | this.pickerInput = this.pickerInputRef.current; 18 | this.pickerButton = this.pickerButtonRef.current; 19 | 20 | // Set up drag-n-drop file upload 21 | let dropper = this.dropperRef.current; 22 | 23 | let showDropper = () => { 24 | dropper.style.visibility = "visible"; 25 | }; 26 | 27 | let hideDropper = () => { 28 | dropper.style.visibility = "hidden"; 29 | }; 30 | 31 | let allowDrag = (e) => { 32 | e.preventDefault(); 33 | e.dataTransfer.dropEffect = "copy"; 34 | }; 35 | 36 | let handleDrop = (e) => { 37 | e.preventDefault(); 38 | hideDropper(); 39 | this.uploadFiles(e.dataTransfer.files); 40 | }; 41 | 42 | // Show dropper when dragged into anywhere in the window 43 | window.addEventListener("dragenter", (e) => { 44 | showDropper(); 45 | }); 46 | 47 | // Constantly call preventDefault to allow drag to continue 48 | dropper.addEventListener("dragenter", allowDrag); 49 | dropper.addEventListener("dragover", allowDrag); 50 | 51 | // Hide the dropper upon leaving the dropper area 52 | dropper.addEventListener("dragleave", (e) => { 53 | hideDropper(); 54 | }); 55 | 56 | // Handle the drop 57 | dropper.addEventListener("drop", handleDrop); 58 | } 59 | 60 | handleFileInputChange = (event) => { 61 | if (event.target.files && event.target.files[0]) { 62 | this.uploadFiles(event.target.files); 63 | } 64 | }; 65 | 66 | /** 67 | * Sorts files by name, then calls this.props.addFiles on them 68 | * @param {File[]} files Files to add 69 | */ 70 | uploadFiles = (files) => { 71 | files = Array.from(files); // In case files passed in was directly from event object 72 | files.sort((a, b) => (a.name > b.name ? 1 : -1)); // Sort by filename 73 | this.props.addFiles(files, this.props.revealUploadedFiles); 74 | }; 75 | 76 | handlePickerInputClick = () => { 77 | this.pickerInput.value = null; 78 | }; 79 | 80 | handlePickerButtonClick = () => { 81 | this.pickerInput.click(); 82 | }; 83 | 84 | render() { 85 | return ( 86 |
87 |
88 | 96 |
101 | Upload 102 |
103 | 104 |
105 |
106 |
107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/components/SidebarUploader/SidebarUploader.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .SidebarUploader { 4 | position: relative; 5 | user-select: none; 6 | } 7 | 8 | .picker input { 9 | display: none; 10 | } 11 | 12 | .picker-button { 13 | position: relative; 14 | background-color: $primary; 15 | color: black; 16 | padding: 20px; 17 | text-align: center; 18 | cursor: pointer; 19 | overflow: hidden; 20 | font-family: $font-family; 21 | &::before { 22 | content: ""; 23 | position: absolute; 24 | left: 50%; 25 | top: 50%; 26 | background-color: $darker; 27 | height: 350%; 28 | width: 150%; 29 | border-radius: 50%; 30 | transform: translate(-50%, -50%) scale(0); 31 | transition: transform 100ms ease-out; 32 | } 33 | span { 34 | // Needed because only elements with non-static position are affected by z-index, 35 | // and we want span to appear above ::before 36 | position: relative; 37 | } 38 | } 39 | 40 | @media (hover: hover) and (pointer: fine) { 41 | .picker-button:hover { 42 | transition: color 100ms ease-out; 43 | color: white; 44 | &::before { 45 | transform: translate(-50%, -50%) scale(1); 46 | transition: transform 100ms ease-out; 47 | } 48 | } 49 | } 50 | 51 | .dropper { 52 | z-index: 999; 53 | position: fixed; 54 | width: 100%; 55 | height: 100%; 56 | top: 0; 57 | left: 0; 58 | background-color: black; 59 | opacity: 0.8; 60 | visibility: hidden; 61 | } 62 | -------------------------------------------------------------------------------- /src/components/Toolbar/Toolbar.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "./Toolbar.scss"; 4 | 5 | export default class Toolbar extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | canDecreaseMargin = () => { 11 | return this.props.viewerFile && this.props.margin > this.props.minMargin; 12 | }; 13 | 14 | canIncreaseMargin = () => { 15 | return this.props.viewerFile && this.props.margin < this.props.maxMargin; 16 | }; 17 | 18 | canDecreaseZoom = () => { 19 | return this.props.viewerFile && this.props.zoom > this.props.minZoom; 20 | }; 21 | 22 | canIncreaseZoom = () => { 23 | return this.props.viewerFile && this.props.zoom < this.props.maxZoom; 24 | }; 25 | 26 | canPrevViewerFile = () => { 27 | return ( 28 | this.props.viewerFile && 29 | this.props.files.indexOf(this.props.viewerFile) !== 0 30 | ); 31 | }; 32 | 33 | canNextViewerFile = () => { 34 | return ( 35 | this.props.viewerFile && 36 | this.props.files.indexOf(this.props.viewerFile) !== 37 | this.props.files.length - 1 38 | ); 39 | }; 40 | 41 | render() { 42 | return ( 43 |
44 |
48 | Shrinking arrows 54 |
55 | 56 |
60 | Expanding arrows 66 |
67 | 68 |
72 | Magnifying glass zoom out 78 |
79 | 80 |
84 | Magnifying glass zoom in 90 |
91 | 92 |
96 | Left arrow 102 |
103 | 104 |
108 | Right arrow 114 |
115 |
116 | ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/components/Toolbar/Toolbar.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .Toolbar { 4 | background-color: $dark; 5 | display: flex; 6 | flex-direction: row; 7 | justify-content: center; 8 | align-items: center; 9 | flex-shrink: 0; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Viewer/Viewer.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | const jszip = require("jszip"); 3 | 4 | import QuickNav from "../QuickNav/QuickNav.jsx"; 5 | 6 | import "./Viewer.scss"; 7 | 8 | export default class Viewer extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | imageUrls: [], // The image URLs directly used in tags. 13 | isLoaded: false, // Show loading animation? 14 | error: false, // Show error? 15 | }; 16 | this.viewerRef = React.createRef(); 17 | } 18 | 19 | componentDidMount() { 20 | this.processFile(this.props.viewerFile); 21 | } 22 | 23 | componentDidUpdate(prevProps) { 24 | if (this.props.viewerFile !== prevProps.viewerFile) { 25 | this.processFile(this.props.viewerFile); 26 | } 27 | if ( 28 | this.props.zoom !== prevProps.zoom || 29 | this.props.margin !== prevProps.margin 30 | ) { 31 | this.forceUpdate(); 32 | } 33 | } 34 | 35 | /** 36 | * Processes the file for viewing and sets the state accordingly 37 | * Unzips the file, creates URLs for all entries, and sets the URLs into state 38 | * If file is falsy, will revoke all URLs and wipe this.state.imageUrls to force 39 | * a rerender of the welcome screen. 40 | * @param {File} file The file to process for viewing. 41 | */ 42 | processFile = (file) => { 43 | this.revokeUrls(this.state.imageUrls); // Always free memory first. 44 | if (file) { 45 | this.setState( 46 | { 47 | // Clear images, show loader, and hide error. 48 | imageUrls: [], 49 | isLoaded: false, 50 | error: false, 51 | }, 52 | () => { 53 | // Process file (unzip, load image, or show error). 54 | const zipRe = /\.(cbz|cbr|zip|rar|7z|7zip)$/gi; 55 | const imageRe = /\.(jpe?g|png|gif)$/gi; 56 | if (zipRe.test(file.name)) { 57 | // Display using zip file. 58 | this.unzip(this.props.viewerFile).then((blobs) => { 59 | this.setState( 60 | { 61 | imageUrls: this.createUrls(blobs), 62 | isLoaded: true, 63 | }, 64 | () => { 65 | this.viewerRef.current.scrollTop = 0; 66 | } 67 | ); 68 | }); 69 | } else if (imageRe.test(file.name)) { 70 | // Display single image. 71 | this.setState( 72 | { 73 | imageUrls: this.createUrls([file]), 74 | isLoaded: true, 75 | }, 76 | () => { 77 | this.viewerRef.current.scrollTop = 0; 78 | } 79 | ); 80 | } else { 81 | // Display error. 82 | this.setState({ 83 | isLoaded: true, 84 | error: true, 85 | }); 86 | } 87 | } 88 | ); 89 | } else { 90 | // No file selected. Clear images, hide loader, hide error. 91 | this.setState({ 92 | imageUrls: [], 93 | isLoaded: true, 94 | error: false, 95 | }); 96 | } 97 | }; 98 | 99 | /** 100 | * Returns an array of Promises of Blobs of each zip entry 101 | * @param {File} zipFile The ZIP file to unzip 102 | * @returns {Promise} Promise of array of Blobs 103 | */ 104 | unzip = (zipFile) => { 105 | return jszip.loadAsync(zipFile).then(function (zip) { 106 | let re = /\.(jpe?g|png|gif)$/i; 107 | let imageFilenames = Object.keys(zip.files).filter(function (filename) { 108 | // Ignore non-image files 109 | return re.test(filename.toLowerCase()); 110 | }).sort((a, b) => a.localeCompare(b)); 111 | 112 | let blobPromises = []; 113 | for (let filename of imageFilenames) { 114 | let file = zip.files[filename]; 115 | blobPromises.push(file.async("blob")); 116 | } 117 | return Promise.all(blobPromises); 118 | }); 119 | }; 120 | 121 | /** 122 | * Returns an array of object URLs for passed array of files 123 | * @param {File[]} files Array of files to create object URLs for 124 | */ 125 | createUrls = (files) => { 126 | let urls = []; 127 | for (let file of files) { 128 | urls.push(URL.createObjectURL(file)); 129 | } 130 | return urls; 131 | }; 132 | 133 | /** 134 | * Revokes all URLs in the passed array, freeing memory 135 | * @param {DOMString[]} urls Array of DOMString URLs (of images, in this case) 136 | */ 137 | revokeUrls = (urls) => { 138 | for (let url of urls) { 139 | URL.revokeObjectURL(url); 140 | } 141 | }; 142 | 143 | /** 144 | * Creates and returns an array of files with src attributes linked 145 | * @returns Array of 146 | */ 147 | getImageElems = () => { 148 | let imageElemArr = []; 149 | for (let i = 0; i < this.state.imageUrls.length; i++) { 150 | let imageUrl = this.state.imageUrls[i]; 151 | imageElemArr.push( 152 | 160 | ); 161 | } 162 | return imageElemArr; 163 | }; 164 | 165 | render() { 166 | return ( 167 |
168 | {this.props.viewerFile && this.state.isLoaded ? ( 169 | 176 | ) : ( 177 | "" 178 | )} 179 | 180 | {this.state.isLoaded ? ( 181 | "" 182 | ) : ( 183 |
184 | Loading GIF 185 |
186 | )} 187 | 188 | {this.props.viewerFile ? ( 189 | "" 190 | ) : ( 191 |
192 | Welcome GIF 193 |
194 | drag 'n drop your .cbz files 195 |
196 |
197 | or use the Upload button 198 |
199 |
200 | )} 201 | 202 | {this.state.error ? ( 203 |
204 | Error image 205 |
Could not load this file.
206 |
207 | Are you using an unsupported file extension? 208 |
209 |
210 | ) : ( 211 | "" 212 | )} 213 | 214 |
220 | {this.getImageElems()} 221 |
222 | 223 | {this.props.viewerFile && this.state.isLoaded ? ( 224 | 231 | ) : ( 232 | "" 233 | )} 234 |
235 | ); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/components/Viewer/Viewer.scss: -------------------------------------------------------------------------------- 1 | @import "../../variables.scss"; 2 | 3 | .Viewer { 4 | z-index: 1; 5 | position: relative; 6 | background-color: $darker; 7 | overflow-y: auto; 8 | flex-grow: 1; 9 | display: flex; 10 | flex-direction: column; 11 | width: 100%; 12 | align-items: center; 13 | user-select: none; 14 | } 15 | 16 | .Viewer-QuickNav-top { 17 | margin-top: 80px; 18 | } 19 | 20 | .Viewer-image-wrapper { 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | max-width: 100%; 25 | img { 26 | max-width: 100%; 27 | width: 100%; 28 | } 29 | } 30 | 31 | .Viewer-loading, 32 | .Viewer-error, 33 | .Viewer-splash { 34 | max-width: 100%; 35 | width: 100%; 36 | height: 100%; 37 | display: flex; 38 | flex-direction: column; 39 | justify-content: center; 40 | align-items: center; 41 | } 42 | 43 | .Viewer-loading img, 44 | .Viewer-error img, 45 | .Viewer-splash img { 46 | width: 500px; 47 | max-width: 100%; 48 | } 49 | 50 | .Viewer-error-text, 51 | .Viewer-error-text-small, 52 | .Viewer-splash-text, 53 | .Viewer-splash-text-small { 54 | font-family: $font-family; 55 | color: white; 56 | } 57 | 58 | .Viewer-error-text, 59 | .Viewer-splash-text { 60 | font-size: 32px; 61 | margin-top: 20px; 62 | } 63 | 64 | .Viewer-error-text-small, 65 | .Viewer-splash-text-small { 66 | font-size: 14px; 67 | } 68 | 69 | @media only screen and (max-width: 700px) { 70 | .Viewer-error-text, 71 | .Viewer-splash-text { 72 | font-size: 20px; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ame 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App.jsx"; 4 | 5 | import "./index.scss"; 6 | 7 | ReactDOM.render(, document.querySelector("#root")); 8 | 9 | /* 10 | if ("serviceworker" in navigator) { 11 | window.addEventListener("load", () => { 12 | navigator.serviceWorker.register("/sw.js"); 13 | }); 14 | } 15 | */ 16 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables.scss"; 2 | 3 | // Reset styles 4 | * { 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | // Fullscreen application 10 | html, 11 | body, 12 | #root, 13 | #root > div { 14 | width: 100%; 15 | height: 100%; 16 | } 17 | 18 | body { 19 | font-size: 16px; 20 | background-color: $darker; 21 | } 22 | -------------------------------------------------------------------------------- /src/variables.scss: -------------------------------------------------------------------------------- 1 | // @import url("https://fonts.googleapis.com/css2?family=Raleway:wght@400;700&display=swap"); 2 | @import url("https://fonts.googleapis.com/css2?family=Raleway:wght@700&display=swap"); 3 | 4 | $primary: #74b9ff; 5 | $secondary: #0984e3; 6 | 7 | $dark: #222222; 8 | $darker: #111111; 9 | 10 | $font-family: "Raleway", sans-serif; 11 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 4 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 5 | 6 | module.exports = { 7 | entry: path.join(__dirname, "src", "index.js"), 8 | resolve: { 9 | extensions: ["*", ".js", ".jsx"], 10 | }, 11 | plugins: [ 12 | new CleanWebpackPlugin(), 13 | new CopyWebpackPlugin({ 14 | patterns: [{ from: "./src/assets/icon.png", to: "assets/icon.png" }], 15 | }), 16 | new HtmlWebpackPlugin({ 17 | favicon: path.join(__dirname, "src", "assets", "favicon.ico"), 18 | template: path.join(__dirname, "src", "index.html"), 19 | }), 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const { merge } = require("webpack-merge"); 4 | const common = require("./webpack.common.js"); 5 | 6 | module.exports = merge(common, { 7 | mode: "development", 8 | output: { 9 | path: path.join(__dirname, "docs"), 10 | filename: "index.bundle.js", 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.jsx?$/, 16 | exclude: /node_modules/, 17 | use: ["babel-loader"], 18 | }, 19 | { 20 | test: /\.scss$/, 21 | use: ["style-loader", "css-loader", "sass-loader"], 22 | }, 23 | { 24 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 25 | type: "asset/resource", 26 | generator: { 27 | filename: "assets/[name][ext]", 28 | }, 29 | }, 30 | ], 31 | }, 32 | plugins: [new webpack.HotModuleReplacementPlugin()], 33 | devServer: { 34 | contentBase: path.join(__dirname, "docs"), 35 | hot: true, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { merge } = require("webpack-merge"); 3 | const common = require("./webpack.common.js"); 4 | 5 | module.exports = merge(common, { 6 | mode: "production", 7 | output: { 8 | path: path.join(__dirname, "docs"), 9 | filename: "index.[contenthash].bundle.js", 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.jsx?$/, 15 | exclude: /node_modules/, 16 | use: ["babel-loader"], 17 | }, 18 | { 19 | test: /\.scss$/, 20 | use: ["style-loader", "css-loader", "sass-loader"], 21 | }, 22 | { 23 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 24 | type: "asset/resource", 25 | generator: { 26 | filename: "assets/[hash][ext]", 27 | }, 28 | }, 29 | ], 30 | }, 31 | }); 32 | --------------------------------------------------------------------------------