├── .yarnrc.yml
├── packages
├── web
│ ├── src
│ │ ├── global.d.ts
│ │ ├── routes
│ │ │ ├── index.tsx
│ │ │ ├── __root.tsx
│ │ │ └── dir
│ │ │ │ ├── $dirId
│ │ │ │ ├── file.module.css
│ │ │ │ └── file.tsx
│ │ │ │ └── $dirId.tsx
│ │ ├── index.tsx
│ │ ├── index.css
│ │ ├── util.ts
│ │ ├── routeTree.gen.ts
│ │ └── Uploader.tsx
│ ├── public
│ │ └── robots.txt
│ ├── tsconfig.json
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
├── electron
│ ├── vite.main.config.ts
│ ├── vite.renderer.config.ts
│ ├── index.html
│ ├── src
│ │ ├── index.css
│ │ ├── renderer.ts
│ │ └── main.ts
│ ├── package.json
│ └── forge.config.js
├── cli
│ ├── tsconfig.json
│ ├── package.json
│ └── src
│ │ └── index.ts
└── lib
│ ├── tsconfig.json
│ ├── src
│ ├── ffmpeg.ts
│ ├── args.ts
│ └── index.ts
│ └── package.json
├── logo.png
├── .github
├── FUNDING.yml
└── workflows
│ ├── test.yml
│ └── publish.yml
├── screenshot.png
├── .eslintignore
├── tsconfig.json
├── .eslintrc.cjs
├── LICENSE
├── package.json
├── .gitignore
└── README.md
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/packages/web/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.module.css';
2 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mifi/ezshare/HEAD/logo.png
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: mifi
2 | custom: https://mifi.no/thanks
3 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mifi/ezshare/HEAD/screenshot.png
--------------------------------------------------------------------------------
/packages/web/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /packages/electron/out
2 | /packages/cli/dist
3 | /packages/lib/dist
4 | /packages/web/dist
5 |
6 | /packages/web/src/routeTree.gen.ts
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "packages/cli" },
4 | { "path": "packages/lib" },
5 | { "path": "packages/web" },
6 | ],
7 | "files": []
8 | }
9 |
--------------------------------------------------------------------------------
/packages/electron/vite.main.config.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vitejs.dev/config
5 | export default defineConfig({});
6 |
--------------------------------------------------------------------------------
/packages/electron/vite.renderer.config.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vitejs.dev/config
5 | export default defineConfig({});
6 |
--------------------------------------------------------------------------------
/packages/cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest", "@tsconfig/node18/tsconfig.json"],
3 | "compilerOptions": {
4 | "outDir": "dist",
5 | "declaration": true,
6 | },
7 | "include": [
8 | "src/**/*",
9 | ],
10 | }
11 |
--------------------------------------------------------------------------------
/packages/lib/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest", "@tsconfig/node18/tsconfig.json"],
3 | "compilerOptions": {
4 | "outDir": "dist",
5 | "declaration": true,
6 | },
7 | "include": [
8 | "src/**/*",
9 | ],
10 | }
11 |
--------------------------------------------------------------------------------
/packages/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest", "@tsconfig/vite-react/tsconfig.json"],
3 | "compilerOptions": {
4 | "declaration": true,
5 | "types": ["vite-plugin-svgr/client"],
6 | },
7 | "include": [
8 | "src/**/*",
9 | ],
10 | }
--------------------------------------------------------------------------------
/packages/web/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, redirect } from '@tanstack/react-router';
2 |
3 |
4 | // eslint-disable-next-line import/prefer-default-export
5 | export const Route = createFileRoute('/')({
6 | beforeLoad: () => {
7 | throw redirect({ to: '/dir/$dirId', params: { dirId: '/' } });
8 | },
9 | });
10 |
--------------------------------------------------------------------------------
/packages/electron/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ezshare
6 |
7 |
8 |
9 |
10 |
11 |
12 | ezshare HTTP server is running.
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/packages/web/src/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet, createRootRoute } from '@tanstack/react-router';
2 | import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
3 |
4 |
5 | // eslint-disable-next-line import/prefer-default-export
6 | export const Route = createRootRoute({
7 | // eslint-disable-next-line no-use-before-define
8 | component: Root,
9 | });
10 |
11 | function Root() {
12 | return (
13 | <>
14 |
15 |
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['mifi'],
3 |
4 | overrides: [
5 | {
6 | files: ['./packages/web/src/**/*.{js,mjs,cjs,mjs,jsx,ts,mts,tsx}'],
7 | rules: {
8 | 'import/no-extraneous-dependencies': 'off',
9 | },
10 | env: {
11 | node: false,
12 | browser: true,
13 | },
14 | },
15 | {
16 | files: ['./packages/web/src/routes/**/*.{js,mjs,cjs,mjs,jsx,ts,mts,tsx}'],
17 | rules: {
18 | 'unicorn/filename-case': 'off',
19 | },
20 | },
21 | ],
22 | };
23 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | env:
9 | # https://github.com/actions/setup-node/issues/531
10 | SKIP_YARN_COREPACK_CHECK: true
11 | steps:
12 | - uses: actions/checkout@v5
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: 22
16 | - run: corepack enable
17 | - run: yarn --immutable
18 | - run: yarn dedupe --check
19 | - run: yarn build
20 | - run: yarn test
21 | - run: yarn lint
22 |
--------------------------------------------------------------------------------
/packages/electron/src/index.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | width: 100vw;
6 | height: 100vh;
7 | font-family: sans-serif;
8 | }
9 |
10 | .dynamic {
11 | display: flex;
12 | flex-direction: column;
13 | gap: 1em;
14 | flex-wrap: wrap;
15 | }
16 |
17 | .url {
18 | margin: .5em;
19 | width: 14em;
20 | overflow: hidden;
21 | }
22 |
23 | .url canvas {
24 | width: 100% !important;
25 | height: auto !important;
26 | }
27 |
28 | .url .text {
29 | margin-top: .5em;
30 | text-align: center;
31 | overflow: hidden;
32 | text-overflow: ellipsis;
33 | }
34 |
--------------------------------------------------------------------------------
/packages/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 | ezshare
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/packages/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { defineConfig } from 'vite';
3 | // eslint-disable-next-line import/no-extraneous-dependencies
4 | import react from '@vitejs/plugin-react';
5 | import { tanstackRouter } from '@tanstack/router-plugin/vite';
6 |
7 |
8 | export default defineConfig({
9 | clearScreen: false,
10 | plugins: [
11 | // Please make sure that '@tanstack/router-plugin' is passed before '@vitejs/plugin-react'
12 | tanstackRouter({
13 | target: 'react',
14 | autoCodeSplitting: true,
15 | }),
16 | react(),
17 | ],
18 | server: {
19 | open: false,
20 | port: 3000,
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/packages/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@ezshare/cli",
3 | "version": "2.0.1",
4 | "type": "module",
5 | "bin": {
6 | "ezshare": "dist/index.js"
7 | },
8 | "scripts": {
9 | "build": "tsc --preserveWatchOutput",
10 | "start": "node --enable-source-maps --watch dist --dev-mode",
11 | "dev": "yarn build && concurrently -k \"yarn build --watch\" \"yarn start\"",
12 | "prepack": "yarn build"
13 | },
14 | "files": [
15 | "/dist/**"
16 | ],
17 | "publishConfig": {
18 | "access": "public"
19 | },
20 | "dependencies": {
21 | "@ezshare/lib": "workspace:^",
22 | "concurrently": "*",
23 | "qrcode-terminal": "^0.12.0"
24 | },
25 | "devDependencies": {
26 | "@tsconfig/node18": "^18.2.4",
27 | "@tsconfig/strictest": "^2.0.5",
28 | "typescript": "*"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | workflow_dispatch:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | build:
10 | strategy:
11 | matrix:
12 | os: [macos-latest, ubuntu-latest, windows-latest]
13 | runs-on: ${{ matrix.os }}
14 | timeout-minutes: 60
15 | permissions:
16 | contents: write
17 | env:
18 | # https://github.com/actions/setup-node/issues/531
19 | SKIP_YARN_COREPACK_CHECK: true
20 | steps:
21 | - uses: actions/checkout@v5
22 | - uses: actions/setup-node@v4
23 | with:
24 | node-version: 22
25 | - run: corepack enable
26 | - run: yarn --immutable
27 | - run: yarn build
28 | - run: yarn workspace @ezshare/electron publish
29 | env:
30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31 |
--------------------------------------------------------------------------------
/packages/electron/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@ezshare/electron",
3 | "productName": "ezshare",
4 | "version": "2.0.0",
5 | "private": true,
6 | "type": "commonjs",
7 | "main": ".vite/build/main.js",
8 | "dependencies": {
9 | "@ezshare/lib": "workspace:^"
10 | },
11 | "scripts": {
12 | "dev": "electron-forge start",
13 | "package": "electron-forge package",
14 | "make": "electron-forge make",
15 | "publish": "electron-forge publish"
16 | },
17 | "packageManager": "yarn@4.9.4",
18 | "devDependencies": {
19 | "@electron-forge/cli": "^7.8.3",
20 | "@electron-forge/maker-zip": "^7.8.3",
21 | "@electron-forge/plugin-vite": "^7.8.3",
22 | "@electron-forge/publisher-github": "^7.8.3",
23 | "@types/qrcode": "^1",
24 | "electron": "^37.3.1",
25 | "qrcode": "^1.5.4",
26 | "vite": "5"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { RouterProvider, createRouter } from '@tanstack/react-router';
4 |
5 | import 'sweetalert2/src/sweetalert2.scss';
6 | import 'fontsource-roboto';
7 |
8 | import './index.css';
9 |
10 | // Import the generated route tree
11 | import { routeTree } from './routeTree.gen';
12 |
13 |
14 | // Create a new router instance
15 | const router = createRouter({ routeTree });
16 |
17 | // Register the router instance for type safety
18 | declare module '@tanstack/react-router' {
19 | interface Register {
20 | router: typeof router
21 | }
22 | }
23 |
24 | const container = document.getElementById('root');
25 | const root = createRoot(container!);
26 | root.render(
27 |
28 |
29 | ,
30 | );
31 |
--------------------------------------------------------------------------------
/packages/lib/src/ffmpeg.ts:
--------------------------------------------------------------------------------
1 | import { execa } from 'execa';
2 |
3 |
4 | export default function Ffmpeg({ ffmpegPath }: { ffmpegPath: string }) {
5 | let hasFfmpeg = false;
6 |
7 | async function runStartupCheck() {
8 | // will throw if exit code != 0
9 | await execa(ffmpegPath, ['-hide_banner', '-f', 'lavfi', '-i', 'nullsrc=s=256x256:d=1', '-f', 'null', '-']);
10 | hasFfmpeg = true;
11 | }
12 |
13 | async function renderThumbnail(filePath: string) {
14 | const { stdout } = await execa(ffmpegPath, [ // todo stream instead
15 | // '-ss', String(timestamp),
16 | '-i', filePath,
17 | '-vf', 'scale=-2:200',
18 | '-vframes', '1',
19 | '-q:v', '10',
20 | '-c:v', 'mjpeg',
21 | '-update', '1',
22 | '-f', 'image2',
23 | '-',
24 | ], { encoding: 'buffer' });
25 |
26 | return stdout;
27 | }
28 |
29 | return {
30 | runStartupCheck,
31 | renderThumbnail,
32 | hasFfmpeg: () => hasFfmpeg,
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Mikael Finstad
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 |
--------------------------------------------------------------------------------
/packages/cli/src/index.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import assert from 'node:assert';
4 | import { dirname } from 'node:path';
5 | import qrcode from 'qrcode-terminal';
6 | import { fileURLToPath } from 'node:url';
7 |
8 | import App, { parseArgs } from '@ezshare/lib';
9 |
10 |
11 | const args = parseArgs(process.argv.slice(2));
12 |
13 | const indexUrl = await import.meta.resolve?.('@ezshare/web/dist/index.html');
14 | assert(indexUrl);
15 | const webPath = dirname(fileURLToPath(indexUrl));
16 |
17 | const { start, runStartupCheck, getUrls, sharedPath } = App({ ...args, webPath });
18 |
19 | const urls = getUrls();
20 | if (urls.length === 0) {
21 | console.warn('No network interfaces detected.');
22 | } else {
23 | await runStartupCheck();
24 |
25 | await start();
26 |
27 | console.log('Server listening:');
28 | urls.forEach((url) => {
29 | console.log();
30 | console.log(`Scan this QR code on your phone or enter ${url}`);
31 | console.log();
32 | qrcode.generate(url);
33 | });
34 | if (urls.length > 1) {
35 | console.log('Note that there are multiple QR codes above, one for each network interface. (scroll up)');
36 | }
37 |
38 | console.log(`Sharing path ${sharedPath}`);
39 | }
40 |
--------------------------------------------------------------------------------
/packages/web/src/index.css:
--------------------------------------------------------------------------------
1 | /* https://colorhunt.co/palette/175167 */
2 |
3 | body {
4 | margin: 0;
5 | font-family: 'Roboto', sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 |
9 | background: #f9f9f9;
10 | color: rgba(0,0,0,0.8);
11 | overflow: hidden;
12 | }
13 |
14 | h1, h2, h3 {
15 | text-align: center;
16 | margin-top: 0px;
17 | margin-left: 8px;
18 | margin-right: 8px;
19 | color: rgba(0,0,0,0.6);
20 | font-weight: 400;
21 | text-transform: uppercase;
22 | }
23 |
24 | h2 {
25 | margin-bottom: 0.3em;
26 | }
27 |
28 | .icon-spin {
29 | -webkit-animation: icon-spin 2s infinite linear;
30 | animation: icon-spin 2s infinite linear;
31 | }
32 |
33 | @-webkit-keyframes icon-spin {
34 | 0% {
35 | -webkit-transform: rotate(0deg);
36 | transform: rotate(0deg);
37 | }
38 | 100% {
39 | -webkit-transform: rotate(359deg);
40 | transform: rotate(359deg);
41 | }
42 | }
43 |
44 | @keyframes icon-spin {
45 | 0% {
46 | -webkit-transform: rotate(0deg);
47 | transform: rotate(0deg);
48 | }
49 | 100% {
50 | -webkit-transform: rotate(359deg);
51 | transform: rotate(359deg);
52 | }
53 | }
--------------------------------------------------------------------------------
/packages/electron/forge.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const { dirname } = require('node:path');
3 |
4 |
5 | module.exports = {
6 | packagerConfig: {
7 | asar: true,
8 | extraResource: [
9 | dirname(require.resolve('@ezshare/web/dist/index.html')),
10 | ],
11 | },
12 | rebuildConfig: {},
13 | makers: [
14 | {
15 | name: '@electron-forge/maker-zip',
16 | platforms: ['darwin', 'win32', 'linux'],
17 | config: {},
18 | },
19 | ],
20 | plugins: [
21 | {
22 | name: '@electron-forge/plugin-vite',
23 | config: {
24 | build: [
25 | {
26 | entry: 'src/main.ts',
27 | config: 'vite.main.config.ts',
28 | target: 'main',
29 | },
30 | ],
31 | renderer: [
32 | {
33 | name: 'main_window',
34 | config: 'vite.renderer.config.ts',
35 | },
36 | ],
37 | },
38 | },
39 | ],
40 | publishers: [
41 | {
42 | name: '@electron-forge/publisher-github',
43 | config: {
44 | repository: {
45 | owner: 'mifi',
46 | name: 'ezshare',
47 | },
48 | },
49 | },
50 | ],
51 | };
52 |
--------------------------------------------------------------------------------
/packages/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@ezshare/web",
3 | "version": "0.0.2",
4 | "type": "module",
5 | "files": [
6 | "/dist"
7 | ],
8 | "publishConfig": {
9 | "access": "public"
10 | },
11 | "scripts": {
12 | "build": "vite build",
13 | "dev": "vite"
14 | },
15 | "devDependencies": {
16 | "@tanstack/react-router": "^1.131.27",
17 | "@tanstack/react-router-devtools": "^1.131.27",
18 | "@tanstack/router-plugin": "^1.131.27",
19 | "@tsconfig/strictest": "^2.0.5",
20 | "@tsconfig/vite-react": "^3.0.2",
21 | "@types/css-modules": "^1.0.5",
22 | "@types/react": "^18.3.12",
23 | "@types/react-dom": "^18.3.1",
24 | "@vitejs/plugin-react": "^4.2.1",
25 | "axios": "^0.28.0",
26 | "fontsource-roboto": "^3.0.3",
27 | "framer-motion": "^11.11.10",
28 | "react": "^18.3.1",
29 | "react-circular-progressbar": "^2.1.0",
30 | "react-clipboard.js": "^2.0.16",
31 | "react-dom": "^18.3.1",
32 | "react-dropzone": "^10.2.2",
33 | "react-icons": "^3.9.0",
34 | "sass-embedded": "^1.80.3",
35 | "sweetalert2": "^11.22.5",
36 | "tiny-invariant": "^1.3.3",
37 | "vite": "^7.1.3",
38 | "vite-plugin-svgr": "^4.3.0",
39 | "zod": "^4.0.17"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/electron/src/renderer.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import QRCode from 'qrcode';
3 |
4 | import './index.css';
5 |
6 | const { ipcRenderer } = window.require('electron');
7 |
8 | async function fetchData() {
9 | const container = document.getElementById('dynamic');
10 | if (!container) throw new Error('No container found');
11 |
12 | const { urls }: { urls: string[] } = await ipcRenderer.invoke('getData');
13 |
14 | container.replaceChildren();
15 |
16 | await Promise.all(urls.map(async (url) => {
17 | const canvas = document.createElement('canvas');
18 | canvas.width = 300;
19 | canvas.height = 300;
20 |
21 | const node = document.createElement('div');
22 | node.className = 'url';
23 |
24 | const textNode = document.createElement('div');
25 | textNode.textContent = url;
26 | textNode.className = 'text';
27 |
28 | node.append(canvas);
29 | node.append(textNode);
30 | container.append(node);
31 |
32 | await new Promise((resolve, reject) => QRCode.toCanvas(canvas, url, { margin: 0 }, (error) => {
33 | if (error) reject(error);
34 | else resolve();
35 | }));
36 | }));
37 | }
38 |
39 | document.addEventListener('DOMContentLoaded', fetchData);
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ezshare",
3 | "version": "1.12.0",
4 | "description": "Easily share and receive files over a local network",
5 | "main": "dist/index.js",
6 | "repository": "https://github.com/mifi/ezshare",
7 | "author": "Mikael Finstad ",
8 | "license": "MIT",
9 | "engines": {
10 | "node": ">=18"
11 | },
12 | "workspaces": [
13 | "packages/*"
14 | ],
15 | "type": "module",
16 | "scripts": {
17 | "test": "exit 0",
18 | "lint": "eslint .",
19 | "build": "yarn workspaces foreach -Apt run build",
20 | "clean": "git clean -Xfd packages -e '!node_modules' -e '!**/node_modules/**/*'",
21 | "dev:cli": "yarn build && yarn workspaces foreach -Rpi --from @ezshare/cli run dev",
22 | "dev:electron": "yarn build && yarn workspaces foreach -Rp -i --from @ezshare/electron run dev"
23 | },
24 | "devDependencies": {
25 | "@typescript-eslint/eslint-plugin": "^6.12.0",
26 | "@typescript-eslint/parser": "^6.12.0",
27 | "concurrently": "^5.1.0",
28 | "eslint": "^8.2.0",
29 | "eslint-config-mifi": "^0.0.6",
30 | "eslint-plugin-import": "^2.25.3",
31 | "eslint-plugin-jsx-a11y": "^6.5.1",
32 | "eslint-plugin-react": "^7.28.0",
33 | "eslint-plugin-react-hooks": "^4.3.0",
34 | "eslint-plugin-unicorn": "^51.0.1",
35 | "typescript": "^5.6.3"
36 | },
37 | "packageManager": "yarn@4.9.4"
38 | }
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | .pnp.*
58 | .yarn/*
59 | !.yarn/patches
60 | !.yarn/plugins
61 | !.yarn/releases
62 | !.yarn/sdks
63 | !.yarn/versions
64 |
65 | # dotenv environment variables file
66 | .env
67 |
68 | .vite
69 |
70 | tsconfig.tsbuildinfo
71 |
72 | /packages/electron/out
73 | /packages/cli/dist
74 | /packages/lib/dist
75 | /packages/web/dist
76 |
--------------------------------------------------------------------------------
/packages/web/src/routes/dir/$dirId/file.module.css:
--------------------------------------------------------------------------------
1 | .dialog {
2 | --duration: 0.2s;
3 |
4 | border: .1em solid var(--black-a5);
5 | background: var(--white-a8);
6 | color: var(--gray-12);
7 | backdrop-filter: blur(2em);
8 | border-radius: .5em;
9 | padding: 1.7em;
10 | box-shadow: 0 0 1em .3em var(--black-a1);
11 | transform-origin: center;
12 | transition:
13 | translate var(--duration) ease-out,
14 | scale var(--duration) ease-out,
15 | opacity var(--duration) ease-out,
16 | display var(--duration) ease-out allow-discrete;
17 |
18 | &[open] {
19 | /* Post-Entry (Normal) State */
20 | scale: 1;
21 | opacity: 1;
22 |
23 | /* Pre-Entry State */
24 | @starting-style {
25 | scale: 0.6;
26 | opacity: 0;
27 | }
28 | }
29 |
30 | &::backdrop {
31 | background-color: var(--black-a7);
32 | animation: overlayShow 600ms cubic-bezier(0.16, 1, 0.3, 1);
33 | }
34 |
35 | h1 {
36 | text-transform: uppercase;
37 | font-size: 1.3em;
38 | margin-top: 0;
39 | }
40 | }
41 |
42 | .dialog * {
43 | -webkit-user-select: none;
44 | user-select: none;
45 | -webkit-touch-callout:none;
46 | touch-action: pan-x pan-y;
47 | }
48 |
49 | :global(.dark-theme) .dialog {
50 | border: .1em solid var(--white-a3);
51 | background: var(--black-a4);
52 | box-shadow: 0 0 1em .3em var(--black-a2);
53 | }
54 |
55 | @keyframes overlayShow {
56 | from {
57 | opacity: 0;
58 | }
59 | to {
60 | opacity: 1;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/packages/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@ezshare/lib",
3 | "version": "0.0.2",
4 | "type": "module",
5 | "main": "dist/index.js",
6 | "scripts": {
7 | "build": "tsc --preserveWatchOutput",
8 | "dev": "yarn build --watch"
9 | },
10 | "files": [
11 | "/dist/**"
12 | ],
13 | "publishConfig": {
14 | "access": "public"
15 | },
16 | "types": "dist/index.d.ts",
17 | "dependencies": {
18 | "@ezshare/web": "workspace:^",
19 | "archiver": "^3.1.1",
20 | "basic-auth": "^2.0.1",
21 | "body-parser": "^1.19.0",
22 | "clipboardy": "^2.3.0",
23 | "content-disposition": "^0.5.3",
24 | "execa": "^9.6.0",
25 | "express": "^4.17.1",
26 | "express-async-handler": "^1.1.4",
27 | "filenamify": "^4.2.0",
28 | "formidable": "^3.5.2",
29 | "http-proxy-middleware": "^3.0.3",
30 | "is-path-inside": "^4.0.0",
31 | "lodash": "^4.17.15",
32 | "morgan": "^1.9.1",
33 | "p-map": "^4.0.0",
34 | "range-parser": "^1.2.1",
35 | "yargs": "^18.0.0"
36 | },
37 | "devDependencies": {
38 | "@tsconfig/node18": "^18.2.4",
39 | "@tsconfig/strictest": "^2.0.5",
40 | "@types/archiver": "3",
41 | "@types/basic-auth": "^1.1.8",
42 | "@types/content-disposition": "^0.5.8",
43 | "@types/express": "4",
44 | "@types/formidable": "3",
45 | "@types/lodash": "^4.17.12",
46 | "@types/morgan": "^1.9.9",
47 | "@types/node": "18",
48 | "@types/qrcode-terminal": "^0.12.2",
49 | "@types/yargs": "^17.0.33",
50 | "typescript": "*"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/packages/web/src/util.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Swal from 'sweetalert2';
3 | import invariant from 'tiny-invariant';
4 |
5 | export interface EzshareFile {
6 | path: string,
7 | fileName: string
8 | isDir: boolean,
9 | }
10 |
11 | export interface CurrentDir {
12 | sharedPath?: string, cwd: string, files: EzshareFile[]
13 | }
14 |
15 | export const Toast = Swal.mixin({
16 | toast: true,
17 | showConfirmButton: false,
18 | timer: 3000,
19 | position: 'top',
20 | });
21 |
22 | export const rootPath = '/';
23 |
24 | export const colorLink = '#db6400';
25 | export const colorLink2 = '#ffa62b';
26 |
27 | export const boxBackgroundColor = '#fff';
28 | export const headingBackgroundColor = '#16697a';
29 | export const iconColor = '#ffa62b'; // 'rgba(0,0,0,0.3)'
30 |
31 | export const mightBeVideo = ({ isDir, fileName }: { isDir?: boolean, fileName: string }) => !isDir && /\.(mp4|m4v|mov|qt|webm|mkv|avi|flv|vob|ogg|ogv|mpe?g|m2v|mp2|mpv)$/i.test(fileName);
32 | export const mightBeImage = ({ isDir, fileName }: { isDir?: boolean, fileName: string }) => !isDir && /\.(jpe?g|png|gif|webp)$/i.test(fileName);
33 |
34 | export const getDownloadUrl = (path: string, cacheBuster?: boolean, forceDownload?: boolean) => `/api/download?f=${encodeURIComponent(path)}&forceDownload=${forceDownload ? 'true' : 'false'}&_=${cacheBuster ? Date.now() : 0}`;
35 | export const getThumbUrl = (path: string) => `/api/thumbnail?f=${encodeURIComponent(path)}`;
36 |
37 | export interface MainContext {
38 | currentPath: string,
39 | currentDir: CurrentDir,
40 | loadDir: (path: string) => Promise,
41 | }
42 |
43 | export const Context = React.createContext(undefined);
44 |
45 | export function useContext() {
46 | const context = React.useContext(Context);
47 | invariant(context);
48 | return context;
49 | }
50 |
--------------------------------------------------------------------------------
/packages/lib/src/args.ts:
--------------------------------------------------------------------------------
1 | import yargs from 'yargs/yargs';
2 | import assert from 'node:assert';
3 |
4 |
5 | export default function parse(args: string[]) {
6 | const usage = `
7 | Usage
8 | $ ezshare [shared_path]
9 |
10 | Options
11 | [shared_path] If omitted, will use current directory
12 | --port Port to listen (default 8080)
13 | --max-upload-size Max upload file size (default 16 GB)
14 | --zip-compression-level ZIP compression level (default 0, no compression - faster)
15 | --username Username for basic authentication
16 | --password Password for basic authentication
17 | --ffmpeg-path Path to ffmpeg executable (default 'ffmpeg')
18 |
19 | Examples
20 | $ ezshare
21 | Shares all files and folders under the current directory (cd)
22 |
23 | $ ezshare /Users/me
24 | Shares all files and folders under /Users/me
25 | `;
26 |
27 | const { port, maxUploadSize, zipCompressionLevel, devMode, username, password, ffmpegPath, _ } = yargs(args).usage(usage).options({
28 | devMode: { type: 'boolean', default: false },
29 | port: { type: 'number', default: 8080 },
30 | maxUploadSize: { type: 'number', default: 16 * 1024 * 1024 * 1024 },
31 | zipCompressionLevel: { type: 'number', default: 0 },
32 | username: { type: 'string' },
33 | password: { type: 'string' },
34 | ffmpegPath: { type: 'string', default: 'ffmpeg' },
35 | }).parseSync();
36 |
37 | if (zipCompressionLevel != null) {
38 | assert(zipCompressionLevel <= 9 && zipCompressionLevel >= 0, 'zip-compression-level must be between 0 and 9');
39 | }
40 |
41 | assert((username == null && password == null) || (username != null && password != null), 'If username is provided, password must also be provided');
42 | const auth = username && password ? { username, password } : undefined;
43 |
44 | const sharedPath = _[0];
45 | assert(typeof sharedPath === 'string' || sharedPath == null);
46 |
47 | return {
48 | sharedPath,
49 | port,
50 | maxUploadSize,
51 | zipCompressionLevel,
52 | devMode,
53 | auth,
54 | ffmpegPath,
55 | };
56 | }
57 |
--------------------------------------------------------------------------------
/packages/electron/src/main.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { app, BrowserWindow, ipcMain } from 'electron';
3 | import { join } from 'node:path';
4 |
5 | import Ezshare, { parseArgs } from '@ezshare/lib';
6 |
7 |
8 | declare global {
9 | const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
10 | const MAIN_WINDOW_VITE_NAME: string;
11 | }
12 |
13 | function createWindow() {
14 | const mainWindow = new BrowserWindow({
15 | width: 800,
16 | height: 600,
17 | webPreferences: {
18 | contextIsolation: false,
19 | nodeIntegration: true,
20 | },
21 | });
22 |
23 | if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
24 | mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
25 | } else {
26 | mainWindow.loadFile(join(__dirname, '..', 'renderer', MAIN_WINDOW_VITE_NAME, 'index.html'));
27 | }
28 | }
29 |
30 | app.on('window-all-closed', () => {
31 | if (process.platform !== 'darwin') app.quit();
32 | });
33 |
34 | app.on('activate', () => {
35 | if (BrowserWindow.getAllWindows().length === 0) {
36 | createWindow();
37 | }
38 | });
39 |
40 | (async () => {
41 | await app.whenReady();
42 |
43 | const ignoreFirstArgs = process.defaultApp ? 2 : 1;
44 | // production: First arg is the LosslessCut executable
45 | // dev: First 2 args are electron and the index.js
46 | const argsWithoutAppName = process.argv.length > ignoreFirstArgs ? process.argv.slice(ignoreFirstArgs) : [];
47 |
48 | const args = parseArgs(argsWithoutAppName);
49 | const webPath = app.isPackaged ? join(process.resourcesPath, 'dist') : join(__dirname, '..', '..', '..', 'web', 'dist');
50 | const { start, runStartupCheck, getUrls } = Ezshare({ ...args, webPath });
51 |
52 | const urls = getUrls();
53 | if (urls.length === 0) {
54 | console.warn('No network interfaces detected.');
55 | } else {
56 | await runStartupCheck();
57 |
58 | await start();
59 |
60 | console.log('Server listening');
61 |
62 | ipcMain.handle('getData', async () => ({
63 | urls,
64 | }));
65 |
66 | createWindow();
67 | }
68 | // eslint-disable-next-line unicorn/prefer-top-level-await
69 | })().catch((err) => console.error(err));
70 |
--------------------------------------------------------------------------------
/packages/web/src/routeTree.gen.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | // @ts-nocheck
4 |
5 | // noinspection JSUnusedGlobalSymbols
6 |
7 | // This file was automatically generated by TanStack Router.
8 | // You should NOT make any changes in this file as it will be overwritten.
9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10 |
11 | import { Route as rootRouteImport } from './routes/__root'
12 | import { Route as IndexRouteImport } from './routes/index'
13 | import { Route as DirDirIdRouteImport } from './routes/dir/$dirId'
14 | import { Route as DirDirIdFileRouteImport } from './routes/dir/$dirId/file'
15 |
16 | const IndexRoute = IndexRouteImport.update({
17 | id: '/',
18 | path: '/',
19 | getParentRoute: () => rootRouteImport,
20 | } as any)
21 | const DirDirIdRoute = DirDirIdRouteImport.update({
22 | id: '/dir/$dirId',
23 | path: '/dir/$dirId',
24 | getParentRoute: () => rootRouteImport,
25 | } as any)
26 | const DirDirIdFileRoute = DirDirIdFileRouteImport.update({
27 | id: '/file',
28 | path: '/file',
29 | getParentRoute: () => DirDirIdRoute,
30 | } as any)
31 |
32 | export interface FileRoutesByFullPath {
33 | '/': typeof IndexRoute
34 | '/dir/$dirId': typeof DirDirIdRouteWithChildren
35 | '/dir/$dirId/file': typeof DirDirIdFileRoute
36 | }
37 | export interface FileRoutesByTo {
38 | '/': typeof IndexRoute
39 | '/dir/$dirId': typeof DirDirIdRouteWithChildren
40 | '/dir/$dirId/file': typeof DirDirIdFileRoute
41 | }
42 | export interface FileRoutesById {
43 | __root__: typeof rootRouteImport
44 | '/': typeof IndexRoute
45 | '/dir/$dirId': typeof DirDirIdRouteWithChildren
46 | '/dir/$dirId/file': typeof DirDirIdFileRoute
47 | }
48 | export interface FileRouteTypes {
49 | fileRoutesByFullPath: FileRoutesByFullPath
50 | fullPaths: '/' | '/dir/$dirId' | '/dir/$dirId/file'
51 | fileRoutesByTo: FileRoutesByTo
52 | to: '/' | '/dir/$dirId' | '/dir/$dirId/file'
53 | id: '__root__' | '/' | '/dir/$dirId' | '/dir/$dirId/file'
54 | fileRoutesById: FileRoutesById
55 | }
56 | export interface RootRouteChildren {
57 | IndexRoute: typeof IndexRoute
58 | DirDirIdRoute: typeof DirDirIdRouteWithChildren
59 | }
60 |
61 | declare module '@tanstack/react-router' {
62 | interface FileRoutesByPath {
63 | '/': {
64 | id: '/'
65 | path: '/'
66 | fullPath: '/'
67 | preLoaderRoute: typeof IndexRouteImport
68 | parentRoute: typeof rootRouteImport
69 | }
70 | '/dir/$dirId': {
71 | id: '/dir/$dirId'
72 | path: '/dir/$dirId'
73 | fullPath: '/dir/$dirId'
74 | preLoaderRoute: typeof DirDirIdRouteImport
75 | parentRoute: typeof rootRouteImport
76 | }
77 | '/dir/$dirId/file': {
78 | id: '/dir/$dirId/file'
79 | path: '/file'
80 | fullPath: '/dir/$dirId/file'
81 | preLoaderRoute: typeof DirDirIdFileRouteImport
82 | parentRoute: typeof DirDirIdRoute
83 | }
84 | }
85 | }
86 |
87 | interface DirDirIdRouteChildren {
88 | DirDirIdFileRoute: typeof DirDirIdFileRoute
89 | }
90 |
91 | const DirDirIdRouteChildren: DirDirIdRouteChildren = {
92 | DirDirIdFileRoute: DirDirIdFileRoute,
93 | }
94 |
95 | const DirDirIdRouteWithChildren = DirDirIdRoute._addFileChildren(
96 | DirDirIdRouteChildren,
97 | )
98 |
99 | const rootRouteChildren: RootRouteChildren = {
100 | IndexRoute: IndexRoute,
101 | DirDirIdRoute: DirDirIdRouteWithChildren,
102 | }
103 | export const routeTree = rootRouteImport
104 | ._addFileChildren(rootRouteChildren)
105 | ._addFileTypes()
106 |
--------------------------------------------------------------------------------
/packages/web/src/Uploader.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import axios, { AxiosError } from 'axios';
3 |
4 | import { FaFileUpload } from 'react-icons/fa';
5 | import { useDropzone } from 'react-dropzone';
6 |
7 | import { CircularProgressbar } from 'react-circular-progressbar';
8 | import 'react-circular-progressbar/dist/styles.css';
9 | import { boxBackgroundColor, iconColor, Toast } from './util';
10 |
11 |
12 | export default function Uploader({ onUploadSuccess, cwd }: {
13 | onUploadSuccess: () => void,
14 | cwd: string,
15 | }) {
16 | const [uploadProgress, setUploadProgress] = useState();
17 | const [uploadSpeed, setUploadSpeed] = useState();
18 |
19 | const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: File[]) => {
20 | // console.log(acceptedFiles);
21 |
22 | if (rejectedFiles && rejectedFiles.length > 0) {
23 | Toast.fire({ icon: 'warning', title: 'Some file was not accepted' });
24 | }
25 |
26 | async function upload() {
27 | let dataTotal: number;
28 | let dataLoaded: number;
29 | let startTime: number;
30 |
31 | try {
32 | // Toast.fire({ title: `${acceptedFiles.length} ${rejectedFiles.length}` });
33 | setUploadProgress(0);
34 | const data = new FormData();
35 | acceptedFiles.forEach((file) => data.append('files', file));
36 |
37 | const onUploadProgress = (progressEvent: ProgressEvent) => {
38 | dataTotal = progressEvent.total;
39 | dataLoaded = progressEvent.loaded;
40 | if (!startTime && dataLoaded) startTime = Date.now();
41 | setUploadProgress(dataLoaded / dataTotal);
42 | if (dataLoaded && startTime) setUploadSpeed(dataLoaded / ((Date.now() - startTime) / 1000));
43 | };
44 |
45 | await axios.post(`/api/upload?path=${encodeURIComponent(cwd)}`, data, { onUploadProgress });
46 |
47 | Toast.fire({ icon: 'success', title: 'File(s) uploaded successfully' });
48 | onUploadSuccess();
49 | } catch (err) {
50 | console.error('Upload failed', err);
51 | const message = (err instanceof AxiosError && err.response?.data.error.message) || (err as Error).message;
52 | Toast.fire({ icon: 'error', title: `Upload failed: ${message}` });
53 | } finally {
54 | setUploadProgress(undefined);
55 | setUploadSpeed(undefined);
56 | }
57 | }
58 |
59 | upload();
60 | }, [cwd, onUploadSuccess]);
61 |
62 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });
63 |
64 | if (uploadProgress != null) {
65 | const percentage = Math.round(uploadProgress * 100);
66 | return (
67 |
68 |
69 |
70 |
71 | {uploadSpeed &&
{(uploadSpeed / 1e6).toFixed(2)}MB/s
}
72 |
73 | );
74 | }
75 |
76 | return (
77 | // eslint-disable-next-line react/jsx-props-no-spreading
78 |
79 | {/* eslint-disable-next-line react/jsx-props-no-spreading */}
80 |
81 |
82 |
83 |
84 |
85 | {isDragActive ? 'Drop files here to upload' : 'Drag \'n drop some files here, or press to select files to upload'}
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | A simple **file server** that lets you easily share many big files like photos and videos with friends (or between your devices) over a local network without requiring an internet connection. It starts an HTTP server that lists all files and directories in the directory where it is started from. Then anyone can then connect to the server and download files or automatically generated ZIP of whole directories, kind of like a self hosted Google Drive. The client can also upload files to the server via their browser, and clipboard card be shared both ways. A QR code is generated for convenience.
4 |
5 | 
6 |
7 | ## Features
8 | - Local two-way transfers without going through the internet
9 | - Send full quality photos and videos to others without needing a fast internet connection
10 | - Handles really big files and directories
11 | - Handles a LOT of files
12 | - Auto ZIPs directories on-the-fly
13 | - Two-way sharing of clipboard
14 | - The web client works on all major platforms, including iOS and Android (however the server must run on a Mac/Windows/Linux computer)
15 | - Video/image playback and slideshows
16 |
17 | ## Install (with Node.js / npm)
18 |
19 | - Install [Node.js](https://nodejs.org) and open a terminal:
20 |
21 | ```bash
22 | npm install -g ezshare
23 | ```
24 |
25 | ## Install (standalone)
26 |
27 | If you don't want to install Node.js, you can download Electron based executable of `ezshare` from Releases.
28 |
29 | ## Migrate from v1 to v2
30 |
31 | ```bash
32 | npm uninstall -g ezshare
33 | npm i -g @ezshare/cli
34 | ```
35 |
36 | ## Usage
37 |
38 | - Open a terminal and run:
39 | - `cd /path/to/your/shared/folder`
40 | - `ezshare`
41 | - Open the browser in the other end to the printed URL
42 | - Start to upload or download files to/from this folder!
43 | - **Note** that the two devices need to be on the same WiFi (or possibly personal hotspot)
44 |
45 | **Alternatively** you can pass it the path you want to share:
46 | ```bash
47 | ezshare /your/shared/folder
48 | ```
49 |
50 | For more info run `ezshare --help`
51 |
52 | ## Supported platforms
53 | - The web client with all operating systems that have a modern browser. iOS, Android, Mac, Windows, ++
54 | - The command line application works on all major desktop OS (Mac, Windows, Linux)
55 |
56 | ## Share over internet without NAT
57 |
58 | If you want to share a file over internet, you can use a service like [Ngrok](https://ngrok.com/) or [Cloudflare tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/).
59 |
60 | Ngrok:
61 | ```bash
62 | ngrok http http://ip.of.ezshare:8080/
63 | ```
64 |
65 | Cloudflare tunnel:
66 | ```bash
67 | cloudflared tunnel --url http://ip.of.ezshare:8080/ --no-autoupdate
68 | ```
69 |
70 | Then just share the URL you are given from one of these services.
71 |
72 | More alternatives:
73 | - https://github.com/anderspitman/awesome-tunneling
74 |
75 | ## Development
76 |
77 | ### Running CLI locally
78 |
79 | ```bash
80 | yarn dev:cli
81 | ```
82 |
83 | ### Running Electron locally
84 |
85 | ```bash
86 | yarn dev:electron
87 | ```
88 |
89 | ### Package locally
90 |
91 | ```bash
92 | yarn build && yarn workspace @ezshare/electron package
93 | ```
94 |
95 | ## Release
96 |
97 | First push and wait for green GitHub Actions.
98 |
99 | Version whichever packages are changed:
100 |
101 | ```bash
102 | yarn workspace @ezshare/web version patch
103 | yarn workspace @ezshare/lib version patch
104 | yarn workspace @ezshare/cli version patch
105 | yarn workspace @ezshare/electron version patch
106 |
107 | git add packages/*/package.json
108 | git commit -m Release
109 | ```
110 |
111 | Now build and publish to npm:
112 |
113 | ```bash
114 | yarn build
115 | yarn workspace @ezshare/web npm publish
116 | yarn workspace @ezshare/lib npm publish
117 | yarn workspace @ezshare/cli npm publish
118 | ```
119 |
120 | - `git push`
121 | - Now trigger [workflow dispatch](https://github.com/mifi/ezshare/actions/workflows/publish.yml) to build the Electron version.
122 | - Wait for GitHub Actions run
123 | - Edit draft to add release notes, and check that artifacts got added.
124 |
125 | ## Credits
126 | - Icon made by [Freepik](https://www.flaticon.com/authors/freepik) from [www.flaticon.com](https://www.flaticon.com/)
127 |
128 | ## See also
129 | - https://github.com/claudiodangelis/qr-filetransfer
130 | - https://github.com/shivensinha4/qr-fileshare
131 |
132 | ---
133 |
134 | Made with ❤️ in 🇳🇴
135 |
136 | [More apps by mifi.no](https://mifi.no/)
137 |
138 | Follow me on [GitHub](https://github.com/mifi/), [YouTube](https://www.youtube.com/channel/UC6XlvVH63g0H54HSJubURQA), [IG](https://www.instagram.com/mifi.no/), [Twitter](https://twitter.com/mifi_no) for more awesome content!
139 |
--------------------------------------------------------------------------------
/packages/lib/src/index.ts:
--------------------------------------------------------------------------------
1 | import express, { Response } from 'express';
2 | import Formidable from 'formidable';
3 | import { createReadStream } from 'node:fs';
4 | import { join, basename, relative, resolve as resolvePath } from 'node:path';
5 | import assert from 'node:assert';
6 | import * as fs from 'node:fs/promises';
7 | import morgan from 'morgan';
8 | import asyncHandler from 'express-async-handler';
9 | import archiver from 'archiver';
10 | import pMap from 'p-map';
11 | import os from 'node:os';
12 | import contentDisposition from 'content-disposition';
13 | import { createProxyMiddleware } from 'http-proxy-middleware';
14 | import clipboardy from 'clipboardy';
15 | import bodyParser from 'body-parser';
16 | import filenamify from 'filenamify';
17 | import stream from 'node:stream/promises';
18 | import parseRange from 'range-parser';
19 | import isPathInside from 'is-path-inside';
20 | import basicAuth from 'basic-auth';
21 | import { timingSafeEqual } from 'node:crypto';
22 |
23 | import Ffmpeg from './ffmpeg.js';
24 |
25 | export { default as parseArgs } from './args.js';
26 |
27 |
28 | const maxFields = 1000;
29 | const debug = false;
30 |
31 | const pathExists = (path: string) => fs.access(path, fs.constants.F_OK).then(() => true).catch(() => false);
32 |
33 | export default ({ sharedPath: sharedPathIn, port, maxUploadSize, zipCompressionLevel, devMode, auth, ffmpegPath, webPath }: {
34 | sharedPath: string | undefined,
35 | port: number,
36 | maxUploadSize: number,
37 | zipCompressionLevel: number,
38 | devMode: boolean,
39 | auth?: { username: string, password: string } | undefined,
40 | ffmpegPath: string,
41 | webPath: string,
42 | }) => {
43 | const ffmpeg = Ffmpeg({ ffmpegPath });
44 |
45 | // console.log({ sharedPath: sharedPathIn, port, maxUploadSize, zipCompressionLevel });
46 | const sharedPath = sharedPathIn ? resolvePath(sharedPathIn) : process.cwd();
47 |
48 | function arePathsEqual(path1: string, path2: string) {
49 | return relative(path1, path2) === '';
50 | }
51 |
52 | async function getFileAbsPath(relPath: string | undefined) {
53 | if (relPath == null) return sharedPath;
54 | const absPath = join(sharedPath, join('/', relPath));
55 | const realPath = await fs.realpath(absPath);
56 | assert(isPathInside(realPath, sharedPath) || arePathsEqual(realPath, sharedPath), `Path must be within shared path ${realPath} ${sharedPath}`);
57 | return realPath;
58 | }
59 |
60 | const app = express();
61 |
62 | app.use((req, res, next) => {
63 | if (auth != null) {
64 | const authRes = basicAuth(req);
65 | try {
66 | assert(authRes);
67 | assert(timingSafeEqual(Buffer.from(authRes.name, 'utf8'), Buffer.from(auth.username, 'utf8'))
68 | && timingSafeEqual(Buffer.from(authRes.pass, 'utf8'), Buffer.from(auth.password, 'utf8')));
69 | } catch {
70 | res.set('WWW-Authenticate', 'Basic realm="ezshare"');
71 | res.status(401).send('Authentication required.');
72 | return;
73 | }
74 | }
75 |
76 | next();
77 | });
78 |
79 | if (debug) app.use(morgan('dev'));
80 |
81 | // NOTE: Must support non latin characters
82 | app.post('/api/upload', bodyParser.json(), asyncHandler(async (req, res) => {
83 | // console.log(req.headers)
84 | const uploadDirPathIn = req.query['path'] || '/';
85 | assert(typeof uploadDirPathIn === 'string');
86 |
87 | const uploadDirPath = await getFileAbsPath(uploadDirPathIn);
88 |
89 | // parse a file upload
90 | const form = Formidable({
91 | keepExtensions: true,
92 | uploadDir: uploadDirPath,
93 | maxFileSize: maxUploadSize,
94 | maxFields,
95 | });
96 |
97 | form.parse(req, async (err, _fields, { files: filesIn }) => {
98 | if (err) {
99 | console.error('Upload failed', err);
100 | res.status(400).send({ error: { message: err.message } });
101 | return;
102 | }
103 |
104 | if (filesIn) {
105 | const files = Array.isArray(filesIn) ? filesIn : [filesIn];
106 |
107 | // console.log(JSON.stringify({ fields, files }, null, 2));
108 | console.log('Uploaded files to', uploadDirPath);
109 | files.forEach((f) => console.log(f.originalFilename, `(${f.size} bytes)`));
110 |
111 | await pMap(files, async (file) => {
112 | try {
113 | const targetPath = join(uploadDirPath, filenamify(file.originalFilename ?? 'file', { maxLength: 255 }));
114 | if (!(await pathExists(targetPath))) await fs.rename(file.filepath, targetPath); // to prevent overwrites
115 | } catch (err2) {
116 | console.error(`Failed to rename ${file.originalFilename}`, err2);
117 | }
118 | }, { concurrency: 10 });
119 | }
120 | res.end();
121 | });
122 | }));
123 |
124 | // NOTE: Must support non latin characters
125 | app.post('/api/paste', bodyParser.urlencoded({ extended: false }), asyncHandler(async (req, res) => {
126 | // eslint-disable-next-line unicorn/prefer-ternary
127 | if (req.body.saveAsFile === 'true') {
128 | await fs.writeFile(join(sharedPath, `client-clipboard-${Date.now()}.txt`), req.body.clipboard);
129 | } else {
130 | await clipboardy.write(req.body.clipboard);
131 | }
132 | res.end();
133 | }));
134 |
135 | // NOTE: Must support non latin characters
136 | app.post('/api/copy', asyncHandler(async (_req, res) => {
137 | res.send(await clipboardy.read());
138 | }));
139 |
140 | async function serveDirZip(filePath: string, res: Response) {
141 | const archive = archiver('zip', {
142 | zlib: { level: zipCompressionLevel },
143 | });
144 |
145 | res.writeHead(200, {
146 | 'Content-Type': 'application/zip',
147 | // NOTE: Must support non latin characters
148 | 'Content-disposition': contentDisposition(`${basename(filePath)}.zip`),
149 | });
150 |
151 | const promise = stream.pipeline(archive, res);
152 |
153 | archive.directory(filePath, basename(filePath));
154 | archive.finalize();
155 |
156 | await promise;
157 | }
158 |
159 | async function serveResumableFileDownload({ filePath, range, res, forceDownload }: {
160 | filePath: string,
161 | range: string | undefined,
162 | res: Response,
163 | forceDownload: boolean,
164 | }) {
165 | if (forceDownload) {
166 | // Set the filename in the Content-disposition header
167 | res.set('Content-disposition', contentDisposition(basename(filePath)));
168 | }
169 |
170 | const { size: fileSize } = await fs.stat(filePath);
171 |
172 | if (range) {
173 | const subranges = parseRange(fileSize, range);
174 | assert(typeof subranges !== 'number');
175 | if (subranges.type !== 'bytes') throw new Error(`Invalid range type ${subranges.type}`);
176 |
177 | if (subranges.length !== 1) throw new Error('Only a single range is supported');
178 | const { start, end } = subranges[0]!;
179 |
180 | const contentLength = (end - start) + 1;
181 |
182 | // Set headers for resumable download
183 | res.status(206).set({
184 | 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
185 | 'Accept-Ranges': 'bytes',
186 | 'Content-Length': contentLength,
187 | 'Content-Type': 'application/octet-stream',
188 | });
189 |
190 | await stream.pipeline(createReadStream(filePath, { start, end }), res);
191 | } else {
192 | // Standard download without resuming
193 | res.set({
194 | // 'Content-Type': 'application/octet-stream',
195 | 'Content-Length': fileSize,
196 | });
197 |
198 | await stream.pipeline(createReadStream(filePath), res);
199 | }
200 | }
201 |
202 | app.get('/api/download', asyncHandler(async (req, res) => {
203 | const { f } = req.query;
204 | assert(typeof f === 'string');
205 | const filePath = await getFileAbsPath(f);
206 | const forceDownload = req.query['forceDownload'] === 'true';
207 |
208 | const lstat = await fs.lstat(filePath);
209 | if (lstat.isDirectory()) {
210 | await serveDirZip(filePath, res);
211 | } else {
212 | const { range } = req.headers;
213 | await serveResumableFileDownload({ filePath, range, res, forceDownload });
214 | }
215 | }));
216 |
217 | app.get('/api/thumbnail', asyncHandler(async (req, res) => {
218 | const { f } = req.query;
219 | assert(typeof f === 'string');
220 | const filePath = await getFileAbsPath(f);
221 |
222 | // todo limit concurrency?
223 | if (!ffmpeg.hasFfmpeg()) {
224 | res.status(500).end();
225 | return;
226 | }
227 | const thumbnail = await ffmpeg.renderThumbnail(filePath);
228 | res.set('Cache-Control', 'private, max-age=300');
229 | res.set('Content-Type', 'image/jpeg');
230 | res.send(Buffer.from(thumbnail));
231 | }));
232 |
233 | app.get('/api/browse', asyncHandler(async (req, res) => {
234 | const browseRelPath = req.query['p'] || '/';
235 | assert(typeof browseRelPath === 'string');
236 | const browseAbsPath = await getFileAbsPath(browseRelPath);
237 |
238 | let readdirEntries = await fs.readdir(browseAbsPath, { withFileTypes: true });
239 | readdirEntries = readdirEntries.sort(({ name: a }, { name: b }) => new Intl.Collator(undefined, { numeric: true }).compare(a, b));
240 |
241 | const entries = (await pMap(readdirEntries, async (entry) => {
242 | try {
243 | // TODO what if a file called ".."
244 | const entryRelPath = join(browseRelPath, entry.name);
245 | const entryAbsPath = join(browseAbsPath, entry.name);
246 | const entryRealPath = await fs.realpath(entryAbsPath);
247 |
248 | if (!entryRealPath.startsWith(sharedPath)) {
249 | console.warn('Ignoring symlink pointing outside shared path', entryRealPath);
250 | return [];
251 | }
252 |
253 | const stat = await fs.lstat(entryRealPath);
254 | const isDir = stat.isDirectory();
255 |
256 | return [{
257 | path: entryRelPath,
258 | isDir,
259 | fileName: entry.name,
260 | }];
261 | } catch (err) {
262 | console.warn((err as Error).message);
263 | // https://github.com/mifi/ezshare/issues/29
264 | return [];
265 | }
266 | }, { concurrency: 10 })).flat();
267 |
268 | res.send({
269 | files: [
270 | { path: join(browseRelPath, '..'), fileName: '..', isDir: true },
271 | ...entries,
272 | ],
273 | cwd: browseRelPath,
274 | sharedPath,
275 | });
276 | }));
277 |
278 |
279 | app.get('/api/zip-files', asyncHandler(async (req, res) => {
280 | const zipFileName = `${new Date().toISOString().replace(/^(\d+-\d+-\d+)T(\d+):(\d+):(\d+).*$/, '$1 $2.$3.$3')}.zip`;
281 | const { files: filesJson } = req.query;
282 | assert(typeof filesJson === 'string');
283 |
284 | const files = JSON.parse(filesJson) as unknown;
285 | assert(Array.isArray(files));
286 |
287 | const archive = archiver('zip', { zlib: { level: zipCompressionLevel } });
288 |
289 | res.writeHead(200, {
290 | 'Content-Type': 'application/zip',
291 | // NOTE: Must support non latin characters
292 | 'Content-Disposition': contentDisposition(zipFileName),
293 | });
294 |
295 | const promise = stream.pipeline(archive, res);
296 |
297 | await pMap(files, async (file: unknown) => {
298 | assert(typeof file === 'string');
299 | const absPath = await getFileAbsPath(file);
300 | // Add each file to the archive:
301 | archive.file(absPath, { name: file });
302 | }, { concurrency: 1 });
303 |
304 | archive.finalize();
305 |
306 | await promise;
307 | }));
308 |
309 | function getUrls() {
310 | const interfaces = os.networkInterfaces();
311 | return Object.values(interfaces).flatMap((addresses) => (addresses != null ? addresses : [])).filter(({ family, address }) => family === 'IPv4' && address !== '127.0.0.1').map(({ address }) => `http://${address}:${port}/`);
312 | }
313 |
314 | let started = false;
315 |
316 | async function startServer() {
317 | assert(!started, 'Server already started');
318 | started = true;
319 |
320 | // Serving the frontend depending on dev/production
321 | if (devMode) {
322 | app.use('/', createProxyMiddleware({ target: 'http://localhost:3000', ws: true }));
323 | } else {
324 | app.use('/', express.static(webPath));
325 |
326 | // Default fallback to index.html because it's a SPA (so user can open any deep link)
327 | app.use('*', (_req, res) => res.sendFile(join(webPath, 'index.html')));
328 | }
329 |
330 | return new Promise((resolve) => {
331 | app.listen(port, resolve);
332 | });
333 | }
334 |
335 | return {
336 | runStartupCheck: ffmpeg.runStartupCheck,
337 | getUrls,
338 | start: startServer,
339 | sharedPath,
340 | };
341 | };
342 |
--------------------------------------------------------------------------------
/packages/web/src/routes/dir/$dirId/file.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, useNavigate, useRouter, useSearch } from '@tanstack/react-router';
2 | import z from 'zod';
3 | import { useState, useEffect, useCallback, CSSProperties, useRef, KeyboardEventHandler, WheelEventHandler, MouseEventHandler, ReactEventHandler, useMemo } from 'react';
4 | import { FaList, FaTimes, FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
5 |
6 | import { getDownloadUrl, mightBeImage, mightBeVideo, useContext } from '../../../util';
7 | import styles from './file.module.css';
8 |
9 | // eslint-disable-next-line import/prefer-default-export
10 | export const Route = createFileRoute('/dir/$dirId/file')({
11 | // eslint-disable-next-line no-use-before-define
12 | component: ViewingFile,
13 | validateSearch: z.object({
14 | p: z.string(),
15 | }).parse,
16 | });
17 |
18 | const buttonStyle: CSSProperties = { all: 'unset', padding: '.1em .3em', cursor: 'pointer', fontSize: '2em' };
19 |
20 | function ViewingFile() {
21 | const { currentDir } = useContext();
22 | const playableFiles = useMemo(() => currentDir.files.filter((f) => !f.isDir && (mightBeVideo(f) || mightBeImage(f))), [currentDir.files]);
23 | const { p: path } = useSearch({ from: Route.fullPath });
24 | const navigate = useNavigate({ from: Route.fullPath });
25 |
26 | const viewingFile = useMemo(() => (currentDir.files.find((f) => f.path === path) ?? currentDir.files[0]), [path, currentDir.files]);
27 |
28 | const dialogRef = useRef(null);
29 | const pointerStartX = useRef();
30 | const lastWheel = useRef(0);
31 | const [showControls, setShowControls] = useState(false);
32 | const [playlistMode, setPlaylistMode] = useState(false);
33 | const [muted, setMuted] = useState(true);
34 | const [progress, setProgress] = useState(0);
35 | const [canPlayVideo, setCanPlayVideo] = useState(false);
36 | const [videoError, setVideoError] = useState(null);
37 |
38 | const setRelViewingFile = useCallback((rel: number) => {
39 | const navigatePath = (f: { path: string } | undefined) => {
40 | if (f == null) return;
41 | navigate({ search: { p: f.path }, replace: true });
42 | };
43 |
44 | if (viewingFile == null) {
45 | navigatePath(playableFiles[0]);
46 | } else {
47 | const currentIndex = playableFiles.findIndex((f) => f.path === viewingFile?.path);
48 | const nextIndex = (currentIndex + rel + playableFiles.length) % playableFiles.length;
49 | navigatePath(playableFiles[nextIndex]);
50 | }
51 | }, [navigate, playableFiles, viewingFile]);
52 |
53 | const { history } = useRouter();
54 |
55 | const handleClose = useCallback(() => history.go(-1), [history]);
56 | const handleNext = useCallback(() => setRelViewingFile(1), [setRelViewingFile]);
57 | const handlePrev = useCallback(() => setRelViewingFile(-1), [setRelViewingFile]);
58 |
59 | const mediaRef = useRef(null);
60 |
61 | const isVideo = useMemo(() => viewingFile != null && mightBeVideo(viewingFile), [viewingFile]);
62 | const isImage = useMemo(() => viewingFile != null && mightBeImage(viewingFile), [viewingFile]);
63 |
64 | useEffect(() => {
65 | if (mediaRef.current) {
66 | mediaRef.current.focus({ preventScroll: true });
67 | }
68 | setShowControls(false);
69 | setCanPlayVideo(false);
70 | setProgress(0);
71 | setVideoError(null);
72 |
73 | if (isImage && playlistMode) {
74 | const slideTime = 5000;
75 | const startTime = Date.now();
76 |
77 | let t: number | undefined;
78 |
79 | // ken burns zoom
80 | const animation = mediaRef.current?.animate([
81 | { transform: 'scale(1)', offset: 0 },
82 | { transform: 'scale(1.05)', offset: 1 },
83 | ], {
84 | duration: slideTime,
85 | fill: 'none',
86 | });
87 |
88 | const tick = () => {
89 | t = setTimeout(() => {
90 | const now = Date.now();
91 |
92 | const p = Math.max(0, Math.min(1, (now - startTime) / slideTime));
93 | setProgress(p);
94 |
95 | if (now - startTime >= slideTime) {
96 | handleNext();
97 | return;
98 | }
99 | tick();
100 | }, 40);
101 | };
102 |
103 | tick();
104 |
105 | return () => {
106 | if (t != null) clearTimeout(t);
107 | animation?.cancel();
108 | };
109 | }
110 |
111 | return undefined;
112 | }, [handleNext, isImage, playlistMode, viewingFile]);
113 |
114 | const handleKeyDown = useCallback>((e) => {
115 | if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
116 |
117 | // eslint-disable-next-line unicorn/prefer-switch
118 | if (e.key === 'ArrowLeft') {
119 | e.preventDefault();
120 | e.stopPropagation();
121 | handlePrev();
122 | } else if (e.key === 'ArrowRight') {
123 | e.preventDefault();
124 | e.stopPropagation();
125 | handleNext();
126 | } else if (e.key === 'Escape') {
127 | e.preventDefault();
128 | e.stopPropagation();
129 | handleClose();
130 | }
131 | }, [handleClose, handleNext, handlePrev]);
132 |
133 | const handlePointerDown = useCallback>((e) => {
134 | pointerStartX.current = e.clientX;
135 | }, []);
136 |
137 | const handlePointerUp = useCallback>((e) => {
138 | if (pointerStartX.current == null) return;
139 |
140 | const diff = pointerStartX.current - e.clientX;
141 | if (Math.abs(diff) > 50) {
142 | if (diff > 0) handleNext();
143 | else handlePrev();
144 | }
145 |
146 | pointerStartX.current = undefined;
147 | }, [handleNext, handlePrev]);
148 |
149 | const handleWheel = useCallback>((e) => {
150 | // Trackpad horizontal swipes come as wheel events
151 | if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
152 | if (Date.now() - lastWheel.current < 500) return; // ignore if too fast
153 | lastWheel.current = Date.now();
154 | e.preventDefault();
155 |
156 | if (e.deltaX > 0) handleNext();
157 | else handlePrev();
158 | }
159 | }, [handleNext, handlePrev]);
160 |
161 | const handleClick = useCallback>((e) => {
162 | e.stopPropagation();
163 | if (mediaRef.current != null && 'play' in mediaRef.current) {
164 | if (mediaRef.current.paused) {
165 | mediaRef.current.play();
166 | } else {
167 | mediaRef.current.pause();
168 | }
169 | }
170 | }, []);
171 |
172 | const handlePrevClick = useCallback>((e) => {
173 | e.preventDefault();
174 | e.stopPropagation();
175 | handlePrev();
176 | }, [handlePrev]);
177 |
178 | const handleNextClick = useCallback>((e) => {
179 | e.preventDefault();
180 | e.stopPropagation();
181 | handleNext();
182 | }, [handleNext]);
183 |
184 | const handlePlaylistModeClick = useCallback>((e) => {
185 | e.stopPropagation();
186 | setPlaylistMode((v) => !v);
187 | }, []);
188 |
189 | const handleMuteClick = useCallback>((e) => {
190 | e.stopPropagation();
191 | setMuted((v) => !v);
192 | }, []);
193 |
194 | const handleVideoEnded = useCallback>(() => {
195 | if (playlistMode) {
196 | handleNext();
197 | }
198 | }, [handleNext, playlistMode]);
199 |
200 | const scrubbingRef = useRef(false);
201 | const handleScrubDown = useCallback>((e) => {
202 | e.preventDefault();
203 | e.stopPropagation();
204 | scrubbingRef.current = true;
205 | }, []);
206 | const handleScrubUp = useCallback>((e) => {
207 | e.preventDefault();
208 | e.stopPropagation();
209 | scrubbingRef.current = false;
210 | }, []);
211 | const handleScrub = useCallback>((e) => {
212 | e.preventDefault();
213 | e.stopPropagation();
214 | if (!scrubbingRef.current) return;
215 | const target = e.target as HTMLDivElement;
216 | const p = e.clientX / target.clientWidth;
217 | if (mediaRef.current && mediaRef.current instanceof HTMLVideoElement) {
218 | mediaRef.current.currentTime = mediaRef.current.duration * p;
219 | }
220 | }, []);
221 |
222 | const handleVideoTimeUpdate = useCallback>((e) => {
223 | if (e.currentTarget instanceof HTMLVideoElement) {
224 | setProgress(e.currentTarget.currentTime / e.currentTarget.duration);
225 | }
226 | }, []);
227 |
228 | const handleVideoError = useCallback>((e) => {
229 | if (e.target instanceof HTMLVideoElement) {
230 | setVideoError(e.target.error);
231 | }
232 |
233 | if (playlistMode) {
234 | setTimeout(() => {
235 | handleNext();
236 | }, 300);
237 | }
238 | }, [handleNext, playlistMode]);
239 |
240 | function renderPreview() {
241 | if (viewingFile == null) {
242 | return null;
243 | }
244 |
245 | if (isVideo) {
246 | return (
247 | <>
248 | {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
249 |