diff --git a/.gitignore b/.gitignore
index 6fd060f87..7d9a0cd94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
dist/css/*
dist/js/*
+!dist/js/flowplayer/
+!dist/js/flowplayer/
node_modules
.sass-cache
.idea
diff --git a/dist/index.html b/dist/index.html
index 4bfac2bd2..d3fbbb0f6 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -21,6 +21,7 @@
+
@@ -29,6 +30,7 @@
+
diff --git a/dist/js/flowplayer/flowplayer-3.2.13.min.js b/dist/js/flowplayer/flowplayer-3.2.13.min.js
new file mode 100644
index 000000000..eba948758
--- /dev/null
+++ b/dist/js/flowplayer/flowplayer-3.2.13.min.js
@@ -0,0 +1,22 @@
+/*
+ * flowplayer.js The Flowplayer API
+ *
+ * Copyright 2009-2011 Flowplayer Oy
+ *
+ * This file is part of Flowplayer.
+ *
+ * Flowplayer is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Flowplayer is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Flowplayer. If not, see .
+ *
+ */
+!function(){function h(p){console.log("$f.fireEvent",[].slice.call(p))}function l(r){if(!r||typeof r!="object"){return r}var p=new r.constructor();for(var q in r){if(r.hasOwnProperty(q)){p[q]=l(r[q])}}return p}function n(u,r){if(!u){return}var p,q=0,s=u.length;if(s===undefined){for(p in u){if(r.call(u[p],p,u[p])===false){break}}}else{for(var t=u[0];q1){var u=arguments[1],r=(arguments.length==3)?arguments[2]:{};if(typeof u=="string"){u={src:u}}u=j({bgcolor:"#000000",version:[10,1],expressInstall:"http://releases.flowplayer.org/swf/expressinstall.swf",cachebusting:false},u);if(typeof p=="string"){if(p.indexOf(".")!=-1){var t=[];n(o(p),function(){t.push(new b(this,l(u),l(r)))});return new d(t)}else{var s=c(p);return new b(s!==null?s:l(p),l(u),l(r))}}else{if(p){return new b(p,l(u),l(r))}}}return null};j(window.$f,{fireEvent:function(){var q=[].slice.call(arguments);var r=$f(q[0]);return r?r._fireEvent(q.slice(1)):null},addPlugin:function(p,q){b.prototype[p]=q;return $f},each:n,extend:j});if(typeof jQuery=="function"){jQuery.fn.flowplayer=function(r,q){if(!arguments.length||typeof arguments[0]=="number"){var p=[];this.each(function(){var s=$f(this);if(s){p.push(s)}});return arguments.length?p[arguments[0]]:new d(p)}return this.each(function(){$f(this,l(r),q?l(q):{})})}}}();!function(){var h=document.all,j="http://get.adobe.com/flashplayer",c=typeof jQuery=="function",e=/(\d+)[^\d]+(\d+)[^\d]*(\d*)/,b={width:"100%",height:"100%",id:"_"+(""+Math.random()).slice(9),allowfullscreen:true,allowscriptaccess:"always",quality:"high",version:[3,0],onFail:null,expressInstall:null,w3c:false,cachebusting:false};if(window.attachEvent){window.attachEvent("onbeforeunload",function(){__flash_unloadHandler=function(){};__flash_savedUnloadHandler=function(){}})}function i(m,l){if(l){for(var f in l){if(l.hasOwnProperty(f)){m[f]=l[f]}}}return m}function a(f,n){var m=[];for(var l in f){if(f.hasOwnProperty(l)){m[l]=n(f[l])}}return m}window.flashembed=function(f,m,l){if(typeof f=="string"){f=document.getElementById(f.replace("#",""))}if(!f){return}if(typeof m=="string"){m={src:m}}return new d(f,i(i({},b),m),l)};var g=i(window.flashembed,{conf:b,getVersion:function(){var m,f,o;try{o=navigator.plugins["Shockwave Flash"];if(o[0].enabledPlugin!=null){f=o.description.slice(16)}}catch(p){try{m=new ActiveXObject("ShockwaveFlash.ShockwaveFlash.7");f=m&&m.GetVariable("$version")}catch(n){try{m=new ActiveXObject("ShockwaveFlash.ShockwaveFlash.6");f=m&&m.GetVariable("$version")}catch(l){}}}f=e.exec(f);return f?[1*f[1],1*f[(f[1]*1>9?2:3)]*1]:[0,0]},asString:function(l){if(l===null||l===undefined){return null}var f=typeof l;if(f=="object"&&l.push){f="array"}switch(f){case"string":l=l.replace(new RegExp('(["\\\\])',"g"),"\\$1");l=l.replace(/^\s?(\d+\.?\d*)%/,"$1pct");return'"'+l+'"';case"array":return"["+a(l,function(o){return g.asString(o)}).join(",")+"]";case"function":return'"function()"';case"object":var m=[];for(var n in l){if(l.hasOwnProperty(n)){m.push('"'+n+'":'+g.asString(l[n]))}}return"{"+m.join(",")+"}"}return String(l).replace(/\s/g," ").replace(/\'/g,'"')},getHTML:function(o,l){o=i({},o);var n='";if(o.w3c||h){n+=' '}o.width=o.height=o.id=o.w3c=o.src=null;o.onFail=o.version=o.expressInstall=null;for(var m in o){if(o[m]){n+=' '}}var p="";if(l){for(var f in l){if(l[f]){var q=l[f];p+=f+"="+(/function|object/.test(typeof q)?g.asString(q):q)+"&"}}p=p.slice(0,-1);n+=' "}n+=" ";return n},isSupported:function(f){return k[0]>f[0]||k[0]==f[0]&&k[1]>=f[1]}});var k=g.getVersion();function d(f,n,m){if(g.isSupported(n.version)){f.innerHTML=g.getHTML(n,m)}else{if(n.expressInstall&&g.isSupported([6,65])){f.innerHTML=g.getHTML(i(n,{src:n.expressInstall}),{MMredirectURL:encodeURIComponent(location.href),MMplayerType:"PlugIn",MMdoctitle:document.title})}else{if(!f.innerHTML.replace(/\s/g,"")){f.innerHTML="Flash version "+n.version+" or greater is required "+(k[0]>0?"Your version is "+k:"You have no flash plugin installed")+" "+(f.tagName=="A"?"
Click here to download latest version
":"Download latest version from here
");if(f.tagName=="A"||f.tagName=="DIV"){f.onclick=function(){location.href=j}}}if(n.onFail){var l=n.onFail.call(this);if(typeof l=="string"){f.innerHTML=l}}}}if(h){window[n.id]=document.getElementById(n.id)}i(this,{getRoot:function(){return f},getOptions:function(){return n},getConf:function(){return m},getApi:function(){return f.firstChild}})}if(c){jQuery.tools=jQuery.tools||{version:"@VERSION"};jQuery.tools.flashembed={conf:b};jQuery.fn.flashembed=function(l,f){return this.each(function(){$(this).data("flashembed",flashembed(this,l,f))})}}}();
\ No newline at end of file
diff --git a/dist/js/flowplayer/flowplayer-3.2.18.swf b/dist/js/flowplayer/flowplayer-3.2.18.swf
new file mode 100644
index 000000000..aed1fcb12
Binary files /dev/null and b/dist/js/flowplayer/flowplayer-3.2.18.swf differ
diff --git a/dist/js/flowplayer/flowplayer.controls-3.2.16.swf b/dist/js/flowplayer/flowplayer.controls-3.2.16.swf
new file mode 100644
index 000000000..eacc8c029
Binary files /dev/null and b/dist/js/flowplayer/flowplayer.controls-3.2.16.swf differ
diff --git a/js/app.js b/js/app.js
index ac6d67509..99d89f991 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1,15 +1,10 @@
-var appStyles = {
- width: '800px',
- marginLeft: 'auto',
- marginRight: 'auto',
-};
var App = React.createClass({
getInitialState: function() {
// For now, routes are in format ?page or ?page=args
var match, param, val;
[match, param, val] = window.location.search.match(/\??([^=]*)(?:=(.*))?/);
- if (['settings', 'help', 'start', 'watch', 'report'].indexOf(param) != -1) {
+ if (['settings', 'help', 'start', 'watch', 'report', 'files'].indexOf(param) != -1) {
var viewingPage = param;
} else {
var viewingPage = 'home';
@@ -44,31 +39,21 @@ var App = React.createClass({
}
});
},
- componentDidMount: function() {
- lbry.getStartNotice(function(notice) {
- if (notice) {
- alert(notice);
- }
- });
- },
render: function() {
if (this.state.viewingPage == 'home') {
- var content = ;
+ return ;
} else if (this.state.viewingPage == 'settings') {
- var content = ;
+ return ;
} else if (this.state.viewingPage == 'help') {
- var content = ;
+ return ;
} else if (this.state.viewingPage == 'watch') {
- var content = ;
+ return ;
} else if (this.state.viewingPage == 'report') {
- var content = ;
+ return ;
+ } else if (this.state.viewingPage == 'files') {
+ return ;
} else if (this.state.viewingPage == 'start') {
- var content = ;
+ return ;
}
- return (
-
- {content}
-
- );
}
});
\ No newline at end of file
diff --git a/js/component/common.js b/js/component/common.js
index c1dd5610e..c4f9b7631 100644
--- a/js/component/common.js
+++ b/js/component/common.js
@@ -3,9 +3,10 @@
var Icon = React.createClass({
propTypes: {
style: React.PropTypes.object,
+ fixed: React.PropTypes.boolean,
},
render: function() {
- var className = 'icon ' + this.props.icon;
+ var className = 'icon ' + ('fixed' in this.props ? 'icon-fixed-width ' : '') + this.props.icon;
return
}
});
@@ -18,7 +19,8 @@ var Link = React.createClass({
className = (this.props.button ? 'button-block button-' + this.props.button : 'button-text') +
(this.props.hidden ? ' hidden' : '') + (this.props.disabled ? ' disabled' : '');
return (
-
+
{this.props.icon ? icon : '' }
{this.props.label}
@@ -26,6 +28,79 @@ var Link = React.createClass({
}
});
+// Generic menu styles
+var menuStyle = {
+ border: '1px solid #aaa',
+ padding: '4px',
+ whiteSpace: 'nowrap',
+};
+
+var Menu = React.createClass({
+ handleWindowClick: function(e) {
+ if (this.props.toggleButton && ReactDOM.findDOMNode(this.props.toggleButton).contains(e.target)) {
+ // Toggle button was clicked
+ this.setState({
+ open: !this.state.open
+ });
+ } else if (this.state.open && !this.refs.div.contains(e.target)) {
+ // Menu is open and user clicked outside of it
+ this.setState({
+ open: false
+ });
+ }
+ },
+ propTypes: {
+ openButton: React.PropTypes.element,
+ },
+ getInitialState: function() {
+ return {
+ open: false,
+ };
+ },
+ componentDidMount: function() {
+ window.addEventListener('click', this.handleWindowClick, false);
+ },
+ componentWillUnmount: function() {
+ window.removeEventListener('click', this.handleWindowClick, false);
+ },
+ render: function() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+});
+
+var menuItemStyle = {
+ display: 'block',
+};
+var MenuItem = React.createClass({
+ propTypes: {
+ href: React.PropTypes.string,
+ label: React.PropTypes.string,
+ icon: React.PropTypes.string,
+ onClick: React.PropTypes.function,
+ },
+ getDefaultProps: function() {
+ return {
+ iconPosition: 'left',
+ }
+ },
+ render: function() {
+ var icon = (this.props.icon ? : null);
+
+ return (
+
+ {this.props.iconPosition == 'left' ? icon : null}
+ {this.props.label}
+ {this.props.iconPosition == 'left' ? null : icon}
+
+ );
+ }
+});
+
var creditAmountStyle = {
color: '#216C2A',
fontWeight: 'bold',
@@ -48,4 +123,16 @@ var CreditAmount = React.createClass({
);
}
+});
+
+var subPageLogoStyle = {
+ maxWidth: '150px',
+ display: 'block',
+ marginTop: '36px',
+};
+
+var SubPageLogo = React.createClass({
+ render: function() {
+ return ;
+ }
});
\ No newline at end of file
diff --git a/js/lbry.js b/js/lbry.js
index 3c8d7a0a8..16b8f5ef8 100644
--- a/js/lbry.js
+++ b/js/lbry.js
@@ -103,6 +103,22 @@ lbry.getFileStatus = function(name, callback) {
lbry.call('get_lbry_file', { 'name': name }, callback);
}
+lbry.getFilesInfo = function(callback) {
+ lbry.call('get_lbry_files', {}, callback);
+}
+
+lbry.startFile = function(name, callback) {
+ lbry.call('start_lbry_file', { name: name }, callback);
+}
+
+lbry.stopFile = function(name, callback) {
+ lbry.call('stop_lbry_file', { name: name }, callback);
+}
+
+lbry.deleteFile = function(name, callback) {
+ lbry.call('delete_lbry_file', { name: name }, callback)
+}
+
lbry.getVersionInfo = function(callback) {
lbry.call('version', {}, callback);
};
@@ -154,6 +170,26 @@ lbry.imagePath = function(file)
return lbry.rootPath + '/img/' + file;
}
+lbry.getMediaType = function(filename) {
+ var dotIndex = filename.lastIndexOf('.');
+ if (dotIndex == -1) {
+ return 'unknown';
+ }
+
+ var ext = filename.substr(dotIndex + 1);
+ if (/^mp4|mov|m4v|flv|f4v$/i.test(ext)) {
+ return 'video';
+ } else if (/^mp3|m4a|aac|wav|flac|ogg$/i.test(ext)) {
+ return 'audio';
+ } else if (/^html|htm|pdf|odf|doc|docx|md|markdown|txt$/i.test(ext)) {
+ return 'document';
+ } else {
+ return 'unknown';
+ }
+}
+
lbry.stop = function(callback) {
lbry.call('stop', {}, callback);
};
+
+
diff --git a/js/page/help.js b/js/page/help.js
index c4599692f..64f0845cb 100644
--- a/js/page/help.js
+++ b/js/page/help.js
@@ -3,7 +3,8 @@
var HelpPage = React.createClass({
render: function() {
return (
-
+
+
Troubleshooting
Here are the most commonly encountered problems and what to try doing about them.
diff --git a/js/page/home.js b/js/page/home.js
index 914c24248..a05acd075 100644
--- a/js/page/home.js
+++ b/js/page/home.js
@@ -228,23 +228,41 @@ var Header = React.createClass({
});
var topBarStyle = {
- 'float': 'right'
+ 'float': 'right',
+ 'position': 'relative',
+ 'height': '26px',
},
balanceStyle = {
'marginRight': '5px'
-},
-closeIconStyle = {
- 'color': '#ff5155'
};
+var mainMenuStyle = {
+ position: 'absolute',
+ top: '26px',
+ right: '0px',
+};
+
+var MainMenu = React.createClass({
+ render: function() {
+ var isLinux = /linux/i.test(navigator.userAgent); // @TODO: find a way to use getVersionInfo() here without messy state management
+ return (
+
+
+
+
+
+ {isLinux ?
+ : null}
+
+
+ );
+ }
+});
+
var TopBar = React.createClass({
- onClose: function() {
- window.location.href = "?start";
- },
getInitialState: function() {
return {
balance: 0,
- showClose: /linux/i.test(navigator.userAgent) // @TODO: find a way to use getVersionInfo() here without messy state management
};
},
componentDidMount: function() {
@@ -254,27 +272,34 @@ var TopBar = React.createClass({
});
}.bind(this));
},
-
+ onClose: function() {
+ window.location.href = "?start";
+ },
render: function() {
return (
-
- { ' ' }
-
- { ' ' }
-
+
+
+
);
}
});
var HomePage = React.createClass({
+ componentDidMount: function() {
+ lbry.getStartNotice(function(notice) {
+ if (notice) {
+ alert(notice);
+ }
+ });
+ },
render: function() {
return (
-
+
diff --git a/js/page/my_files.js b/js/page/my_files.js
new file mode 100644
index 000000000..8802c0cc2
--- /dev/null
+++ b/js/page/my_files.js
@@ -0,0 +1,138 @@
+var removeIconColumnStyle = {
+ fontSize: '1.3em',
+ height: '120px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+},
+progressBarStyle = {
+ height: '15px',
+ width: '230px',
+ backgroundColor: '#444',
+ border: '2px solid #eee',
+ display: 'inline-block',
+},
+myFilesRowImgStyle = {
+ maxHeight: '100px',
+ display: 'block',
+ marginLeft: 'auto',
+ marginRight: 'auto',
+ float: 'left'
+};
+
+var MyFilesRow = React.createClass({
+ onRemoveClicked: function() {
+ var alertText = 'Are you sure you\'d like to remove "' + this.props.title + '?" This will ' +
+ (this.completed ? ' stop the download and ' : '') +
+ 'permanently remove the file from your system.';
+
+ if (confirm(alertText)) {
+ lbry.deleteFile(this.props.lbryUri);
+ }
+ },
+ onPauseResumeClicked: function() {
+ if (this.props.stopped) {
+ lbry.startFile(this.props.lbryUri);
+ } else {
+ lbry.stopFile(this.props.lbryUri);
+ }
+ },
+ render: function() {
+ if (this.props.completed) {
+ var pauseLink = null;
+ var curProgressBarStyle = {display: 'none'};
+ } else {
+ var pauseLink =
{ this.onPauseResumeClicked() }} />;
+
+ var curProgressBarStyle = Object.assign({}, progressBarStyle);
+ curProgressBarStyle.width = this.props.ratioLoaded * 230;
+ curProgressBarStyle.borderRightWidth = 230 - (this.props.ratioLoaded * 230) + 2;
+ }
+
+ if (this.props.showWatchButton) {
+ // No support for lbry:// URLs in Windows or on Chrome yet
+ if (/windows|win32/i.test(navigator.userAgent) || (window.chrome && window.navigator.vendor == "Google Inc.")) {
+ var watchUri = "/?watch=" + this.props.lbryUri;
+ } else {
+ var watchUri = 'lbry://' + this.props.lbryUri;
+ }
+
+ var watchLink =
;
+ } else {
+ var watchLink = null;
+ }
+
+ return (
+
+
+
+
+
+
{this.props.title}
+
+ { ' ' }
+ {this.props.completed ? 'Download complete' : (parseInt(this.props.ratioLoaded * 100) + '%')}
+
{ pauseLink }
+
{ watchLink }
+
+
+ { this.onRemoveClicked() } } />
+
+
+ );
+ }
+});
+
+var MyFilesPage = React.createClass({
+ getInitialState: function() {
+ return {
+ filesInfo: null,
+ };
+ },
+ componentWillMount: function() {
+ this.updateFilesInfo();
+ },
+ updateFilesInfo: function() {
+ lbry.getFilesInfo((filesInfo) => {
+ this.setState({
+ filesInfo: (filesInfo ? filesInfo : []),
+ });
+ setTimeout(() => { this.updateFilesInfo() }, 1000);
+ });
+ },
+ render: function() {
+ if (this.state.filesInfo === null) {
+ return null;
+ }
+
+ if (!this.state.filesInfo.length) {
+ var content =
You haven't downloaded anything from LBRY yet. Go ! ;
+ } else {
+ var content = [];
+ for (let fileInfo of this.state.filesInfo) {
+ let {completed, written_bytes, total_bytes, lbry_uri, file_name, stopped, metadata} = fileInfo;
+ let {name, stream_name, thumbnail} = metadata;
+
+ var title = (name || stream_name || ('lbry://' + lbry_uri));
+ var ratioLoaded = written_bytes / total_bytes;
+ var showWatchButton = (lbry.getMediaType(file_name) == 'video');
+
+ content.push(
);
+ }
+ }
+ return (
+
+
+ My files
+ {content}
+
+
+ );
+ }
+});
\ No newline at end of file
diff --git a/js/page/report.js b/js/page/report.js
index ddfb999c4..bd46f2919 100644
--- a/js/page/report.js
+++ b/js/page/report.js
@@ -20,7 +20,8 @@ var ReportPage = React.createClass({
},
render: function() {
return (
-
+
+
Report a bug
Please describe the problem you experienced and any information you think might be useful to us. Links to screenshots are great!
diff --git a/js/page/settings.js b/js/page/settings.js
index 958bcd312..31b7c1d27 100644
--- a/js/page/settings.js
+++ b/js/page/settings.js
@@ -73,7 +73,8 @@ var SettingsPage = React.createClass({
}
return (
-
+
+
Settings
Run on startup
diff --git a/js/page/start.js b/js/page/start.js
index 1c5b1d0b6..2f91a91d1 100644
--- a/js/page/start.js
+++ b/js/page/start.js
@@ -4,7 +4,8 @@ var StartPage = React.createClass({
},
render: function() {
return (
-
+
+
LBRY has closed
diff --git a/js/page/watch.js b/js/page/watch.js
index a34e5eddb..c0af88688 100644
--- a/js/page/watch.js
+++ b/js/page/watch.js
@@ -1,6 +1,6 @@
var videoStyle = {
width: '100%',
- height: '100%',
+// height: '100%',
backgroundColor: '#000'
};
@@ -19,19 +19,6 @@ var WatchPage = React.createClass({
lbry.getStream(this.props.name);
this.updateLoadStatus();
},
- reloadIfNeeded: function() {
- // Fallback option for loading problems: every 15 seconds, if the video hasn't reported being
- // playable yet, ask it to reload.
- if (!this.state.readyToPlay) {
- this._video.load()
- setTimeout(() => { this.reloadIfNeeded() }, 15000);
- }
- },
- onCanPlay: function() {
- this.setState({
- readyToPlay: true
- });
- },
updateLoadStatus: function() {
lbry.getFileStatus(this.props.name, (status) => {
if (!status || status.code != 'running' || status.written_bytes == 0) {
@@ -44,31 +31,20 @@ var WatchPage = React.createClass({
setTimeout(() => { this.updateLoadStatus() }, 250);
} else {
this.setState({
- loadStatusMessage: "Buffering",
- downloadStarted: true,
- });
- setTimeout(() => { this.reloadIfNeeded() }, 15000);
+ readyToPlay: true
+ })
+ flowplayer('player', 'js/flowplayer/flowplayer-3.2.18.swf');
}
});
},
render: function() {
- if (!this.state.downloadStarted) {
- var video = null;
- } else {
- // If the download has started, render the behind the scenes so it can start loading.
- // When the video is actually ready to play, the loading text is hidden and the video shown.
- var video = {this._video = video}}/>;
- }
-
return (
-
+
Loading lbry://{this.props.name}
{this.state.loadStatusMessage}...
- {video}
+
);
}
diff --git a/scss/_gui.scss b/scss/_gui.scss
index a85f09ab8..1c866e86f 100644
--- a/scss/_gui.scss
+++ b/scss/_gui.scss
@@ -13,6 +13,22 @@ body
position: relative;
}
+.page {
+ margin-left: auto;
+ margin-right: auto;
+ width: 800px;
+
+ &.full-width {
+ width: 100%;
+ }
+}
+
+.icon-fixed-width {
+ /* This borrowed is from a component of Font Awesome we're not using, maybe add it? */
+ width: (18em / 14);
+ text-align: center;
+}
+
section
{
margin-bottom: $spacing-vertical;
@@ -22,7 +38,7 @@ section
}
}
-h1 { font-size: 2.0em; margin-bottom: $spacing-vertical / 2; margin-top: $spacing-vertical * 1.5; }
+h1 { font-size: 2.0em; margin-bottom: $spacing-vertical / 2; margin-top: $spacing-vertical; }
h2 { font-size: 1.75em; }
h3 { font-size: 1.4em; }
h4 { font-size: 1.2em; }
@@ -127,15 +143,34 @@ input[type="search"]
.button-text
{
color: $color-primary;
- text-decoration: underline;
+ .icon
+ {
+ &:first-child {
+ padding-right: 5px;
+ }
+ &:last-child:not(:only-child) {
+ padding-left: 5px;
+ }
+ }
+
+ &:not(.no-underline) {
+ text-decoration: underline;
+ .icon {
+ text-decoration: none;
+ }
+ }
&:hover
{
opacity: 0.70;
transition: opacity .225s ease;
+ text-decoration: underline;
+ .icon {
+ text-decoration: none;
+ }
}
}
-.icon {
+.icon:only-child {
position: relative;
top: 0.16em;
}