diff --git a/src/api/endpoint/api/dist-tags.js b/src/api/endpoint/api/dist-tags.js index dd1f77fd1..c5c081dd6 100644 --- a/src/api/endpoint/api/dist-tags.js +++ b/src/api/endpoint/api/dist-tags.js @@ -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']); + }, }); }); diff --git a/src/api/endpoint/api/package.js b/src/api/endpoint/api/package.js index 2dcf85cc7..55be61786 100644 --- a/src/api/endpoint/api/package.js +++ b/src/api/endpoint/api/package.js @@ -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, }); }); diff --git a/src/api/web/api.js b/src/api/web/api.js index 54128564d..d7a54bc1c 100644 --- a/src/api/web/api.js +++ b/src/api/web/api.js @@ -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}`; +} diff --git a/src/lib/local-storage.js b/src/lib/local-storage.js index 4bab31052..2ca6ac964 100644 --- a/src/lib/local-storage.js +++ b/src/lib/local-storage.js @@ -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 { diff --git a/src/lib/storage.js b/src/lib/storage.js index ed3bcf927..81330f513 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -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); }); }); } diff --git a/src/webui/src/components/Header/index.js b/src/webui/src/components/Header/index.js index a20b01d6b..c835bb7d8 100644 --- a/src/webui/src/components/Header/index.js +++ b/src/webui/src/components/Header/index.js @@ -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 (
diff --git a/src/webui/src/components/Help/index.js b/src/webui/src/components/Help/index.js index eae02ce6f..8dcb34690 100644 --- a/src/webui/src/components/Help/index.js +++ b/src/webui/src/components/Help/index.js @@ -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 (
diff --git a/src/webui/src/components/PackageDetail/index.js b/src/webui/src/components/PackageDetail/index.js index 7a9ed0719..404fb5ae1 100644 --- a/src/webui/src/components/PackageDetail/index.js +++ b/src/webui/src/components/PackageDetail/index.js @@ -18,7 +18,6 @@ const PackageDetail = (props) => { return (

{ props.package }

-
{displayState(props.readMe)}
diff --git a/src/webui/src/components/PackageDetail/packageDetail.scss b/src/webui/src/components/PackageDetail/packageDetail.scss index b495ccf0b..b80704ce3 100644 --- a/src/webui/src/components/PackageDetail/packageDetail.scss +++ b/src/webui/src/components/PackageDetail/packageDetail.scss @@ -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 { diff --git a/src/webui/src/components/PackageSidebar/index.jsx b/src/webui/src/components/PackageSidebar/index.jsx new file mode 100644 index 000000000..aae925562 --- /dev/null +++ b/src/webui/src/components/PackageSidebar/index.jsx @@ -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 ( + + ); + } +} +PackageSidebar.propTypes = { + packageName: PropTypes.string.isRequired +}; + +function Module(props) { + return ( +
+

+ {props.title} + {props.description && {props.description}} +

+
+ {props.children} +
+
+ ); +} + +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 ( +
+ {avatarDescription} + {props.name} +
+ ); +} +MaintainerInfo.propTypes = { + title: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + avatar: PropTypes.string.isRequired +}; diff --git a/src/webui/src/components/PackageSidebar/style.scss b/src/webui/src/components/PackageSidebar/style.scss new file mode 100644 index 000000000..d5e1a8d94 --- /dev/null +++ b/src/webui/src/components/PackageSidebar/style.scss @@ -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; +} diff --git a/src/webui/src/modules/detail/detail.scss b/src/webui/src/modules/detail/detail.scss index 4b6872f2d..f190f8634 100644 --- a/src/webui/src/modules/detail/detail.scss +++ b/src/webui/src/modules/detail/detail.scss @@ -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; + } + } +} diff --git a/src/webui/src/modules/detail/index.js b/src/webui/src/modules/detail/index.js index 952d5041c..a286111ae 100644 --- a/src/webui/src/modules/detail/index.js +++ b/src/webui/src/modules/detail/index.js @@ -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 ; } - return ; + return ( +
+ + +
+ ); } } diff --git a/src/webui/src/styles/global.scss b/src/webui/src/styles/global.scss index 1ad86925a..54bfd514e 100644 --- a/src/webui/src/styles/global.scss +++ b/src/webui/src/styles/global.scss @@ -3,6 +3,13 @@ body { font-family: $font; font-size: 12px; + color: $text-black; +} + +ul { + margin: 0; + padding: 0; + list-style: none; } :global { diff --git a/src/webui/src/styles/variable.scss b/src/webui/src/styles/variable.scss index 508056a78..352371ec7 100644 --- a/src/webui/src/styles/variable.scss +++ b/src/webui/src/styles/variable.scss @@ -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; diff --git a/src/webui/utils/getRegistryURL.js b/src/webui/utils/getRegistryURL.js new file mode 100644 index 000000000..2a4aeaf3f --- /dev/null +++ b/src/webui/utils/getRegistryURL.js @@ -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}`; +}