├── types ├── react-split.d.ts └── monaco-themes.d.ts ├── .nowignore ├── .gitignore ├── public ├── favicon.ico ├── zip-192.png ├── zip-512.png ├── apple-touch-icon.png ├── manifest.json └── index.html ├── src ├── index.tsx ├── ErrorBoundary.tsx ├── Files.tsx ├── LaunchQueue.tsx ├── InfoLink.tsx ├── ServiceWorker.tsx ├── Info.tsx ├── Nav.tsx ├── App.css └── App.tsx ├── tsconfig.json ├── README.md ├── vercel.json ├── LICENSE ├── webpack.config.js ├── .github └── workflows │ └── codeql-analysis.yml └── package.json /types/react-split.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-split' 2 | -------------------------------------------------------------------------------- /.nowignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | *.log 4 | .idea 5 | -------------------------------------------------------------------------------- /types/monaco-themes.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'monaco-themes' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | *.log 4 | 5 | .vercel 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubgit/zipadee/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/zip-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubgit/zipadee/HEAD/public/zip-192.png -------------------------------------------------------------------------------- /public/zip-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubgit/zipadee/HEAD/public/zip-512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubgit/zipadee/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { App } from './App' 4 | import { ErrorBoundary } from './ErrorBoundary' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ) 12 | -------------------------------------------------------------------------------- /src/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export class ErrorBoundary extends React.Component { 4 | public readonly state: { 5 | error?: string 6 | } = {} 7 | 8 | public static getDerivedStateFromError(error: Error): { error: string } { 9 | console.error(error) 10 | return { error: error.message } 11 | } 12 | 13 | public render(): React.ReactNode { 14 | if (this.state.error) { 15 | return
{this.state.error}
16 | } 17 | 18 | return this.props.children 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["src", "types"] 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | 3 | Run `vercel dev` to start serving the app. 4 | 5 | ## Deployment 6 | 7 | Run `vercel --target production` to deploy the app via [Vercel](https://vercel.com). 8 | 9 | ## Bundle Analysis 10 | 11 | 1. `npm -g install webpack-bundle-analyzer` 12 | 2. `yarn run --silent webpack --profile --json > stats.json` 13 | 3. `webpack-bundle-analyzer stats.json` 14 | 15 | ## Credits 16 | 17 | * Favicon made by Freepik from www.flaticon.com, licensed CC 3.0 BY. 18 | -------------------------------------------------------------------------------- /src/Files.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import React from 'react' 3 | 4 | export const Files: React.FC<{ 5 | files: string[] 6 | selectedFilename?: string 7 | selectFile: (file: string) => void 8 | }> = React.memo(({ files, selectedFilename, selectFile }) => { 9 | return ( 10 |
11 | {files.map((file) => ( 12 |
selectFile(file)} 19 | title={file} 20 | > 21 | {file} 22 |
23 | ))} 24 |
25 | ) 26 | }) 27 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Zipadee", 3 | "short_name": "Zipadee", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#4795ff", 7 | "theme_color": "#ffffff", 8 | "icons": [ 9 | { 10 | "src": "/zip-192.png", 11 | "type": "image/png", 12 | "sizes": "192x192" 13 | }, 14 | { 15 | "src": "/zip-512.png", 16 | "type": "image/png", 17 | "sizes": "512x512" 18 | } 19 | ], 20 | "file_handlers": [ 21 | { 22 | "action": "/", 23 | "accept": { 24 | "application/zip": [".zip"], 25 | "application/x-chrome-extension": [".crx"], 26 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"] 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "zipadee", 4 | "alias": "zipadee", 5 | "routes": [ 6 | { 7 | "src": "/workers/(.*)", 8 | "dest": "/workers/$1" 9 | }, 10 | { 11 | "src": "/js/(.*)", 12 | "headers": { "cache-control": "s-maxage=31536000,immutable" }, 13 | "dest": "/js/$1" 14 | }, 15 | { "src": "/precache-manifest.(.*)", "dest": "/precache-manifest.$1" }, 16 | { 17 | "src": "/service-worker.js", 18 | "headers": { "cache-control": "s-maxage=0" }, 19 | "dest": "/service-worker.js" 20 | }, 21 | { "src": "/(.*)\\.(.*)", "dest": "/$1.$2" }, 22 | { "src": "/sockjs-node/(.*)", "dest": "/sockjs-node/$1" }, 23 | { 24 | "src": "/(.*)", 25 | "headers": { "cache-control": "s-maxage=0" }, 26 | "dest": "/index.html" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/LaunchQueue.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect } from 'react' 2 | 3 | export const LaunchQueue = memo<{ 4 | setFile: (file: File) => void 5 | setFilename: (filename: string) => void 6 | }>(({ setFile, setFilename }) => { 7 | useEffect(() => { 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 9 | // @ts-ignore 10 | if ('launchQueue' in window && 'files' in LaunchParams.prototype) { 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 12 | // @ts-ignore 13 | window.launchQueue.setConsumer( 14 | async (launchParams: { files: FileSystemFileHandle[] }) => { 15 | if (!launchParams.files.length) { 16 | return 17 | } 18 | const [fileHandle] = launchParams.files 19 | const file = await fileHandle.getFile() 20 | setFile(file) 21 | setFilename(file.name) 22 | } 23 | ) 24 | } 25 | }, [setFile, setFilename]) 26 | 27 | return null 28 | }) 29 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alf Eaton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/InfoLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const InfoLink: React.FC<{ href: string; title: string }> = React.memo( 4 | ({ href, title }) => ( 5 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | ) 21 | -------------------------------------------------------------------------------- /src/ServiceWorker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react' 2 | import { Workbox } from 'workbox-window' 3 | 4 | export const ServiceWorker: React.FC<{ file?: File }> = React.memo( 5 | ({ file }) => { 6 | const [workbox, setWorkbox] = useState() 7 | const [registration, setRegistration] = 8 | useState() 9 | 10 | useEffect(() => { 11 | const workbox = new Workbox('/service-worker.js') 12 | 13 | workbox.addEventListener('waiting', () => { 14 | setWorkbox(workbox) 15 | }) 16 | 17 | workbox 18 | .register() 19 | .then((registration) => { 20 | setRegistration(registration) 21 | }) 22 | .catch((error) => { 23 | console.error(error) 24 | }) 25 | }, []) 26 | 27 | useEffect(() => { 28 | if (file && registration) { 29 | registration.update().catch((error) => { 30 | console.error(error) 31 | }) 32 | } 33 | }, [file, registration]) 34 | 35 | const handleReload = useCallback(() => { 36 | if (workbox) { 37 | workbox.addEventListener('controlling', () => { 38 | window.location.reload() 39 | }) 40 | 41 | workbox.messageSW({ type: 'SKIP_WAITING' }).catch((error) => { 42 | console.error(error) 43 | }) 44 | } 45 | }, [workbox]) 46 | 47 | if (!workbox) { 48 | return null 49 | } 50 | 51 | return ( 52 | 60 | ) 61 | } 62 | ) 63 | -------------------------------------------------------------------------------- /src/Info.tsx: -------------------------------------------------------------------------------- 1 | import { fileOpen } from 'browser-fs-access' 2 | import React, { useCallback } from 'react' 3 | 4 | export const Info: React.FC<{ 5 | setFile: (file: File) => void 6 | setFilename: (filename: string) => void 7 | }> = ({ setFile, setFilename }) => { 8 | // open a file picker 9 | const handleOpen = useCallback(() => { 10 | fileOpen() 11 | .then(async (file) => { 12 | if (!file) { 13 | return 14 | } 15 | 16 | setFile(file) 17 | setFilename(file.name) 18 | }) 19 | .catch((error) => { 20 | console.error(error) 21 | // setError(error) 22 | }) 23 | }, [setFile, setFilename]) 24 | 25 | // https://wicg.github.io/file-system-access/#draganddrop-example 26 | const dropzoneRef = useCallback( 27 | (element) => { 28 | if (!element) { 29 | return 30 | } 31 | 32 | element.addEventListener('dragover', (event: DragEvent) => { 33 | // Prevent navigation. 34 | event.preventDefault() 35 | }) 36 | 37 | element.addEventListener('drop', async (event: DragEvent) => { 38 | // Prevent navigation. 39 | event.preventDefault() 40 | 41 | if (event.dataTransfer) { 42 | const [item] = event.dataTransfer.items 43 | 44 | // kind will be 'file' for file/directory entries. 45 | if (item && item.kind === 'file') { 46 | if (item.getAsFileSystemHandle) { 47 | const handle = await item.getAsFileSystemHandle() 48 | 49 | if (handle.kind === 'file') { 50 | const file = await handle.getFile() 51 | if (file) { 52 | file.handle = handle 53 | setFile(file) 54 | setFilename(file.name) 55 | } 56 | } 57 | } else { 58 | const file = await item.getAsFile() 59 | if (file) { 60 | setFile(file) 61 | setFilename(file.name) 62 | } 63 | } 64 | } 65 | } 66 | }) 67 | }, 68 | [setFile, setFilename] 69 | ) 70 | 71 | return ( 72 |
73 |
74 |
Edit the contents of a ZIP file
75 |
76 | (including EPUB, DOCX, XLSX, PPTX, ODT, CRX) 77 |
78 |
79 | Drag a ZIP 80 |
81 | or click to select a file 82 |
83 |
84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 3 | const CopyWebpackPlugin = require('copy-webpack-plugin') 4 | const HtmlWebpackPlugin = require('html-webpack-plugin') 5 | const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin') 6 | const WorkboxWebpackPlugin = require('workbox-webpack-plugin') 7 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin') 8 | 9 | const dist = path.resolve(process.cwd(), 'dist') 10 | 11 | const isDevelopment = process.env.NODE_ENV !== 'production' 12 | 13 | module.exports = { 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | use: { 19 | loader: 'babel-loader', 20 | options: { 21 | plugins: isDevelopment ? ['react-refresh/babel'] : [], 22 | }, 23 | }, 24 | exclude: /node_modules/, 25 | }, 26 | { 27 | test: /\.css$/, 28 | use: ['style-loader', 'css-loader'], 29 | }, 30 | { 31 | test: /\.ttf$/, 32 | use: ['file-loader'], 33 | }, 34 | ], 35 | }, 36 | output: { 37 | filename: 'js/[name].[contenthash].js', 38 | path: dist, 39 | }, 40 | resolve: { 41 | extensions: ['.tsx', '.ts', '.js'], 42 | fallback: { 43 | buffer: require.resolve('buffer'), 44 | stream: require.resolve('stream-browserify'), 45 | }, 46 | }, 47 | performance: { 48 | hints: false, 49 | }, 50 | plugins: [ 51 | new CleanWebpackPlugin(), 52 | new CopyWebpackPlugin({ 53 | patterns: [ 54 | { from: 'public/manifest.json' }, 55 | { from: 'public/zip-192.png' }, 56 | { from: 'public/zip-512.png' }, 57 | { from: 'public/apple-touch-icon.png' }, 58 | ], 59 | }), 60 | new HtmlWebpackPlugin({ 61 | favicon: 'public/favicon.ico', 62 | template: 'public/index.html', 63 | title: 'Zipadee', 64 | ga: 'UA-143268750-2', 65 | }), 66 | new MonacoWebpackPlugin({ 67 | output: 'workers', 68 | filename: '[name].worker.[contenthash].js', 69 | languages: [ 70 | 'css', 71 | 'html', 72 | 'javascript', 73 | 'json', 74 | 'markdown', 75 | 'php', 76 | 'python', 77 | 'shell', 78 | 'typescript', 79 | 'xml', 80 | 'yaml', 81 | ], 82 | }), 83 | isDevelopment ? new ReactRefreshWebpackPlugin() : undefined, 84 | isDevelopment ? undefined : new WorkboxWebpackPlugin.GenerateSW({ 85 | swDest: 'service-worker.js', 86 | maximumFileSizeToCacheInBytes: 100 * 1024 * 1024, 87 | }), 88 | ].filter(Boolean), 89 | devServer: { 90 | liveReload: false, 91 | static: [dist], 92 | }, 93 | } 94 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | #schedule: 15 | #- cron: '0 19 * * 2' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['javascript'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zipadee", 3 | "version": "0.2.0", 4 | "private": true, 5 | "license": "MIT", 6 | "repository": "hubgit/zipadee", 7 | "scripts": { 8 | "dev": "webpack serve --mode development --env development", 9 | "build": "NODE_ENV=production webpack --mode=production", 10 | "lint": "eslint 'src/**/*.{ts,tsx}'", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "assert": "^2.0.0", 15 | "balloon-css": "^1.2.0", 16 | "browser-fs-access": "^0.20.5", 17 | "buffer": "^6.0.3", 18 | "classnames": "^2.3.1", 19 | "image-type": "^4.1.0", 20 | "jszip": "3.7.1", 21 | "monaco-editor": "^0.28.1", 22 | "monaco-themes": "^0.3.3", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2", 25 | "react-split": "^2.0.13", 26 | "resize-observer-polyfill": "^1.5.1", 27 | "stream-browserify": "^3.0.0", 28 | "workbox-window": "^6.3.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.12.9", 32 | "@babel/plugin-proposal-class-properties": "^7.12.1", 33 | "@babel/preset-env": "^7.12.7", 34 | "@babel/preset-react": "^7.12.7", 35 | "@babel/preset-typescript": "^7.12.7", 36 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.1", 37 | "@types/classnames": "^2.2.11", 38 | "@types/jest": "27.0.2", 39 | "@types/jszip": "^3.4.1", 40 | "@types/node": "16.10.3", 41 | "@types/react": "17.0.27", 42 | "@types/react-dom": "17.0.9", 43 | "@types/wicg-file-system-access": "^2020.9.5", 44 | "@types/wicg-native-file-system": "^2020.6.0", 45 | "@types/workbox-window": "^4.3.3", 46 | "@typescript-eslint/eslint-plugin": "^4.9.0", 47 | "@typescript-eslint/parser": "^4.9.0", 48 | "babel-loader": "^8.2.2", 49 | "clean-webpack-plugin": "^4.0.0", 50 | "copy-webpack-plugin": "^9.0.1", 51 | "css-loader": "^6.3.0", 52 | "eslint": "^7.15.0", 53 | "eslint-config-prettier": "^8.3.0", 54 | "eslint-plugin-import": "^2.22.1", 55 | "eslint-plugin-prettier": "^4.0.0", 56 | "eslint-plugin-promise": "^5.1.0", 57 | "eslint-plugin-react": "^7.21.5", 58 | "eslint-plugin-react-hooks": "^4.2.0", 59 | "file-loader": "^6.2.0", 60 | "html-webpack-plugin": "^5.3.2", 61 | "monaco-editor-webpack-plugin": "^4.2.0", 62 | "prettier": "^2.2.1", 63 | "react-refresh": "^0.10.0", 64 | "style-loader": "^3.3.0", 65 | "type-fest": "^2.3.4", 66 | "typescript": "^4.7.2", 67 | "webpack": "^5.10.0", 68 | "webpack-cli": "^4.2.0", 69 | "webpack-dev-server": "^4.3.1", 70 | "workbox-webpack-plugin": "^6.0.2" 71 | }, 72 | "babel": { 73 | "presets": [ 74 | "@babel/env", 75 | "@babel/react", 76 | "@babel/typescript" 77 | ], 78 | "plugins": [ 79 | "@babel/proposal-class-properties" 80 | ] 81 | }, 82 | "browserslist": [ 83 | "last 2 years and >2%" 84 | ], 85 | "prettier": { 86 | "printWidth": 80, 87 | "semi": false, 88 | "singleQuote": true, 89 | "trailingComma": "es5" 90 | }, 91 | "eslintConfig": { 92 | "env": { 93 | "browser": true, 94 | "es6": true 95 | }, 96 | "parserOptions": { 97 | "ecmaFeatures": { 98 | "jsx": true 99 | }, 100 | "ecmaVersion": 2018, 101 | "sourceType": "module" 102 | }, 103 | "parser": "@typescript-eslint/parser", 104 | "plugins": [ 105 | "@typescript-eslint", 106 | "prettier", 107 | "react", 108 | "react-hooks" 109 | ], 110 | "extends": [ 111 | "plugin:@typescript-eslint/recommended", 112 | "prettier", 113 | "plugin:prettier/recommended", 114 | "plugin:react/recommended", 115 | "plugin:promise/recommended", 116 | "plugin:import/errors", 117 | "plugin:import/warnings" 118 | ], 119 | "rules": { 120 | "@typescript-eslint/explicit-function-return-type": 0, 121 | "promise/always-return": 0, 122 | "react/display-name": 0, 123 | "react/prop-types": 0, 124 | "react-hooks/rules-of-hooks": "error", 125 | "react-hooks/exhaustive-deps": "warn" 126 | }, 127 | "settings": { 128 | "import/resolver": { 129 | "node": { 130 | "extensions": [ 131 | ".js", 132 | ".jsx", 133 | ".ts", 134 | ".tsx" 135 | ] 136 | } 137 | }, 138 | "react": { 139 | "version": "16.8" 140 | } 141 | }, 142 | "overrides": [ 143 | { 144 | "files": [ 145 | "*.js" 146 | ], 147 | "rules": { 148 | "@typescript-eslint/no-var-requires": 0 149 | } 150 | } 151 | ] 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Nav.tsx: -------------------------------------------------------------------------------- 1 | import { fileSave } from 'browser-fs-access' 2 | import JSZip from 'jszip' 3 | import * as monaco from 'monaco-editor' 4 | // import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' 5 | import React, { useCallback, useEffect, useRef, useState } from 'react' 6 | import { InfoLink } from './InfoLink' 7 | import { ServiceWorker } from './ServiceWorker' 8 | 9 | interface BeforeInstallPromptEvent extends Event { 10 | readonly userChoice: Promise<{ 11 | outcome: 'accepted' | 'dismissed' 12 | platform: string 13 | }> 14 | 15 | prompt(): Promise 16 | } 17 | 18 | export const Nav: React.FC<{ 19 | changed: boolean 20 | editor?: monaco.editor.IStandaloneCodeEditor 21 | file?: File 22 | filename?: string 23 | handleReset: () => void 24 | setChanged: (changed: boolean) => void 25 | setError: (error: string) => void 26 | setFile: (file: File) => void 27 | setFilename: (filename: string) => void 28 | zip?: JSZip 29 | }> = React.memo( 30 | ({ 31 | changed, 32 | // editor, 33 | file, 34 | filename, 35 | handleReset, 36 | setChanged, 37 | setError, 38 | setFile, 39 | setFilename, 40 | zip, 41 | }) => { 42 | const [installPrompt, setInstallPrompt] = 43 | useState() 44 | 45 | // download the updated zip 46 | const downloadZip = useCallback(async () => { 47 | if (zip && file && filename) { 48 | try { 49 | const blob = await zip.generateAsync({ 50 | type: 'blob', 51 | mimeType: 'application/zip', 52 | }) 53 | 54 | const newFile = new File([blob], filename) 55 | 56 | try { 57 | if (file.name === filename) { 58 | // save 59 | newFile.handle = await fileSave(blob, {}, file.handle) 60 | } else { 61 | const extensions = ['.zip'] 62 | const extension = filename.split('.').pop() 63 | if (extension) { 64 | extensions.unshift(`.${extension}`) 65 | } 66 | 67 | // save as 68 | newFile.handle = await fileSave(blob, { 69 | fileName: filename, 70 | extensions, 71 | }) 72 | } 73 | } catch (error) { 74 | newFile.handle = await fileSave(blob, { 75 | fileName: filename, 76 | extensions: ['.zip'], 77 | }) 78 | } 79 | 80 | setFile(newFile) 81 | setChanged(false) 82 | } catch (error) { 83 | setError(error.message) 84 | } 85 | } 86 | }, [zip, file, filename, setChanged, setError, setFile]) 87 | 88 | // prompt the user to install the app when appropriate 89 | useEffect(() => { 90 | const listener = (event: Event) => { 91 | setInstallPrompt(() => event as BeforeInstallPromptEvent) 92 | } 93 | 94 | window.addEventListener('beforeinstallprompt', listener) 95 | 96 | return () => { 97 | window.removeEventListener('beforeinstallprompt', listener) 98 | } 99 | }, []) 100 | 101 | // show the install prompt when the install button is clicked 102 | const showInstallPrompt = useCallback(async () => { 103 | if (installPrompt) { 104 | await installPrompt.prompt() 105 | 106 | installPrompt.userChoice 107 | .then((choiceResult) => { 108 | console.log(`Install ${choiceResult}`) 109 | }) 110 | .catch((error) => { 111 | setError(error.message) 112 | }) 113 | } 114 | }, [installPrompt, setError]) 115 | 116 | const filenameRef = useRef(null) 117 | 118 | // handle edits to the file name 119 | const handleFilenameChange = useCallback( 120 | (event) => { 121 | setFilename(event.target.value) 122 | setChanged(true) 123 | }, 124 | [setChanged, setFilename] 125 | ) 126 | 127 | // handle submission of the file name form 128 | const handleFilenameSubmit = useCallback( 129 | (event: React.FormEvent) => { 130 | event.preventDefault() 131 | 132 | if (filenameRef.current) { 133 | filenameRef.current.blur() 134 | } 135 | }, 136 | [filenameRef] 137 | ) 138 | 139 | return ( 140 | 205 | ) 206 | } 207 | ) 208 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Source Sans Pro', Helvetica, Arial, sans-serif; 3 | margin: 0; 4 | background: #4795ff; 5 | color: #fff; 6 | } 7 | 8 | a { 9 | text-decoration: none; 10 | color: inherit; 11 | } 12 | 13 | .container { 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | width: 100vw; 19 | height: 100vh; 20 | overflow: hidden; 21 | } 22 | 23 | .error { 24 | color: white; 25 | background-color: red; 26 | background-image: linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%, transparent 75%, transparent); 27 | } 28 | 29 | .nav { 30 | box-sizing: border-box; 31 | width: 100%; 32 | flex-shrink: 0; 33 | display: flex; 34 | justify-content: space-between; 35 | align-items: center; 36 | padding: 8px 16px; 37 | z-index: 2; 38 | border-bottom: 1px solid #2c61a9; 39 | min-height: 52px; 40 | } 41 | 42 | .nav-group { 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | } 47 | 48 | .nav-group:first-of-type { 49 | justify-content: flex-start; 50 | } 51 | 52 | .nav-group:last-of-type { 53 | justify-content: flex-end; 54 | } 55 | 56 | .brand { 57 | margin: 0 1ch; 58 | font-size: 24px; 59 | font-weight: 200; 60 | text-transform: uppercase; 61 | } 62 | 63 | .main { 64 | flex: 1; 65 | display: flex; 66 | flex-direction: column; 67 | justify-content: center; 68 | align-items: center; 69 | overflow: hidden; 70 | box-sizing: border-box; 71 | width: 100%; 72 | } 73 | 74 | .split { 75 | display: flex; 76 | flex: 1; 77 | width: 100%; 78 | overflow: hidden; 79 | } 80 | 81 | .button { 82 | border: none; 83 | background: none; 84 | cursor: pointer; 85 | padding: 0.5em 1em; 86 | display: flex; 87 | align-items: center; 88 | justify-content: center; 89 | color: inherit; 90 | font-size: inherit; 91 | transition: background-color 0.25s, transform 0.25s; 92 | background: #273e7c77; 93 | text-transform: uppercase; 94 | box-sizing: border-box; 95 | white-space: nowrap; 96 | overflow: hidden; 97 | text-overflow: ellipsis; 98 | flex-shrink: 0; 99 | margin: 0 4px; 100 | line-height: 1; 101 | } 102 | 103 | .button:hover { 104 | background: #273e7c 105 | } 106 | 107 | .button:active { 108 | transform: translateY(2px); 109 | } 110 | 111 | .button:focus { 112 | outline: none; 113 | } 114 | 115 | .info-link { 116 | margin-left: 8px; 117 | } 118 | 119 | .info-icon { 120 | height: 32px; 121 | width: 32px; 122 | fill: currentColor; 123 | opacity: 0.75; 124 | } 125 | 126 | .info-icon:hover { 127 | opacity: 1; 128 | } 129 | 130 | .editor { 131 | flex: 1; 132 | height: 100%; 133 | display: flex; 134 | flex-direction: column; 135 | } 136 | 137 | .sidebar { 138 | display: flex; 139 | flex-direction: column; 140 | background: #444; 141 | color: white; 142 | max-width: min-content; 143 | } 144 | 145 | .files { 146 | flex: 1; 147 | overflow-y: auto; 148 | overflow-x: hidden; 149 | } 150 | 151 | .file { 152 | cursor: pointer; 153 | padding: 8px 16px; 154 | transition: background-color 0.25s; 155 | white-space: nowrap; 156 | overflow: hidden; 157 | text-overflow: ellipsis; 158 | flex-shrink: 0; 159 | font-size: 90%; 160 | } 161 | 162 | .file:hover { 163 | background-color: #3168b380; 164 | } 165 | 166 | .file.selected { 167 | background-color: #3168b3; 168 | } 169 | 170 | .infobox { 171 | padding: 32px 64px; 172 | display: flex; 173 | flex-direction: column; 174 | justify-content: center; 175 | align-items: center; 176 | text-align: center; 177 | border: 3px solid #000; 178 | cursor: pointer; 179 | transition: box-shadow 0.25s; 180 | font-size: 200%; 181 | box-shadow: 8px 8px 0 #00000077; 182 | min-height: 300px; 183 | } 184 | 185 | .infobox:hover { 186 | box-shadow: 8px 8px 0 #000000; 187 | } 188 | 189 | .infobox:focus { 190 | outline: none; 191 | } 192 | 193 | .error { 194 | color: white; 195 | background-color: red; 196 | background-image: linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%, transparent 75%, transparent); 197 | } 198 | 199 | .message { 200 | padding: 16px; 201 | display: flex; 202 | align-items: center; 203 | justify-content: center; 204 | line-height: 1; 205 | } 206 | 207 | .monaco { 208 | flex: 1; 209 | } 210 | 211 | .hidden { 212 | display: none; 213 | } 214 | 215 | .filename { 216 | background: #4795ff; 217 | color: white; 218 | padding: 4px 16px; 219 | cursor: pointer; 220 | display: flex; 221 | align-items: center; 222 | justify-content: space-between; 223 | flex-shrink: 0; 224 | white-space: nowrap; 225 | min-height: 32px; 226 | } 227 | 228 | .narrow .filename { 229 | padding: 4px; 230 | } 231 | 232 | .filename-section { 233 | display: flex; 234 | align-items: center; 235 | overflow: hidden; 236 | flex-shrink: 0; 237 | } 238 | 239 | .filename-section-filename { 240 | flex: 1; 241 | justify-content: flex-start; 242 | } 243 | 244 | .filename-section-actions { 245 | justify-content: flex-end; 246 | } 247 | 248 | .selected-filename { 249 | white-space: nowrap; 250 | overflow: hidden; 251 | text-overflow: ellipsis; 252 | } 253 | 254 | .button.reset { 255 | border: none; 256 | background: none; 257 | cursor: pointer; 258 | padding: 8px; 259 | display: flex; 260 | align-items: center; 261 | justify-content: center; 262 | text-transform: none; 263 | } 264 | 265 | .reset:focus { 266 | outline: none; 267 | } 268 | 269 | .logo { 270 | width: 32px; 271 | height: 32px; 272 | transition: width 0.25s, height 0.25s; 273 | } 274 | 275 | .header-section-file { 276 | flex: 1; 277 | overflow: hidden; 278 | } 279 | 280 | .download { 281 | background: #36b536; 282 | border: 1px solid white; 283 | } 284 | 285 | .download:hover { 286 | background: #16d41c; 287 | } 288 | 289 | .install { 290 | background: #273e7c00; 291 | } 292 | 293 | .install:hover { 294 | background: #273e7c77; 295 | } 296 | 297 | .toggle-preview { 298 | font-size: 90%; 299 | } 300 | 301 | .toggle-files { 302 | padding: 4px 8px; 303 | background: none; 304 | } 305 | 306 | .intro { 307 | text-shadow: 0 1px 1px #444; 308 | line-height: 1; 309 | } 310 | 311 | .intro .button { 312 | margin: 32px 0; 313 | } 314 | 315 | .extensions { 316 | font-size: 50%; 317 | margin-top: 16px; 318 | } 319 | 320 | .choose { 321 | font-size: 24px; 322 | margin-top: 24px; 323 | line-height: 1.2; 324 | } 325 | 326 | .preview { 327 | display: flex; 328 | flex: 1; 329 | background-color: #4795ff; 330 | background-image: linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%, transparent 75%, transparent); 331 | overflow-y: auto; 332 | height: 100%; 333 | } 334 | 335 | .preview-image { 336 | max-width: 100%; 337 | margin: auto; 338 | } 339 | 340 | .filename-form { 341 | padding: 0 8px; 342 | display: flex; 343 | align-items: center; 344 | text-shadow: 0 1px 1px #444; 345 | overflow: hidden; 346 | } 347 | 348 | .narrow .filename-form { 349 | display: none; 350 | } 351 | 352 | .filename-input { 353 | background: none; 354 | color: inherit; 355 | font-size: inherit; 356 | border: 2px solid transparent; 357 | transition: border-color 0.25s; 358 | } 359 | 360 | .filename-input:focus { 361 | outline: none; 362 | border-bottom-color: white; 363 | } 364 | 365 | .gutter { 366 | flex-shrink: 0; 367 | cursor: col-resize; 368 | background: #4795ff; 369 | } 370 | 371 | .narrow [data-balloon-pos]::before, 372 | .narrow [data-balloon-pos]::after { 373 | display: none; 374 | } 375 | 376 | .waiting { 377 | display: none; 378 | } 379 | 380 | .fullscreen .waiting { 381 | display: flex; 382 | } 383 | 384 | .fullscreen .split { 385 | display: none; 386 | } 387 | 388 | .fullscreen .nav { 389 | border-bottom: none; 390 | } 391 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import 'balloon-css' 2 | import classnames from 'classnames' 3 | import { fileSave } from 'browser-fs-access' 4 | import imageType from 'image-type' 5 | import JSZip from 'jszip' 6 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' 7 | import React, { useCallback, useEffect, useState } from 'react' 8 | import ResizeObserver from 'resize-observer-polyfill' 9 | import './App.css' 10 | import { Files } from './Files' 11 | import Split from 'react-split' 12 | import { Nav } from './Nav' 13 | import { Info } from './Info' 14 | import { LaunchQueue } from './LaunchQueue' 15 | 16 | const chooseLanguage = (filename: string) => { 17 | if (filename.endsWith('-json')) { 18 | return 'json' 19 | } 20 | 21 | return undefined 22 | } 23 | 24 | const decoder = new TextDecoder('utf-8') 25 | 26 | const narrowQuery = window.matchMedia('screen and (max-width: 600px)') 27 | 28 | export const App: React.FC = () => { 29 | const [changed, setChanged] = useState(false) 30 | const [editor, setEditor] = useState() 31 | const [error, setError] = useState() 32 | const [file, setFile] = useState() 33 | const [filename, setFilename] = useState() 34 | const [files, setFiles] = useState() 35 | const [observer, setObserver] = useState() 36 | const [previewURL, setPreviewURL] = useState() 37 | const [selectedFilename, setSelectedFilename] = useState() 38 | const [showPreview, setShowPreview] = useState(true) 39 | const [zip, setZip] = useState() 40 | const [narrow, setNarrow] = useState(narrowQuery.matches) 41 | const [showFiles, setShowFiles] = useState(false) 42 | 43 | // reset 44 | const handleReset = useCallback(() => { 45 | setChanged(false) 46 | setError(undefined) 47 | setFile(undefined) 48 | setFilename(undefined) 49 | setFiles(undefined) 50 | setPreviewURL(undefined) 51 | setSelectedFilename(undefined) 52 | setZip(undefined) 53 | }, []) 54 | 55 | // create the editor when the container node is mounted 56 | const editorRef = useCallback((node: HTMLDivElement | null) => { 57 | if (node) { 58 | import(/* webpackPrefetch: true */ 'monaco-themes/themes/GitHub.json') 59 | .then(async (theme) => { 60 | monaco.editor.defineTheme( 61 | 'github', 62 | theme as monaco.editor.IStandaloneThemeData 63 | ) 64 | 65 | const editor = monaco.editor.create(node, { 66 | wordWrap: 'on', 67 | theme: 'github', 68 | // automaticLayout: true, 69 | }) 70 | 71 | setEditor(editor) 72 | }) 73 | .catch((error) => { 74 | setError(error.message) 75 | }) 76 | } 77 | }, []) 78 | 79 | // select a file 80 | const selectFile = useCallback((selectedFilename: string) => { 81 | setSelectedFilename(selectedFilename) 82 | setShowFiles(false) 83 | }, []) 84 | 85 | // read file list from the zip 86 | useEffect(() => { 87 | if (file) { 88 | if (editor) { 89 | const prevModel = editor.getModel() 90 | 91 | if (prevModel) { 92 | prevModel.dispose() 93 | } 94 | } 95 | 96 | JSZip.loadAsync(file) 97 | .then((zip) => { 98 | setZip(zip) 99 | 100 | const files: string[] = [] 101 | 102 | for (const zipEntry of Object.values(zip.files)) { 103 | if (!zipEntry.dir) { 104 | files.push(zipEntry.name) 105 | } 106 | } 107 | 108 | files.sort() 109 | 110 | setFiles(files) 111 | 112 | if (files.length) { 113 | selectFile(files[0]) 114 | } 115 | }) 116 | .catch((error) => { 117 | if (file.name.endsWith('.zip')) { 118 | setError(error.message) 119 | } else { 120 | setError('This is not a ZIP file') 121 | } 122 | }) 123 | } 124 | }, [editor, file, selectFile]) 125 | 126 | // open the selected file in the editor 127 | useEffect(() => { 128 | if (zip && editor && selectedFilename) { 129 | const language = chooseLanguage(selectedFilename) 130 | const uri = monaco.Uri.file(selectedFilename) 131 | 132 | const prevModel = editor.getModel() 133 | 134 | if (prevModel) { 135 | prevModel.dispose() 136 | } 137 | 138 | zip 139 | .file(selectedFilename)! 140 | .async('uint8array') 141 | .then(async (uint8array) => { 142 | const result = await imageType(uint8array) 143 | 144 | const previewURL = result 145 | ? URL.createObjectURL(new Blob([uint8array], { type: result.mime })) 146 | : undefined 147 | 148 | const code = decoder.decode(uint8array) 149 | const model = monaco.editor.createModel(code, language, uri) 150 | 151 | setPreviewURL(previewURL) 152 | editor.setModel(model) 153 | editor.updateOptions({ readOnly: !!previewURL }) 154 | editor.focus() 155 | }) 156 | .catch((error) => { 157 | setError(error.message) 158 | }) 159 | } 160 | }, [zip, editor, selectedFilename]) 161 | 162 | // redo the editor layout when the container size changes 163 | const editorContainerMounted = useCallback( 164 | (node) => { 165 | if (node && editor && !observer) { 166 | const observer = new ResizeObserver((entries) => { 167 | for (const entry of entries) { 168 | if (node.isSameNode(entry.target)) { 169 | const { 170 | contentRect: { width, height }, 171 | } = entry 172 | 173 | editor.layout({ width, height }) 174 | } 175 | } 176 | }) 177 | 178 | observer.observe(node) 179 | 180 | setObserver(observer) 181 | } 182 | }, 183 | [editor, observer] 184 | ) 185 | 186 | // download an individual file 187 | const downloadSelectedFile = useCallback(() => { 188 | if (zip && selectedFilename) { 189 | setError(undefined) 190 | zip.files[selectedFilename] 191 | .async('blob') 192 | .then((blob) => { 193 | const blobToSave = new Blob([blob], { 194 | type: 'application/octet-stream', // TODO: detect mime type? 195 | }) 196 | const basename = selectedFilename.split('/').pop() 197 | const extension = selectedFilename.split('.').pop() || 'zip' 198 | return fileSave(blobToSave, { 199 | fileName: basename, 200 | extensions: ['.' + extension], 201 | }) 202 | }) 203 | .catch((error) => { 204 | setError(error.message) 205 | }) 206 | } 207 | }, [zip, selectedFilename]) 208 | 209 | // write the updated data to the file 210 | useEffect(() => { 211 | let disposable: monaco.IDisposable | undefined 212 | 213 | if (editor && zip && selectedFilename) { 214 | disposable = editor.onDidChangeModelContent(() => { 215 | zip.file(selectedFilename, editor.getValue()) 216 | setChanged(true) 217 | }) 218 | } 219 | 220 | return () => { 221 | if (disposable) { 222 | disposable.dispose() 223 | } 224 | } 225 | }, [editor, zip, selectedFilename]) 226 | 227 | // toggle the image preview 228 | const togglePreview = useCallback(() => { 229 | setShowPreview((value) => !value) 230 | }, []) 231 | 232 | // toggle the files sidebar 233 | const toggleFiles = useCallback(() => { 234 | setShowFiles((value) => !value) 235 | }, []) 236 | 237 | // observe narrow window 238 | // NOTE: Safari doesn't support MediaQueryList.addEventListener yet, 239 | // so still using deprecated addListener 240 | useEffect(() => { 241 | const handleChange = (event: MediaQueryListEvent) => { 242 | setNarrow(event.matches) 243 | } 244 | 245 | // noinspection JSDeprecatedSymbols 246 | narrowQuery.addListener(handleChange) 247 | 248 | return () => { 249 | // noinspection JSDeprecatedSymbols 250 | narrowQuery.removeListener(handleChange) 251 | } 252 | }, []) 253 | 254 | return ( 255 |
262 |