0
Fork 0
mirror of https://github.com/verdaccio/verdaccio.git synced 2025-01-20 22:52:46 -05:00

feature: new sidebar in detail page

This commit is contained in:
Meeeeow 2017-12-02 21:33:29 +08:00 committed by juanpicado
parent 691bc97007
commit 1ffd4d7c5f
16 changed files with 455 additions and 53 deletions

View file

@ -50,12 +50,16 @@ module.exports = function(route, auth, storage) {
});
route.get('/-/package/:package/dist-tags', can('access'), function(req, res, next) {
storage.get_package(req.params.package, {req: req}, function(err, info) {
if (err) {
return next(err);
}
storage.get_package({
name: req.params.package,
req,
callback: function(err, info) {
if (err) {
return next(err);
}
next(info['dist-tags']);
next(info['dist-tags']);
},
});
});

View file

@ -10,7 +10,7 @@ module.exports = function(route, auth, storage, config) {
const can = Middleware.allow(auth);
// TODO: anonymous user?
route.get('/:package/:version?', can('access'), function(req, res, next) {
storage.get_package(req.params.package, {req: req}, function(err, info) {
const getPackageMetaCallback = function(err, info) {
if (err) {
return next(err);
}
@ -37,6 +37,12 @@ module.exports = function(route, auth, storage, config) {
}
return next( createError[404]('version not found: ' + req.params.version) );
};
storage.get_package({
name: req.params.package,
req,
callback: getPackageMetaCallback,
});
});

View file

@ -3,6 +3,8 @@
const bodyParser = require('body-parser');
const express = require('express');
const marked = require('marked');
const crypto = require('crypto');
const _ = require('lodash');
const Search = require('../../lib/search');
const Middleware = require('./middleware');
const match = Middleware.match;
@ -76,12 +78,16 @@ module.exports = function(config, auth, storage) {
if (req.params.scope) {
packageName = `@${req.params.scope}/${packageName}`;
}
storage.get_package(packageName, {req: req}, function(err, info) {
if (err) {
return next(err);
}
res.set('Content-Type', 'text/plain');
next(marked(info.readme || 'ERROR: No README data found!'));
storage.get_package({
name: packageName,
req,
callback: function(err, info) {
if (err) {
return next(err);
}
res.set('Content-Type', 'text/plain');
next(marked(info.readme || 'ERROR: No README data found!'));
},
});
});
@ -91,22 +97,25 @@ module.exports = function(config, auth, storage) {
const packages = [];
const getPackageInfo = function(i) {
storage.get_package(results[i].ref, (err, entry) => {
if (!err && entry) {
auth.allow_access(entry.name, req.remote_user, function(err, allowed) {
if (err || !allowed) {
return;
}
storage.get_package({
name: results[i].ref,
callback: (err, entry) => {
if (!err && entry) {
auth.allow_access(entry.name, req.remote_user, function(err, allowed) {
if (err || !allowed) {
return;
}
packages.push(entry.versions[entry['dist-tags'].latest]);
});
}
packages.push(entry.versions[entry['dist-tags'].latest]);
});
}
if (i >= results.length - 1) {
next(packages);
} else {
getPackageInfo(i + 1);
}
if (i >= results.length - 1) {
next(packages);
} else {
getPackageInfo(i + 1);
}
},
});
};
@ -138,6 +147,53 @@ module.exports = function(config, auth, storage) {
res.redirect(base);
});
route.get('/sidebar(/@:scope?)?/:package', function(req, res, next) {
storage.get_package({
name: req.params.package,
keepUpLinkData: true,
req,
callback: function(err, info) {
res.set('Content-Type', 'application/json');
if (!err) {
info.latest = info.versions[info['dist-tags'].latest];
let propertyToDelete = ['readme', 'versions'];
_.forEach(propertyToDelete, ((property) => {
delete info[property];
}));
let defaultGravatar = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mm';
if (typeof _.get(info, 'latest.author.email') === 'string') {
info.latest.author.avatar = generateGravatarUrl(info.latest.author.email);
} else {
// _.get can't guarantee author property exist
_.set(info, 'latest.author.avatar', defaultGravatar);
}
if (_.get(info, 'latest.contributors.length', 0) > 0) {
info.latest.contributors = _.map(info.latest.contributors, (contributor) => {
if (typeof contributor.email === 'string') {
contributor.avatar = generateGravatarUrl(contributor.email);
} else {
contributor.avatar = defaultGravatar;
}
return contributor;
}
);
}
res.end(JSON.stringify(info));
} else {
res.status(404);
res.end();
}
},
});
});
// What are you looking for? logout? client side will remove token when user click logout,
// or it will auto expire after 24 hours.
// This token is different with the token send to npm client.
@ -145,3 +201,15 @@ module.exports = function(config, auth, storage) {
return route;
};
/**
* Generate gravatar url from email address
* @param {string} email
* @return {string} url
*/
function generateGravatarUrl(email) {
email = email.trim().toLocaleLowerCase();
let emailMD5 = crypto.createHash('md5').update(email).digest('hex');
return `https://www.gravatar.com/avatar/${emailMD5}`;
}

View file

@ -232,7 +232,7 @@ class Storage implements IStorage {
/**
* Ensure the dist file remains as the same protocol
* @param {Object} hash metadata
* @param {String} upLink registry key
* @param {String} upLinkKey registry key
* @private
*/
_updateUplinkToRemoteProtocol(hash: DistFile, upLinkKey: string): void {

View file

@ -360,28 +360,33 @@ class Storage {
uplink with proxy_access rights against {name} and combines results
into one json object
Used storages: local && uplink (proxy_access)
* @param {*} name
* @param {*} options
* @param {*} callback
*/
get_package(name, options, callback) {
if (_.isFunction(options)) {
callback = options, options = {};
}
this.localStorage.getPackageMetadata(name, (err, data) => {
* @param {object} options
* @property {string} options.name Package Name
* @property {object} options.req Express `req` object
* @property {boolean} options.keepUpLinkData keep up link info in package meta, last update, etc.
* @property {function} options.callback Callback for receive data
*/
get_package(options) {
this.localStorage.getPackageMetadata(options.name, {req: options.req}, (err, data) => {
if (err && (!err.status || err.status >= 500)) {
// report internal errors right away
return callback(err);
return options.callback(err);
}
this._syncUplinksMetadata(name, data, options, function(err, result, uplink_errors) {
this._syncUplinksMetadata(options.name, data, {req: options.req}, function(err, result, uplink_errors) {
if (err) {
return callback(err);
return options.callback(err);
}
const propertyToKeep = [];
Array.prototype.push.apply(propertyToKeep, WHITELIST);
if (options.keepUpLinkData === true) {
propertyToKeep.push('_uplinks');
}
for (let i in result) {
if (WHITELIST.indexOf(i) === -1) {
if (propertyToKeep.indexOf(i) === -1) { // Remove sections like '_uplinks' from response
delete result[i];
}
}
@ -391,7 +396,7 @@ class Storage {
// npm can throw if this field doesn't exist
result._attachments = {};
callback(null, result, uplink_errors);
options.callback(null, result, uplink_errors);
});
});
}

View file

@ -11,6 +11,7 @@ import storage from '../../../utils/storage';
import classes from './header.scss';
import './logo.png';
import getRegistryURL from '../../../utils/getRegistryURL';
export default class Header extends React.Component {
state = {
@ -138,8 +139,7 @@ export default class Header extends React.Component {
}
render() {
// Don't add slash if it's not a sub directory
const registryURL = `${location.origin}${location.pathname === '/' ? '' : location.pathname}`;
const registryURL = getRegistryURL();
return (
<header className={ classes.header }>

View file

@ -1,16 +1,15 @@
import React from 'react';
import SyntaxHighlighter, {registerLanguage} from 'react-syntax-highlighter/dist/light';
import sunburst from 'react-syntax-highlighter/src/styles/sunburst';
import js from 'react-syntax-highlighter/dist/languages/javascript';
import classes from './help.scss';
import getRegistryURL from '../../../utils/getRegistryURL';
registerLanguage('javascript', js);
const Help = () => {
// Don't add slash if it's not a sub directory
const registryURL = `${location.origin}${location.pathname === '/' ? '' : location.pathname}`;
const registryURL = getRegistryURL();
return (
<div className={classes.help}>

View file

@ -18,7 +18,6 @@ const PackageDetail = (props) => {
return (
<div className={classes.pkgDetail}>
<h1 className={ classes.title }>{ props.package }</h1>
<hr/>
<div className={classes.readme}>
{displayState(props.readMe)}
</div>

View file

@ -2,8 +2,13 @@
.pkgDetail {
.title {
font-size: 28px;
color: $text-black;
font-size: 38px;
color: $primary-color;
border-bottom: 1px solid $border-color;
text-transform: capitalize;
font-weight: 600;
margin: 0 0 10px;
padding-bottom: 5px;
}
.readme {

View file

@ -0,0 +1,182 @@
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import API from '../../../utils/api';
import classes from './style.scss';
import getRegistryURL from '../../../utils/getRegistryURL';
export default class PackageSidebar extends React.Component {
state = {
lastUpdate: 'Loading',
recentReleases: [],
author: null,
contributors: null,
showAllContributors: false,
dependencies: {}
};
constructor(props) {
super(props);
this.showAllContributors = this.showAllContributors.bind(this);
}
async componentDidMount() {
let packageMeta;
try {
packageMeta = (await API.get(`sidebar/${this.props.packageName}`)).data;
} catch (err) {
this.setState({
failed: true
});
return;
}
let lastUpdate = 0;
Object.keys(packageMeta._uplinks).forEach((upLinkName) => {
const status = packageMeta._uplinks[upLinkName];
if (status.fetched > lastUpdate) {
lastUpdate = status.fetched;
}
});
let recentReleases = Object.keys(packageMeta.time).map((version) => {
return {
version,
time: packageMeta.time[version]
};
});
recentReleases = recentReleases.slice(recentReleases.length - 3, recentReleases.length).reverse();
this.setState({
lastUpdate: (new Date(lastUpdate)).toLocaleString(),
recentReleases,
author: packageMeta.latest.author,
contributors: packageMeta.latest.contributors,
dependencies: packageMeta.latest.dependencies,
showAllContributors: _.size(packageMeta.latest.contributors) <= 5
});
}
showAllContributors() {
this.setState({
showAllContributors: true
});
}
render() {
let {author, contributors, recentReleases, lastUpdate, showAllContributors, dependencies} = this.state;
let uniqueContributors = [];
if (contributors) {
uniqueContributors = _.filter(_.uniqBy(contributors, (contributor) => contributor.name), (contributor) => {
return contributor.name !== _.get(author, 'name');
})
.slice(0, 5);
}
return (
<aside>
<Module
title="Last Sync"
description={lastUpdate}
className={classes.releasesModule}
>
<ul>
{recentReleases.length > 0 && recentReleases.map((versionInfo) => {
return (
<li key={versionInfo.version}>
<span>{versionInfo.version}</span>
<span>{(new Date(versionInfo.time)).toLocaleString()}</span>
</li>
);
})}
</ul>
</Module>
<Module
title="Maintainers"
className={classes.authorsModule}
>
<ul>
{author && <MaintainerInfo title="Author" name={author.name} avatar={author.avatar}/>}
{contributors && (showAllContributors ? contributors : uniqueContributors).map((contributor, index) => {
return <MaintainerInfo key={index} title="Contributors" name={contributor.name} avatar={contributor.avatar}/>;
})}
</ul>
{!this.state.showAllContributors && (
<button
onClick={this.showAllContributors}
className={classes.showAllContributors}
title="Current list only show the author and first 5 contributors unique by name"
>
Show all contributor
</button>
)}
</Module>
{/* Package management module? Help us implement it! */}
<Module
title="Dependencies"
className={classes.dependenciesModule}
>
<ul>
{_.size(dependencies) ? (
Object.keys(dependencies).map((dependenceName, index) => {
return (
<li key={index} title={`Depend on version: ${dependencies[dependenceName]}`}>
<a href={`${getRegistryURL()}/#/detail/${dependenceName}`} target="_blank">{dependenceName}</a>
{index + 1 < _.size(dependencies) && <span>,&nbsp;</span>}
</li>
);
})
): <p className={classes.emptyPlaceholder}>Zero dependencies</p>}
</ul>
</Module>
</aside>
);
}
}
PackageSidebar.propTypes = {
packageName: PropTypes.string.isRequired
};
function Module(props) {
return (
<div className={`${classes.module} ${props.className}`}>
<h2 className={classes.moduleTitle}>
{props.title}
{props.description && <span>{props.description}</span>}
</h2>
<div>
{props.children}
</div>
</div>
);
}
Module.propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
children: PropTypes.any.isRequired,
className: PropTypes.string
};
function MaintainerInfo(props) {
let avatarDescription = `${props.title} ${props.name}'s avatar`;
return (
<div className={classes.maintainer} title={props.name}>
<img src={props.avatar} alt={avatarDescription} title={avatarDescription}/>
<span>{props.name}</span>
</div>
);
}
MaintainerInfo.propTypes = {
title: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
avatar: PropTypes.string.isRequired
};

View file

@ -0,0 +1,90 @@
@import '../../styles/variable';
.module {
.moduleTitle {
display: flex;
align-items: flex-end;
font-size: 24px;
color: $primary-color;
margin: 0 0 10px;
padding: 5px 0;
font-weight: 600;
border-bottom: 1px solid $border-color;
span { // description
font-size: 14px;
color: $text-grey;
margin-left: auto;
font-weight: lighter;
}
}
}
.releasesModule {
li {
display: flex;
font-size: 14px;
line-height: 2;
span:last-child {
margin-left: auto;
}
}
}
.authorsModule {
.maintainer {
$mine-height: 30px;
display: flex;
line-height: $mine-height;
cursor: default;
&:not(:last-child) {
margin-bottom: 10px;
}
img {
width: $mine-height;
height: $mine-height;
margin-right: 10px;
border-radius: 100%;
flex-shrink: 0;
}
span {
font-size: 14px;
flex-shrink: 1;
white-space: nowrap;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
}
}
.showAllContributors {
cursor: pointer;
width: 100%;
background: none;
border: none;
font-size: 14px;
color: $primary-color;
text-align: center;
padding: 10px 0;
}
}
.dependenciesModule {
li {
display: inline-block;
font-size: 14px;
line-height: 1.5;
a {
color: $primary-color;
}
}
}
.emptyPlaceholder {
text-align: center;
margin: 20px 0;
font-size: 16px;
color: $text-grey;
}

View file

@ -1,2 +1,25 @@
@import '../../styles/variable';
.twoColumn {
@include container-size();
margin: auto 10px;
display: flex;
> div {
&:first-child {
flex-shrink: 1;
min-width: 300px;
width: 100%;
}
}
> aside {
&:last-child {
margin-left: auto;
padding-left: 15px;
flex-shrink: 0;
width: 285px;
}
}
}

View file

@ -7,6 +7,9 @@ import PackageDetail from '../../components/PackageDetail';
import NotFound from '../../components/NotFound';
import API from '../../../utils/api';
import classes from './detail.scss';
import PackageSidebar from '../../components/PackageSidebar/index';
const loadingMessage = 'Loading...';
export default class Detail extends React.Component {
@ -39,6 +42,11 @@ export default class Detail extends React.Component {
} else if (isEmpty(this.state.readMe)) {
return <Loading text={loadingMessage} />;
}
return <PackageDetail readMe={this.state.readMe} package={this.props.match.params.package}/>;
return (
<div className={classes.twoColumn}>
<PackageDetail readMe={this.state.readMe} package={this.props.match.params.package}/>
<PackageSidebar packageName={this.props.match.params.package} />
</div>
);
}
}

View file

@ -3,6 +3,13 @@
body {
font-family: $font;
font-size: 12px;
color: $text-black;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
:global {

View file

@ -4,13 +4,15 @@
margin-right: auto;
width: 100%;
min-width: 400px;
max-width: 960px;
max-width: 1140px;
}
$space-lg: 30px;
$font: "Arial";
// Font family from Bootstrap v4 Reboot.css
$font: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/* Colors */
$primary-color: #de4136;
$border-color: #e4e8f1;
$border-color: #e3e3e3;
$text-black: #3c3c3c;
$text-grey: #95989A;

View file

@ -0,0 +1,4 @@
export default function getRegistryURL() {
// Don't add slash if it's not a sub directory
return `${location.origin}${location.pathname === '/' ? '' : location.pathname}`;
}