Overview

This single page React web app shows directory listing on a webserver - in this example the ./apk subdirectory.

The access URL would thus be something like

https://FULL_URL/apk/

Whenever any new files are added, shortly after they arrive in the directory, they will appear on the webpage listing.

Serverside (Apache)

Virtual host configuration

In the <VirtualHost *:443> stanza of the Apache configuration:

<Location "/apk">
    DirectoryIndex index.html
    Options ExecCGI
</Location>

Script to run as CGI in the directory:

This script index.cgi will be accessed via an AJAX call in the React app using the following relative URL:

./?listing

In the script, if the param listing is supplied, a JSON output of .apk files is produced, otherwise the browser is redirected to the React web app’s entry point, ./index.html.

#!/usr/bin/env bash

if [[ "$QUERY_STRING" == "listing" ]]; then
	echo "Access-Control-Allow-Origin: *"
	echo "Content-Type: application/json"
	echo
	echo -n "["
	started=""
	for i in *.apk; do
		[ -z $started ] && started=1 || echo ','
		echo -n '{"file":"'$i'",'
		echo -n '"ts":'$(stat -c%Y $i)
		echo -n "}"
	done
	echo "]"
	exit 0
fi

echo "Location: ./index.html"
echo
exit 0

Clientside React app

Important configuration settings

In package.json, the following setting is required, to ensure that the React app will run correctly from a subdirectory.

"homepage": "./apk"

Basis React app

Could look something like this:

import './App.css'
import {useCallback, useEffect, useRef, useState} from "react"
import Listing from "./Listing";

const URL = './index.cgi?listing'
const INTERVAL = 35 * 1000 /* update interval in msecs, e.g. every 35 secs */

const App = () => {

    const [files, setFiles] = useState([])
    const [lastFetch, setLastFetch] = useState(null)
    const [newSince, setNewSince] = useState([])

    const mountedRef = useRef(false)
    const firstAccessRef = useRef(+new Date())

    const doFetch = useCallback(() => {
        if (!mountedRef.current) return
        fetch(URL)
            .then(r => r.json())
            .then(data => {
                setLastFetch(+new Date())
                data.sort((a, b) => a.ts < b.ts ? 1 : -1)
                if (mountedRef.current) {
                    setFiles(data)
                }
                if (firstAccessRef && data) {
                    let _newSince = data.filter(({ts}) => (
                        ts * 1000 > firstAccessRef.current
                    ))
                    if (_newSince.length && newSince.length === 0) {
                        return setNewSince(_newSince)
                    }
                    _newSince.map(({ts, file}) => {
                        return setNewSince(prev => {
                            let i
                            i = prev.findIndex(e => ( 
                                e.ts === ts && e.file === file)
                            )
                            if (i >= 0) return prev
                            i = prev.findIndex(e => (
                                e.ts !== ts && e.file === file)
                            )
                            if (i >= 0) {
                                prev = prev.splice(i, 1)
                                return [...prev, {ts, file}]
                            }
                            return prev.concat({ts, file})
                        })
                    })
                }
            })
    }, [newSince])

    useEffect(() => {
        const plural = newSince.length > 1
        const isAre = plural ? 'are' : 'is'
        if (newSince.length) {
            console.log(`There ${isAre} ${newSince.length} new or updated `
                + `file${plural ? 's' : ''} since page was loaded:`)
            console.table(newSince)
        }
    }, [newSince])

    useEffect(() => {
        mountedRef.current = true
        doFetch()
        return () => mountedRef.current = false
    }, [doFetch])

    /* cf https://usehooks.com/useWindowSize/ */
    const useWindowSize = () => {
        const [windowSize, setWindowSize] = useState({
            width: undefined,
            height: undefined,
        });
        useEffect(() => {
            const handleResize = () => {
                setWindowSize({
                    width: window.innerWidth,
                    height: window.innerHeight,
                });
            }
            window.addEventListener('resize', handleResize);
            handleResize();
            return () => window.removeEventListener(
                'resize', handleResize
            )
        }, [])
        return windowSize
    }

    /* cf 
    https://overreacted.io/making-setinterval-declarative-with-react-hooks/
    */
    const useInterval = (callback, delay) => {
        const savedCallback = useRef()

        useEffect(() => {
            savedCallback.current = callback
        }, [callback])

        useEffect(() => {
            const tick = () => {
                savedCallback.current()
            }
            if (delay !== null) {
                let id = setInterval(tick, delay)
                return () => clearInterval(id)
            }
        }, [delay])
    }

    useInterval(() => {
        doFetch()
    }, INTERVAL)

    const { width } = useWindowSize()

    /* small devices: change flex direction to column */
    let direction = width > 1024 ? 'row' : 'column'

    return (
        <div
            className="App"
            style={{
                backgroundColor: 'rgba(128,63,63,0.5)',
                minHeight: '99vh',
            }}
        >
            <header>
                <h1><code>.apk</code> Files for Download</h1>
            </header>

            <Listing
                direction={direction}
                files={files}
                newSince={newSince}
            />

            <footer>
                <p>last checked: <code>
                    {(new Date(lastFetch)).toISOString()}
                </code></p>
            </footer>
        </div>
    )
}

export default App