New "browse" UI

Also, separated out browse, ?meta, and ?module request handlers.

Fixes #82
This commit is contained in:
Michael Jackson
2019-07-24 17:55:13 -07:00
parent ea35b3c4b0
commit 34baab07ab
57 changed files with 2431 additions and 686 deletions

View File

@ -0,0 +1,356 @@
/** @jsx jsx */
import { Global, css, jsx } from '@emotion/core';
import { Fragment } from 'react';
import PropTypes from 'prop-types';
import { fontSans, fontMono } from '../utils/style.js';
import { PackageInfoProvider } from './PackageInfo.js';
import DirectoryViewer from './DirectoryViewer.js';
import FileViewer from './FileViewer.js';
import { TwitterIcon, GitHubIcon } from './Icons.js';
import SelectDownArrow from './images/SelectDownArrow.png';
const globalStyles = css`
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
${fontSans}
font-size: 16px;
line-height: 1.5;
background: white;
color: black;
}
code {
${fontMono}
}
th,
td {
padding: 0;
}
select {
font-size: inherit;
}
#root {
display: flex;
flex-direction: column;
}
`;
// Adapted from https://github.com/highlightjs/highlight.js/blob/master/src/styles/atom-one-light.css
const lightCodeStyles = css`
.code-listing {
background: #fbfdff;
color: #383a42;
}
.code-comment,
.code-quote {
color: #a0a1a7;
font-style: italic;
}
.code-doctag,
.code-keyword,
.code-link,
.code-formula {
color: #a626a4;
}
.code-section,
.code-name,
.code-selector-tag,
.code-deletion,
.code-subst {
color: #e45649;
}
.code-literal {
color: #0184bb;
}
.code-string,
.code-regexp,
.code-addition,
.code-attribute,
.code-meta-string {
color: #50a14f;
}
.code-built_in,
.code-class .code-title {
color: #c18401;
}
.code-attr,
.code-variable,
.code-template-variable,
.code-type,
.code-selector-class,
.code-selector-attr,
.code-selector-pseudo,
.code-number {
color: #986801;
}
.code-symbol,
.code-bullet,
.code-meta,
.code-selector-id,
.code-title {
color: #4078f2;
}
.code-emphasis {
font-style: italic;
}
.code-strong {
font-weight: bold;
}
`;
const linkStyle = {
color: '#0076ff',
textDecoration: 'none',
':hover': {
textDecoration: 'underline'
}
};
export default function App({
packageName,
packageVersion,
availableVersions = [],
filename,
target
}) {
function handleChange(event) {
window.location.href = window.location.href.replace(
'@' + packageVersion,
'@' + event.target.value
);
}
const breadcrumbs = [];
if (filename === '/') {
breadcrumbs.push(packageName);
} else {
let url = `/browse/${packageName}@${packageVersion}`;
breadcrumbs.push(
<a href={`${url}/`} css={linkStyle}>
{packageName}
</a>
);
const segments = filename
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.split('/');
const lastSegment = segments.pop();
segments.forEach(segment => {
url += `/${segment}`;
breadcrumbs.push(
<a href={`${url}/`} css={linkStyle}>
{segment}
</a>
);
});
breadcrumbs.push(lastSegment);
}
// TODO: Provide a user pref to go full width?
const maxContentWidth = 940;
return (
<PackageInfoProvider
packageName={packageName}
packageVersion={packageVersion}
>
<Fragment>
<Global styles={globalStyles} />
<Global styles={lightCodeStyles} />
<div css={{ flex: '1 0 auto' }}>
<div
css={{
maxWidth: maxContentWidth,
padding: '0 20px',
margin: '0 auto'
}}
>
<header css={{ textAlign: 'center' }}>
<h1 css={{ fontSize: '3rem', marginTop: '2rem' }}>
<a href="/" css={{ color: '#000', textDecoration: 'none' }}>
UNPKG
</a>
</h1>
{/*
<nav>
<a href="#" css={{ ...linkStyle, color: '#c400ff' }}>
Become a Sponsor
</a>
</nav>
*/}
</header>
<header
css={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<h1 css={{ fontSize: '1.5rem' }}>
<nav>
{breadcrumbs.map((link, index) => (
<span key={index}>
{index !== 0 && (
<span css={{ paddingLeft: 5, paddingRight: 5 }}>/</span>
)}
{link}
</span>
))}
</nav>
</h1>
<div>
<label htmlFor="version">Version:</label>{' '}
<select
name="version"
defaultValue={packageVersion}
onChange={handleChange}
css={{
appearance: 'none',
cursor: 'pointer',
padding: '4px 24px 4px 8px',
fontWeight: 600,
fontSize: '0.9em',
color: '#24292e',
border: '1px solid rgba(27,31,35,.2)',
borderRadius: 3,
backgroundColor: '#eff3f6',
backgroundImage: `url(${SelectDownArrow})`,
backgroundPosition: 'right 8px center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'auto 25%',
':hover': {
backgroundColor: '#e6ebf1',
borderColor: 'rgba(27,31,35,.35)'
},
':active': {
backgroundColor: '#e9ecef',
borderColor: 'rgba(27,31,35,.35)',
boxShadow: 'inset 0 0.15em 0.3em rgba(27,31,35,.15)'
}
}}
>
{availableVersions.map(v => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
</header>
</div>
<div
css={{
maxWidth: maxContentWidth,
padding: '0 20px',
margin: '0 auto',
'@media (max-width: 700px)': {
padding: 0,
margin: 0
}
}}
>
{target.type === 'directory' ? (
<DirectoryViewer path={target.path} details={target.details} />
) : target.type === 'file' ? (
<FileViewer path={target.path} details={target.details} />
) : null}
</div>
</div>
<footer
css={{
marginTop: '5rem',
background: 'black',
color: '#aaa'
}}
>
<div
css={{
maxWidth: maxContentWidth,
padding: '10px 20px',
margin: '0 auto',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<p>&copy; {new Date().getFullYear()} UNPKG</p>
<p css={{ fontSize: '1.5rem' }}>
<a
title="Twitter"
href="https://twitter.com/unpkg"
css={{
color: '#aaa',
display: 'inline-block',
':hover': { color: 'white' }
}}
>
<TwitterIcon />
</a>
<a
title="GitHub"
href="https://github.com/mjackson/unpkg"
css={{
color: '#aaa',
display: 'inline-block',
marginLeft: '1rem',
':hover': { color: 'white' }
}}
>
<GitHubIcon />
</a>
</p>
</div>
</footer>
</Fragment>
</PackageInfoProvider>
);
}
if (process.env.NODE_ENV !== 'production') {
const targetType = PropTypes.shape({
path: PropTypes.string.isRequired,
type: PropTypes.oneOf(['directory', 'file']).isRequired,
details: PropTypes.object.isRequired
});
App.propTypes = {
packageName: PropTypes.string.isRequired,
packageVersion: PropTypes.string.isRequired,
availableVersions: PropTypes.arrayOf(PropTypes.string),
filename: PropTypes.string.isRequired,
target: targetType.isRequired
};
}

View File

@ -0,0 +1,182 @@
/** @jsx jsx */
import { jsx } from '@emotion/core';
import PropTypes from 'prop-types';
import VisuallyHidden from '@reach/visually-hidden';
import sortBy from 'sort-by';
import { formatBytes } from '../utils/format.js';
import { DirectoryIcon, CodeFileIcon } from './Icons.js';
const linkStyle = {
color: '#0076ff',
textDecoration: 'none',
':hover': {
textDecoration: 'underline'
}
};
const tableCellStyle = {
paddingTop: 6,
paddingRight: 3,
paddingBottom: 6,
paddingLeft: 3,
borderTop: '1px solid #eaecef'
};
const iconCellStyle = {
...tableCellStyle,
color: '#424242',
width: 17,
paddingRight: 2,
paddingLeft: 10,
'@media (max-width: 700px)': {
paddingLeft: 20
}
};
const typeCellStyle = {
...tableCellStyle,
textAlign: 'right',
paddingRight: 10,
'@media (max-width: 700px)': {
paddingRight: 20
}
};
function getRelName(path, base) {
return path.substr(base.length > 1 ? base.length + 1 : 1);
}
export default function DirectoryViewer({ path, details: entries }) {
const rows = [];
if (path !== '/') {
rows.push(
<tr key="..">
<td css={iconCellStyle} />
<td css={tableCellStyle}>
<a title="Parent directory" href="../" css={linkStyle}>
..
</a>
</td>
<td css={tableCellStyle}></td>
<td css={typeCellStyle}></td>
</tr>
);
}
const { subdirs, files } = Object.keys(entries).reduce(
(memo, key) => {
const { subdirs, files } = memo;
const entry = entries[key];
if (entry.type === 'directory') {
subdirs.push(entry);
} else if (entry.type === 'file') {
files.push(entry);
}
return memo;
},
{ subdirs: [], files: [] }
);
subdirs.sort(sortBy('path')).forEach(({ path: dirname }) => {
const relName = getRelName(dirname, path);
const href = relName + '/';
rows.push(
<tr key={relName}>
<td css={iconCellStyle}>
<DirectoryIcon />
</td>
<td css={tableCellStyle}>
<a title={relName} href={href} css={linkStyle}>
{relName}
</a>
</td>
<td css={tableCellStyle}>-</td>
<td css={typeCellStyle}>-</td>
</tr>
);
});
files
.sort(sortBy('path'))
.forEach(({ path: filename, size, contentType }) => {
const relName = getRelName(filename, path);
const href = relName;
rows.push(
<tr key={relName}>
<td css={iconCellStyle}>
<CodeFileIcon />
</td>
<td css={tableCellStyle}>
<a title={relName} href={href} css={linkStyle}>
{relName}
</a>
</td>
<td css={tableCellStyle}>{formatBytes(size)}</td>
<td css={typeCellStyle}>{contentType}</td>
</tr>
);
});
return (
<div
css={{
border: '1px solid #dfe2e5',
borderRadius: 3,
borderTopWidth: 0,
'@media (max-width: 700px)': {
borderRightWidth: 0,
borderLeftWidth: 0
}
}}
>
<table
css={{
width: '100%',
borderCollapse: 'collapse',
borderRadius: 2,
background: '#fff'
}}
>
<thead>
<tr>
<th>
<VisuallyHidden>Icon</VisuallyHidden>
</th>
<th>
<VisuallyHidden>Name</VisuallyHidden>
</th>
<th>
<VisuallyHidden>Size</VisuallyHidden>
</th>
<th>
<VisuallyHidden>Content Type</VisuallyHidden>
</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
);
}
if (process.env.NODE_ENV !== 'production') {
DirectoryViewer.propTypes = {
path: PropTypes.string.isRequired,
details: PropTypes.objectOf(
PropTypes.shape({
path: PropTypes.string.isRequired,
type: PropTypes.oneOf(['directory', 'file']).isRequired,
contentType: PropTypes.string, // file only
integrity: PropTypes.string, // file only
size: PropTypes.number // file only
})
).isRequired
};
}

View File

@ -0,0 +1,212 @@
/** @jsx jsx */
import { jsx } from '@emotion/core';
import PropTypes from 'prop-types';
import { formatBytes } from '../utils/format.js';
import { createHTML } from '../utils/markup.js';
import { usePackageInfo } from './PackageInfo.js';
function getBasename(path) {
const segments = path.split('/');
return segments[segments.length - 1];
}
function ImageViewer({ path, uri }) {
return (
<div css={{ padding: 20, textAlign: 'center' }}>
<img title={getBasename(path)} src={uri} />
</div>
);
}
function CodeListing({ highlights }) {
const lines = highlights.slice(0);
const hasTrailingNewline = lines.length && lines[lines.length - 1] === '';
if (hasTrailingNewline) {
lines.pop();
}
return (
<div
className="code-listing"
css={{
overflowX: 'auto',
overflowY: 'hidden',
paddingTop: 5,
paddingBottom: 5
}}
>
<table
css={{
border: 'none',
borderCollapse: 'collapse',
borderSpacing: 0
}}
>
<tbody>
{lines.map((line, index) => {
const lineNumber = index + 1;
return (
<tr key={index}>
<td
id={`L${lineNumber}`}
css={{
paddingLeft: 10,
paddingRight: 10,
color: 'rgba(27,31,35,.3)',
textAlign: 'right',
verticalAlign: 'top',
width: '1%',
minWidth: 50,
userSelect: 'none'
}}
>
<span>{lineNumber}</span>
</td>
<td
id={`LC${lineNumber}`}
css={{
paddingLeft: 10,
paddingRight: 10,
color: '#24292e',
whiteSpace: 'pre'
}}
>
<code dangerouslySetInnerHTML={createHTML(line)} />
</td>
</tr>
);
})}
{!hasTrailingNewline && (
<tr key="no-newline">
<td
css={{
paddingLeft: 10,
paddingRight: 10,
color: 'rgba(27,31,35,.3)',
textAlign: 'right',
verticalAlign: 'top',
width: '1%',
minWidth: 50,
userSelect: 'none'
}}
>
\
</td>
<td
css={{
paddingLeft: 10,
color: 'rgba(27,31,35,.3)',
userSelect: 'none'
}}
>
No newline at end of file
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
function BinaryViewer() {
return (
<div css={{ padding: 20 }}>
<p css={{ textAlign: 'center' }}>No preview available.</p>
</div>
);
}
export default function FileViewer({ path, details }) {
const { packageName, packageVersion } = usePackageInfo();
const { highlights, uri, language, size } = details;
const segments = path.split('/');
const filename = segments[segments.length - 1];
return (
<div
css={{
border: '1px solid #dfe2e5',
borderRadius: 3,
'@media (max-width: 700px)': {
borderRightWidth: 0,
borderLeftWidth: 0
}
}}
>
<div
css={{
padding: 10,
background: '#f6f8fa',
color: '#424242',
border: '1px solid #d1d5da',
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
margin: '-1px -1px 0',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
'@media (max-width: 700px)': {
paddingRight: 20,
paddingLeft: 20
}
}}
>
<span>{formatBytes(size)}</span> <span>{language}</span>{' '}
<a
title={filename}
href={`/${packageName}@${packageVersion}${path}`}
css={{
display: 'inline-block',
textDecoration: 'none',
padding: '2px 8px',
fontWeight: 600,
fontSize: '0.9rem',
color: '#24292e',
backgroundColor: '#eff3f6',
border: '1px solid rgba(27,31,35,.2)',
borderRadius: 3,
':hover': {
backgroundColor: '#e6ebf1',
borderColor: 'rgba(27,31,35,.35)'
},
':active': {
backgroundColor: '#e9ecef',
borderColor: 'rgba(27,31,35,.35)',
boxShadow: 'inset 0 0.15em 0.3em rgba(27,31,35,.15)'
}
}}
>
View Raw
</a>
</div>
{highlights ? (
<CodeListing highlights={highlights} />
) : uri ? (
<ImageViewer path={path} uri={uri} />
) : (
<BinaryViewer />
)}
</div>
);
}
if (process.env.NODE_ENV !== 'production') {
FileViewer.propTypes = {
path: PropTypes.string.isRequired,
details: PropTypes.shape({
contentType: PropTypes.string.isRequired,
highlights: PropTypes.arrayOf(PropTypes.string), // code
uri: PropTypes.string, // images
integrity: PropTypes.string.isRequired,
language: PropTypes.string.isRequired,
size: PropTypes.number.isRequired
}).isRequired
};
}

View File

@ -0,0 +1,24 @@
/** @jsx jsx */
import { jsx } from '@emotion/core';
import { GoFileDirectory, GoFile } from 'react-icons/go';
import { FaTwitter, FaGithub } from 'react-icons/fa';
function createIcon(Type, { css, ...rest }) {
return <Type css={{ ...css, verticalAlign: 'text-bottom' }} {...rest} />;
}
export function DirectoryIcon(props) {
return createIcon(GoFileDirectory, props);
}
export function CodeFileIcon(props) {
return createIcon(GoFile, props);
}
export function TwitterIcon(props) {
return createIcon(FaTwitter, props);
}
export function GitHubIcon(props) {
return createIcon(FaGithub, props);
}

View File

@ -0,0 +1,11 @@
import React, { createContext, useContext } from 'react';
const Context = createContext();
export function PackageInfoProvider({ children, ...rest }) {
return <Context.Provider children={children} value={rest} />;
}
export function usePackageInfo() {
return useContext(Context);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B