Skip to content

Commit

Permalink
Folder view
Browse files Browse the repository at this point in the history
  • Loading branch information
platypii committed Jun 6, 2024
1 parent 8841b24 commit 2c4509e
Show file tree
Hide file tree
Showing 13 changed files with 256 additions and 18 deletions.
9 changes: 5 additions & 4 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-non-null-asserted-optional-chain": "warn",
"arrow-spacing": "error",
"camelcase": "off",
"comma-spacing": "error",
Expand All @@ -33,10 +34,10 @@
"jsdoc/check-param-names": "error",
"jsdoc/check-property-names": "error",
"jsdoc/check-tag-names": "error",
"jsdoc/require-param": "error",
"jsdoc/require-param-type": "error",
"jsdoc/require-returns": "error",
"jsdoc/require-returns-type": "error",
"jsdoc/require-param": "warn",
"jsdoc/require-param-type": "warn",
"jsdoc/require-returns": "warn",
"jsdoc/require-returns-type": "warn",
"jsdoc/sort-tags": "error",
"no-constant-condition": "off",
"no-extra-parens": "error",
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
package-lock.json
node_modules
*.tgz
.vscode
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-replace": "5.0.7",
"@rollup/plugin-terser": "0.4.4",
"@rollup/plugin-typescript": "11.1.6",
"@types/node": "20.14.2",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
Expand All @@ -30,6 +31,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"rollup": "4.18.0",
"tslib": "2.6.3",
"typescript": "5.4.5",
"vitest": "1.6.0"
}
Expand Down
4 changes: 2 additions & 2 deletions public/bundle.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/bundle.min.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8">
<title>hyperparam</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="HighTable.css">
<link rel="stylesheet" href="/public/styles.css">
<link rel="stylesheet" href="/public/HighTable.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Mulish:wght@400;600&display=swap"/>
</head>
<body>
<div id="app"></div>
<script type="module" src="bundle.min.js"></script>
<script type="module" src="/public/bundle.min.js"></script>
</body>
</html>
4 changes: 4 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
import replace from '@rollup/plugin-replace'
import terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'

export default {
input: 'src/render.js',
Expand All @@ -19,5 +20,8 @@ export default {
}),
resolve({ browser: true }),
terser(),
typescript({
exclude: ['test/**'],
}),
],
}
67 changes: 67 additions & 0 deletions src/Folder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { useEffect, useRef, useState } from 'react'
import { FileMetadata, getFileDate, getFileDateShort, getFileSize, listFiles } from './files.js'
import Layout, { Spinner, cn } from './Layout.js'

/**
* Folder browser page
*/
export default function Folder() {
// State to hold file listing
const [files, setFiles] = useState<FileMetadata[]>()
const [error, setError] = useState<Error>()
const listRef = useRef<HTMLUListElement>(null)

// Folder path from url
const path = location.pathname.split('/')
let prefix = decodeURI(path.slice(2).join('/'))

// Fetch files on component mount
useEffect(() => {
listFiles(prefix)
.then(setFiles)
.catch(error => {
setFiles([])
setError(error)
})
}, [prefix])

function fileUrl(file: FileMetadata): string {
const key = prefix + '/' + file.key
return file.key.endsWith('/') ? `/files/${key}` : `/files/${key}`
}

return (
<Layout error={error} title={prefix}>
<nav className='top-header'>
<div className='path'>
<a href='/files'>/</a>
{prefix && prefix.split('/').map((sub, depth) =>
<a href={'/files/' + path.slice(2, depth + 3).join('/')} key={depth}>{sub}/</a>
)}
</div>
</nav>

{files && files.length > 0 && <ul className='file-list' ref={listRef}>
{files.map((file, index) =>
<li key={index}>
<a href={fileUrl(file)}>
<span className={cn('file-name', file.key.endsWith('/') ? 'icon-directory' : 'icon-file')}>
{file.key}
</span>
{!file.key.endsWith('/') && <>
<span className='file-size' title={file.fileSize?.toLocaleString() + ' bytes'}>
{getFileSize(file)}
</span>
<span className='file-date' title={getFileDate(file)}>
{getFileDateShort(file)}
</span>
</>}
</a>
</li>
)}
</ul>}
{files?.length === 0 && <div className='center'>No files</div>}
{files === undefined && <Spinner className='center' />}
</Layout>
)
}
68 changes: 68 additions & 0 deletions src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { ReactNode } from 'react'

interface LayoutProps {
children: ReactNode
className?: string
error?: Error
title?: string
}

/**
* Layout for shared UI.
* Content div style can be overridden by className prop.
*
* @param {Object} props
* @param {ReactNode} props.children - content to display inside the layout
* @param {string} props.className - additional class names to apply to the content container
* @param {Error} props.error - error message to display
* @param {string} props.title - page title
* @returns rendered layout component
*/
export default function Layout({ children, className, error, title }: LayoutProps) {
const errorMessage = error?.toString()
if (error) console.error(error)

return <>
<head>
<title>{title ? `${title} - hyperparam` : 'hyperparam'}</title>
<meta content="hyperparam is the missing UI for machine learning" name="description" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link href="/favicon.png" rel="icon" />
</head>
<main className='main'>
<Sidebar />
<div className='content-container'>
<div className={cn('content', className)}>
{children}
</div>
<div className={cn('error-bar', error && 'show-error')}>{errorMessage}</div>
</div>
</main>
</>
}

function Sidebar() {
return (
<nav>
<a className="brand" href='/'>
<img
alt="hyperparam"
height={26}
src="/assets/logo.svg"
width={26} />
hyperparam
</a>
</nav>
)
}

/**
* Helper function to join class names
*/
export function cn(...names: (string | undefined | false)[]): string {
return names.filter(n => n).join(' ')
}

export function Spinner({ className }: { className: string }) {
return <div className={cn('spinner', className)}></div>
}
83 changes: 83 additions & 0 deletions src/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export interface FileMetadata {
key: string
eTag?: string
fileSize?: number
lastModified: string
}

export interface FileContent<T> {
body: T
key: string
contentLength?: number
contentType?: string
eTag?: string
fileName?: string
fileSize?: number
lastModified?: string
contentRange?: string
}

/**
* List user files from server
*
* @param prefix file path prefix
*/
export async function listFiles(prefix: string, recursive?: boolean): Promise<FileMetadata[]> {
const rec = recursive ? '&recursive=true' : ''
prefix = encodeURIComponent(prefix)
const res = await fetch(`/api/store/list?prefix=${prefix}${rec}`)
if (res.ok) {
return await res.json()
} else {
throw new Error(`file list error ${res.status} ${await res.text()}`)
}
}

export function getFileDateShort(file?: { lastModified?: string }): string {
const date = new Date(file?.lastModified!)
// time if within last 24 hours, date otherwise
const time = date.getTime()
const now = Date.now()
if (now - time < 86400000) {
return date.toLocaleTimeString()
}
return date.toLocaleDateString()
}

/**
* Parse date from lastModified field and format as locale string
*
* @param file file-like object with lastModified
* @param file.lastModified last modified date string
* @returns formatted date string
*/
export function getFileDate(file?: { lastModified?: string }): string {
const date = new Date(file?.lastModified!)
return isFinite(date.getTime()) ? date.toLocaleString() : ''
}

/**
* Format file size in human readable format
*
* @param file file-like object with fileSize
* @param file.fileSize file size in bytes
* @returns formatted file size string
*/
export function getFileSize(file?: { fileSize?: number }): string {
return file?.fileSize !== undefined ? formatFileSize(file?.fileSize) : ''
}

/**
* Returns the file size in human readable format
*
* @param bytes file size in bytes
* @returns formatted file size string
*/
function formatFileSize(bytes: number): string {
const sizes = ['b', 'kb', 'mb', 'gb', 'tb']
if (bytes === 0) return '0 b'
const i = Math.floor(Math.log2(bytes) / 10)
if (i === 0) return bytes + ' b'
const base = bytes / Math.pow(1024, i)
return (base < 10 ? base.toFixed(1) : Math.round(base)) + ' ' + sizes[i]
}
10 changes: 9 additions & 1 deletion src/render.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import HighTable from 'hightable'
import React from 'react'
import ReactDOM from 'react-dom'
import Folder from './Folder.js'

const header = ['ID', 'Name', 'Age', 'UUID', 'JSON']
const data = {
Expand Down Expand Up @@ -29,6 +30,13 @@ function render() {

// @ts-expect-error TODO: fix react createRoot type
const root = ReactDOM.createRoot(app)
root.render(React.createElement(HighTable, { data }))
if (location.pathname.endsWith('/')) {
// Render folder view
root.render(React.createElement(Folder))
} else {
// Render file view
root.render(React.createElement(HighTable, { data }))
}

}
render()
17 changes: 10 additions & 7 deletions src/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,19 @@ function handleRequest(req) {
if (!req.url) return { status: 400, content: 'bad request' }
const parsedUrl = url.parse(req.url, true)
const pathname = parsedUrl.pathname || ''
console.log(`request ${req.method} ${pathname}`)

if (pathname.endsWith('/index.html')) {
// redirect index.html to /
return { status: 301, content: pathname.slice(0, -10) }
} else if (pathname.endsWith('/')) {
if (pathname === '/' || pathname === '/files') {
// redirect to /files
return { status: 301, content: '/files/' }
} else if (pathname.startsWith('/files/')) {
// serve index.html
return handleStatic(`${pathname}index.html`)
} else {
return handleStatic('/index.html')
} else if (pathname.startsWith('/public/')) {
// serve static files
return handleStatic(pathname)
return handleStatic(pathname.slice(7))
} else {
return { status: 404, content: 'not found' }
}
}

Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"jsx": "react",
"allowJs": true,
"checkJs": true,
"lib": ["esnext", "dom"],
Expand Down

0 comments on commit 2c4509e

Please sign in to comment.