Seed Support #56

Closed
ocnios wants to merge 173 commits from master into build
100 changed files with 4694 additions and 3423 deletions

24
.appveyor.yml Normal file
View file

@ -0,0 +1,24 @@
# Test against the latest version of this Node.js version
environment:
nodejs_version: 7
GH_TOKEN:
secure: LiI5jyuHUw6XbH4kC3gP1HX4P/v4rwD/gCNtaFhQu2AvJz1/1wALkp5ECnIxRySN
key_pass:
secure: u6DydPcdrUJlxGL9uc7yQRYG8+5rY6aAEE9nfCSzFyNzZlX9NniOp8Uh5ZKQqX7bGEngLI6ipbLfiJvn0XFnhbn2iTkOuMqOXVJVOehvwlQ=
pfx_key:
secure: 1mwqyRy7hDqDjDK+TIAoaXyXzpNgwruFNA6TPkinUcVM7A+NLD33RQLnfnwVy+R5ovD2pUfhQ6+N0Fqebv6tZh436LIEsock+6IOdpgFwrg=
# find with: Get-Childitem Path "C:\Program Files (x86)\Microsoft SDKs\Windows\" -Include *signtool* -File -Recurse -ErrorAction SilentlyContinue
SIGNTOOL_PATH: C:\Program Files (x86)\Microsoft SDKs\Windows\v7.1A\Bin\signtool.exe
skip_branch_with_pr: true
clone_folder: C:\projects\lbry-app
build_script:
- ps: build\build.ps1
test: off
artifacts:
- path: dist\*.exe
name: LBRY

View file

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.9.2rc15
current_version = 0.10.0
commit = True
tag = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)((?P<release>[a-z]+)(?P<candidate>\d+))?

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ dist
/app/node_modules
/build/venv
/lbry-app-venv
/lbry-venv
/daemon/build
/daemon/venv
/daemon/requirements.txt

6
.gitmodules vendored
View file

@ -1,6 +0,0 @@
[submodule "lbry"]
path = lbry
url = https://github.com/lbryio/lbry.git
[submodule "lbryum"]
path = lbryum
url = https://github.com/lbryio/lbryum.git

View file

@ -8,6 +8,29 @@ Web UI version numbers should always match the corresponding version of LBRY App
## [Unreleased]
### Added
*
*
### Changed
*
*
### Fixed
* Error modals now display full screen properly
*
### Deprecated
*
*
### Removed
*
*
## [0.10.0] - 2017-05-04
### Added
* The UI has been overhauled to use an omnibar and drop the sidebar.
* The app is much more responsive switching pages. It no longer reloads the entire page and all assets on each page change.
* lbry.js now offers a subscription model for wallet balance similar to file info.
* Fixed file info subscribes not being unsubscribed in unmount.
@ -16,16 +39,24 @@ Web UI version numbers should always match the corresponding version of LBRY App
* New-style API calls return promises instead of using callbacks
* Wherever possible, use outpoints for unique IDs instead of names or SD hashes
* New publishes now display immediately in My Files, even before they hit the lbrynet file manager.
* New welcome flow for new users
* Redesigned UI for Discover
* Handle more of price calculations at the daemon layer to improve page load time
* Add special support for building channel claims in lbryuri module
* Enable windows code signing of binary
### Changed
* Update process now easier and more reliable
* Updated search to be compatible with new Lighthouse servers
* Cleaned up shutdown logic
*
* Support lbry v0.10 API signatures
### Fixed
* Fix Watch page and progress bars for new API changes
*
*
## [0.9.0rc15] - 2017-03-09
### Added

View file

@ -45,4 +45,4 @@ to create distributable packages, which is run by calling:
### Development on Windows
This project has currently only been worked on in Linux and macOS. If you are on Windows, you can
checkout out the build steps in [appveyor.yml](https://github.com/lbryio/lbry-app/blob/master/appveyor.yml) and probably figure out something from there.
checkout out the build steps in [appveyor.yml](https://github.com/lbryio/lbry-app/blob/master/.appveyor.yml) and probably figure out something from there.

View file

@ -1,11 +1,18 @@
const {app, BrowserWindow, ipcMain} = require('electron');
const url = require('url');
const path = require('path');
const jayson = require('jayson');
const semver = require('semver');
const https = require('https');
// tree-kill has better cross-platform handling of
// killing a process. child-process.kill was unreliable
const kill = require('tree-kill');
const child_process = require('child_process');
const assert = require('assert');
const {version: localVersion} = require(app.getAppPath() + '/package.json');
const VERSION_CHECK_INTERVAL = 30 * 60 * 1000;
const LATEST_RELEASE_API_URL = 'https://api.github.com/repos/lbryio/lbry-app/releases/latest';
let client = jayson.client.http('http://localhost:5279/lbryapi');
@ -17,12 +24,58 @@ let daemonSubprocess;
// This is set to true right before we try to shut the daemon subprocess --
// if it dies when we didn't ask it to shut down, we want to alert the user.
let daemonSubprocessKillRequested = false;
let daemonStopRequested = false;
// When a quit is attempted, we cancel the quit, do some preparations, then
// this is set to true and app.quit() is called again to quit for real.
let readyToQuit = false;
function checkForNewVersion(callback) {
function formatRc(ver) {
// Adds dash if needed to make RC suffix semver friendly
return ver.replace(/([^-])rc/, '$1-rc');
}
let result = '';
const opts = {
headers: {
'User-Agent': `LBRY/${localVersion}`,
}
};
const req = https.get(Object.assign(opts, url.parse(LATEST_RELEASE_API_URL)), (res) => {
res.on('data', (data) => {
result += data;
});
res.on('end', () => {
console.log('Local version:', localVersion);
const tagName = JSON.parse(result).tag_name;
const [_, remoteVersion] = tagName.match(/^v([\d.]+(?:-?rc\d+)?)$/);
if (!remoteVersion) {
console.log('Malformed remote version string:', tagName);
if (win) {
win.webContents.send('version-info-received', null);
}
} else {
console.log('Remote version:', remoteVersion);
const upgradeAvailable = semver.gt(formatRc(remoteVersion), formatRc(localVersion));
console.log(upgradeAvailable ? 'Upgrade available' : 'No upgrade available');
if (win) {
win.webContents.send('version-info-received', {remoteVersion, localVersion, upgradeAvailable});
}
}
})
});
req.on('error', (err) => {
console.log('Failed to get current version from GitHub. Error:', err);
if (win) {
win.webContents.send('version-info-received', null);
}
});
}
ipcMain.on('version-info-requested', checkForNewVersion);
/*
* Replacement for Electron's shell.openItem. The Electron version doesn't
* reliably work from the main process, and we need to be able to run it
@ -62,9 +115,9 @@ function getPidsForProcessName(name) {
}
function createWindow () {
win = new BrowserWindow({backgroundColor: '#155B4A'}) //$color-primary
win = new BrowserWindow({backgroundColor: '#155B4A', minWidth: 800, minHeight: 600 }) //$color-primary
win.maximize()
//win.webContents.openDevTools()
// win.webContents.openDevTools();
win.loadURL(`file://${__dirname}/dist/index.html`)
win.on('closed', () => {
win = null
@ -74,8 +127,8 @@ function createWindow () {
function handleDaemonSubprocessExited() {
console.log('The daemon has exited.');
daemonSubprocess = null;
if (!daemonSubprocessKillRequested) {
// We didn't stop the daemon subprocess on purpose, so display a
if (!daemonStopRequested) {
// We didn't request to stop the daemon, so display a
// warning and schedule a quit.
//
// TODO: maybe it would be better to restart the daemon?
@ -107,7 +160,6 @@ function launchDaemon() {
daemonSubprocess.stdout.on('data', (buf) => {console.log(String(buf).trim());});
daemonSubprocess.stderr.on('data', (buf) => {console.log(String(buf).trim());});
daemonSubprocess.on('exit', handleDaemonSubprocessExited);
console.log('lbrynet daemon has launched')
}
/*
@ -209,31 +261,27 @@ app.on('activate', () => {
// When a quit is attempted, this is called. It attempts to shutdown the daemon,
// then calls quitNow() to quit for real.
function shutdownDaemonAndQuit(evenIfNotStartedByApp = false) {
if (daemonSubprocess) {
console.log('Killing lbrynet-daemon process');
daemonSubprocessKillRequested = true;
kill(daemonSubprocess.pid, undefined, (err) => {
console.log('Killed lbrynet-daemon process');
quitNow();
});
} else if (evenIfNotStartedByApp) {
console.log('Stopping lbrynet-daemon, even though app did not start it');
function doShutdown() {
console.log('Asking daemon to shut down down');
daemonStopRequested = true;
client.request('daemon_stop', [], (err, res) => {
if (err) {
// We could get an error because the daemon is already stopped (good)
// or because it's running but not responding properly (bad).
// So try to force kill any daemons that are still running.
console.log(`received error when stopping lbrynet-daemon. Error message: ${err.message}`);
forceKillAllDaemonsAndQuit();
console.log(`received error when stopping lbrynet-daemon. Error message: ${err.message}\n`);
console.log('You will need to manually kill the daemon.');
} else {
console.log('Successfully stopped daemon via RPC call.')
quitNow();
}
});
} else {
}
if (daemonSubprocess) {
doShutdown();
} else if (!evenIfNotStartedByApp) {
console.log('Not killing lbrynet-daemon because app did not start it');
quitNow();
} else {
doShutdown();
}
// Is it safe to start the installer before the daemon finishes running?

View file

@ -24,6 +24,9 @@ const baseTemplate = [
{
role: 'paste',
},
{
role: 'selectall',
},
]
},
{

View file

@ -1,6 +1,6 @@
{
"name": "LBRY",
"version": "0.9.2rc15",
"version": "0.10.0",
"main": "main.js",
"description": "LBRY is a fully decentralized, open-source protocol facilitating the discovery, access, and (sometimes) purchase of data.",
"author": {
@ -12,6 +12,7 @@
"install": "^0.8.7",
"jayson": "^2.0.2",
"npm": "^4.2.0",
"semver": "^5.3.0",
"tree-kill": "^1.1.0"
}
}

View file

@ -1,55 +0,0 @@
# Test against the latest version of this Node.js version
environment:
nodejs_version: "6"
GH_TOKEN:
secure: LiI5jyuHUw6XbH4kC3gP1HX4P/v4rwD/gCNtaFhQu2AvJz1/1wALkp5ECnIxRySN
skip_branch_with_pr: true
clone_folder: C:\projects\lbry-electron
# Install scripts. (runs after repo cloning)
install:
# needed to deal with submodules
- git submodule update --init --recursive
- python build\set_version.py
- python build\set_build.py
# Get the latest stable version of Node.js or io.js
- ps: Install-Product node $env:nodejs_version
# install modules
- npm install
- cd app
- npm install
- cd ..
# create daemon and cli executable
- cd daemon
- ps: .\build.ps1
- cd ..
# build ui
- cd ui
- npm install
- node_modules\.bin\node-sass --output dist\css --sourcemap=none scss\
- node_modules\.bin\webpack
- ps: Copy-Item dist ..\app\ -recurse
- cd ..
# copy executables into ui
- ps: Copy-Item daemon\dist\lbrynet-daemon.exe app\dist
- ps: Copy-Item daemon\dist\lbrynet-cli.exe app\dist
build_script:
# build electron app
- node_modules\.bin\build -p never
# for debugging, see what was built
- python build\zip_daemon.py
- dir dist
- pip install -r build\requirements.txt
- python build\release_on_tag.py
test: off
artifacts:
- path: dist\*.exe
name: LBRY
- path: dist\*.zip
name: lbrynet-daemon

1
build/DAEMON_URL Normal file
View file

@ -0,0 +1 @@
https://github.com/lbryio/lbry/releases/download/v0.10.3rc1/lbrynet-daemon-v0.10.3rc1-OSNAME.zip

40
build/build.ps1 Normal file
View file

@ -0,0 +1,40 @@
pip install -r build\requirements.txt
python build\set_version.py
# Get the latest stable version of Node.js or io.js
Install-Product node $env:nodejs_version
# install node modules
npm install
cd app
npm install
cd ..
# build ui
cd ui
npm install
node_modules\.bin\node-sass --output dist\css --sourcemap=none scss\
node_modules\.bin\webpack
Copy-Item dist ..\app\ -recurse
cd ..
# get daemon and cli executable
$daemon_url = (Get-Content build\DAEMON_URL -Raw).replace("OSNAME", "windows")
Invoke-WebRequest -Uri $daemon_url -OutFile daemon.zip
Expand-Archive daemon.zip -DestinationPath app\dist\
dir app\dist\ # verify that daemon binary is there
rm daemon.zip
# build electron app
node_modules\.bin\build -p never
$binary_name = Get-ChildItem -Path dist -Filter '*.exe' -Name
$new_name = $binary_name -replace '^LBRY Setup (.*)\.exe$', 'LBRY_$1.exe'
Rename-Item -Path "dist\$binary_name" -NewName $new_name
dir dist # verify that binary was built/named correctly
# sign binary
nuget install secure-file -ExcludeVersion
secure-file\tools\secure-file -decrypt build\lbry2.pfx.enc -secret "$env:pfx_key"
& ${env:SIGNTOOL_PATH} sign /f build\lbry2.pfx /p "$env:key_pass" /tr http://tsa.starfieldtech.com /td SHA256 /fd SHA256 dist\*.exe
python build\release_on_tag.py

View file

@ -7,7 +7,18 @@ ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
cd "$ROOT"
BUILD_DIR="$ROOT/build"
LINUX=false
OSX=false
if [ "$(uname)" == "Darwin" ]; then
OSX=true
elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then
LINUX=true
else
echo "Platform detection failed"
exit 1
fi
if $OSX; then
ICON="$BUILD_DIR/icon.icns"
else
ICON="$BUILD_DIR/icons/lbry48.png"
@ -32,7 +43,6 @@ if [ "$FULL_BUILD" == "true" ]; then
set -u
pip install -r "$BUILD_DIR/requirements.txt"
python "$BUILD_DIR/set_version.py"
python "$BUILD_DIR/set_build.py"
fi
[ -d "$ROOT/dist" ] && rm -rf "$ROOT/dist"
@ -62,24 +72,15 @@ npm install
# daemon and cli #
####################
(
cd "$ROOT/daemon"
# copy requirements from lbry, but remove lbryum (we'll add it back in below)
grep -v lbryum "$ROOT/lbry/requirements.txt" > requirements.txt
# for electron, we install lbryum and lbry using submodules
echo "../lbryum" >> requirements.txt
echo "../lbry" >> requirements.txt
# also add pyinstaller
echo "PyInstaller==3.2.1" >> requirements.txt
pip install -r requirements.txt
pyinstaller -y daemon.onefile.spec
pyinstaller -y cli.onefile.spec
mv dist/lbrynet-daemon dist/lbrynet-cli "$ROOT/app/dist/"
)
python "$BUILD_DIR/zip_daemon.py"
if $OSX; then
OSNAME="macos"
else
OSNAME="linux"
fi
DAEMON_URL="$(cat "$BUILD_DIR/DAEMON_URL" | sed "s/OSNAME/${OSNAME}/")"
wget --quiet "$DAEMON_URL" -O "$BUILD_DIR/daemon.zip"
unzip "$BUILD_DIR/daemon.zip" -d "$ROOT/app/dist/"
rm "$BUILD_DIR/daemon.zip"
###################
# Build the app #
@ -91,12 +92,18 @@ python "$BUILD_DIR/zip_daemon.py"
)
if [ "$FULL_BUILD" == "true" ]; then
if [ "$(uname)" == "Darwin" ]; then
if $OSX; then
security unlock-keychain -p ${KEYCHAIN_PASSWORD} osx-build.keychain
fi
node_modules/.bin/build -p never
if $OSX; then
binary_name=$(find "$ROOT/dist" -iname "*dmg")
new_name=$(basename "$binary_name" | sed 's/-/_/')
mv "$binary_name" "$(dirname "$binary_name")/$new_name"
fi
# electron-build has a publish feature, but I had a hard time getting
# it to reliably work and it also seemed difficult to configure. Not proud of
# this, but it seemed better to write my own.

View file

@ -1,8 +1,5 @@
import argparse
import datetime
import re
import sys
CHANGELOG_START_RE = re.compile(r'^\#\# \[Unreleased\]')
CHANGELOG_END_RE = re.compile(r'^\#\# \[.*\] - \d{4}-\d{2}-\d{2}')
@ -14,84 +11,67 @@ EMPTY_RE = re.compile(r'^\w*\*\w*$')
ENTRY_RE = re.compile(r'\* (.*)')
VALID_SECTIONS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
# allocate some entries to cut-down on merge conflicts
TEMPLATE = """### Added
*
*
*
### Changed
*
*
*
### Fixed
*
*
### Deprecated
*
*
### Removed
*
*
"""
def main():
print "i am broken"
return 1
parser = argparse.ArgumentParser()
parser.add_argument('changelog')
parser.add_argument('version')
args = parser.parse_args()
bump(changelog, version)
class Changelog(object):
def __init__(self, path):
self.path = path
self.start = []
self.unreleased = []
self.rest = []
self._parse()
def bump(changelog, version):
with open(changelog) as fp:
def _parse(self):
with open(self.path) as fp:
lines = fp.readlines()
start = []
unreleased = []
rest = []
unreleased_start_found = False
unreleased_end_found = False
for line in lines:
if not unreleased_start_found:
start.append(line)
self.start.append(line)
if CHANGELOG_START_RE.search(line):
unreleased_start_found = True
continue
if unreleased_end_found:
rest.append(line)
self.rest.append(line)
continue
if CHANGELOG_END_RE.search(line):
rest.append(line)
self.rest.append(line)
unreleased_end_found = True
continue
if CHANGELOG_ERROR_RE.search(line):
raise Exception(
'Failed to parse {}: {}'.format(changelog, 'unexpected section header found'))
unreleased.append(line)
'Failed to parse {}: {}'.format(self.path, 'unexpected section header found'))
self.unreleased.append(line)
today = datetime.datetime.today()
header = '## [{}] - {}\n'.format(version, today.strftime('%Y-%m-%d'))
released = normalize(unreleased)
if not released:
# If we don't have anything in the Unreleased section, then leave the
# changelog as it is and return None
return
self.unreleased = self._normalize_section(self.unreleased)
changelog_data = (
''.join(start) +
TEMPLATE +
header +
'\n'.join(released) + '\n\n'
+ ''.join(rest)
)
with open(changelog, 'w') as fp:
fp.write(changelog_data)
return '\n'.join(released) + '\n\n'
def normalize(lines):
@staticmethod
def _normalize_section(lines):
"""Parse a changelog entry and output a normalized form"""
sections = {}
current_section_name = None
@ -124,8 +104,26 @@ def normalize(lines):
output.append('### {}'.format(section))
for entry in sections[section]:
output.append(' * {}'.format(entry))
output.append("\n")
return output
def get_unreleased(self):
return '\n'.join(self.unreleased) if self.unreleased else None
if __name__ == '__main__':
sys.exit(main())
def bump(self, version):
if not self.unreleased:
return
today = datetime.datetime.today()
header = "## [{}] - {}\n\n".format(version, today.strftime('%Y-%m-%d'))
changelog_data = (
''.join(self.start) +
TEMPLATE +
header +
'\n'.join(self.unreleased) + '\n\n'
+ ''.join(self.rest)
)
with open(self.path, 'w') as fp:
fp.write(changelog_data)

View file

@ -1,13 +0,0 @@
#!/bin/bash
# https://github.com/lbryio/lbry-app/commit/4386102ba3bf8c731a075797756111d73c31a47a
# https://github.com/lbryio/lbry-app/commit/a3a376922298b94615f7514ca59988b73a522f7f
# Appveyor and Teamcity struggle with SSH urls in submodules, so we use HTTPS
# But locally, SSH urls are way better since they dont require a password
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "DIR"
git config submodule.lbry.url git@github.com:lbryio/lbry.git
git config submodule.lbryum.url git@github.com:lbryio/lbryum.git

BIN
build/lbry2.pfx.enc Normal file

Binary file not shown.

View file

@ -72,7 +72,6 @@ if ! cmd_exists pip; then
fi
if $LINUX && [ "$(pip list --format=columns | grep setuptools | wc -l)" -ge 1 ]; then
#$INSTALL python-setuptools
$SUDO pip install setuptools
fi
@ -88,3 +87,14 @@ if ! cmd_exists node; then
brew install node
fi
fi
if ! cmd_exists unzip; then
if $LINUX; then
$INSTALL unzip
elif $OSX; then
echo "unzip required"
exit 1
# not sure this works, but OSX should come with unzip
# brew install unzip
fi
fi

View file

@ -1,13 +1,12 @@
"""Trigger a release.
"""Bump version and create Github release
This script is to be run locally (not on a build server).
This script should be run locally, not on a build server.
"""
import argparse
import contextlib
import logging
import os
import re
import string
import requests
import subprocess
import sys
@ -16,122 +15,137 @@ import github
import changelog
# TODO: ask bumpversion for these
LBRY_PARTS = ('major', 'minor', 'patch', 'release', 'candidate')
LBRYUM_PARTS = ('major', 'minor', 'patch')
ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
DAEMON_URL_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'DAEMON_URL')
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"lbry_part", help="part of lbry version to bump",
choices=LBRY_PARTS
)
parser.add_argument(
"--skip-lbryum", help="skip bumping lbryum, even if there are changes",
action="store_true",
)
parser.add_argument(
"--lbryum-part", help="part of lbryum version to bump",
choices=LBRYUM_PARTS
)
parser.add_argument(
"--last-release",
help=("manually set the last release version. The default is to query and parse the"
" value from the release page.")
)
parser.add_argument(
"--skip-sanity-checks", action="store_true")
parser.add_argument(
"--require-changelog", action="store_true",
help=("Set this flag to raise an exception if a submodules has changes without a"
" corresponding changelog entry. The default is to log a warning")
)
parser.add_argument(
"--skip-push", action="store_true",
help="Set to not push changes to remote repo"
)
bumpversion_parts = get_bumpversion_parts()
parser = argparse.ArgumentParser()
parser.add_argument("part", choices=bumpversion_parts, help="part of version to bump")
parser.add_argument("--skip-sanity-checks", action="store_true")
parser.add_argument("--skip-push", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--confirm", action="store_true")
args = parser.parse_args()
base = git.Repo(os.getcwd())
if args.dry_run:
print "DRY RUN. Nothing will be committed/pushed."
repo = Repo('lbry-app', args.part, ROOT)
branch = 'master'
print 'Current version: {}'.format(repo.current_version)
print 'New version: {}'.format(repo.new_version)
with open(DAEMON_URL_FILE, 'r') as f:
daemon_url_template = f.read().strip()
daemon_version = re.search('/(?P<version>v[^/]+)', daemon_url_template)
print 'Daemon version: {} ({})'.format(
daemon_version.group('version'), daemon_url_template)
if not args.confirm and not confirm():
print "Aborting"
return 1
if not args.skip_sanity_checks:
run_sanity_checks(base, branch)
run_sanity_checks(repo, branch)
repo.assert_new_tag_is_absent()
base_repo = Repo('lbry-app', args.lbry_part, os.getcwd())
base_repo.assert_new_tag_is_absent()
is_rc = re.search('\drc\d+$', repo.new_version) is not None
# only have a release message for real releases, not for RCs
release_msg = '' if is_rc else repo.get_unreleased_changelog()
last_release = args.last_release or base_repo.get_last_tag()
logging.info('Last release: %s', last_release)
if args.dry_run:
print "rc: " + ("yes" if is_rc else "no")
print "release message: \n" + (release_msg or " NO MESSAGE FOR RCs")
return
gh_token = get_gh_token()
auth = github.Github(gh_token)
github_repo = auth.get_repo('lbryio/lbry-app')
names = ['lbryum', 'lbry']
repos = {name: Repo(name, get_part(args, name)) for name in names}
changelogs = {}
for repo in repos.values():
logging.info('Processing repo: %s', repo.name)
repo.checkout(branch)
last_submodule_hash = base_repo.get_submodule_hash(last_release, repo.name)
if repo.has_changes_from_revision(last_submodule_hash):
if repo.name == 'lbryum':
if args.skip_lbryum:
continue
if not repo.part:
repo.part = get_lbryum_part()
entry = repo.get_changelog_entry()
if entry:
changelogs[repo.name] = entry.strip()
repo.add_changelog()
else:
msg = 'Changelog entry is missing for {}'.format(repo.name)
if args.require_changelog:
raise Exception(msg)
else:
logging.warning(msg)
else:
logging.warning('Submodule %s has no changes.', repo.name)
if repo.name == 'lbryum':
# The other repos have their version track each other so need to bump
# them even if there aren't any changes, but lbryum should only be
# bumped if it has changes
continue
# bumpversion will fail if there is already the tag we want in the repo
repo.assert_new_tag_is_absent()
if not is_rc:
repo.bump_changelog()
repo.bumpversion()
release_msg = get_release_msg(changelogs, names)
new_tag = repo.get_new_tag()
github_repo.create_git_release(new_tag, new_tag, release_msg, draft=True, prerelease=is_rc)
for name in names:
base.git.add(name)
base_repo.bumpversion()
current_tag = base.git.describe()
is_rc = re.match('\drc\d+$', current_tag) is not None
github_repo.create_git_release(current_tag, current_tag, release_msg, draft=True,
prerelease=is_rc)
no_change_msg = ('No change since the last release. This release is simply a placeholder'
' so that LBRY and LBRY App track the same version')
lbrynet_daemon_release_msg = changelogs.get('lbry', no_change_msg)
auth.get_repo('lbryio/lbry').create_git_release(
current_tag, current_tag, lbrynet_daemon_release_msg, draft=True)
if not args.skip_push:
for repo in repos.values():
repo.git.push(follow_tags=True)
base.git.push(follow_tags=True, recurse_submodules='check')
if args.skip_push:
print (
'Skipping push; you will have to reset and delete tags if '
'you want to run this script again.'
)
else:
logging.info('Skipping push; you will have to reset and delete tags if '
'you want to run this script again. Take a look at reset.sh; '
'it probably does what you want.')
repo.git_repo.git.push(follow_tags=True, recurse_submodules='check')
class Repo(object):
def __init__(self, name, part, directory):
self.name = name
self.part = part
if not self.part:
raise Exception('Part required')
self.directory = directory
self.git_repo = git.Repo(self.directory)
self._bumped = False
self.current_version = self._get_current_version()
self.new_version = self._get_new_version()
self._changelog = changelog.Changelog(os.path.join(self.directory, 'CHANGELOG.md'))
def get_new_tag(self):
return 'v' + self.new_version
def get_unreleased_changelog(self):
return self._changelog.get_unreleased()
def bump_changelog(self):
self._changelog.bump(self.new_version)
with pushd(self.directory):
self.git_repo.git.add(os.path.basename(self._changelog.path))
def _get_current_version(self):
with pushd(self.directory):
output = subprocess.check_output(
['bumpversion', '--dry-run', '--list', '--allow-dirty', self.part])
return re.search('^current_version=(.*)$', output, re.M).group(1)
def _get_new_version(self):
with pushd(self.directory):
output = subprocess.check_output(
['bumpversion', '--dry-run', '--list', '--allow-dirty', self.part])
return re.search('^new_version=(.*)$', output, re.M).group(1)
def bumpversion(self):
if self._bumped:
raise Exception('Cowardly refusing to bump a repo twice')
with pushd(self.directory):
subprocess.check_call(['bumpversion', '--allow-dirty', self.part])
self._bumped = True
def assert_new_tag_is_absent(self):
new_tag = self.get_new_tag()
tags = self.git_repo.git.tag()
if new_tag in tags.split('\n'):
raise Exception('Tag {} is already present in repo {}.'.format(new_tag, self.name))
def is_behind(self, branch):
self.git_repo.remotes.origin.fetch()
rev_list = '{branch}...origin/{branch}'.format(branch=branch)
commits_behind = self.git_repo.git.rev_list(rev_list, right_only=True, count=True)
commits_behind = int(commits_behind)
return commits_behind > 0
def get_bumpversion_parts():
with pushd(ROOT):
output = subprocess.check_output([
'bumpversion', '--dry-run', '--list', '--allow-dirty', 'fake-part',
])
parse_line = re.search('^parse=(.*)$', output, re.M).group(1)
return tuple(re.findall('<([^>]+)>', parse_line))
def get_gh_token():
@ -148,131 +162,72 @@ in the future"""
return raw_input('token: ').strip()
def get_lbryum_part():
print """The lbryum repo has changes but you didn't specify how to bump the
version. Please enter one of: {}""".format(', '.join(LBRYUM_PARTS))
while True:
part = raw_input('part: ').strip()
if part in LBRYUM_PARTS:
return part
print 'Invalid part. Enter one of: {}'.format(', '.join(LBRYUM_PARTS))
def confirm():
try:
return raw_input('Is this what you want? [y/N] ').strip().lower() == 'y'
except KeyboardInterrupt:
return False
def get_release_msg(changelogs, names):
lines = []
for name in names:
entry = changelogs.get(name)
if not entry:
continue
lines.append('## {}\n'.format(name))
lines.append('{}\n'.format(entry))
return '\n'.join(lines)
def run_sanity_checks(base, branch):
if base.is_dirty():
def run_sanity_checks(repo, branch):
if repo.git_repo.is_dirty():
print 'Cowardly refusing to release a dirty repo'
sys.exit(1)
if base.active_branch.name != branch:
if repo.git_repo.active_branch.name != branch:
print 'Cowardly refusing to release when not on the {} branch'.format(branch)
sys.exit(1)
if is_behind(base, branch):
if repo.is_behind(branch):
print 'Cowardly refusing to release when behind origin'
sys.exit(1)
check_bumpversion()
def is_behind(base, branch):
base.remotes.origin.fetch()
rev_list = '{branch}...origin/{branch}'.format(branch=branch)
commits_behind = base.git.rev_list(rev_list, right_only=True, count=True)
commits_behind = int(commits_behind)
return commits_behind > 0
def check_bumpversion():
def require_new_version():
print 'Install bumpversion: pip install -U git+https://github.com/lbryio/bumpversion.git'
if not is_custom_bumpversion_version():
print (
'Install LBRY\'s fork of bumpversion: '
'pip install -U git+https://github.com/lbryio/bumpversion.git'
)
sys.exit(1)
if not check_daemon_urls():
sys.exit(1)
def check_daemon_urls():
success = True
with open(DAEMON_URL_FILE, 'r') as f:
daemon_url_template = f.read().strip()
if "OSNAME" not in daemon_url_template:
print "Daemon URL must include the string 'OSNAME'"
return False
for osname in ('linux', 'macos', 'windows'):
if not check_url(daemon_url_template.replace('OSNAME', osname)):
success = False
print "Daemon URL for " + osname + " does not work"
return success
def check_url(url):
url = url.strip()
r = requests.head(url)
if r.status_code >= 400:
return False
elif r.status_code >= 300:
new_location = r.headers.get('Location').strip()
if new_location == url:
# self-loop
return False
if "github-cloud.s3.amazonaws.com/releases" in new_location:
# HEAD doesnt work on s3 links, so assume its good
return True
return check_url(new_location)
return True
def is_custom_bumpversion_version():
try:
output = subprocess.check_output(['bumpversion', '-v'], stderr=subprocess.STDOUT)
output = output.strip()
if output != 'bumpversion 0.5.4-lbry':
require_new_version()
except (subprocess.CalledProcessError, OSError) as err:
require_new_version()
def get_part(args, name):
return getattr(args, name + '_part') or args.lbry_part
class Repo(object):
def __init__(self, name, part, directory=None):
self.name = name
self.part = part
self.directory = directory or os.path.join(os.getcwd(), name)
self.git_repo = git.Repo(self.directory)
self.saved_commit = None
self._bumped = False
def get_last_tag(self):
return string.split(self.git_repo.git.describe(tags=True), '-')[0]
def get_submodule_hash(self, revision, submodule_path):
line = getattr(self.git_repo.git, 'ls-tree')(revision, submodule_path)
return string.split(line)[2] if line else None
def has_changes_from_revision(self, revision):
commit = str(self.git_repo.commit())
logging.info('%s =? %s', commit, revision)
return commit != revision
def save_commit(self):
self.saved_commit = self.git_repo.commit()
logging.info('Saved ', self.git_repo.commit(), self.saved_commit)
def checkout(self, branch):
self.git_repo.git.checkout(branch)
self.git_repo.git.pull(rebase=True)
def get_changelog_entry(self):
filename = os.path.join(self.directory, 'CHANGELOG.md')
return changelog.bump(filename, self.new_version())
def add_changelog(self):
with pushd(self.directory):
self.git_repo.git.add('CHANGELOG.md')
def new_version(self):
if self._bumped:
raise Exception('Cannot calculate a new version on an already bumped repo')
if not self.part:
raise Exception('Cannot calculate a new version without a part')
with pushd(self.directory):
output = subprocess.check_output(
['bumpversion', '--dry-run', '--list', '--allow-dirty', self.part])
return re.search('^new_version=(.*)$', output, re.M).group(1)
def bumpversion(self):
if self._bumped:
raise Exception('Cowardly refusing to bump a repo twice')
if not self.part:
raise Exception('Cannot bump version for {}: no part specified'.format(repo.name))
with pushd(self.directory):
subprocess.check_call(['bumpversion', '--allow-dirty', self.part])
self._bumped = True
def assert_new_tag_is_absent(self):
new_tag = 'v' + self.new_version()
tags = self.git_repo.git.tag()
if new_tag in tags.split('\n'):
raise Exception('Tag {} is already present in repo {}.'.format(new_tag, self.name))
@property
def git(self):
return self.git_repo.git
output = subprocess.check_output(['bumpversion', '-v'], stderr=subprocess.STDOUT).strip()
if output == 'bumpversion 0.5.4-lbry':
return True
except (subprocess.CalledProcessError, OSError):
pass
return False
@contextlib.contextmanager
@ -284,10 +239,4 @@ def pushd(new_dir):
if __name__ == '__main__':
logging.basicConfig(
format="%(asctime)s %(levelname)-8s %(name)s:%(lineno)d: %(message)s",
level='INFO'
)
sys.exit(main())
else:
log = logging.getLogger('__name__')

View file

@ -1,6 +1,5 @@
import glob
import json
import logging
import os
import platform
import subprocess
@ -10,15 +9,13 @@ import github
import requests
import uritemplate
from lbrynet.core import log_support
def main():
try:
current_tag = subprocess.check_output(
['git', 'describe', '--exact-match', 'HEAD']).strip()
except subprocess.CalledProcessError:
log.info('Stopping as we are not currently on a tag')
print 'Stopping as we are not currently on a tag'
return
if 'GH_TOKEN' not in os.environ:
@ -27,20 +24,15 @@ def main():
gh_token = os.environ['GH_TOKEN']
auth = github.Github(gh_token)
app_repo = auth.get_repo('lbryio/lbry-app')
daemon_repo = auth.get_repo('lbryio/lbry')
repo = auth.get_repo('lbryio/lbry-app')
if not check_repo_has_tag(app_repo, current_tag):
log.info('Tag %s is not in repo %s', current_tag, app_repo)
if not check_repo_has_tag(repo, current_tag):
print 'Tag {} is not in repo {}'.format(current_tag, repo)
# TODO: maybe this should be an error
return
# daemon = get_daemon_artifact()
# release = get_release(daemon_repo, current_tag)
# upload_asset(release, daemon, gh_token)
app = get_app_artifact()
release = get_release(app_repo, current_tag)
release = get_release(repo, current_tag)
upload_asset(release, app, gh_token)
@ -60,21 +52,18 @@ def get_release(current_repo, current_tag):
def get_app_artifact():
this_dir = os.path.dirname(os.path.realpath(__file__))
system = platform.system()
if system == 'Darwin':
return glob.glob('dist/mac/LBRY*.dmg')[0]
return glob.glob(this_dir + '/../dist/mac/LBRY*.dmg')[0]
elif system == 'Linux':
return glob.glob('dist/LBRY*.deb')[0]
return glob.glob(this_dir + '/../dist/LBRY*.deb')[0]
elif system == 'Windows':
return glob.glob('dist/LBRY*.exe')[0]
return glob.glob(this_dir + '/../dist/LBRY*.exe')[0]
else:
raise Exception("I don't know about any artifact on {}".format(system))
def get_daemon_artifact():
return glob.glob('dist/*.zip')[0]
def upload_asset(release, asset_to_upload, token):
basename = os.path.basename(asset_to_upload)
if is_asset_already_uploaded(release, basename):
@ -84,30 +73,26 @@ def upload_asset(release, asset_to_upload, token):
try:
return _upload_asset(release, asset_to_upload, token, _curl_uploader)
except Exception:
log.exception('Failed to upload')
print 'Failed uploading on attempt {}'.format(count + 1)
count += 1
def _upload_asset(release, asset_to_upload, token, uploader):
basename = os.path.basename(asset_to_upload)
upload_uri = uritemplate.expand(
release.upload_url,
{'name': basename}
)
upload_uri = uritemplate.expand(release.upload_url, {'name': basename})
output = uploader(upload_uri, asset_to_upload, token)
if 'errors' in output:
raise Exception(output)
else:
log.info('Successfully uploaded to %s', output['browser_download_url'])
print 'Successfully uploaded to {}'.format(output['browser_download_url'])
# requests doesn't work on windows / linux / osx.
def _requests_uploader(upload_uri, asset_to_upload, token):
log.info('Using requests to upload %s to %s', asset_to_upload, upload_uri)
print 'Using requests to upload {} to {}'.format(asset_to_upload, upload_uri)
with open(asset_to_upload, 'rb') as f:
response = requests.post(upload_uri, data=f, auth=('', token))
output = response.json()
return output
return response.json()
# curl -H "Content-Type: application/json" -X POST -d '{"username":"xyz","password":"xyz"}' http://localhost:3000/api/login
@ -118,7 +103,7 @@ def _curl_uploader(upload_uri, asset_to_upload, token):
# half a day trying to debug before deciding to switch to curl.
#
# TODO: actually set the content type
log.info('Using curl to upload %s to %s', asset_to_upload, upload_uri)
print 'Using curl to upload {} to {}'.format(asset_to_upload, upload_uri)
cmd = [
'curl',
'-sS',
@ -141,21 +126,16 @@ def _curl_uploader(upload_uri, asset_to_upload, token):
print stderr
print 'stdout from curl:'
print stdout
output = json.loads(stdout)
return output
return json.loads(stdout)
def is_asset_already_uploaded(release, basename):
for asset in release.raw_data['assets']:
if asset['name'] == basename:
log.info('File %s has already been uploaded to %s', basename, release.tag_name)
print 'File {} has already been uploaded to {}'.format(basename, release.tag_name)
return True
return False
if __name__ == '__main__':
log = logging.getLogger('release-on-tag')
log_support.configure_console(level='INFO')
sys.exit(main())
else:
log = logging.getLogger(__name__)

View file

@ -1,21 +0,0 @@
#!/bin/bash
set -euxo pipefail
ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
cd "$ROOT"
(
cd lbry
git tag -d $(git describe)
git reset --hard origin/master
)
(
cd lbryum
git tag -d $(git describe)
git reset --hard origin/master
)
git tag -d $(git describe)
git reset --hard HEAD~1

View file

@ -1,28 +0,0 @@
"""Set the build version to be 'dev', 'qa', 'rc', 'release'"""
import os.path
import re
import subprocess
import sys
def main():
build = get_build()
with open(os.path.join('lbry', 'lbrynet', 'build_type.py'), 'w') as f:
f.write('BUILD = "{}"'.format(build))
def get_build():
try:
tag = subprocess.check_output(['git', 'describe', '--exact-match']).strip()
if re.match('v\d+\.\d+\.\d+rc\d+', tag):
return 'rc'
else:
return 'release'
except subprocess.CalledProcessError:
# if the build doesn't have a tag
return 'qa'
if __name__ == '__main__':
sys.exit(main())

View file

@ -1,58 +1,20 @@
"""Set the package version to the output of `git describe`"""
import argparse
import json
from __future__ import print_function
import os.path
import re
import subprocess
import sys
import fileinput
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--version', help="defaults to the output of `git describe`")
args = parser.parse_args()
if args.version:
version = args.version
filename = os.path.abspath(
os.path.join(os.path.abspath(__file__), '..', '..', 'ui', 'js', 'lbryio.js'))
for line in fileinput.input(filename, inplace=True):
if line.startswith(' enabled: false'):
print(' enabled: true')
else:
tag = subprocess.check_output(['git', 'describe']).strip()
try:
version = get_version_from_tag(tag)
except InvalidVersionTag:
# this should be an error but its easier to handle here
# than in the calling scripts.
print 'Tag cannot be converted to a version, Exitting'
return
set_version(version)
class InvalidVersionTag(Exception):
pass
def get_version_from_tag(tag):
match = re.match('v([\d.]+)', tag)
if match:
return match.group(1)
else:
raise InvalidVersionTag('Failed to parse version from tag {}'.format(tag))
def set_version(version):
package_file = os.path.join('app', 'package.json')
with open(package_file) as fp:
package_data = json.load(fp)
package_data['version'] = version
with open(package_file, 'w') as fp:
json.dump(package_data, fp, indent=2, separators=(',', ': '))
with open(os.path.join('lbry', 'lbrynet', '__init__.py'), 'w') as fp:
fp.write(LBRYNET_TEMPLATE.format(version=version))
LBRYNET_TEMPLATE = """
__version__ = "{version}"
version = tuple(__version__.split('.'))
"""
print(line, end='')
if __name__ == '__main__':

View file

@ -1,28 +0,0 @@
import os
import platform
import subprocess
import sys
import zipfile
def main():
tag = subprocess.check_output(['git', 'describe']).strip()
zipfilename = 'lbrynet-daemon-{}-{}.zip'.format(tag, get_system_label())
full_filename = os.path.join('dist', zipfilename)
executables = ['lbrynet-daemon', 'lbrynet-cli']
ext = '.exe' if platform.system() == 'Windows' else ''
with zipfile.ZipFile(full_filename, 'w') as myzip:
for executable in executables:
myzip.write(os.path.join('app', 'dist', executable + ext), executable + ext)
def get_system_label():
system = platform.system()
if system == 'Darwin':
return 'macos'
else:
return system.lower()
if __name__ == '__main__':
sys.exit(main())

View file

@ -1,28 +0,0 @@
$env:Path += ";C:\MinGW\bin\"
$env:Path += ";C:\Program Files (x86)\Windows Kits\10\bin\x86\"
gcc --version
mingw32-make --version
# build/install miniupnpc manually
tar zxf miniupnpc-1.9.tar.gz
cd miniupnpc-1.9
mingw32-make.exe -f Makefile.mingw
python.exe setupmingw32.py build --compiler=mingw32
python.exe setupmingw32.py install
cd ..\
Remove-Item -Recurse -Force miniupnpc-1.9
# copy requirements from lbry, but remove lbryum (we'll add it back in below) and gmpy and miniupnpc (installed manually)
Get-Content ..\lbry\requirements.txt | Select-String -Pattern 'lbryum|gmpy|miniupnpc' -NotMatch | Out-File requirements.txt
# add in gmpy wheel
Add-Content requirements.txt "./gmpy-1.17-cp27-none-win32.whl"
# for electron, we install lbryum and lbry using submodules
Add-Content requirements.txt "../lbryum"
Add-Content requirements.txt "../lbry"
pip.exe install pyinstaller
pip.exe install -r requirements.txt
pyinstaller -y daemon.onefile.spec
pyinstaller -y cli.onefile.spec

View file

@ -1,58 +0,0 @@
# -*- mode: python -*-
import platform
import os
cwd = os.getcwd()
if os.path.basename(cwd) != 'daemon':
raise Exception('The build needs to be run from the same directory as the spec file')
repo_base = os.path.abspath(os.path.join(cwd, '..'))
system = platform.system()
if system == 'Darwin':
icns = os.path.join(repo_base, 'build', 'icon.icns')
elif system == 'Linux':
icns = os.path.join(repo_base, 'build', 'icons', '256x256.png')
elif system == 'Windows':
icns = os.path.join(repo_base, 'build', 'icons', 'lbry256.ico')
else:
print 'Warning: System {} has no icons'.format(system)
icns = None
block_cipher = None
a = Analysis(
['cli.py'],
pathex=[cwd],
binaries=None,
datas=[],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher
)
pyz = PYZ(
a.pure,
a.zipped_data,
cipher=block_cipher
)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name='lbrynet-cli',
debug=False,
strip=False,
upx=True,
console=True,
icon=icns
)

View file

@ -1,7 +0,0 @@
from lbrynet.lbrynet_daemon import DaemonCLI
import logging
logging.basicConfig()
if __name__ == '__main__':
DaemonCLI.main()

View file

@ -1,77 +0,0 @@
# -*- mode: python -*-
import platform
import os
import lbryum
cwd = os.getcwd()
if os.path.basename(cwd) != 'daemon':
raise Exception('The build needs to be run from the same directory as the spec file')
repo_base = os.path.abspath(os.path.join(cwd, '..'))
system = platform.system()
if system == 'Darwin':
icns = os.path.join(repo_base, 'build', 'icon.icns')
elif system == 'Linux':
icns = os.path.join(repo_base, 'build', 'icons', '256x256.png')
elif system == 'Windows':
icns = os.path.join(repo_base, 'build', 'icons', 'lbry256.ico')
else:
print 'Warning: System {} has no icons'.format(system)
icns = None
block_cipher = None
languages = (
'chinese_simplified.txt', 'japanese.txt', 'spanish.txt',
'english.txt', 'portuguese.txt'
)
datas = [
(
os.path.join(os.path.dirname(lbryum.__file__), 'wordlist', language),
'lbryum/wordlist'
)
for language in languages
]
a = Analysis(
['daemon.py'],
pathex=[cwd],
binaries=None,
datas=datas,
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher
)
pyz = PYZ(
a.pure, a.zipped_data,
cipher=block_cipher
)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name='lbrynet-daemon',
debug=False,
strip=False,
upx=True,
console=True,
icon=icns
)

View file

@ -1,4 +0,0 @@
from lbrynet.lbrynet_daemon import DaemonControl
if __name__ == '__main__':
DaemonControl.start()

Binary file not shown.

Binary file not shown.

1
lbry

@ -1 +0,0 @@
Subproject commit 043e2d0ab96030468d53d02e311fd848f35c2dc1

1
lbryum

@ -1 +0,0 @@
Subproject commit 121bda3963ee94f0c9c027813c55b71b38219739

View file

@ -42,5 +42,6 @@
"devDependencies": {
"electron": "^1.4.15",
"electron-builder": "^11.7.0"
}
},
"dependencies": {}
}

View file

@ -7,16 +7,16 @@ import HelpPage from './page/help.js';
import WatchPage from './page/watch.js';
import ReportPage from './page/report.js';
import StartPage from './page/start.js';
import ClaimCodePage from './page/claim_code.js';
import ReferralPage from './page/referral.js';
import RewardsPage from './page/rewards.js';
import RewardPage from './page/reward.js';
import WalletPage from './page/wallet.js';
import DetailPage from './page/show.js';
import ShowPage from './page/show.js';
import PublishPage from './page/publish.js';
import SearchPage from './page/search.js';
import DiscoverPage from './page/discover.js';
import SplashScreen from './component/splash.js';
import DeveloperPage from './page/developer.js';
import lbryuri from './lbryuri.js';
import {FileListDownloaded, FileListPublished} from './page/file-list.js';
import Drawer from './component/drawer.js';
import Header from './component/header.js';
import {Modal, ExpandableModal} from './component/modal.js';
import {Link} from './component/link.js';
@ -38,17 +38,12 @@ var App = React.createClass({
message: 'Error message',
data: 'Error data',
},
_fullScreenPages: ['watch'],
_storeHistoryOfNextRender: false,
_upgradeDownloadItem: null,
_isMounted: false,
_version: null,
// Temporary workaround since electron-dl throws errors when you try to get the filename
getDefaultProps: function() {
return {
address: window.location.search
};
},
getUpdateUrl: function() {
switch (process.platform) {
case 'darwin':
@ -80,15 +75,13 @@ var App = React.createClass({
let [isMatch, viewingPage, pageArgs] = address.match(/\??([^=]*)(?:=(.*))?/);
return {
viewingPage: viewingPage,
pageArgs: pageArgs === undefined ? null : pageArgs
pageArgs: pageArgs === undefined ? null : decodeURIComponent(pageArgs)
};
},
getInitialState: function() {
var match, param, val, viewingPage, pageArgs,
drawerOpenRaw = sessionStorage.getItem('drawerOpen');
return Object.assign(this.getViewingPageAndArgs(this.props.address), {
drawerOpen: drawerOpenRaw !== null ? JSON.parse(drawerOpenRaw) : true,
return Object.assign(this.getViewingPageAndArgs(window.location.search), {
viewingPage: 'discover',
appUrl: null,
errorInfo: null,
modal: null,
downloadProgress: null,
@ -96,6 +89,8 @@ var App = React.createClass({
});
},
componentWillMount: function() {
window.addEventListener("popstate", this.onHistoryPop);
document.addEventListener('unhandledError', (event) => {
this.alertError(event.detail);
});
@ -112,7 +107,10 @@ var App = React.createClass({
if (target.matches('a[href^="?"]')) {
event.preventDefault();
if (this._isMounted) {
this.setState(this.getViewingPageAndArgs(target.getAttribute('href')));
let appUrl = target.getAttribute('href');
this._storeHistoryOfNextRender = true;
this.setState(Object.assign({}, this.getViewingPageAndArgs(appUrl), { appUrl: appUrl }));
document.body.scrollTop = 0;
}
}
target = target.parentNode;
@ -120,28 +118,16 @@ var App = React.createClass({
});
if (!sessionStorage.getItem('upgradeSkipped')) {
lbry.checkNewVersionAvailable(({isAvailable}) => {
if (!isAvailable) {
return;
}
lbry.getVersionInfo((versionInfo) => {
this._version = versionInfo.lbrynet_version;
lbry.getVersionInfo().then(({remoteVersion, upgradeAvailable}) => {
if (upgradeAvailable) {
this._version = remoteVersion;
this.setState({
modal: 'upgrade',
});
});
}
});
}
},
openDrawer: function() {
sessionStorage.setItem('drawerOpen', true);
this.setState({ drawerOpen: true });
},
closeDrawer: function() {
sessionStorage.setItem('drawerOpen', false);
this.setState({ drawerOpen: false });
},
closeModal: function() {
this.setState({
modal: null,
@ -152,6 +138,28 @@ var App = React.createClass({
},
componentWillUnmount: function() {
this._isMounted = false;
window.removeEventListener("popstate", this.onHistoryPop);
},
onHistoryPop: function() {
this.setState(this.getViewingPageAndArgs(location.search));
},
onSearch: function(term) {
this._storeHistoryOfNextRender = true;
const isShow = term.startsWith('lbry://');
this.setState({
viewingPage: isShow ? "show" : "search",
appUrl: (isShow ? "?show=" : "?search=") + encodeURIComponent(term),
pageArgs: term
});
},
onSubmit: function(uri) {
this._storeHistoryOfNextRender = true;
this.setState({
address: uri,
appUrl: "?show=" + encodeURIComponent(uri),
viewingPage: "show",
pageArgs: uri
})
},
handleUpgradeClicked: function() {
// Make a new directory within temp directory so the filename is guaranteed to be available
@ -205,12 +213,6 @@ var App = React.createClass({
modal: null,
});
},
onSearch: function(term) {
this.setState({
viewingPage: 'discover',
pageArgs: term
});
},
alertError: function(error) {
var errorInfoList = [];
for (let key of Object.keys(error)) {
@ -224,81 +226,57 @@ var App = React.createClass({
errorInfo: <ul className="error-modal__error-list">{errorInfoList}</ul>,
});
},
getHeaderLinks: function()
{
switch(this.state.viewingPage)
{
case 'wallet':
case 'send':
case 'receive':
case 'claim':
case 'referral':
return {
'?wallet' : 'Overview',
'?send' : 'Send',
'?receive' : 'Receive',
'?claim' : 'Claim Beta Code',
'?referral' : 'Check Referral Credit',
};
case 'downloaded':
case 'published':
return {
'?downloaded': 'Downloaded',
'?published': 'Published',
};
default:
return null;
}
},
getMainContent: function()
getContentAndAddress: function()
{
switch(this.state.viewingPage)
{
case 'search':
return [this.state.pageArgs ? this.state.pageArgs : "Search", 'icon-search', <SearchPage query={this.state.pageArgs} />];
case 'settings':
return <SettingsPage />;
return ["Settings", "icon-gear", <SettingsPage />];
case 'help':
return <HelpPage />;
case 'watch':
return <WatchPage name={this.state.pageArgs} />;
return ["Help", "icon-question", <HelpPage />];
case 'report':
return <ReportPage />;
return ['Report an Issue', 'icon-file', <ReportPage />];
case 'downloaded':
return <FileListDownloaded />;
return ["Downloads & Purchases", "icon-folder", <FileListDownloaded />];
case 'published':
return <FileListPublished />;
return ["Publishes", "icon-folder", <FileListPublished />];
case 'start':
return <StartPage />;
case 'claim':
return <ClaimCodePage />;
case 'referral':
return <ReferralPage />;
return ["Start", "icon-file", <StartPage />];
case 'rewards':
return ["Rewards", "icon-bank", <RewardsPage />];
case 'wallet':
case 'send':
case 'receive':
return <WalletPage viewingPage={this.state.viewingPage} />;
return [this.state.viewingPage.charAt(0).toUpperCase() + this.state.viewingPage.slice(1), "icon-bank", <WalletPage viewingPage={this.state.viewingPage} />]
case 'show':
return <DetailPage name={this.state.pageArgs} />;
return [lbryuri.normalize(this.state.pageArgs), "icon-file", <ShowPage uri={this.state.pageArgs} />];
case 'publish':
return <PublishPage />;
return ["Publish", "icon-upload", <PublishPage />];
case 'developer':
return <DeveloperPage />;
return ["Developer", "icon-file", <DeveloperPage />];
case 'discover':
default:
return <DiscoverPage {... this.state.pageArgs !== null ? {query: this.state.pageArgs} : {} } />;
return ["Home", "icon-home", <DiscoverPage />];
}
},
render: function() {
var mainContent = this.getMainContent(),
headerLinks = this.getHeaderLinks(),
searchQuery = this.state.viewingPage == 'discover' && this.state.pageArgs ? this.state.pageArgs : '';
let [address, wunderBarIcon, mainContent] = this.getContentAndAddress();
lbry.setTitle(address);
if (this._storeHistoryOfNextRender) {
this._storeHistoryOfNextRender = false;
history.pushState({}, document.title, this.state.appUrl);
}
return (
this.state.viewingPage == 'watch' ?
this._fullScreenPages.includes(this.state.viewingPage) ?
mainContent :
<div id="window" className={ this.state.drawerOpen ? 'drawer-open' : 'drawer-closed' }>
<Drawer onCloseDrawer={this.closeDrawer} viewingPage={this.state.viewingPage} />
<div id="main-content" className={ headerLinks ? 'with-sub-nav' : 'no-sub-nav' }>
<Header onOpenDrawer={this.openDrawer} initialQuery={searchQuery} onSearch={this.onSearch} links={headerLinks} viewingPage={this.state.viewingPage} />
<div id="window">
<Header onSearch={this.onSearch} onSubmit={this.onSubmit} address={address} wunderBarIcon={wunderBarIcon} viewingPage={this.state.viewingPage} />
<div id="main-content">
{mainContent}
</div>
<Modal isOpen={this.state.modal == 'upgrade'} contentLabel="Update available"
@ -325,7 +303,7 @@ var App = React.createClass({
</div>
</Modal>
<ExpandableModal isOpen={this.state.modal == 'error'} contentLabel="Error" className="error-modal"
overlayClassName="error-modal-overlay" onConfirmed={this.closeModal}
overlayClassName="modal-overlay error-modal-overlay" onConfirmed={this.closeModal}
extraContent={this.state.errorInfo}>
<h3 className="modal__header">Error</h3>

301
ui/js/component/auth.js Normal file
View file

@ -0,0 +1,301 @@
import React from "react";
import lbryio from "../lbryio.js";
import Modal from "./modal.js";
import ModalPage from "./modal-page.js";
import {Link, RewardLink} from "../component/link.js";
import {FormRow} from "../component/form.js";
import {CreditAmount, Address} from "../component/common.js";
import {getLocal, getSession, setSession, setLocal} from '../utils.js';
const SubmitEmailStage = React.createClass({
getInitialState: function() {
return {
rewardType: null,
email: '',
submitting: false
};
},
handleEmailChanged: function(event) {
this.setState({
email: event.target.value,
});
},
onEmailSaved: function(email) {
this.props.setStage("confirm", { email: email })
},
handleSubmit: function(event) {
event.preventDefault();
this.setState({
submitting: true,
});
lbryio.call('user_email', 'new', {email: this.state.email}, 'post').then(() => {
this.onEmailSaved(this.state.email);
}, (error) => {
if (error.xhr && error.xhr.status == 409) {
this.onEmailSaved(this.state.email);
return;
} else if (this._emailRow) {
this._emailRow.showError(error.message)
}
this.setState({ submitting: false });
});
},
render: function() {
return (
<section>
<form onSubmit={this.handleSubmit}>
<FormRow ref={(ref) => { this._emailRow = ref }} type="text" label="Email" placeholder="scrwvwls@lbry.io"
name="email" value={this.state.email}
onChange={this.handleEmailChanged} />
<div className="form-row-submit">
<Link button="primary" label="Next" disabled={this.state.submitting} onClick={this.handleSubmit} />
</div>
</form>
</section>
);
}
});
const ConfirmEmailStage = React.createClass({
getInitialState: function() {
return {
rewardType: null,
code: '',
submitting: false,
errorMessage: null,
};
},
handleCodeChanged: function(event) {
this.setState({
code: event.target.value,
});
},
handleSubmit: function(event) {
event.preventDefault();
this.setState({
submitting: true,
});
const onSubmitError = (error) => {
if (this._codeRow) {
this._codeRow.showError(error.message)
}
this.setState({ submitting: false });
};
lbryio.call('user_email', 'confirm', {verification_token: this.state.code, email: this.props.email}, 'post').then((userEmail) => {
if (userEmail.IsVerified) {
this.props.setStage("welcome")
} else {
onSubmitError(new Error("Your email is still not verified.")) //shouldn't happen?
}
}, onSubmitError);
},
render: function() {
return (
<section>
<form onSubmit={this.handleSubmit}>
<FormRow label="Verification Code" ref={(ref) => { this._codeRow = ref }} type="text"
name="code" placeholder="a94bXXXXXXXXXXXXXX" value={this.state.code} onChange={this.handleCodeChanged}
helper="A verification code is required to access this version."/>
<div className="form-row-submit form-row-submit--with-footer">
<Link button="primary" label="Verify" disabled={this.state.submitting} onClick={this.handleSubmit} />
</div>
<div className="form-field__helper">
No code? <Link onClick={() => { this.props.setStage("nocode")}} label="Click here" />.
</div>
</form>
</section>
);
}
});
const WelcomeStage = React.createClass({
propTypes: {
endAuth: React.PropTypes.func,
},
getInitialState: function() {
return {
hasReward: false,
rewardAmount: null,
}
},
onRewardClaim: function(reward) {
this.setState({
hasReward: true,
rewardAmount: reward.amount
})
},
render: function() {
return (
!this.state.hasReward ?
<Modal type="custom" isOpen={true} contentLabel="Welcome to LBRY" {...this.props}>
<section>
<h3 className="modal__header">Welcome to LBRY.</h3>
<p>Using LBRY is like dating a centaur. Totally normal up top, and <em>way different</em> underneath.</p>
<p>Up top, LBRY is similar to popular media sites.</p>
<p>Below, LBRY is controlled by users -- you -- via blockchain and decentralization.</p>
<p>Thank you for making content freedom possible! Here's a nickel, kid.</p>
<div style={{textAlign: "center", marginBottom: "12px"}}>
<RewardLink type="new_user" button="primary" onRewardClaim={this.onRewardClaim} onRewardFailure={() => this.props.setStage(null)} onConfirmed={() => { this.props.setStage(null) }} />
</div>
</section>
</Modal> :
<Modal type="alert" overlayClassName="modal-overlay modal-overlay--clear" isOpen={true} contentLabel="Welcome to LBRY" {...this.props} onConfirmed={() => { this.props.setStage(null) }}>
<section>
<h3 className="modal__header">About Your Reward</h3>
<p>You earned a reward of <CreditAmount amount={this.state.rewardAmount} label={false} /> LBRY credits, or <em>LBC</em>.</p>
<p>This reward will show in your Wallet momentarily, probably while you are reading this message.</p>
<p>LBC is used to compensate creators, to publish, and to have say in how the network works.</p>
<p>No need to understand it all just yet! Try watching or downloading something next.</p>
<p>Finally, know that LBRY is a beta and that it earns the name.</p>
</section>
</Modal>
);
}
});
const ErrorStage = React.createClass({
render: function() {
return (
<section>
<p>An error was encountered that we cannot continue from.</p>
<p>At least we're earning the name beta.</p>
{ this.props.errorText ? <p>Message: {this.props.errorText}</p> : '' }
<Link button="alt" label="Try Reload" onClick={() => { window.location.reload() } } />
</section>
);
}
});
const PendingStage = React.createClass({
render: function() {
return (
<section>
<p>Preparing for first access <span className="busy-indicator"></span></p>
</section>
);
}
});
const CodeRequiredStage = React.createClass({
_balanceSubscribeId: null,
getInitialState: function() {
return {
balance: 0,
address: getLocal('wallet_address')
}
},
componentWillMount: function() {
this._balanceSubscribeId = lbry.balanceSubscribe((balance) => {
this.setState({
balance: balance
});
})
if (!this.state.address) {
lbry.getUnusedAddress((address) => {
setLocal('wallet_address', address);
this.setState({ address: address });
});
}
},
componentWillUnmount: function() {
if (this._balanceSubscribeId) {
lbry.balanceUnsubscribe(this._balanceSubscribeId)
}
},
render: function() {
const disabled = this.state.balance < 1;
return (
<div>
<section className="section-spaced">
<p>Access to LBRY is restricted as we build and scale the network.</p>
<p>There are two ways in:</p>
<h3>Own LBRY Credits</h3>
<p>If you own at least 1 LBC, you can get in right now.</p>
<p style={{ textAlign: "center"}}><Link onClick={() => { setLocal('auth_bypassed', true); this.props.setStage(null); }}
disabled={disabled} label="Let Me In" button={ disabled ? "alt" : "primary" } /></p>
<p>Your balance is <CreditAmount amount={this.state.balance} />. To increase your balance, send credits to this address:</p>
<p><Address address={ this.state.address ? this.state.address : "Generating Address..." } /></p>
<p>If you don't understand how to send credits, then...</p>
</section>
<section>
<h3>Wait For A Code</h3>
<p>If you provide your email, you'll automatically receive a notification when the system is open.</p>
<p><Link onClick={() => { this.props.setStage("email"); }} label="Return" /></p>
</section>
</div>
);
}
});
export const AuthOverlay = React.createClass({
_stages: {
pending: PendingStage,
error: ErrorStage,
nocode: CodeRequiredStage,
email: SubmitEmailStage,
confirm: ConfirmEmailStage,
welcome: WelcomeStage
},
getInitialState: function() {
return {
stage: "pending",
stageProps: {}
};
},
setStage: function(stage, stageProps = {}) {
this.setState({
stage: stage,
stageProps: stageProps
})
},
componentWillMount: function() {
lbryio.authenticate().then((user) => {
if (!user.HasVerifiedEmail) {
if (getLocal('auth_bypassed')) {
this.setStage(null)
} else {
this.setStage("email", {})
}
} else {
lbryio.call('reward', 'list', {}).then((userRewards) => {
userRewards.filter(function(reward) {
return reward.RewardType == "new_user" && reward.TransactionID;
}).length ?
this.setStage(null) :
this.setStage("welcome")
});
}
}).catch((err) => {
this.setStage("error", { errorText: err.message })
document.dispatchEvent(new CustomEvent('unhandledError', {
detail: {
message: err.message,
data: err.stack
}
}));
})
},
render: function() {
if (!this.state.stage) {
return null;
}
const StageContent = this._stages[this.state.stage];
return (
this.state.stage != "welcome" ?
<ModalPage className="modal-page--full" isOpen={true} contentLabel="Authentication" {...this.props}>
<h1>LBRY Early Access</h1>
<StageContent {...this.state.stageProps} setStage={this.setStage} />
</ModalPage> :
<StageContent setStage={this.setStage} {...this.state.stageProps} />
);
}
});

View file

@ -1,31 +1,29 @@
import React from 'react';
import lbry from '../lbry.js';
import uri from '../uri.js';
import lbryuri from '../lbryuri.js';
import {Icon} from './common.js';
const ChannelIndicator = React.createClass({
const UriIndicator = React.createClass({
propTypes: {
uri: React.PropTypes.string.isRequired,
claimInfo: React.PropTypes.object.isRequired,
hasSignature: React.PropTypes.bool.isRequired,
signatureIsValid: React.PropTypes.bool,
},
render: function() {
const {name, has_signature, signature_is_valid} = this.props.claimInfo;
if (!has_signature) {
return null;
}
const uriObj = uri.parseLbryUri(this.props.uri);
if (!uriObj.isChannel) {
return null;
const uriObj = lbryuri.parse(this.props.uri);
if (!this.props.hasSignature || !uriObj.isChannel) {
return <span className="empty">Anonymous</span>;
}
const channelUriObj = Object.assign({}, uriObj);
delete channelUriObj.path;
const channelUri = uri.buildLbryUri(channelUriObj, false);
delete channelUriObj.contentName;
const channelUri = lbryuri.build(channelUriObj, false);
let icon, modifier;
if (!signature_is_valid) {
icon = 'icon-check-circle';
if (this.props.signatureIsValid) {
modifier = 'valid';
} else {
icon = 'icon-times-circle';
@ -33,11 +31,13 @@ const ChannelIndicator = React.createClass({
}
return (
<span>
by <strong>{channelUri}</strong> {' '}
<Icon icon={icon} className={`channel-indicator__icon channel-indicator__icon--${modifier}`} />
{channelUri} {' '}
{ !this.props.signatureIsValid ?
<Icon icon={icon} className={`channel-indicator__icon channel-indicator__icon--${modifier}`} /> :
'' }
</span>
);
}
});
export default ChannelIndicator;
export default UriIndicator;

View file

@ -1,6 +1,5 @@
import React from 'react';
import lbry from '../lbry.js';
import $clamp from 'clamp-js-main';
//component/icon.js
export let Icon = React.createClass({
@ -19,29 +18,15 @@ export let Icon = React.createClass({
export let TruncatedText = React.createClass({
propTypes: {
lines: React.PropTypes.number,
height: React.PropTypes.string,
auto: React.PropTypes.bool,
lines: React.PropTypes.number
},
getDefaultProps: function() {
return {
lines: null,
height: null,
auto: true,
}
},
componentDidMount: function() {
// Manually round up the line height, because clamp.js doesn't like fractional-pixel line heights.
// Need to work directly on the style object because setting the style prop doesn't update internal styles right away.
this.refs.span.style.lineHeight = Math.ceil(parseFloat(getComputedStyle(this.refs.span).lineHeight)) + 'px';
$clamp(this.refs.span, {
clamp: this.props.lines || this.props.height || 'auto',
});
},
render: function() {
return <span ref="span" className="truncated-text">{this.props.children}</span>;
return <span className="truncated-text" style={{ WebkitLineClamp: this.props.lines }}>{this.props.children}</span>;
}
});
@ -54,45 +39,109 @@ export let BusyMessage = React.createClass({
}
});
var creditAmountStyle = {
color: '#216C2A',
fontWeight: 'bold',
fontSize: '0.8em'
}, estimateStyle = {
fontSize: '0.8em',
color: '#aaa',
};
export let CurrencySymbol = React.createClass({
render: function() { return <span>LBC</span>; }
});
export let CreditAmount = React.createClass({
propTypes: {
amount: React.PropTypes.number,
precision: React.PropTypes.number
amount: React.PropTypes.number.isRequired,
precision: React.PropTypes.number,
isEstimate: React.PropTypes.bool,
label: React.PropTypes.bool,
showFree: React.PropTypes.bool,
look: React.PropTypes.oneOf(['indicator', 'plain']),
},
getDefaultProps: function() {
return {
precision: 1,
label: true,
showFree: false,
look: 'indicator',
}
},
render: function() {
var formattedAmount = lbry.formatCredits(this.props.amount, this.props.precision ? this.props.precision : 1);
const formattedAmount = lbry.formatCredits(this.props.amount, this.props.precision);
let amountText;
if (this.props.showFree && parseFloat(formattedAmount) == 0) {
amountText = 'free';
} else if (this.props.label) {
amountText = formattedAmount + (parseFloat(formattedAmount) == 1 ? ' credit' : ' credits');
} else {
amountText = formattedAmount;
}
return (
<span className="credit-amount">
<span style={creditAmountStyle}>{formattedAmount} {parseFloat(formattedAmount) == 1.0 ? 'credit' : 'credits'}</span>
{ this.props.isEstimate ? <span style={estimateStyle}> (est)</span> : null }
<span className={`credit-amount credit-amount--${this.props.look}`}>
<span>
{amountText}
</span>
{ this.props.isEstimate ? <span className="credit-amount__estimate" title="This is an estimate and does not include data fees">*</span> : null }
</span>
);
}
});
export let FilePrice = React.createClass({
_isMounted: false,
propTypes: {
uri: React.PropTypes.string.isRequired,
look: React.PropTypes.oneOf(['indicator', 'plain']),
},
getDefaultProps: function() {
return {
look: 'indicator',
}
},
componentWillMount: function() {
this.setState({
cost: null,
isEstimate: null,
});
},
componentDidMount: function() {
this._isMounted = true;
lbry.getCostInfo(this.props.uri).then(({cost, includesData}) => {
if (this._isMounted) {
this.setState({
cost: cost,
isEstimate: !includesData,
});
}
}, (err) => {
// If we get an error looking up cost information, do nothing
});
},
componentWillUnmount: function() {
this._isMounted = false;
},
render: function() {
if (this.state.cost === null) {
return <span className={`credit-amount credit-amount--${this.props.look}`}>???</span>;
}
return <CreditAmount label={false} amount={this.state.cost} isEstimate={this.state.isEstimate} showFree={true} />
}
});
var addressStyle = {
fontFamily: '"Consolas", "Lucida Console", "Adobe Source Code Pro", monospace',
};
export let Address = React.createClass({
_inputElem: null,
propTypes: {
address: React.PropTypes.string,
},
render: function() {
return (
<span style={addressStyle}>{this.props.address}</span>
<input className="input-copyable" type="text" ref={(input) => { this._inputElem = input; }}
onFocus={() => { this._inputElem.select(); }} style={addressStyle} readOnly="readonly" value={this.props.address}></input>
);
}
});
@ -131,6 +180,9 @@ export let Thumbnail = React.createClass({
this._isMounted = false;
},
render: function() {
return <img ref="img" onError={this.handleError} {... this.props} src={this.state.imageUri} />
const className = this.props.className ? this.props.className : '',
otherProps = Object.assign({}, this.props)
delete otherProps.className;
return <img ref="img" onError={this.handleError} {...otherProps} className={className} src={this.state.imageUri} />
},
});

View file

@ -1,67 +0,0 @@
import lbry from '../lbry.js';
import React from 'react';
import {Link} from './link.js';
var DrawerItem = React.createClass({
getDefaultProps: function() {
return {
subPages: [],
};
},
render: function() {
var isSelected = (this.props.viewingPage == this.props.href.substr(1) ||
this.props.subPages.indexOf(this.props.viewingPage) != -1);
return <Link {...this.props} className={ 'drawer-item ' + (isSelected ? 'drawer-item-selected' : '') } />
}
});
var drawerImageStyle = { //@TODO: remove this, img should be properly scaled once size is settled
height: '36px'
};
var Drawer = React.createClass({
_balanceSubscribeId: null,
handleLogoClicked: function(event) {
if ((event.ctrlKey || event.metaKey) && event.shiftKey) {
window.location.href = '?developer'
event.preventDefault();
}
},
getInitialState: function() {
return {
balance: 0,
};
},
componentDidMount: function() {
this._balanceSubscribeId = lbry.balanceSubscribe(function(balance) {
this.setState({
balance: balance
});
}.bind(this));
},
componentWillUnmount: function() {
if (this._balanceSubscribeId) {
lbry.balanceUnsubscribe(this._balanceSubscribeId)
}
},
render: function() {
return (
<nav id="drawer">
<div id="drawer-handle">
<Link title="Close" onClick={this.props.onCloseDrawer} icon="icon-bars" className="close-drawer-link"/>
<a href="?discover" onMouseUp={this.handleLogoClicked}><img src={lbry.imagePath("lbry-dark-1600x528.png")} style={drawerImageStyle}/></a>
</div>
<DrawerItem href='?discover' viewingPage={this.props.viewingPage} label="Discover" icon="icon-search" />
<DrawerItem href='?publish' viewingPage={this.props.viewingPage} label="Publish" icon="icon-upload" />
<DrawerItem href='?downloaded' subPages={['published']} viewingPage={this.props.viewingPage} label="My Files" icon='icon-cloud-download' />
<DrawerItem href="?wallet" subPages={['send', 'receive', 'claim', 'referral']} viewingPage={this.props.viewingPage} label="My Wallet" badge={lbry.formatCredits(this.state.balance) } icon="icon-bank" />
<DrawerItem href='?settings' viewingPage={this.props.viewingPage} label="Settings" icon='icon-gear' />
<DrawerItem href='?help' viewingPage={this.props.viewingPage} label="Help" icon='icon-question-circle' />
</nav>
);
}
});
export default Drawer;

View file

@ -1,76 +1,24 @@
import React from 'react';
import lbry from '../lbry.js';
import lbryuri from '../lbryuri.js';
import {Link} from '../component/link.js';
import {Icon} from '../component/common.js';
import Modal from './modal.js';
import FormField from './form.js';
import {Icon, FilePrice} from '../component/common.js';
import {Modal} from './modal.js';
import {FormField} from './form.js';
import {ToolTip} from '../component/tooltip.js';
import {DropDownMenu, DropDownMenuItem} from './menu.js';
const {shell} = require('electron');
let WatchLink = React.createClass({
propTypes: {
streamName: React.PropTypes.string,
downloadStarted: React.PropTypes.bool,
},
startVideo: function() {
window.location = '?watch=' + this.props.streamName;
},
handleClick: function() {
this.setState({
loading: true,
});
if (this.props.downloadStarted) {
this.startVideo();
} else {
lbry.getCostInfoForName(this.props.streamName, ({cost}) => {
lbry.getBalance((balance) => {
if (cost > balance) {
this.setState({
modal: 'notEnoughCredits',
loading: false,
});
} else {
this.startVideo();
}
});
});
}
},
getInitialState: function() {
return {
modal: null,
loading: false,
};
},
closeModal: function() {
this.setState({
modal: null,
});
},
render: function() {
return (
<div className="button-set-item">
<Link button="primary" disabled={this.state.loading} label="Watch" icon="icon-play" onClick={this.handleClick} />
<Modal contentLabel="Not enough credits" isOpen={this.state.modal == 'notEnoughCredits'} onConfirmed={this.closeModal}>
You don't have enough LBRY credits to pay for this stream.
</Modal>
</div>
);
}
});
let FileActionsRow = React.createClass({
_isMounted: false,
_fileInfoSubscribeId: null,
propTypes: {
streamName: React.PropTypes.string,
uri: React.PropTypes.string,
outpoint: React.PropTypes.string.isRequired,
metadata: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.string]),
contentType: React.PropTypes.string,
contentType: React.PropTypes.string.isRequired,
},
getInitialState: function() {
return {
@ -79,7 +27,7 @@ let FileActionsRow = React.createClass({
menuOpen: false,
deleteChecked: false,
attemptingDownload: false,
attemptingRemove: false
attemptingRemove: false,
}
},
onFileInfoUpdate: function(fileInfo) {
@ -95,15 +43,15 @@ let FileActionsRow = React.createClass({
attemptingDownload: true,
attemptingRemove: false
});
lbry.getCostInfoForName(this.props.streamName, ({cost}) => {
lbry.getCostInfo(this.props.uri).then(({cost}) => {
lbry.getBalance((balance) => {
if (cost > balance) {
this.setState({
modal: 'notEnoughCredits',
attemptingDownload: false,
});
} else {
lbry.getStream(this.props.streamName, (streamInfo) => {
} else if (this.state.affirmedPurchase) {
lbry.get({uri: this.props.uri}).then((streamInfo) => {
if (streamInfo === null || typeof streamInfo !== 'object') {
this.setState({
modal: 'timedOut',
@ -111,6 +59,11 @@ let FileActionsRow = React.createClass({
});
}
});
} else {
this.setState({
attemptingDownload: false,
modal: 'affirmPurchase'
})
}
});
});
@ -153,6 +106,13 @@ let FileActionsRow = React.createClass({
attemptingDownload: false
});
},
onAffirmPurchase: function() {
this.setState({
affirmedPurchase: true,
modal: null
});
this.tryDownload();
},
openMenu: function() {
this.setState({
menuOpen: !this.state.menuOpen,
@ -196,11 +156,10 @@ let FileActionsRow = React.createClass({
linkBlock = <Link label="Open" button="text" icon="icon-folder-open" onClick={this.onOpenClick} />;
}
const uri = lbryuri.normalize(this.props.uri);
const title = this.props.metadata ? this.props.metadata.title : uri;
return (
<div>
{this.props.contentType && this.props.contentType.startsWith('video/')
? <WatchLink streamName={this.props.streamName} downloadStarted={!!this.state.fileInfo} />
: null}
{this.state.fileInfo !== null || this.state.fileInfo.isMine
? linkBlock
: null}
@ -209,18 +168,22 @@ let FileActionsRow = React.createClass({
<DropDownMenuItem key={0} onClick={this.handleRevealClicked} label={openInFolderMessage} />
<DropDownMenuItem key={1} onClick={this.handleRemoveClicked} label="Remove..." />
</DropDownMenu> : '' }
<Modal type="confirm" isOpen={this.state.modal == 'affirmPurchase'}
contentLabel="Confirm Purchase" onConfirmed={this.onAffirmPurchase} onAborted={this.closeModal}>
Are you sure you'd like to buy <strong>{title}</strong> for <strong><FilePrice uri={uri} metadata={this.props.metadata} label={false} look="plain" /></strong> credits?
</Modal>
<Modal isOpen={this.state.modal == 'notEnoughCredits'} contentLabel="Not enough credits"
onConfirmed={this.closeModal}>
You don't have enough LBRY credits to pay for this stream.
</Modal>
<Modal isOpen={this.state.modal == 'timedOut'} contentLabel="Download failed"
onConfirmed={this.closeModal}>
LBRY was unable to download the stream <strong>lbry://{this.props.streamName}</strong>.
LBRY was unable to download the stream <strong>{uri}</strong>.
</Modal>
<Modal isOpen={this.state.modal == 'confirmRemove'} contentLabel="Not enough credits"
type="confirm" confirmButtonLabel="Remove" onConfirmed={this.handleRemoveConfirmed}
onAborted={this.closeModal}>
<p>Are you sure you'd like to remove <cite>{this.props.metadata.title}</cite> from LBRY?</p>
<p>Are you sure you'd like to remove <cite>{title}</cite> from LBRY?</p>
<label><FormField type="checkbox" checked={this.state.deleteChecked} onClick={this.handleDeleteCheckboxClicked} /> Delete this file from my computer</label>
</Modal>
@ -234,7 +197,7 @@ export let FileActions = React.createClass({
_fileInfoSubscribeId: null,
propTypes: {
streamName: React.PropTypes.string,
uri: React.PropTypes.string,
outpoint: React.PropTypes.string.isRequired,
metadata: React.PropTypes.oneOfType([React.PropTypes.object, React.PropTypes.string]),
contentType: React.PropTypes.string,
@ -261,7 +224,8 @@ export let FileActions = React.createClass({
componentDidMount: function() {
this._isMounted = true;
this._fileInfoSubscribeId = lbry.fileInfoSubscribe(this.props.outpoint, this.onFileInfoUpdate);
lbry.getStreamAvailability(this.props.uri, (availability) => {
lbry.get_availability({uri: this.props.uri}, (availability) => {
if (this._isMounted) {
this.setState({
available: availability > 0,
@ -291,10 +255,10 @@ export let FileActions = React.createClass({
return (<section className="file-actions">
{
fileInfo || this.state.available || this.state.forceShowActions
? <FileActionsRow outpoint={this.props.outpoint} metadata={this.props.metadata} streamName={this.props.streamName}
? <FileActionsRow outpoint={this.props.outpoint} metadata={this.props.metadata} uri={this.props.uri}
contentType={this.props.contentType} />
: <div>
<div className="button-set-item empty">This file is not currently available.</div>
<div className="button-set-item empty">Content unavailable.</div>
<ToolTip label="Why?"
body="The content on LBRY is hosted by its users. It appears there are no users connected that have this file at the moment."
className="button-set-item" />

View file

@ -1,67 +1,23 @@
import React from 'react';
import lbry from '../lbry.js';
import lbryuri from '../lbryuri.js';
import {Link} from '../component/link.js';
import {FileActions} from '../component/file-actions.js';
import {Thumbnail, TruncatedText, CreditAmount} from '../component/common.js';
import ChannelIndicator from '../component/channel-indicator.js';
let FilePrice = React.createClass({
_isMounted: false,
propTypes: {
uri: React.PropTypes.string
},
getInitialState: function() {
return {
cost: null,
costIncludesData: null,
}
},
componentDidMount: function() {
this._isMounted = true;
lbry.getCostInfoForName(this.props.uri, ({cost, includesData}) => {
if (this._isMounted) {
this.setState({
cost: cost,
costIncludesData: includesData,
});
}
}, () => {
// If we get an error looking up cost information, do nothing
});
},
componentWillUnmount: function() {
this._isMounted = false;
},
render: function() {
if (this.state.cost === null)
{
return null;
}
return (
<span className="file-tile__cost">
<CreditAmount amount={this.state.cost} isEstimate={!this.state.costIncludesData}/>
</span>
);
}
});
import {BusyMessage, TruncatedText, FilePrice} from '../component/common.js';
import UriIndicator from '../component/channel-indicator.js';
/*should be merged into FileTile once FileTile is refactored to take a single id*/
export let FileTileStream = React.createClass({
_fileInfoSubscribeId: null,
_isMounted: null,
_metadata: null,
propTypes: {
uri: React.PropTypes.string,
claimInfo: React.PropTypes.object,
metadata: React.PropTypes.object,
contentType: React.PropTypes.string.isRequired,
outpoint: React.PropTypes.string,
hasSignature: React.PropTypes.bool,
signatureIsValid: React.PropTypes.bool,
hideOnRemove: React.PropTypes.bool,
hidePrice: React.PropTypes.bool,
obscureNsfw: React.PropTypes.bool
@ -70,20 +26,15 @@ export let FileTileStream = React.createClass({
return {
showNsfwHelp: false,
isHidden: false,
available: null,
}
},
getDefaultProps: function() {
return {
obscureNsfw: !lbry.getClientSetting('showNsfw'),
hidePrice: false
hidePrice: false,
hasSignature: false,
}
},
componentWillMount: function() {
const {value: {stream: {metadata, source: {contentType}}}} = this.props.claimInfo;
this._metadata = metadata;
this._contentType = contentType;
},
componentDidMount: function() {
this._isMounted = true;
if (this.props.hideOnRemove) {
@ -103,7 +54,7 @@ export let FileTileStream = React.createClass({
}
},
handleMouseOver: function() {
if (this.props.obscureNsfw && this.props.metadata && this._metadata.nsfw) {
if (this.props.obscureNsfw && this.props.metadata && this.props.metadata.nsfw) {
this.setState({
showNsfwHelp: true,
});
@ -121,39 +72,37 @@ export let FileTileStream = React.createClass({
return null;
}
const metadata = this._metadata;
const isConfirmed = typeof metadata == 'object';
const title = isConfirmed ? metadata.title : ('lbry://' + this.props.uri);
const uri = lbryuri.normalize(this.props.uri);
const metadata = this.props.metadata;
const isConfirmed = !!metadata;
const title = isConfirmed ? metadata.title : uri;
const obscureNsfw = this.props.obscureNsfw && isConfirmed && metadata.nsfw;
const primaryUrl = "?show=" + uri;
return (
<section className={ 'file-tile card ' + (obscureNsfw ? 'card-obscured ' : '') } onMouseEnter={this.handleMouseOver} onMouseLeave={this.handleMouseOut}>
<div className={"row-fluid card-content file-tile__row"}>
<div className="span3">
<a href={'?show=' + this.props.uri}><Thumbnail className="file-tile__thumbnail" src={metadata.thumbnail} alt={'Photo for ' + (title || this.props.uri)} /></a>
<section className={ 'file-tile card ' + (obscureNsfw ? 'card--obscured ' : '') } onMouseEnter={this.handleMouseOver} onMouseLeave={this.handleMouseOut}>
<a href={primaryUrl} className="card__link">
<div className={"card__inner file-tile__row"}>
<div className="card__media"
style={{ backgroundImage: "url('" + (metadata && metadata.thumbnail ? metadata.thumbnail : lbry.imagePath('default-thumb.svg')) + "')" }}>
</div>
<div className="span9">
<div className="file-tile__content">
<div className="card__title-primary">
{ !this.props.hidePrice
? <FilePrice uri={this.props.uri} />
: null}
<div className="meta"><a href={'?show=' + this.props.uri}>{'lbry://' + this.props.uri}</a></div>
<h3 className="file-tile__title">
<a href={'?show=' + this.props.uri}>
<TruncatedText lines={1}>
{title}
</TruncatedText>
</a>
</h3>
<ChannelIndicator uri={this.props.uri} claimInfo={this.props.claimInfo} />
<FileActions uri={this.props.uri} outpoint={this.props.outpoint} metadata={metadata} contentType={this._contentType} />
<p className="file-tile__description">
<div className="meta">{uri}</div>
<h3><TruncatedText lines={1}>{title}</TruncatedText></h3>
</div>
<div className="card__content card__subtext">
<TruncatedText lines={3}>
{isConfirmed
? metadata.description
: <span className="empty">This file is pending confirmation.</span>}
</TruncatedText>
</p>
</div>
</div>
</div>
</a>
{this.state.showNsfwHelp
? <div className='card-overlay'>
<p>
@ -167,12 +116,113 @@ export let FileTileStream = React.createClass({
}
});
export let FileCardStream = React.createClass({
_fileInfoSubscribeId: null,
_isMounted: null,
_metadata: null,
propTypes: {
uri: React.PropTypes.string,
claimInfo: React.PropTypes.object,
outpoint: React.PropTypes.string,
hideOnRemove: React.PropTypes.bool,
hidePrice: React.PropTypes.bool,
obscureNsfw: React.PropTypes.bool
},
getInitialState: function() {
return {
showNsfwHelp: false,
isHidden: false,
}
},
getDefaultProps: function() {
return {
obscureNsfw: !lbry.getClientSetting('showNsfw'),
hidePrice: false,
hasSignature: false,
}
},
componentDidMount: function() {
this._isMounted = true;
if (this.props.hideOnRemove) {
this._fileInfoSubscribeId = lbry.fileInfoSubscribe(this.props.outpoint, this.onFileInfoUpdate);
}
},
componentWillUnmount: function() {
if (this._fileInfoSubscribeId) {
lbry.fileInfoUnsubscribe(this.props.outpoint, this._fileInfoSubscribeId);
}
},
onFileInfoUpdate: function(fileInfo) {
if (!fileInfo && this._isMounted && this.props.hideOnRemove) {
this.setState({
isHidden: true
});
}
},
handleMouseOver: function() {
this.setState({
hovered: true,
});
},
handleMouseOut: function() {
this.setState({
hovered: false,
});
},
render: function() {
if (this.state.isHidden) {
return null;
}
const uri = lbryuri.normalize(this.props.uri);
const metadata = this.props.metadata;
const isConfirmed = !!metadata;
const title = isConfirmed ? metadata.title : uri;
const obscureNsfw = this.props.obscureNsfw && isConfirmed && metadata.nsfw;
const primaryUrl = '?show=' + uri;
return (
<section className={ 'card card--small card--link ' + (obscureNsfw ? 'card--obscured ' : '') } onMouseEnter={this.handleMouseOver} onMouseLeave={this.handleMouseOut}>
<div className="card__inner">
<a href={primaryUrl} className="card__link">
<div className="card__title-identity">
<h5 title={title}><TruncatedText lines={1}>{title}</TruncatedText></h5>
<div className="card__subtitle">
{ !this.props.hidePrice ? <span style={{float: "right"}}><FilePrice uri={this.props.uri} metadata={metadata} /></span> : null}
<UriIndicator uri={uri} metadata={metadata} contentType={this.props.contentType}
hasSignature={this.props.hasSignature} signatureIsValid={this.props.signatureIsValid} />
</div>
</div>
<div className="card__media" style={{ backgroundImage: "url('" + metadata.thumbnail + "')" }}></div>
<div className="card__content card__subtext card__subtext--two-lines">
<TruncatedText lines={2}>
{isConfirmed
? metadata.description
: <span className="empty">This file is pending confirmation.</span>}
</TruncatedText>
</div>
</a>
{this.state.showNsfwHelp && this.state.hovered
? <div className='card-overlay'>
<p>
This content is Not Safe For Work.
To view adult content, please change your <Link className="button-text" href="?settings" label="Settings" />.
</p>
</div>
: null}
</div>
</section>
);
}
});
export let FileTile = React.createClass({
_isMounted: false,
_isResolvePending: false,
propTypes: {
uri: React.PropTypes.string.isRequired,
available: React.PropTypes.bool,
},
getInitialState: function() {
@ -181,30 +231,54 @@ export let FileTile = React.createClass({
claimInfo: null
}
},
componentDidMount: function() {
this._isMounted = true;
lbry.resolve({uri: this.props.uri}).then(({claim: claimInfo}) => {
const {value: {stream: {metadata}}, txid, nout} = claimInfo;
if (this._isMounted && claimInfo.value.stream.metadata) {
resolve: function(uri) {
this._isResolvePending = true;
lbry.resolve({uri: uri}).then((resolutionInfo) => {
this._isResolvePending = false;
if (this._isMounted && resolutionInfo && resolutionInfo.claim && resolutionInfo.claim.value &&
resolutionInfo.claim.value.stream && resolutionInfo.claim.value.stream.metadata) {
// In case of a failed lookup, metadata will be null, in which case the component will never display
this.setState({
outpoint: txid + ':' + nout,
claimInfo: claimInfo,
claimInfo: resolutionInfo.claim,
});
}
});
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.uri != this.props.uri) {
this.setState(this.getInitialState());
this.resolve(nextProps.uri);
}
},
componentDidMount: function() {
this._isMounted = true;
this.resolve(this.props.uri);
},
componentWillUnmount: function() {
this._isMounted = false;
},
render: function() {
if (!this.state.claimInfo || !this.state.outpoint) {
if (!this.state.claimInfo) {
if (this.props.displayStyle == 'card') {
return <FileCardStream outpoint={null} metadata={{title: this.props.uri, description: "Loading..."}} contentType={null} hidePrice={true}
hasSignature={false} signatureIsValid={false} uri={this.props.uri} />
}
if (this.props.showEmpty)
{
return this._isResolvePending ?
<BusyMessage message="Loading magic decentralized data" /> :
<div className="empty">{lbryuri.normalize(this.props.uri)} is unclaimed. <Link label="Put something here" href="?publish" /></div>;
}
return null;
}
return <FileTileStream outpoint={this.state.outpoint} claimInfo={this.state.claimInfo}
{... this.props} uri={this.props.uri}/>;
const {txid, nout, has_signature, signature_is_valid,
value: {stream: {metadata, source: {contentType}}}} = this.state.claimInfo;
return this.props.displayStyle == 'card' ?
<FileCardStream outpoint={txid + ':' + nout} metadata={metadata} contentType={contentType}
hasSignature={has_signature} signatureIsValid={signature_is_valid} {... this.props}/> :
<FileTileStream outpoint={txid + ':' + nout} metadata={metadata} contentType={contentType}
hasSignature={has_signature} signatureIsValid={signature_is_valid} {... this.props} />;
}
});

View file

@ -1,28 +1,32 @@
import React from 'react';
import {Icon} from './common.js';
var requiredFieldWarningStyle = {
color: '#cc0000',
transition: 'opacity 400ms ease-in',
};
var formFieldCounter = 0,
formFieldNestedLabelTypes = ['radio', 'checkbox'];
var FormField = React.createClass({
function formFieldId() {
return "form-field-" + (++formFieldCounter);
}
export let FormField = React.createClass({
_fieldRequiredText: 'This field is required',
_type: null,
_element: null,
propTypes: {
type: React.PropTypes.string.isRequired,
hidden: React.PropTypes.bool,
prefix: React.PropTypes.string,
postfix: React.PropTypes.string,
hasError: React.PropTypes.bool
},
getInitialState: function() {
return {
adviceState: 'hidden',
adviceText: null,
isError: null,
errorMessage: null,
}
},
componentWillMount: function() {
if (['text', 'radio', 'checkbox', 'file'].includes(this.props.type)) {
if (['text', 'number', 'radio', 'checkbox', 'file'].includes(this.props.type)) {
this._element = 'input';
this._type = this.props.type;
} else if (this.props.type == 'text-number') {
@ -33,25 +37,11 @@ var FormField = React.createClass({
this._element = this.props.type;
}
},
showAdvice: function(text) {
showError: function(text) {
this.setState({
adviceState: 'shown',
adviceText: text,
isError: true,
errorMessage: text,
});
setTimeout(() => {
this.setState({
adviceState: 'fading',
});
setTimeout(() => {
this.setState({
adviceState: 'hidden',
});
}, 450);
}, 5000);
},
warnRequired: function() {
this.showAdvice(this._fieldRequiredText);
},
focus: function() {
this.refs.field.focus();
@ -60,7 +50,8 @@ var FormField = React.createClass({
if (this.props.type == 'checkbox') {
return this.refs.field.checked;
} else if (this.props.type == 'file') {
return this.refs.field.files[0].path;
return this.refs.field.files.length && this.refs.field.files[0].path ?
this.refs.field.files[0].path : null;
} else {
return this.refs.field.value;
}
@ -70,45 +61,94 @@ var FormField = React.createClass({
},
render: function() {
// Pass all unhandled props to the field element
const otherProps = Object.assign({}, this.props);
delete otherProps.type;
delete otherProps.hidden;
const otherProps = Object.assign({}, this.props),
isError = this.state.isError !== null ? this.state.isError : this.props.hasError,
elementId = this.props.id ? this.props.id : formFieldId(),
renderElementInsideLabel = this.props.label && formFieldNestedLabelTypes.includes(this.props.type);
return (
!this.props.hidden
? <div className="form-field-container">
<this._element type={this._type} className="form-field" name={this.props.name} ref="field" placeholder={this.props.placeholder}
className={'form-field--' + this.props.type + ' ' + (this.props.className || '')}
delete otherProps.type;
delete otherProps.label;
delete otherProps.hasError;
delete otherProps.className;
delete otherProps.postfix;
delete otherProps.prefix;
const element = <this._element id={elementId} type={this._type} name={this.props.name} ref="field" placeholder={this.props.placeholder}
className={'form-field__input form-field__input-' + this.props.type + ' ' + (this.props.className || '') + (isError ? 'form-field__input--error' : '')}
{...otherProps}>
{this.props.children}
</this._element>
<FormFieldAdvice field={this.refs.field} state={this.state.adviceState}>{this.state.adviceText}</FormFieldAdvice>
</div>
: null
);
}
});
</this._element>;
var FormFieldAdvice = React.createClass({
return <div className="form-field">
{ this.props.prefix ? <span className="form-field__prefix">{this.props.prefix}</span> : '' }
{ renderElementInsideLabel ?
<label htmlFor={elementId} className={"form-field__label " + (isError ? 'form-field__label--error' : '')}>
{element}
{this.props.label}
</label> :
element }
{ this.props.postfix ? <span className="form-field__postfix">{this.props.postfix}</span> : '' }
{ isError && this.state.errorMessage ? <div className="form-field__error">{this.state.errorMessage}</div> : '' }
</div>
}
})
export let FormRow = React.createClass({
_fieldRequiredText: 'This field is required',
propTypes: {
state: React.PropTypes.string.isRequired,
label: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.element])
// helper: React.PropTypes.html,
},
getInitialState: function() {
return {
isError: false,
errorMessage: null,
}
},
showError: function(text) {
this.setState({
isError: true,
errorMessage: text,
});
},
showRequiredError: function() {
this.showError(this._fieldRequiredText);
},
clearError: function(text) {
this.setState({
isError: false,
errorMessage: ''
});
},
getValue: function() {
return this.refs.field.getValue();
},
getSelectedElement: function() {
return this.refs.field.getSelectedElement();
},
focus: function() {
this.refs.field.focus();
},
render: function() {
return (
this.props.state != 'hidden'
? <div className="form-field-advice-container">
<div className={'form-field-advice' + (this.props.state == 'fading' ? ' form-field-advice--fading' : '')}>
<Icon icon="icon-caret-up" className="form-field-advice__arrow" />
<div className="form-field-advice__content-container">
<span className="form-field-advice__content">
{this.props.children}
</span>
</div>
</div>
</div>
: null
);
}
});
const fieldProps = Object.assign({}, this.props),
elementId = formFieldId(),
renderLabelInFormField = formFieldNestedLabelTypes.includes(this.props.type);
export default FormField;
if (!renderLabelInFormField) {
delete fieldProps.label;
}
delete fieldProps.helper;
return <div className="form-row">
{ this.props.label && !renderLabelInFormField ?
<div className={"form-row__label-row " + (this.props.labelPrefix ? "form-row__label-row--prefix" : "") }>
<label htmlFor={elementId} className={"form-field__label " + (this.state.isError ? 'form-field__label--error' : '')}>
{this.props.label}
</label>
</div> : '' }
<FormField ref="field" hasError={this.state.isError} {...fieldProps} />
{ !this.state.isError && this.props.helper ? <div className="form-field__helper">{this.props.helper}</div> : '' }
{ this.state.isError ? <div className="form-field__error">{this.state.errorMessage}</div> : '' }
</div>
}
})

View file

@ -1,74 +1,198 @@
import React from 'react';
import lbryuri from '../lbryuri.js';
import {Link} from './link.js';
import {Icon, CreditAmount} from './common.js';
var Header = React.createClass({
_balanceSubscribeId: null,
_isMounted: false,
propTypes: {
onSearch: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired
},
getInitialState: function() {
return {
title: "LBRY",
isScrolled: false
balance: 0
};
},
componentWillMount: function() {
new MutationObserver(function(mutations) {
this.setState({ title: mutations[0].target.textContent });
}.bind(this)).observe(
document.querySelector('title'),
{ subtree: true, characterData: true, childList: true }
);
},
componentDidMount: function() {
document.addEventListener('scroll', this.handleScroll);
this._isMounted = true;
this._balanceSubscribeId = lbry.balanceSubscribe((balance) => {
if (this._isMounted) {
this.setState({balance: balance});
}
});
},
componentWillUnmount: function() {
document.removeEventListener('scroll', this.handleScroll);
if (this.userTypingTimer)
{
clearTimeout(this.userTypingTimer);
this._isMounted = false;
if (this._balanceSubscribeId) {
lbry.balanceUnsubscribe(this._balanceSubscribeId)
}
},
handleScroll: function() {
this.setState({
isScrolled: document.body.scrollTop > 0
render: function() {
return <header id="header">
<div className="header__item">
<Link onClick={() => { lbry.back() }} button="alt button--flat" icon="icon-arrow-left" />
</div>
<div className="header__item">
<Link href="?discover" button="alt button--flat" icon="icon-home" />
</div>
<div className="header__item header__item--wunderbar">
<WunderBar address={this.props.address} icon={this.props.wunderBarIcon}
onSearch={this.props.onSearch} onSubmit={this.props.onSubmit} viewingPage={this.props.viewingPage} />
</div>
<div className="header__item">
<Link href="?wallet" button="text" icon="icon-bank" label={lbry.formatCredits(this.state.balance, 1)} ></Link>
</div>
<div className="header__item">
<Link button="primary button--flat" href="?publish" icon="icon-upload" label="Publish" />
</div>
<div className="header__item">
<Link button="alt button--flat" href="?downloaded" icon="icon-folder" />
</div>
<div className="header__item">
<Link button="alt button--flat" href="?settings" icon="icon-gear" />
</div>
</header>
}
});
},
onQueryChange: function(event) {
if (this.userTypingTimer)
{
clearTimeout(this.userTypingTimer);
class WunderBar extends React.PureComponent {
static propTypes = {
onSearch: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired
}
//@TODO: Switch to React.js timing
var searchTerm = event.target.value;
this.userTypingTimer = setTimeout(() => {
constructor(props) {
super(props);
this._userTypingTimer = null;
this._input = null;
this._stateBeforeSearch = null;
this._resetOnNextBlur = true;
this.onChange = this.onChange.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
this.onReceiveRef = this.onReceiveRef.bind(this);
this.state = {
address: this.props.address,
icon: this.props.icon
};
}
componentWillUnmount() {
if (this.userTypingTimer) {
clearTimeout(this._userTypingTimer);
}
}
onChange(event) {
if (this._userTypingTimer)
{
clearTimeout(this._userTypingTimer);
}
this.setState({ address: event.target.value })
let searchTerm = event.target.value;
this._userTypingTimer = setTimeout(() => {
this._resetOnNextBlur = false;
this.props.onSearch(searchTerm);
}, 800); // 800ms delay, tweak for faster/slower
}
},
render: function() {
componentWillReceiveProps(nextProps) {
if (nextProps.viewingPage !== this.props.viewingPage || nextProps.address != this.props.address) {
this.setState({ address: nextProps.address, icon: nextProps.icon });
}
}
onFocus() {
this._stateBeforeSearch = this.state;
let newState = {
icon: "icon-search",
isActive: true
}
this._focusPending = true;
//below is hacking, improved when we have proper routing
if (!this.state.address.startsWith('lbry://') && this.state.icon !== "icon-search") //onFocus, if they are not on an exact URL or a search page, clear the bar
{
newState.address = '';
}
this.setState(newState);
}
onBlur() {
let commonState = {isActive: false};
if (this._resetOnNextBlur) {
this.setState(Object.assign({}, this._stateBeforeSearch, commonState));
this._input.value = this.state.address;
} else {
this._resetOnNextBlur = true;
this._stateBeforeSearch = this.state;
this.setState(commonState);
}
}
componentDidUpdate() {
this._input.value = this.state.address;
if (this._input && this._focusPending) {
this._input.select();
this._focusPending = false;
}
}
onKeyPress(event) {
if (event.charCode == 13 && this._input.value) {
let uri = null,
method = "onSubmit";
this._resetOnNextBlur = false;
clearTimeout(this._userTypingTimer);
try {
uri = lbryuri.normalize(this._input.value);
this.setState({ value: uri });
} catch (error) { //then it's not a valid URL, so let's search
uri = this._input.value;
method = "onSearch";
}
this.props[method](uri);
this._input.blur();
}
}
onReceiveRef(ref) {
this._input = ref;
}
render() {
return (
<header id="header" className={ (this.state.isScrolled ? 'header-scrolled' : 'header-unscrolled') + ' ' + (this.props.links ? 'header-with-subnav' : 'header-no-subnav') }>
<div className="header-top-bar">
<Link onClick={this.props.onOpenDrawer} icon="icon-bars" className="open-drawer-link" />
<h1>{ this.state.title }</h1>
<div className="header-search">
<input type="search" onChange={this.onQueryChange} defaultValue={this.props.initialQuery}
<div className={'wunderbar' + (this.state.isActive ? ' wunderbar--active' : '')}>
{this.state.icon ? <Icon fixed icon={this.state.icon} /> : '' }
<input className="wunderbar__input" type="search" placeholder="Type a LBRY address or search term"
ref={this.onReceiveRef}
onFocus={this.onFocus}
onBlur={this.onBlur}
onChange={this.onChange}
onKeyPress={this.onKeyPress}
value={this.state.address}
placeholder="Find movies, music, games, and more" />
</div>
</div>
{
this.props.links ?
<SubHeader links={this.props.links} viewingPage={this.props.viewingPage} /> :
''
}
</header>
);
}
});
}
var SubHeader = React.createClass({
export let SubHeader = React.createClass({
render: function() {
var links = [],
let links = [],
viewingUrl = '?' + this.props.viewingPage;
for (let link of Object.keys(this.props.links)) {
@ -79,7 +203,7 @@ var SubHeader = React.createClass({
);
}
return (
<nav className="sub-header">
<nav className={'sub-header' + (this.props.modifier ? ' sub-header--' + this.props.modifier : '')}>
{links}
</nav>
);

View file

@ -1,5 +1,7 @@
import React from 'react';
import {Icon} from './common.js';
import Modal from '../component/modal.js';
import rewards from '../rewards.js';
export let Link = React.createClass({
propTypes: {
@ -39,7 +41,7 @@ export let Link = React.createClass({
content = (
<span {... 'button' in this.props ? {className: 'button__content'} : {}}>
{'icon' in this.props ? <Icon icon={this.props.icon} fixed={true} /> : null}
{<span className="link-label">{this.props.label}</span>}
{this.props.label ? <span className="link-label">{this.props.label}</span> : null}
{'badge' in this.props ? <span className="badge">{this.props.badge}</span> : null}
</span>
);
@ -53,3 +55,79 @@ export let Link = React.createClass({
);
}
});
export let RewardLink = React.createClass({
propTypes: {
type: React.PropTypes.string.isRequired,
claimed: React.PropTypes.bool,
onRewardClaim: React.PropTypes.func,
onRewardFailure: React.PropTypes.func
},
refreshClaimable: function() {
switch(this.props.type) {
case 'new_user':
this.setState({ claimable: true });
return;
case 'first_publish':
lbry.claim_list_mine().then((list) => {
this.setState({
claimable: list.length > 0
})
});
return;
}
},
componentWillMount: function() {
this.refreshClaimable();
},
getInitialState: function() {
return {
claimable: true,
pending: false,
errorMessage: null
}
},
claimReward: function() {
this.setState({
pending: true
})
rewards.claimReward(this.props.type).then((reward) => {
this.setState({
pending: false,
errorMessage: null
})
if (this.props.onRewardClaim) {
this.props.onRewardClaim(reward);
}
}).catch((error) => {
this.setState({
errorMessage: error.message,
pending: false
})
})
},
clearError: function() {
if (this.props.onRewardFailure) {
this.props.onRewardFailure()
}
this.setState({
errorMessage: null
})
},
render: function() {
return (
<div className="reward-link">
{this.props.claimed
? <span><Icon icon="icon-check" /> Reward claimed.</span>
: <Link button={this.props.button ? this.props.button : 'alt'} disabled={this.state.pending || !this.state.claimable }
label={ this.state.pending ? "Claiming..." : "Claim Reward"} onClick={this.claimReward} />}
{this.state.errorMessage ?
<Modal isOpen={true} contentLabel="Reward Claim Error" className="error-modal" onConfirmed={this.clearError}>
{this.state.errorMessage}
</Modal>
: ''}
</div>
);
}
});

View file

@ -9,9 +9,6 @@ var LoadScreen = React.createClass({
details: React.PropTypes.string,
isWarning: React.PropTypes.bool,
},
handleCancelClick: function() {
history.back();
},
getDefaultProps: function() {
return {
isWarning: false,
@ -34,9 +31,6 @@ var LoadScreen = React.createClass({
<BusyMessage message={this.props.message} />
</h3>
{this.props.isWarning ? <Icon icon="icon-warning" /> : null} <span className={'load-screen__details ' + (this.props.isWarning ? 'load-screen__details--warning' : '')}>{this.props.details}</span>
{window.history.length > 1
? <div><Link label="Cancel" onClick={this.handleCancelClick} className='load-screen__cancel-link button-text' /></div>
: null}
</div>
</div>
);

View file

@ -0,0 +1,18 @@
import React from 'react';
import ReactModal from 'react-modal';
export const ModalPage = React.createClass({
render: function() {
return (
<ReactModal onCloseRequested={this.props.onAborted || this.props.onConfirmed} {...this.props}
className={(this.props.className || '') + ' modal-page'}
overlayClassName="modal-overlay">
<div className="modal-page__content">
{this.props.children}
</div>
</ReactModal>
);
}
});
export default ModalPage;

View file

@ -6,6 +6,7 @@ import {Link} from './link.js';
export const Modal = React.createClass({
propTypes: {
type: React.PropTypes.oneOf(['alert', 'confirm', 'custom']),
overlay: React.PropTypes.bool,
onConfirmed: React.PropTypes.func,
onAborted: React.PropTypes.func,
confirmButtonLabel: React.PropTypes.string,
@ -16,6 +17,7 @@ export const Modal = React.createClass({
getDefaultProps: function() {
return {
type: 'alert',
overlay: true,
confirmButtonLabel: 'OK',
abortButtonLabel: 'Cancel',
confirmButtonDisabled: false,
@ -26,7 +28,7 @@ export const Modal = React.createClass({
return (
<ReactModal onCloseRequested={this.props.onAborted || this.props.onConfirmed} {...this.props}
className={(this.props.className || '') + ' modal'}
overlayClassName={(this.props.overlayClassName || '') + ' modal-overlay'}>
overlayClassName={![null, undefined, ""].includes(this.props.overlayClassName) ? this.props.overlayClassName : 'modal-overlay'}>
<div>
{this.props.children}
</div>

21
ui/js/component/notice.js Normal file
View file

@ -0,0 +1,21 @@
import React from 'react';
export const Notice = React.createClass({
propTypes: {
isError: React.PropTypes.bool,
},
getDefaultProps: function() {
return {
isError: false,
};
},
render: function() {
return (
<section className={'notice ' + (this.props.isError ? 'notice--error ' : '') + (this.props.className || '')}>
{this.props.children}
</section>
);
},
});
export default Notice;

View file

@ -0,0 +1,57 @@
import React from 'react';
import lbry from '../lbry.js';
export const SnackBar = React.createClass({
_displayTime: 5, // in seconds
_hideTimeout: null,
getInitialState: function() {
return {
snacks: []
}
},
handleSnackReceived: function(event) {
// if (this._hideTimeout) {
// clearTimeout(this._hideTimeout);
// }
let snacks = this.state.snacks;
snacks.push(event.detail);
this.setState({ snacks: snacks});
},
componentWillMount: function() {
document.addEventListener('globalNotice', this.handleSnackReceived);
},
componentWillUnmount: function() {
document.removeEventListener('globalNotice', this.handleSnackReceived);
},
render: function() {
if (!this.state.snacks.length) {
this._hideTimeout = null; //should be unmounting anyway, but be safe?
return null;
}
let snack = this.state.snacks[0];
if (this._hideTimeout === null) {
this._hideTimeout = setTimeout(() => {
this._hideTimeout = null;
let snacks = this.state.snacks;
snacks.shift();
this.setState({ snacks: snacks });
}, this._displayTime * 1000);
}
return (
<div className="snack-bar">
{snack.message}
{snack.linkText && snack.linkTarget ?
<a className="snack-bar__action" href={snack.linkTarget}>{snack.linkText}</a> : ''}
</div>
);
},
});
export default SnackBar;

View file

@ -13,11 +13,12 @@ var SplashScreen = React.createClass({
isLagging: false,
}
},
updateStatus: function(was_lagging=false) {
lbry.getDaemonStatus(this._updateStatusCallback);
updateStatus: function() {
lbry.status().then(this._updateStatusCallback);
},
_updateStatusCallback: function(status) {
if (status.code == 'started') {
const startupStatus = status.startup_status
if (startupStatus.code == 'started') {
// Wait until we are able to resolve a name before declaring
// that we are done.
// TODO: This is a hack, and the logic should live in the daemon
@ -27,27 +28,35 @@ var SplashScreen = React.createClass({
isLagging: false
});
lbry.resolveName('one', () => {
lbry.resolve({uri: 'lbry://one'}).then(() => {
window.sessionStorage.setItem('loaded', 'y')
this.props.onLoadDone();
});
return;
}
this.setState({
details: status.message + (status.is_lagging ? '' : '...'),
isLagging: status.is_lagging,
details: startupStatus.message + (startupStatus.is_lagging ? '' : '...'),
isLagging: startupStatus.is_lagging,
});
setTimeout(() => {
this.updateStatus(status.is_lagging);
this.updateStatus();
}, 500);
},
componentDidMount: function() {
lbry.connect((connected) => {
lbry.connect().then((isConnected) => {
if (isConnected) {
this.updateStatus();
});
} else {
this.setState({
isLagging: true,
message: "Failed to connect to LBRY",
details: "LBRY was unable to start and connect properly."
})
}
})
},
render: function() {
return <LoadScreen message={this.props.message} details={this.state.details} isWarning={this.state.isLagging} />;
return <LoadScreen message={this.props.message} details={this.state.details} isWarning={this.state.isLagging} />
}
});

View file

@ -1,32 +1,46 @@
import lbryio from './lbryio.js';
import lighthouse from './lighthouse.js';
import jsonrpc from './jsonrpc.js';
import {getLocal, setLocal} from './utils.js';
import lbryuri from './lbryuri.js';
import {getLocal, getSession, setSession, setLocal} from './utils.js';
const {remote} = require('electron');
const {remote, ipcRenderer} = require('electron');
const menu = remote.require('./menu/main-menu');
/**
* Records a publish attempt in local storage. Returns a dictionary with all the data needed to
* needed to make a dummy claim or file info object.
*/
function savePendingPublish(name) {
function savePendingPublish({name, channel_name}) {
let uri;
if (channel_name) {
uri = lbryuri.build({name: channel_name, path: name}, false);
} else {
uri = lbryuri.build({name: name}, false);
}
const pendingPublishes = getLocal('pendingPublishes') || [];
const newPendingPublish = {
claim_id: 'pending_claim_' + name,
txid: 'pending_' + name,
name, channel_name,
claim_id: 'pending_claim_' + uri,
txid: 'pending_' + uri,
nout: 0,
outpoint: 'pending_' + name + ':0',
name: name,
outpoint: 'pending_' + uri + ':0',
time: Date.now(),
};
setLocal('pendingPublishes', [...pendingPublishes, newPendingPublish]);
return newPendingPublish;
}
function removePendingPublish({name, outpoint}) {
setLocal('pendingPublishes', getPendingPublishes().filter(
(pub) => pub.name != name && pub.outpoint != outpoint
));
/**
* If there is a pending publish with the given name or outpoint, remove it.
* A channel name may also be provided along with name.
*/
function removePendingPublishIfNeeded({name, channel_name, outpoint}) {
function pubMatches(pub) {
return pub.outpoint === outpoint || (pub.name === name && (!channel_name || pub.channel_name === channel_name));
}
setLocal('pendingPublishes', getPendingPublishes().filter(pub => !pubMatches(pub)));
}
/**
@ -35,61 +49,30 @@ function removePendingPublish({name, outpoint}) {
*/
function getPendingPublishes() {
const pendingPublishes = getLocal('pendingPublishes') || [];
const newPendingPublishes = [];
for (let pendingPublish of pendingPublishes) {
if (Date.now() - pendingPublish.time <= lbry.pendingPublishTimeout) {
newPendingPublishes.push(pendingPublish);
}
}
const newPendingPublishes = pendingPublishes.filter(pub => Date.now() - pub.time <= lbry.pendingPublishTimeout);
setLocal('pendingPublishes', newPendingPublishes);
return newPendingPublishes
return newPendingPublishes;
}
/**
* Gets a pending publish attempt by its name or (fake) outpoint. If none is found (or one is found
* but it has timed out), returns null.
* Gets a pending publish attempt by its name or (fake) outpoint. A channel name can also be
* provided along withe the name. If no pending publish is found, returns null.
*/
function getPendingPublish({name, outpoint}) {
function getPendingPublish({name, channel_name, outpoint}) {
const pendingPublishes = getPendingPublishes();
const pendingPublishIndex = pendingPublishes.findIndex(
({name: itemName, outpoint: itemOutpoint}) => itemName == name || itemOutpoint == outpoint
);
const pendingPublish = pendingPublishes[pendingPublishIndex];
if (pendingPublishIndex == -1) {
return null;
} else if (Date.now() - pendingPublish.time > lbry.pendingPublishTimeout) {
// Pending publish timed out, so remove it from the stored list and don't match
const newPendingPublishes = pendingPublishes.slice();
newPendingPublishes.splice(pendingPublishIndex, 1);
setLocal('pendingPublishes', newPendingPublishes);
return null;
} else {
return pendingPublish;
}
return pendingPublishes.find(
pub => pub.outpoint === outpoint || (pub.name === name && (!channel_name || pub.channel_name === channel_name))
) || null;
}
function pendingPublishToDummyClaim({name, outpoint, claim_id, txid, nout}) {
return {
name: name,
outpoint: outpoint,
claim_id: claim_id,
txid: txid,
nout: nout,
};
function pendingPublishToDummyClaim({channel_name, name, outpoint, claim_id, txid, nout}) {
return {name, outpoint, claim_id, txid, nout, channel_name};
}
function pendingPublishToDummyFileInfo({name, outpoint, claim_id}) {
return {
name: name,
outpoint: outpoint,
claim_id: claim_id,
metadata: "Attempting publication",
};
return {name, outpoint, claim_id, metadata: null};
}
window.pptdfi = pendingPublishToDummyFileInfo;
let lbry = {
isConnected: false,
@ -112,33 +95,55 @@ let lbry = {
};
lbry.call = function (method, params, callback, errorCallback, connectFailedCallback) {
jsonrpc.call(lbry.daemonConnectionString, method, [params], callback, errorCallback, connectFailedCallback);
jsonrpc.call(lbry.daemonConnectionString, method, params, callback, errorCallback, connectFailedCallback);
}
//core
lbry.connect = function(callback)
{
lbry._connectPromise = null;
lbry.connect = function() {
if (lbry._connectPromise === null) {
lbry._connectPromise = new Promise((resolve, reject) => {
// Check every half second to see if the daemon is accepting connections
// Once this returns True, can call getDaemonStatus to see where
// we are in the startup process
function checkDaemonStarted(tryNum = 0) {
lbry.isDaemonAcceptingConnections(function (runningStatus) {
if (runningStatus) {
lbry.isConnected = true;
callback(true);
} else {
resolve(true);
}
else {
if (tryNum <= 600) { // Move # of tries into constant or config option
setTimeout(function () {
checkDaemonStarted(tryNum + 1);
}, 500);
} else {
callback(false);
}, tryNum < 100 ? 200 : 1000);
}
else {
reject(new Error("Unable to connect to LBRY"));
}
}
});
}
checkDaemonStarted();
});
}
return lbry._connectPromise;
}
//kill this but still better than document.title =, which this replaced
lbry.setTitle = function(title) {
document.title = title + " - LBRY";
}
//kill this with proper routing
lbry.back = function() {
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = "?discover";
}
}
lbry.isDaemonAcceptingConnections = function (callback) {
@ -146,10 +151,6 @@ lbry.isDaemonAcceptingConnections = function (callback) {
lbry.call('status', {}, () => callback(true), null, () => callback(false))
};
lbry.getDaemonStatus = function (callback) {
lbry.call('daemon_status', {}, callback);
};
lbry.checkFirstRun = function(callback) {
lbry.call('is_first_run', {}, callback);
}
@ -189,23 +190,6 @@ lbry.sendToAddress = function(amount, address, callback, errorCallback) {
lbry.call("send_amount_to_address", { "amount" : amount, "address": address }, callback, errorCallback);
}
lbry.resolveName = function(name, callback) {
if (!name) {
throw new Error(`Name required.`);
}
lbry.call('resolve_name', { 'name': name }, callback, () => {
// For now, assume any error means the name was not resolved
callback(null);
});
}
lbry.getStream = function(name, callback) {
if (!name) {
throw new Error(`Name required.`);
}
lbry.call('get', { 'name': name }, callback);
};
lbry.getClaimInfo = function(name, callback) {
if (!name) {
throw new Error(`Name required.`);
@ -219,23 +203,6 @@ lbry.getMyClaim = function(name, callback) {
});
}
lbry.getKeyFee = function(name, callback, errorCallback) {
if (!name) {
throw new Error(`Name required.`);
}
lbry.call('stream_cost_estimate', { name: name }, callback, errorCallback);
}
lbry.getTotalCost = function(name, size, callback, errorCallback) {
if (!name) {
throw new Error(`Name required.`);
}
lbry.call('stream_cost_estimate', {
name: name,
size: size,
}, callback, errorCallback);
}
lbry.getPeersForBlobHash = function(blobHash, callback) {
let timedOut = false;
const timeout = setTimeout(() => {
@ -251,16 +218,8 @@ lbry.getPeersForBlobHash = function(blobHash, callback) {
});
}
lbry.getStreamAvailability = function(name, callback, errorCallback) {
if (!name) {
throw new Error(`Name required.`);
}
lbry.call('get_availability', {name: name}, callback, errorCallback);
}
lbry.getCostInfoForName = function(name, callback, errorCallback) {
/**
* Takes a LBRY name; will first try and calculate a total cost using
* Takes a LBRY URI; will first try and calculate a total cost using
* Lighthouse. If Lighthouse can't be reached, it just retrives the
* key fee.
*
@ -269,70 +228,77 @@ lbry.getCostInfoForName = function(name, callback, errorCallback) {
* - includes_data: Boolean; indicates whether or not the data fee info
* from Lighthouse is included.
*/
if (!name) {
throw new Error(`Name required.`);
lbry.costPromiseCache = {}
lbry.getCostInfo = function(uri) {
if (lbry.costPromiseCache[uri] === undefined) {
lbry.costPromiseCache[uri] = new Promise((resolve, reject) => {
const COST_INFO_CACHE_KEY = 'cost_info_cache';
let costInfoCache = getSession(COST_INFO_CACHE_KEY, {})
function cacheAndResolve(cost, includesData) {
costInfoCache[uri] = {cost, includesData};
setSession(COST_INFO_CACHE_KEY, costInfoCache);
resolve({cost, includesData});
}
function getCostWithData(name, size, callback, errorCallback) {
lbry.getTotalCost(name, size, (cost) => {
callback({
cost: cost,
includesData: true,
});
}, errorCallback);
if (!uri) {
return reject(new Error(`URI required.`));
}
function getCostNoData(name, callback, errorCallback) {
lbry.getKeyFee(name, (cost) => {
callback({
cost: cost,
includesData: false,
if (costInfoCache[uri] && costInfoCache[uri].cost) {
return resolve(costInfoCache[uri])
}
function getCost(uri, size) {
lbry.stream_cost_estimate({uri, ... size !== null ? {size} : {}}).then((cost) => {
cacheAndResolve(cost, size !== null);
}, reject);
}
function getCostGenerous(uri) {
// If generous is on, the calculation is simple enough that we might as well do it here in the front end
lbry.resolve({uri: uri}).then((resolutionInfo) => {
const fee = resolutionInfo.claim.value.stream.metadata.fee;
if (fee === undefined) {
cacheAndResolve(0, true);
} else if (fee.currency == 'LBC') {
cacheAndResolve(fee.amount, true);
} else {
lbryio.getExchangeRates().then(({lbc_usd}) => {
cacheAndResolve(fee.amount / lbc_usd, true);
});
}, errorCallback);
}
});
}
const uriObj = lbryuri.parse(uri);
const name = uriObj.path || uriObj.name;
lbry.settings_get({allow_cached: true}).then(({is_generous_host}) => {
if (is_generous_host) {
return getCostGenerous(uri);
}
lighthouse.get_size_for_name(name).then((size) => {
getCostWithData(name, size, callback, errorCallback);
if (size) {
getCost(name, size);
}
else {
getCost(name, null);
}
}, () => {
getCostNoData(name, callback, errorCallback);
getCost(name, null);
});
});
});
}
lbry.getFeaturedDiscoverNames = function(callback) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest;
xhr.open('GET', 'https://api.lbry.io/discover/list', true);
xhr.onload = () => {
if (xhr.status === 200) {
var responseData = JSON.parse(xhr.responseText);
if (responseData.data) //new signature, once api.lbry.io is updated
{
resolve(responseData.data);
}
else
{
resolve(responseData);
}
} else {
reject(Error('Failed to fetch featured names.'));
}
};
xhr.send();
});
return lbry.costPromiseCache[uri];
}
lbry.getMyClaims = function(callback) {
lbry.call('get_name_claims', {}, 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.removeFile = function(outpoint, deleteTargetFile=true, callback) {
this._removedFiles.push(outpoint);
this._updateFileInfoSubscribers(outpoint);
@ -393,12 +359,13 @@ lbry.publish = function(params, fileListedCallback, publishedCallback, errorCall
returnedPending = true;
if (publishedCallback) {
savePendingPublish(params.name);
savePendingPublish({name: params.name, channel_name: params.channel_name});
publishedCallback(true);
}
if (fileListedCallback) {
savePendingPublish(params.name);
const {name, channel_name} = params;
savePendingPublish({name: params.name, channel_name: params.channel_name});
fileListedCallback(true);
}
}, 2000);
@ -408,44 +375,6 @@ lbry.publish = function(params, fileListedCallback, publishedCallback, errorCall
//});
}
lbry.getVersionInfo = function(callback) {
lbry.call('version', {}, callback);
};
lbry.checkNewVersionAvailable = function(callback) {
lbry.call('version', {}, function(versionInfo) {
var ver = versionInfo.lbrynet_version.split('.');
var maj = parseInt(ver[0]),
min = parseInt(ver[1]),
patch = parseInt(ver[2]);
var remoteVer = versionInfo.remote_lbrynet.split('.');
var remoteMaj = parseInt(remoteVer[0]),
remoteMin = parseInt(remoteVer[1]),
remotePatch = parseInt(remoteVer[2]);
if (maj < remoteMaj) {
var newVersionAvailable = true;
} else if (maj == remoteMaj) {
if (min < remoteMin) {
var newVersionAvailable = true;
} else if (min == remoteMin) {
var newVersionAvailable = (patch < remotePatch);
} else {
var newVersionAvailable = false;
}
} else {
var newVersionAvailable = false;
}
callback(newVersionAvailable);
}, function(err) {
if (err.fault == 'NoSuchFunction') {
// Really old daemon that can't report a version
callback(true);
}
});
}
lbry.getClientSettings = function() {
var outSettings = {};
@ -458,6 +387,10 @@ lbry.getClientSettings = function() {
lbry.getClientSetting = function(setting) {
var localStorageVal = localStorage.getItem('setting_' + setting);
if (setting == 'showDeveloperMenu')
{
return true;
}
return (localStorageVal === null ? lbry.defaultClientSettings[setting] : JSON.parse(localStorageVal));
}
@ -550,7 +483,7 @@ lbry.stop = function(callback) {
lbry.fileInfo = {};
lbry._subscribeIdCount = 0;
lbry._fileInfoSubscribeCallbacks = {};
lbry._fileInfoSubscribeInterval = 5000;
lbry._fileInfoSubscribeInterval = 500000;
lbry._balanceSubscribeCallbacks = {};
lbry._balanceSubscribeInterval = 5000;
lbry._removedFiles = [];
@ -560,8 +493,9 @@ lbry._updateClaimOwnershipCache = function(claimId) {
lbry.getMyClaims((claimInfos) => {
lbry._claimIdOwnershipCache[claimId] = !!claimInfos.reduce(function(match, claimInfo) {
return match || claimInfo.claim_id == claimId;
}, false);
});
});
};
lbry._updateFileInfoSubscribers = function(outpoint) {
@ -612,6 +546,7 @@ lbry.fileInfoUnsubscribe = function(outpoint, subscribeId) {
delete lbry._fileInfoSubscribeCallbacks[outpoint][subscribeId];
}
lbry._balanceUpdateInterval = null;
lbry._updateBalanceSubscribers = function() {
lbry.get_balance().then(function(balance) {
for (let callback of Object.values(lbry._balanceSubscribeCallbacks)) {
@ -619,8 +554,8 @@ lbry._updateBalanceSubscribers = function() {
}
});
if (Object.keys(lbry._balanceSubscribeCallbacks).length) {
setTimeout(() => {
if (!lbry._balanceUpdateInterval && Object.keys(lbry._balanceSubscribeCallbacks).length) {
lbry._balanceUpdateInterval = setInterval(() => {
lbry._updateBalanceSubscribers();
}, lbry._balanceSubscribeInterval);
}
@ -635,6 +570,9 @@ lbry.balanceSubscribe = function(callback) {
lbry.balanceUnsubscribe = function(subscribeId) {
delete lbry._balanceSubscribeCallbacks[subscribeId];
if (lbry._balanceUpdateInterval && !Object.keys(lbry._balanceSubscribeCallbacks).length) {
clearInterval(lbry._balanceUpdateInterval)
}
}
lbry.showMenuIfNeeded = function() {
@ -646,6 +584,14 @@ lbry.showMenuIfNeeded = function() {
sessionStorage.setItem('menuShown', chosenMenu);
};
lbry.getVersionInfo = function() {
return new Promise((resolve, reject) => {
ipcRenderer.once('version-info-received', (event, versionInfo) => { resolve(versionInfo) });
ipcRenderer.send('version-info-requested');
});
}
/**
* Wrappers for API methods to simulate missing or future behavior. Unlike the old-style stubs,
* these are designed to be transparent wrappers around the corresponding API methods.
@ -657,14 +603,14 @@ lbry.showMenuIfNeeded = function() {
*/
lbry.file_list = function(params={}) {
return new Promise((resolve, reject) => {
const {name, outpoint} = params;
const {name, channel_name, outpoint} = params;
/**
* If we're searching by outpoint, check first to see if there's a matching pending publish.
* Pending publishes use their own faux outpoints that are always unique, so we don't need
* to check if there's a real file.
*/
if (outpoint !== undefined) {
if (outpoint) {
const pendingPublish = getPendingPublish({outpoint});
if (pendingPublish) {
resolve([pendingPublishToDummyFileInfo(pendingPublish)]);
@ -673,14 +619,8 @@ lbry.file_list = function(params={}) {
}
lbry.call('file_list', params, (fileInfos) => {
// Remove any pending publications that are now listed in the file manager
removePendingPublishIfNeeded({name, channel_name, outpoint});
const pendingPublishes = getPendingPublishes();
for (let {name: itemName} of fileInfos) {
if (pendingPublishes.find(() => name == itemName)) {
removePendingPublish({name: name});
}
}
const dummyFileInfos = getPendingPublishes().map(pendingPublishToDummyFileInfo);
resolve([...fileInfos, ...dummyFileInfos]);
}, reject, reject);
@ -690,16 +630,60 @@ lbry.file_list = function(params={}) {
lbry.claim_list_mine = function(params={}) {
return new Promise((resolve, reject) => {
lbry.call('claim_list_mine', params, (claims) => {
// Filter out pending publishes when the name is already in the file manager
const dummyClaims = getPendingPublishes().filter(
(pub) => !claims.find(({name}) => name == pub.name)
).map(pendingPublishToDummyClaim);
for (let {name, channel_name, txid, nout} of claims) {
removePendingPublishIfNeeded({name, channel_name, outpoint: txid + ':' + nout});
}
const dummyClaims = getPendingPublishes().map(pendingPublishToDummyClaim);
resolve([...claims, ...dummyClaims]);
}, reject, reject);
}, reject, reject)
});
}
lbry.resolve = function(params={}) {
const claimCacheKey = 'resolve_claim_cache',
claimCache = getSession(claimCacheKey, {})
return new Promise((resolve, reject) => {
if (!params.uri) {
throw "Resolve has hacked cache on top of it that requires a URI"
}
if (params.uri && claimCache[params.uri] !== undefined) {
resolve(claimCache[params.uri]);
} else {
lbry.call('resolve', params, function(data) {
claimCache[params.uri] = data;
setSession(claimCacheKey, claimCache)
resolve(data)
}, reject)
}
});
}
// Adds caching.
lbry.settings_get = function(params={}) {
return new Promise((resolve, reject) => {
if (params.allow_cached) {
const cached = getSession('settings');
if (cached) {
return resolve(cached);
}
}
lbry.call('settings_get', {}, (settings) => {
setSession('settings', settings);
resolve(settings);
});
});
}
// lbry.get = function(params={}) {
// return function(params={}) {
// return new Promise((resolve, reject) => {
// jsonrpc.call(lbry.daemonConnectionString, "get", params, resolve, reject, reject);
// });
// };
// }
lbry = new Proxy(lbry, {
get: function(target, name) {
if (name in target) {
@ -708,7 +692,7 @@ lbry = new Proxy(lbry, {
return function(params={}) {
return new Promise((resolve, reject) => {
jsonrpc.call(lbry.daemonConnectionString, name, [params], resolve, reject, reject);
jsonrpc.call(lbry.daemonConnectionString, name, params, resolve, reject, reject);
});
};
}

159
ui/js/lbryio.js Normal file
View file

@ -0,0 +1,159 @@
import {getLocal, getSession, setSession, setLocal} from './utils.js';
import lbry from './lbry.js';
const querystring = require('querystring');
const lbryio = {
_accessToken: getLocal('accessToken'),
_authenticationPromise: null,
_user : null,
enabled: false
};
const CONNECTION_STRING = process.env.LBRY_APP_API_URL ? process.env.LBRY_APP_API_URL : 'https://api.lbry.io/';
const EXCHANGE_RATE_TIMEOUT = 20 * 60 * 1000;
lbryio.getExchangeRates = function() {
return new Promise((resolve, reject) => {
const cached = getSession('exchangeRateCache');
if (!cached || Date.now() - cached.time > EXCHANGE_RATE_TIMEOUT) {
lbryio.call('lbc', 'exchange_rate', {}, 'get', true).then(({lbc_usd, lbc_btc, btc_usd}) => {
const rates = {lbc_usd, lbc_btc, btc_usd};
setSession('exchangeRateCache', {
rates: rates,
time: Date.now(),
});
resolve(rates);
});
} else {
resolve(cached.rates);
}
});
}
lbryio.call = function(resource, action, params={}, method='get', evenIfDisabled=false) { // evenIfDisabled is just for development, when we may have some calls working and some not
return new Promise((resolve, reject) => {
if (!lbryio.enabled && !evenIfDisabled && (resource != 'discover' || action != 'list')) {
reject(new Error("LBRY internal API is disabled"))
return
}
const xhr = new XMLHttpRequest;
xhr.addEventListener('error', function (event) {
reject(new Error("Something went wrong making an internal API call."));
});
xhr.addEventListener('timeout', function() {
reject(new Error('XMLHttpRequest connection timed out'));
});
xhr.addEventListener('load', function() {
const response = JSON.parse(xhr.responseText);
if (!response.success) {
if (reject) {
let error = new Error(response.error);
error.xhr = xhr;
reject(error);
} else {
document.dispatchEvent(new CustomEvent('unhandledError', {
detail: {
connectionString: connectionString,
method: action,
params: params,
message: response.error.message,
... response.error.data ? {data: response.error.data} : {},
}
}));
}
} else {
resolve(response.data);
}
});
// For social media auth:
//const accessToken = localStorage.getItem('accessToken');
//const fullParams = {...params, ... accessToken ? {access_token: accessToken} : {}};
// Temp app ID based auth:
const fullParams = {app_id: lbryio.getAccessToken(), ...params};
if (method == 'get') {
xhr.open('get', CONNECTION_STRING + resource + '/' + action + '?' + querystring.stringify(fullParams), true);
xhr.send();
} else if (method == 'post') {
xhr.open('post', CONNECTION_STRING + resource + '/' + action, true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.send(querystring.stringify(fullParams));
}
});
};
lbryio.getAccessToken = () => {
return getLocal('accessToken');
}
lbryio.setAccessToken = (token) => {
setLocal('accessToken', token)
}
lbryio.authenticate = function() {
if (!lbryio.enabled) {
return new Promise((resolve, reject) => {
resolve({
ID: 1,
HasVerifiedEmail: true
})
})
}
if (lbryio._authenticationPromise === null) {
lbryio._authenticationPromise = new Promise((resolve, reject) => {
lbry.status().then((response) => {
let installation_id = response.installation_id;
function setCurrentUser() {
lbryio.call('user', 'me').then((data) => {
lbryio.user = data
resolve(data)
}).catch(function(err) {
lbryio.setAccessToken(null);
if (!getSession('reloadedOnFailedAuth')) {
setSession('reloadedOnFailedAuth', true)
window.location.reload();
} else {
reject(err);
}
})
}
if (!lbryio.getAccessToken()) {
lbryio.call('user', 'new', {
language: 'en',
app_id: installation_id,
}, 'post').then(function(responseData) {
if (!responseData.ID) {
reject(new Error("Received invalid authentication response."));
}
lbryio.setAccessToken(installation_id)
setCurrentUser()
}).catch(function(error) {
/*
until we have better error code format, assume all errors are duplicate application id
if we're wrong, this will be caught by later attempts to make a valid call
*/
lbryio.setAccessToken(installation_id)
setCurrentUser()
})
} else {
setCurrentUser()
}
}).catch(reject);
});
}
return lbryio._authenticationPromise;
}
export default lbryio;

169
ui/js/lbryuri.js Normal file
View file

@ -0,0 +1,169 @@
const CHANNEL_NAME_MIN_LEN = 4;
const CLAIM_ID_MAX_LEN = 40;
const lbryuri = {};
/**
* Parses a LBRY name into its component parts. Throws errors with user-friendly
* messages for invalid names.
*
* N.B. that "name" indicates the value in the name position of the URI. For
* claims for channel content, this will actually be the channel name, and
* the content name is in the path (e.g. lbry://@channel/content)
*
* In most situations, you'll want to use the contentName and channelName keys
* and ignore the name key.
*
* Returns a dictionary with keys:
* - name (string): The value in the "name" position in the URI. Note that this
* could be either content name or channel name; see above.
* - path (string, if persent)
* - claimSequence (int, if present)
* - bidPosition (int, if present)
* - claimId (string, if present)
* - isChannel (boolean)
* - contentName (string): For anon claims, the name; for channel claims, the path
* - channelName (string, if present): Channel name without @
*/
lbryuri.parse = function(uri, requireProto=false) {
// Break into components. Empty sub-matches are converted to null
const componentsRegex = new RegExp(
'^((?:lbry:\/\/)?)' + // protocol
'([^:$#/]*)' + // name (stops at the first separator or end)
'([:$#]?)([^/]*)' + // modifier separator, modifier (stops at the first path separator or end)
'(/?)(.*)' // path separator, path
);
const [proto, name, modSep, modVal, pathSep, path] = componentsRegex.exec(uri).slice(1).map(match => match || null);
let contentName;
// Validate protocol
if (requireProto && !proto) {
throw new Error('LBRY URIs must include a protocol prefix (lbry://).');
}
// Validate and process name
if (!name) {
throw new Error('URI does not include name.');
}
const isChannel = name.startsWith('@');
const channelName = isChannel ? name.slice(1) : name;
if (isChannel) {
if (!channelName) {
throw new Error('No channel name after @.');
}
if (channelName.length < CHANNEL_NAME_MIN_LEN) {
throw new Error(`Channel names must be at least ${CHANNEL_NAME_MIN_LEN} characters.`);
}
contentName = path;
}
const nameBadChars = (channelName || name).match(/[^A-Za-z0-9-]/g);
if (nameBadChars) {
throw new Error(`Invalid character${nameBadChars.length == 1 ? '' : 's'} in name: ${nameBadChars.join(', ')}.`);
}
// Validate and process modifier (claim ID, bid position or claim sequence)
let claimId, claimSequence, bidPosition;
if (modSep) {
if (!modVal) {
throw new Error(`No modifier provided after separator ${modSep}.`);
}
if (modSep == '#') {
claimId = modVal;
} else if (modSep == ':') {
claimSequence = modVal;
} else if (modSep == '$') {
bidPosition = modVal;
}
}
if (claimId && (claimId.length > CLAIM_ID_MAX_LEN || !claimId.match(/^[0-9a-f]+$/))) {
throw new Error(`Invalid claim ID ${claimId}.`);
}
if (claimSequence && !claimSequence.match(/^-?[1-9][0-9]*$/)) {
throw new Error('Claim sequence must be a number.');
}
if (bidPosition && !bidPosition.match(/^-?[1-9][0-9]*$/)) {
throw new Error('Bid position must be a number.');
}
// Validate and process path
if (path) {
if (!isChannel) {
throw new Error('Only channel URIs may have a path.');
}
const pathBadChars = path.match(/[^A-Za-z0-9-]/g);
if (pathBadChars) {
throw new Error(`Invalid character${count == 1 ? '' : 's'} in path: ${nameBadChars.join(', ')}`);
}
contentName = path;
} else if (pathSep) {
throw new Error('No path provided after /');
}
return {
name, path, isChannel,
... contentName ? {contentName} : {},
... channelName ? {channelName} : {},
... claimSequence ? {claimSequence: parseInt(claimSequence)} : {},
... bidPosition ? {bidPosition: parseInt(bidPosition)} : {},
... claimId ? {claimId} : {},
... path ? {path} : {},
};
}
/**
* Takes an object in the same format returned by lbryuri.parse() and builds a URI.
*
* The channelName key will accept names with or without the @ prefix.
*/
lbryuri.build = function(uriObj, includeProto=true, allowExtraProps=false) {
let {name, claimId, claimSequence, bidPosition, path, contentName, channelName} = uriObj;
if (channelName) {
const channelNameFormatted = channelName.startsWith('@') ? channelName : '@' + channelName;
if (!name) {
name = channelNameFormatted;
} else if (name !== channelNameFormatted) {
throw new Error('Received a channel content URI, but name and channelName do not match. "name" represents the value in the name position of the URI (lbry://name...), which for channel content will be the channel name. In most cases, to construct a channel URI you should just pass channelName and contentName.');
}
}
if (contentName) {
if (!name) {
name = contentName;
} else if (!path) {
path = contentName;
}
if (path && path !== contentName) {
throw new Error('path and contentName do not match. Only one is required; most likely you wanted contentName.');
}
}
return (includeProto ? 'lbry://' : '') + name +
(claimId ? `#${claimId}` : '') +
(claimSequence ? `:${claimSequence}` : '') +
(bidPosition ? `\$${bidPosition}` : '') +
(path ? `/${path}` : '');
}
/* Takes a parseable LBRY URI and converts it to standard, canonical format (currently this just
* consists of adding the lbry:// prefix if needed) */
lbryuri.normalize= function(uri) {
const {name, path, bidPosition, claimSequence, claimId} = lbryuri.parse(uri);
return lbryuri.build({name, path, claimSequence, bidPosition, claimId});
}
window.lbryuri = lbryuri;
export default lbryuri;

View file

@ -1,12 +1,12 @@
import lbry from './lbry.js';
import jsonrpc from './jsonrpc.js';
const queryTimeout = 5000;
const maxQueryTries = 5;
const queryTimeout = 3000;
const maxQueryTries = 2;
const defaultServers = [
'http://lighthouse4.lbry.io:50005',
'http://lighthouse5.lbry.io:50005',
'http://lighthouse6.lbry.io:50005',
'http://lighthouse7.lbry.io:50005',
'http://lighthouse8.lbry.io:50005',
'http://lighthouse9.lbry.io:50005',
];
const path = '/';
@ -20,12 +20,9 @@ function getServers() {
}
function call(method, params, callback, errorCallback) {
if (connectTryNum > maxQueryTries) {
if (connectFailedCallback) {
connectFailedCallback();
} else {
throw new Error(`Could not connect to Lighthouse server. Last server attempted: ${server}`);
}
if (connectTryNum >= maxQueryTries) {
errorCallback(new Error(`Could not connect to Lighthouse server. Last server attempted: ${server}`));
return;
}
/**
@ -48,7 +45,7 @@ function call(method, params, callback, errorCallback) {
}, () => {
connectTryNum++;
call(method, params, callback, errorCallback);
});
}, queryTimeout);
}
const lighthouse = new Proxy({}, {

View file

@ -1,9 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import lbry from './lbry.js';
import lbryio from './lbryio.js';
import lighthouse from './lighthouse.js';
import App from './app.js';
import SplashScreen from './component/splash.js';
import SnackBar from './component/snack-bar.js';
import {AuthOverlay} from './component/auth.js';
const {remote} = require('electron');
const contextMenu = remote.require('./menu/context-menu');
@ -16,31 +19,24 @@ window.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
var init = function() {
let init = function() {
window.lbry = lbry;
window.lighthouse = lighthouse;
let canvas = document.getElementById('canvas');
lbry.connect().then(function(isConnected) {
lbryio.authenticate() //start auth process as soon as soon as we can get an install ID
})
function onDaemonReady() {
window.sessionStorage.setItem('loaded', 'y'); //once we've made it here once per session, we don't need to show splash again
ReactDOM.render(<div>{ lbryio.enabled ? <AuthOverlay/> : '' }<App /><SnackBar /></div>, canvas)
}
var canvas = document.getElementById('canvas');
if (window.sessionStorage.getItem('loaded') == 'y') {
ReactDOM.render(<App/>, canvas)
onDaemonReady();
} else {
ReactDOM.render(
<SplashScreen message="Connecting" onLoadDone={function() {
// Redirect to the claim code page if needed. Find somewhere better for this logic
if (!localStorage.getItem('claimCodeDone') && window.location.search == '' || window.location.search == '?' || window.location.search == 'discover') {
lbry.getBalance((balance) => {
if (balance <= 0) {
window.location.href = '?claim';
} else {
ReactDOM.render(<App/>, canvas);
}
});
} else {
ReactDOM.render(<App/>, canvas);
}
}}/>,
canvas
);
ReactDOM.render(<SplashScreen message="Connecting" onLoadDone={onDaemonReady} />, canvas);
}
};

View file

@ -1,158 +0,0 @@
import React from 'react';
import lbry from '../lbry.js';
import Modal from '../component/modal.js';
import {Link} from '../component/link.js';
var claimCodeContentStyle = {
display: 'inline-block',
textAlign: 'left',
width: '600px',
}, claimCodeLabelStyle = {
display: 'inline-block',
cursor: 'default',
width: '130px',
textAlign: 'right',
marginRight: '6px',
};
var ClaimCodePage = React.createClass({
getInitialState: function() {
return {
submitting: false,
modal: null,
referralCredits: null,
activationCredits: null,
failureReason: null,
}
},
handleSubmit: function(event) {
if (typeof event !== 'undefined') {
event.preventDefault();
}
if (!this.refs.code.value) {
this.setState({
modal: 'missingCode',
});
return;
} else if (!this.refs.email.value) {
this.setState({
modal: 'missingEmail',
});
return;
}
this.setState({
submitting: true,
});
lbry.getUnusedAddress((address) => {
var code = this.refs.code.value;
var email = this.refs.email.value;
var xhr = new XMLHttpRequest;
xhr.addEventListener('load', () => {
var response = JSON.parse(xhr.responseText);
if (response.success) {
this.setState({
modal: 'codeRedeemed',
referralCredits: response.referralCredits,
activationCredits: response.activationCredits,
});
} else {
this.setState({
submitting: false,
modal: 'codeRedeemFailed',
failureReason: response.reason,
});
}
});
xhr.addEventListener('error', () => {
this.setState({
submitting: false,
modal: 'couldNotConnect',
});
});
xhr.open('POST', 'https://invites.lbry.io', true);
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send('code=' + encodeURIComponent(code) + '&address=' + encodeURIComponent(address) +
'&email=' + encodeURIComponent(email));
});
},
handleSkip: function() {
this.setState({
modal: 'skipped',
});
},
handleFinished: function() {
localStorage.setItem('claimCodeDone', true);
window.location = '?home';
},
closeModal: function() {
this.setState({
modal: null,
});
},
render: function() {
return (
<main>
<form onSubmit={this.handleSubmit}>
<div className="card">
<h2>Claim your beta invitation code</h2>
<section style={claimCodeContentStyle}>
<p>Thanks for beta testing LBRY! Enter your invitation code and email address below to receive your initial
LBRY credits.</p>
<p>You will be added to our mailing list (if you're not already on it) and will be eligible for future rewards for beta testers.</p>
</section>
<section>
<section><label style={claimCodeLabelStyle} htmlFor="code">Invitation code</label><input name="code" ref="code" /></section>
<section><label style={claimCodeLabelStyle} htmlFor="email">Email</label><input name="email" ref="email" /></section>
</section>
<section>
<Link button="primary" label={this.state.submitting ? "Submitting..." : "Submit"}
disabled={this.state.submitting} onClick={this.handleSubmit} />
<Link button="alt" label="Skip" disabled={this.state.submitting} onClick={this.handleSkip} />
<input type='submit' className='hidden' />
</section>
</div>
</form>
<Modal isOpen={this.state.modal == 'missingCode'} contentLabel="Invitation code required"
onConfirmed={this.closeModal}>
Please enter an invitation code or choose "Skip."
</Modal>
<Modal isOpen={this.state.modal == 'missingEmail'} contentLabel="Email required"
onConfirmed={this.closeModal}>
Please enter an email address or choose "Skip."
</Modal>
<Modal isOpen={this.state.modal == 'codeRedeemFailed'} contentLabel="Failed to redeem code"
onConfirmed={this.closeModal}>
{this.state.failureReason}
</Modal>
<Modal isOpen={this.state.modal == 'codeRedeemed'} contentLabel="Code redeemed"
onConfirmed={this.handleFinished}>
Your invite code has been redeemed. { ' ' }
{this.state.referralCredits > 0
? `You have also earned ${referralCredits} credits from referrals. A total of ${activationCredits + referralCredits}
will be added to your balance shortly.`
: (this.state.activationCredits > 0
? `${this.state.activationCredits} credits will be added to your balance shortly.`
: 'The credits will be added to your balance shortly.')}
</Modal>
<Modal isOpen={this.state.modal == 'skipped'} contentLabel="Welcome to LBRY"
onConfirmed={this.handleFinished}>
Welcome to LBRY! You can visit the Wallet page to redeem an invite code at any time.
</Modal>
<Modal isOpen={this.state.modal == 'couldNotConnect'} contentLabel="Could not connect"
onConfirmed={this.closeModal}>
<p>LBRY couldn't connect to our servers to confirm your invitation code. Please check your internet connection.</p>
If you continue to have problems, you can still browse LBRY and visit the Settings page to redeem your code later.
</Modal>
</main>
);
}
});
export default ClaimCodePage;

View file

@ -1,6 +1,6 @@
import lbry from '../lbry.js';
import React from 'react';
import FormField from '../component/form.js';
import {FormField} from '../component/form.js';
import {Link} from '../component/link.js';
const fs = require('fs');

View file

@ -1,168 +1,62 @@
import React from 'react';
import lbry from '../lbry.js';
import lighthouse from '../lighthouse.js';
import {FileTile} from '../component/file-tile.js';
import {Link} from '../component/link.js';
import lbryio from '../lbryio.js';
import {FileTile, FileTileStream} from '../component/file-tile.js';
import {ToolTip} from '../component/tooltip.js';
import {BusyMessage} from '../component/common.js';
var fetchResultsStyle = {
color: '#888',
textAlign: 'center',
fontSize: '1.2em'
};
var SearchActive = React.createClass({
render: function() {
return (
<div style={fetchResultsStyle}>
<BusyMessage message="Looking up the Dewey Decimals" />
</div>
);
}
});
var searchNoResultsStyle = {
textAlign: 'center'
}, searchNoResultsMessageStyle = {
fontStyle: 'italic',
marginRight: '5px'
};
var SearchNoResults = React.createClass({
render: function() {
return (
<section style={searchNoResultsStyle}>
<span style={searchNoResultsMessageStyle}>No one has checked anything in for {this.props.query} yet.</span>
<Link label="Be the first" href="?publish" />
</section>
);
}
});
var SearchResults = React.createClass({
render: function() {
var rows = [],
seenNames = {}; //fix this when the search API returns claim IDs
this.props.results.forEach(function({name, value}) {
if (!seenNames[name]) {
seenNames[name] = name;
rows.push(
<FileTile key={name} uri={name} sdHash={value.sources.lbry_sd_hash} />
);
}
});
return (
<div>{rows}</div>
);
}
});
var featuredContentLegendStyle = {
fontSize: '12px',
color: '#aaa',
verticalAlign: '15%',
};
var FeaturedContent = React.createClass({
getInitialState: function() {
return {
featuredNames: [],
};
},
componentWillMount: function() {
lbry.getFeaturedDiscoverNames().then((featuredNames) => {
this.setState({ featuredNames: featuredNames });
});
},
render: function() {
const toolTipText = ('Community Content is a public space where anyone can share content with the ' +
const communityCategoryToolTipText = ('Community Content is a public space where anyone can share content with the ' +
'rest of the LBRY community. Bid on the names "one," "two," "three," "four" and ' +
'"five" to put your content here!');
return (
<div className="row-fluid">
<div className="span6">
<h3>Featured Content</h3>
{ this.state.featuredNames.map((name) => { return <FileTile key={name} uri={name} /> }) }
</div>
<div className="span6">
<h3>
Community Content
<ToolTip label="What's this?" body={toolTipText} className="tooltip--header"/>
</h3>
<FileTile uri="one" />
<FileTile uri="two" />
<FileTile uri="three" />
<FileTile uri="four" />
<FileTile uri="five" />
</div>
</div>
);
let FeaturedCategory = React.createClass({
render: function() {
return (<div className="card-row card-row--small">
{ this.props.category ?
<h3 className="card-row__header">{this.props.category}
{ this.props.category.match(/^community/i) ?
<ToolTip label="What's this?" body={communityCategoryToolTipText} className="tooltip--header"/>
: '' }</h3>
: '' }
{ this.props.names.map((name) => { return <FileTile key={name} displayStyle="card" uri={name} /> }) }
</div>)
}
});
var DiscoverPage = React.createClass({
userTypingTimer: null,
componentDidUpdate: function() {
if (this.props.query != this.state.query)
{
this.handleSearchChanged(this.props.query);
}
},
componentWillReceiveProps: function(nextProps, nextState) {
if (nextProps.query != nextState.query)
{
this.handleSearchChanged(nextProps.query);
}
},
handleSearchChanged: function(query) {
this.setState({
searching: true,
query: query,
});
lighthouse.search(query).then(this.searchCallback);
},
componentWillMount: function() {
document.title = "Discover";
if (this.props.query) {
// Rendering with a query already typed
this.handleSearchChanged(this.props.query);
}
},
})
let DiscoverPage = React.createClass({
getInitialState: function() {
return {
results: [],
query: this.props.query,
searching: ('query' in this.props) && (this.props.query.length > 0)
featuredUris: {},
failed: false
};
},
searchCallback: function(results) {
if (this.state.searching) //could have canceled while results were pending, in which case nothing to do
{
this.setState({
results: results,
searching: false //multiple searches can be out, we're only done if we receive one we actually care about
});
componentWillMount: function() {
lbryio.call('discover', 'list', { version: "early-access" } ).then(({Categories, Uris}) => {
let featuredUris = {}
Categories.forEach((category) => {
if (Uris[category] && Uris[category].length) {
featuredUris[category] = Uris[category]
}
})
this.setState({ featuredUris: featuredUris });
}, () => {
this.setState({
failed: true
})
});
},
render: function() {
return (
<main>
{ this.state.searching ? <SearchActive /> : null }
{ !this.state.searching && this.props.query && this.state.results.length ? <SearchResults results={this.state.results} /> : null }
{ !this.state.searching && this.props.query && !this.state.results.length ? <SearchNoResults query={this.props.query} /> : null }
{ !this.props.query && !this.state.searching ? <FeaturedContent /> : null }
</main>
);
return <main>{
this.state.failed ?
<div className="empty">Failed to load landing content.</div> :
<div>
{
Object.keys(this.state.featuredUris).map((category) => {
return this.state.featuredUris[category].length ?
<FeaturedCategory key={category} category={category} names={this.state.featuredUris[category]} /> :
'';
})
}
</div>
}</main>;
}
});

View file

@ -1,11 +1,24 @@
import React from 'react';
import lbry from '../lbry.js';
import lbryuri from '../lbryuri.js';
import {Link} from '../component/link.js';
import FormField from '../component/form.js';
import {FormField} from '../component/form.js';
import {SubHeader} from '../component/header.js';
import {FileTileStream} from '../component/file-tile.js';
import rewards from '../rewards.js';
import lbryio from '../lbryio.js';
import {BusyMessage, Thumbnail} from '../component/common.js';
export let FileListNav = React.createClass({
render: function() {
return <SubHeader modifier="constrained" viewingPage={this.props.viewingPage} links={{
'?downloaded': 'Downloaded',
'?published': 'Published',
}} />;
}
});
export let FileListDownloaded = React.createClass({
_isMounted: false,
@ -16,7 +29,6 @@ export let FileListDownloaded = React.createClass({
},
componentDidMount: function() {
this._isMounted = true;
document.title = "Downloaded Files";
lbry.claim_list_mine().then((myClaimInfos) => {
if (!this._isMounted) { return; }
@ -31,27 +43,25 @@ export let FileListDownloaded = React.createClass({
});
});
},
componentWillUnmount: function() {
this._isMounted = false;
},
render: function() {
let content = "";
if (this.state.fileInfos === null) {
return (
<main className="page">
<BusyMessage message="Loading" />
</main>
);
content = <BusyMessage message="Loading" />;
} else if (!this.state.fileInfos.length) {
return (
<main className="page">
<span>You haven't downloaded anything from LBRY yet. Go <Link href="?discover" label="search for your first download" />!</span>
</main>
);
content = <span>You haven't downloaded anything from LBRY yet. Go <Link href="?discover" label="search for your first download" />!</span>;
} else {
content = <FileList fileInfos={this.state.fileInfos} hidePrices={true} />;
}
return (
<main className="page">
<FileList fileInfos={this.state.fileInfos} hidePrices={true} />
<main className="main--single-column">
<FileListNav viewingPage="downloaded" />
{content}
</main>
);
}
}
});
export let FileListPublished = React.createClass({
@ -62,9 +72,22 @@ export let FileListPublished = React.createClass({
fileInfos: null,
};
},
_requestPublishReward: function() {
lbryio.call('reward', 'list', {}).then(function(userRewards) {
//already rewarded
if (userRewards.filter(function (reward) {
return reward.RewardType == rewards.TYPE_FIRST_PUBLISH && reward.TransactionID;
}).length) {
return;
}
else {
rewards.claimReward(rewards.TYPE_FIRST_PUBLISH).catch(() => {})
}
}, () => {});
},
componentDidMount: function () {
this._isMounted = true;
document.title = "Published Files";
this._requestPublishReward();
lbry.claim_list_mine().then((claimInfos) => {
if (!this._isMounted) { return; }
@ -79,29 +102,27 @@ export let FileListPublished = React.createClass({
});
});
},
componentWillUnmount: function() {
this._isMounted = false;
},
render: function () {
let content = null;
if (this.state.fileInfos === null) {
return (
<main className="page">
<BusyMessage message="Loading" />
</main>
);
content = <BusyMessage message="Loading" />;
}
else if (!this.state.fileInfos.length) {
return (
<main className="page">
<span>You haven't published anything to LBRY yet.</span> Try <Link href="?publish" label="publishing" />!
</main>
);
content = <span>You haven't published anything to LBRY yet. Try <Link href="?publish" label="publishing" />!</span>;
}
else {
content = <FileList fileInfos={this.state.fileInfos} />;
}
return (
<main className="page">
<FileList fileInfos={this.state.fileInfos} />
<main className="main--single-column">
<FileListNav viewingPage="published" />
{content}
</main>
);
}
}
});
export let FileList = React.createClass({
@ -160,14 +181,24 @@ export let FileList = React.createClass({
seenUris = {};
const fileInfosSorted = this._sortFunctions[this.state.sortBy](this.props.fileInfos);
for (let {name, outpoint, metadata} of fileInfosSorted) {
if (!metadata || seenUris[name]) {
for (let {outpoint, name, channel_name, metadata, mime_type, claim_id, has_signature, signature_is_valid} of fileInfosSorted) {
if (seenUris[name] || !claim_id) {
continue;
}
let streamMetadata;
if (metadata) {
streamMetadata = metadata.stream.metadata;
} else {
streamMetadata = null;
}
const uri = lbryuri.build({contentName: name, channelName: channel_name});
seenUris[name] = true;
content.push(<FileTileStream key={outpoint} outpoint={outpoint} name={name} hideOnRemove={true}
hidePrice={this.props.hidePrices} metadata={metadata} />);
content.push(<FileTileStream key={outpoint} outpoint={outpoint} uri={uri} hideOnRemove={true}
hidePrice={this.props.hidePrices} metadata={streamMetadata} contentType={mime_type}
hasSignature={has_signature} signatureIsValid={signature_is_valid} />);
}
return (

View file

@ -3,6 +3,7 @@
import React from 'react';
import lbry from '../lbry.js';
import {Link} from '../component/link.js';
import {SettingsNav} from './settings.js';
import {version as uiVersion} from 'json!../../package.json';
var HelpPage = React.createClass({
@ -24,9 +25,6 @@ var HelpPage = React.createClass({
});
});
},
componentDidMount: function() {
document.title = "Help";
},
render: function() {
let ver, osName, platform, newVerLink;
if (this.state.versionInfo) {
@ -49,30 +47,42 @@ var HelpPage = React.createClass({
}
return (
<main className="page">
<main className="main--single-column">
<SettingsNav viewingPage="help" />
<section className="card">
<div className="card__title-primary">
<h3>Read the FAQ</h3>
</div>
<div className="card__content">
<p>Our FAQ answers many common questions.</p>
<p><Link href="https://lbry.io/faq" label="Read the FAQ" icon="icon-question" button="alt"/></p>
</div>
</section>
<section className="card">
<div className="card__title-primary">
<h3>Get Live Help</h3>
</div>
<div className="card__content">
<p>
Live help is available most hours in the <strong>#help</strong> channel of our Slack chat room.
</p>
<p>
<Link button="alt" label="Join Our Slack" icon="icon-slack" href="https://slack.lbry.io" />
</p>
</div>
</section>
<section className="card">
<h3>Report a Bug</h3>
<div className="card__title-primary"><h3>Report a Bug</h3></div>
<div className="card__content">
<p>Did you find something wrong?</p>
<p><Link href="?report" label="Submit a Bug Report" icon="icon-bug" button="alt" /></p>
<div className="meta">Thanks! LBRY is made by its users.</div>
</div>
</section>
{!ver ? null :
<section className="card">
<h3>About</h3>
<div className="card__title-primary"><h3>About</h3></div>
<div className="card__content">
{ver.lbrynet_update_available || ver.lbryum_update_available ?
<p>A newer version of LBRY is available. <Link href={newVerLink} label={`Download LBRY ${ver.remote_lbrynet} now!`} /></p>
: <p>Your copy of LBRY is up to date.</p>
@ -101,6 +111,7 @@ var HelpPage = React.createClass({
</tr>
</tbody>
</table>
</div>
</section>
}
</main>

View file

@ -1,16 +1,19 @@
import React from 'react';
import lbry from '../lbry.js';
import FormField from '../component/form.js';
import {FormField, FormRow} from '../component/form.js';
import {Link} from '../component/link.js';
import rewards from '../rewards.js';
import lbryio from '../lbryio.js';
import Modal from '../component/modal.js';
var PublishPage = React.createClass({
_requiredFields: ['name', 'bid', 'meta_title', 'meta_author', 'meta_license', 'meta_description'],
_requiredFields: ['meta_title', 'name', 'bid', 'tos_agree'],
_updateChannelList: function(channel) {
// Calls API to update displayed list of channels. If a channel name is provided, will select
// that channel at the same time (used immediately after creating a channel)
lbry.channel_list_mine().then((channels) => {
rewards.claimReward(rewards.TYPE_FIRST_CHANNEL).then(() => {}, () => {})
this.setState({
channels: channels,
... channel ? {channel} : {}
@ -26,20 +29,24 @@ var PublishPage = React.createClass({
submitting: true,
});
var checkFields = this._requiredFields.slice();
let checkFields = this._requiredFields;
if (!this.state.myClaimExists) {
checkFields.push('file');
checkFields.unshift('file');
}
var missingFieldFound = false;
let missingFieldFound = false;
for (let fieldName of checkFields) {
var field = this.refs[fieldName];
if (field.getValue() === '') {
field.warnRequired();
const field = this.refs[fieldName];
if (field) {
if (field.getValue() === '' || field.getValue() === false) {
field.showRequiredError();
if (!missingFieldFound) {
field.focus();
missingFieldFound = true;
}
} else {
field.clearError();
}
}
}
@ -60,14 +67,16 @@ var PublishPage = React.createClass({
var metadata = {};
}
for (let metaField of ['title', 'author', 'description', 'thumbnail', 'license', 'license_url', 'language', 'nsfw']) {
for (let metaField of ['title', 'description', 'thumbnail', 'license', 'license_url', 'language']) {
var value = this.refs['meta_' + metaField].getValue();
if (value !== '') {
metadata[metaField] = value;
}
}
var licenseUrl = this.refs.meta_license_url.getValue();
metadata.nsfw = Boolean(parseInt(!!this.refs.meta_nsfw.getValue()));
const licenseUrl = this.refs.meta_license_url.getValue();
if (licenseUrl) {
metadata.license_url = licenseUrl;
}
@ -110,17 +119,18 @@ var PublishPage = React.createClass({
channels: null,
rawName: '',
name: '',
bid: '',
bid: 10,
hasFile: false,
feeAmount: '',
feeCurrency: 'USD',
channel: 'anonymous',
newChannelName: '@',
newChannelBid: '',
nameResolved: false,
newChannelBid: 10,
nameResolved: null,
myClaimExists: null,
topClaimValue: 0.0,
myClaimValue: 0.0,
myClaimMetadata: null,
myClaimExists: null,
copyrightNotice: '',
otherLicenseDescription: '',
otherLicenseUrl: '',
@ -138,7 +148,7 @@ var PublishPage = React.createClass({
});
},
handlePublishStartedConfirmed: function() {
window.location = "?published";
window.location.href = "?published";
},
handlePublishError: function(error) {
this.setState({
@ -161,57 +171,63 @@ var PublishPage = React.createClass({
}
if (!lbry.nameIsValid(rawName, false)) {
this.refs.name.showAdvice('LBRY names must contain only letters, numbers and dashes.');
this.refs.name.showError('LBRY names must contain only letters, numbers and dashes.');
return;
}
const name = rawName.toLowerCase();
this.setState({
rawName: rawName,
name: name,
nameResolved: null,
myClaimExists: null,
});
var name = rawName.toLowerCase();
lbry.resolveName(name, (info) => {
if (name != this.refs.name.getValue().toLowerCase()) {
lbry.getMyClaim(name, (myClaimInfo) => {
if (name != this.state.name) {
// A new name has been typed already, so bail
return;
}
if (!info) {
this.setState({
name: name,
nameResolved: false,
myClaimExists: false,
myClaimExists: !!myClaimInfo,
});
} else {
lbry.getMyClaim(name, (myClaimInfo) => {
lbry.getClaimInfo(name, (claimInfo) => {
if (name != this.refs.name.getValue()) {
lbry.resolve({uri: name}).then((claimInfo) => {
if (name != this.state.name) {
return;
}
const topClaimIsMine = (myClaimInfo && myClaimInfo.amount >= claimInfo.amount);
if (!claimInfo) {
this.setState({
nameResolved: false,
});
} else {
const topClaimIsMine = (myClaimInfo && myClaimInfo.claim.amount >= claimInfo.claim.amount);
const newState = {
name: name,
nameResolved: true,
topClaimValue: parseFloat(claimInfo.amount),
topClaimValue: parseFloat(claimInfo.claim.amount),
myClaimExists: !!myClaimInfo,
myClaimValue: myClaimInfo ? parseFloat(myClaimInfo.amount) : null,
myClaimValue: myClaimInfo ? parseFloat(myClaimInfo.claim.amount) : null,
myClaimMetadata: myClaimInfo ? myClaimInfo.value : null,
topClaimIsMine: topClaimIsMine,
};
if (topClaimIsMine) {
newState.bid = myClaimInfo.amount;
newState.bid = myClaimInfo.claim.amount;
} else if (this.state.myClaimMetadata) {
// Just changed away from a name we have a claim on, so clear pre-fill
newState.bid = '';
}
this.setState(newState);
});
});
}
}, () => { // Assume an error means the name is available
this.setState({
name: name,
nameResolved: false,
myClaimExists: false,
});
});
});
},
handleBidChange: function(event) {
@ -234,7 +250,7 @@ var PublishPage = React.createClass({
isFee: feeEnabled
});
},
handeLicenseChange: function(event) {
handleLicenseChange: function(event) {
var licenseType = event.target.options[event.target.selectedIndex].getAttribute('data-license-type');
var newState = {
copyrightChosen: licenseType == 'copyright',
@ -242,8 +258,7 @@ var PublishPage = React.createClass({
};
if (licenseType == 'copyright') {
var author = this.refs.meta_author.getValue();
newState.copyrightNotice = 'Copyright ' + (new Date().getFullYear()) + (author ? ' ' + author : '');
newState.copyrightNotice = 'All rights reserved.'
}
this.setState(newState);
@ -274,8 +289,10 @@ var PublishPage = React.createClass({
const newChannelName = (event.target.value.startsWith('@') ? event.target.value : '@' + event.target.value);
if (newChannelName.length > 1 && !lbry.nameIsValid(newChannelName.substr(1), false)) {
this.refs.newChannelName.showAdvice('LBRY channel names must contain only letters, numbers and dashes.');
this.refs.newChannelName.showError('LBRY channel names must contain only letters, numbers and dashes.');
return;
} else {
this.refs.newChannelName.clearError()
}
this.setState({
@ -287,9 +304,14 @@ var PublishPage = React.createClass({
newChannelBid: event.target.value,
});
},
handleTOSChange: function(event) {
this.setState({
TOSAgreed: event.target.checked,
});
},
handleCreateChannelClick: function (event) {
if (this.state.newChannelName.length < 5) {
this.refs.newChannelName.showAdvice('LBRY channel names must be at least 4 characters in length.');
this.refs.newChannelName.showError('LBRY channel names must be at least 4 characters in length.');
return;
}
@ -308,7 +330,7 @@ var PublishPage = React.createClass({
}, 5000);
}, (error) => {
// TODO: better error handling
this.refs.newChannelName.showAdvice('Unable to create channel due to an internal error.');
this.refs.newChannelName.showError('Unable to create channel due to an internal error.');
this.setState({
creatingChannel: false,
});
@ -326,138 +348,68 @@ var PublishPage = React.createClass({
componentWillMount: function() {
this._updateChannelList();
},
componentDidMount: function() {
document.title = "Publish";
},
componentDidUpdate: function() {
},
// Also getting a type warning here too
onFileChange: function() {
if (this.refs.file.getValue()) {
this.setState({ hasFile: true })
} else {
this.setState({ hasFile: false })
}
},
getNameBidHelpText: function() {
if (!this.state.name) {
return "Select a URL for this publish.";
} else if (this.state.nameResolved === false) {
return "This URL is unused.";
} else if (this.state.myClaimExists) {
return "You have already used this URL. Publishing to it again will update your previous publish."
} else if (this.state.topClaimValue) {
return <span>A deposit of at least <strong>{this.state.topClaimValue}</strong> {this.state.topClaimValue == 1 ? 'credit ' : 'credits '}
is required to win <strong>{this.state.name}</strong>. However, you can still get a permanent URL for any amount.</span>
} else {
return '';
}
},
closeModal: function() {
this.setState({
modal: null,
});
},
render: function() {
if (this.state.channels === null) {
return null;
}
const lbcInputHelp = "This LBC remains yours and the deposit can be undone at any time."
return (
<main ref="page">
<main className="main--single-column">
<form onSubmit={this.handleSubmit}>
<section className="card">
<h4>LBRY Name</h4>
<div className="form-row">
lbry://<FormField type="text" ref="name" value={this.state.rawName} onChange={this.handleNameChange} />
{
(!this.state.name ? '' :
(! this.state.nameResolved ? <em> The name <strong>{this.state.name}</strong> is available.</em>
: (this.state.myClaimExists ? <em> You already have a claim on the name <strong>{this.state.name}</strong>. You can use this page to update your claim.</em>
: <em> The name <strong>{this.state.name}</strong> is currently claimed for <strong>{this.state.topClaimValue}</strong> {this.state.topClaimValue == 1 ? 'credit' : 'credits'}.</em>)))
}
<div className="help">What LBRY name would you like to claim for this file?</div>
</div>
</section>
<section className="card">
<h4>Channel</h4>
<div className="form-row">
<FormField type="select" onChange={this.handleChannelChange} value={this.state.channel}>
<option key="anonymous" value="anonymous">Anonymous</option>
{this.state.channels.map(({name}) => <option key={name} value={name}>{name}</option>)}
<option key="new" value="new">New channel...</option>
</FormField>
{this.state.channel == 'new'
? <section>
<label>Name <FormField type="text" onChange={this.handleNewChannelNameChange} ref={newChannelName => { this.refs.newChannelName = newChannelName }}
value={this.state.newChannelName} /></label>
<label>Bid amount <FormField type="text-number" onChange={this.handleNewChannelBidChange} value={this.state.newChannelBid} /> LBC</label>
<Link button="primary" label={!this.state.creatingChannel ? 'Create channel' : 'Creating channel...'} onClick={this.handleCreateChannelClick} disabled={this.state.creatingChannel} />
</section>
: null}
<div className="help">What channel would you like to publish this file under?</div>
</div>
</section>
<section className="card">
<h4>Choose File</h4>
<FormField name="file" ref="file" type="file" />
{ this.state.myClaimExists ? <div className="help">If you don't choose a file, the file from your existing claim will be used.</div> : null }
</section>
<section className="card">
<h4>Bid Amount</h4>
<div className="form-row">
Credits <FormField ref="bid" type="text-number" onChange={this.handleBidChange} value={this.state.bid} placeholder={this.state.nameResolved ? this.state.topClaimValue + 10 : 100} />
<div className="help">How much would you like to bid for this name?
{ !this.state.nameResolved ? <span> Since this name is not currently resolved, you may bid as low as you want, but higher bids help prevent others from claiming your name.</span>
: (this.state.topClaimIsMine ? <span> You currently control this name with a bid of <strong>{this.state.myClaimValue}</strong> {this.state.myClaimValue == 1 ? 'credit' : 'credits'}.</span>
: (this.state.myClaimExists ? <span> You have a non-winning bid on this name for <strong>{this.state.myClaimValue}</strong> {this.state.myClaimValue == 1 ? 'credit' : 'credits'}.
To control this name, you'll need to increase your bid to more than <strong>{this.state.topClaimValue}</strong> {this.state.topClaimValue == 1 ? 'credit' : 'credits'}.</span>
: <span> You must bid over <strong>{this.state.topClaimValue}</strong> {this.state.topClaimValue == 1 ? 'credit' : 'credits'} to claim this name.</span>)) }
<div className="card__title-primary">
<h4>Content</h4>
<div className="card__subtitle">
What are you publishing?
</div>
</div>
</section>
<section className="card">
<h4>Fee</h4>
<div className="form-row">
<label>
<FormField type="radio" onChange={ () => { this.handleFeePrefChange(false) } } checked={!this.state.isFee} /> No fee
</label>
<label>
<FormField type="radio" onChange={ () => { this.handleFeePrefChange(true) } } checked={this.state.isFee} /> { !this.state.isFee ? 'Choose fee...' : 'Fee ' }
<span className={!this.state.isFee ? 'hidden' : ''}>
<FormField type="text-number" onChange={this.handleFeeAmountChange} /> <FormField type="select" onChange={this.handleFeeCurrencyChange}>
<option value="USD">US Dollars</option>
<option value="LBC">LBRY credits</option>
</FormField>
</span>
</label>
<div className="help">
<p>How much would you like to charge for this file?</p>
If you choose to price this content in dollars, the number of credits charged will be adjusted based on the value of LBRY credits at the time of purchase.
<div className="card__content">
<FormRow name="file" label="File" ref="file" type="file" onChange={this.onFileChange}
helper={this.state.myClaimExists ? "If you don't choose a file, the file from your existing claim will be used." : null}/>
</div>
{ !this.state.hasFile ? '' :
<div>
<div className="card__content">
<FormRow label="Title" type="text" ref="meta_title" name="title" placeholder="Titular Title" />
</div>
</section>
<section className="card">
<h4>Your Content</h4>
<div className="form-row">
<label htmlFor="title">Title</label><FormField type="text" ref="meta_title" name="title" placeholder="My Show, Episode 1" />
<div className="card__content">
<FormRow type="text" label="Thumbnail URL" ref="meta_thumbnail" name="thumbnail" placeholder="http://spee.ch/mylogo" />
</div>
<div className="form-row">
<label htmlFor="author">Author</label><FormField type="text" ref="meta_author" name="author" placeholder="My Company, Inc." />
<div className="card__content">
<FormRow label="Description" type="textarea" ref="meta_description" name="description" placeholder="Description of your content" />
</div>
<div className="form-row">
<label htmlFor="license">License</label><FormField type="select" ref="meta_license" name="license" onChange={this.handeLicenseChange}>
<option data-url="https://creativecommons.org/licenses/by/4.0/legalcode">Creative Commons Attribution 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-sa/4.0/legalcode">Creative Commons Attribution-ShareAlike 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nd/4.0/legalcode">Creative Commons Attribution-NoDerivatives 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nc/4.0/legalcode">Creative Commons Attribution-NonCommercial 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode">Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode">Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International</option>
<option>Public Domain</option>
<option data-license-type="copyright" {... this.state.copyrightChosen ? {value: this.state.copyrightNotice} : {}}>Copyrighted...</option>
<option data-license-type="other" {... this.state.otherLicenseChosen ? {value: this.state.otherLicenseDescription} : {}}>Other...</option>
</FormField>
<FormField type="hidden" ref="meta_license_url" name="license_url" value={this.getLicenseUrl()} />
</div>
{this.state.copyrightChosen
? <div className="form-row">
<label htmlFor="copyright-notice" value={this.state.copyrightNotice}>Copyright notice</label><FormField type="text" name="copyright-notice" value={this.state.copyrightNotice} onChange={this.handleCopyrightNoticeChange} />
</div>
: null}
{this.state.otherLicenseChosen
? <div className="form-row">
<label htmlFor="other-license-description">License description</label><FormField type="text" name="other-license-description" onChange={this.handleOtherLicenseDescriptionChange} />
</div>
: null}
{this.state.otherLicenseChosen
? <div className="form-row">
<label htmlFor="other-license-url">License URL</label> <FormField type="text" name="other-license-url" onChange={this.handleOtherLicenseUrlChange} />
</div>
: null}
<div className="form-row">
<label htmlFor="language">Language</label> <FormField type="select" defaultValue="en" ref="meta_language" name="language">
<div className="card__content">
<FormRow label="Language" type="select" defaultValue="en" ref="meta_language" name="language">
<option value="en">English</option>
<option value="zh">Chinese</option>
<option value="fr">French</option>
@ -465,28 +417,138 @@ var PublishPage = React.createClass({
<option value="jp">Japanese</option>
<option value="ru">Russian</option>
<option value="es">Spanish</option>
</FormRow>
</div>
<div className="card__content">
<FormRow type="select" label="Maturity" defaultValue="en" ref="meta_nsfw" name="nsfw">
{/* <option value=""></option> */}
<option value="0">All Ages</option>
<option value="1">Adults Only</option>
</FormRow>
</div>
</div>}
</section>
<section className="card">
<div className="card__title-primary">
<h4>Access</h4>
<div className="card__subtitle">
How much does this content cost?
</div>
</div>
<div className="card__content">
<div className="form-row__label-row">
<label className="form-row__label">Price</label>
</div>
<FormRow label="Free" type="radio" name="isFree" value="1" onChange={ () => { this.handleFeePrefChange(false) } } defaultChecked={!this.state.isFee} />
<FormField type="radio" name="isFree" label={!this.state.isFee ? 'Choose price...' : 'Price ' }
onChange={ () => { this.handleFeePrefChange(true) } } defaultChecked={this.state.isFee} />
<span className={!this.state.isFee ? 'hidden' : ''}>
<FormField type="number" className="form-field__input--inline" step="0.01" placeholder="1.00" onChange={this.handleFeeAmountChange} /> <FormField type="select" onChange={this.handleFeeCurrencyChange}>
<option value="USD">US Dollars</option>
<option value="LBC">LBRY credits</option>
</FormField>
</div>
<div className="form-row">
<label htmlFor="description">Description</label> <FormField type="textarea" ref="meta_description" name="description" placeholder="Description of your content" />
</div>
<div className="form-row">
<label><FormField type="checkbox" ref="meta_nsfw" name="nsfw" placeholder="Description of your content" /> Not Safe For Work</label>
</span>
{ this.state.isFee ?
<div className="form-field__helper">
If you choose to price this content in dollars, the number of credits charged will be adjusted based on the value of LBRY credits at the time of purchase.
</div> : '' }
<FormRow label="License" type="select" ref="meta_license" name="license" onChange={this.handleLicenseChange}>
<option></option>
<option>Public Domain</option>
<option data-url="https://creativecommons.org/licenses/by/4.0/legalcode">Creative Commons Attribution 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-sa/4.0/legalcode">Creative Commons Attribution-ShareAlike 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nd/4.0/legalcode">Creative Commons Attribution-NoDerivatives 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nc/4.0/legalcode">Creative Commons Attribution-NonCommercial 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode">Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International</option>
<option data-url="https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode">Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International</option>
<option data-license-type="copyright" {... this.state.copyrightChosen ? {value: this.state.copyrightNotice} : {}}>Copyrighted...</option>
<option data-license-type="other" {... this.state.otherLicenseChosen ? {value: this.state.otherLicenseDescription} : {}}>Other...</option>
</FormRow>
<FormField type="hidden" ref="meta_license_url" name="license_url" value={this.getLicenseUrl()} />
{this.state.copyrightChosen
? <FormRow label="Copyright notice" type="text" name="copyright-notice"
value={this.state.copyrightNotice} onChange={this.handleCopyrightNoticeChange} />
: null}
{this.state.otherLicenseChosen ?
<FormRow label="License description" type="text" name="other-license-description" onChange={this.handleOtherLicenseDescriptionChange} />
: null}
{this.state.otherLicenseChosen ?
<FormRow label="License URL" type="text" name="other-license-url" onChange={this.handleOtherLicenseUrlChange} />
: null}
</div>
</section>
<section className="card">
<div className="card__title-primary">
<h4>Identity</h4>
<div className="card__subtitle">
Who created this content?
</div>
</div>
<div className="card__content">
<FormRow type="select" tabIndex="1" onChange={this.handleChannelChange} value={this.state.channel}>
<option key="anonymous" value="anonymous">Anonymous</option>
{this.state.channels.map(({name}) => <option key={name} value={name}>{name}</option>)}
<option key="new" value="new">New identity...</option>
</FormRow>
</div>
{this.state.channel == 'new' ?
<div className="card__content">
<FormRow label="Name" type="text" onChange={this.handleNewChannelNameChange} ref={newChannelName => { this.refs.newChannelName = newChannelName }}
value={this.state.newChannelName} />
<FormRow label="Deposit"
postfix="LBC"
step="0.01"
type="number"
helper={lbcInputHelp}
onChange={this.handleNewChannelBidChange}
value={this.state.newChannelBid} />
<div className="form-row-submit">
<Link button="primary" label={!this.state.creatingChannel ? 'Create identity' : 'Creating identity...'} onClick={this.handleCreateChannelClick} disabled={this.state.creatingChannel} />
</div>
</div>
: null}
</section>
<section className="card">
<h4>Additional Content Information (Optional)</h4>
<div className="form-row">
<label htmlFor="meta_thumbnail">Thumbnail URL</label> <FormField type="text" ref="meta_thumbnail" name="thumbnail" placeholder="http://mycompany.com/images/ep_1.jpg" />
<div className="card__title-primary">
<h4>Address</h4>
<div className="card__subtitle">Where should this content permanently reside? <Link label="Read more" href="https://lbry.io/faq/naming" />.</div>
</div>
<div className="card__content">
<FormRow prefix="lbry://" type="text" ref="name" placeholder="myname" value={this.state.rawName} onChange={this.handleNameChange}
helper={this.getNameBidHelpText()} />
</div>
{ this.state.rawName ?
<div className="card__content">
<FormRow ref="bid"
type="number"
step="0.01"
label="Deposit"
postfix="LBC"
onChange={this.handleBidChange}
value={this.state.bid}
placeholder={this.state.nameResolved ? this.state.topClaimValue + 10 : 100}
helper={lbcInputHelp} />
</div> : '' }
</section>
<section className="card">
<div className="card__title-primary">
<h4>Terms of Service</h4>
</div>
<div className="card__content">
<FormRow label={
<span>I agree to the <Link href="https://www.lbry.io/termsofservice" label="LBRY terms of service" checked={this.state.TOSAgreed} /></span>
} type="checkbox" name="tos_agree" ref={(field) => { this.refs.tos_agree = field }} onChange={this.handleTOSChange} />
</div>
</section>
<div className="card-series-submit">
<Link button="primary" label={!this.state.submitting ? 'Publish' : 'Publishing...'} onClick={this.handleSubmit} disabled={this.state.submitting} />
<Link button="cancel" onClick={window.history.back} label="Cancel" />
<Link button="cancel" onClick={lbry.back} label="Cancel" />
<input type="submit" className="hidden" />
</div>
</form>
@ -494,7 +556,7 @@ var PublishPage = React.createClass({
<Modal isOpen={this.state.modal == 'publishStarted'} contentLabel="File published"
onConfirmed={this.handlePublishStartedConfirmed}>
<p>Your file has been published to LBRY at the address <code>lbry://{this.state.name}</code>!</p>
You will now be taken to your My Files page, where your newly published file will be listed. The file will take a few minutes to appear for other LBRY users; until then it will be listed as "pending."
<p>The file will take a few minutes to appear for other LBRY users. Until then it will be listed as "pending" under your published files.</p>
</Modal>
<Modal isOpen={this.state.modal == 'error'} contentLabel="Error publishing file"
onConfirmed={this.closeModal}>

View file

@ -1,130 +0,0 @@
import React from 'react';
import lbry from '../lbry.js';
import {Link} from '../component/link.js';
import Modal from '../component/modal.js';
var referralCodeContentStyle = {
display: 'inline-block',
textAlign: 'left',
width: '600px',
}, referralCodeLabelStyle = {
display: 'inline-block',
cursor: 'default',
width: '130px',
textAlign: 'right',
marginRight: '6px',
};
var ReferralPage = React.createClass({
getInitialState: function() {
return {
submitting: false,
modal: null,
referralCredits: null,
failureReason: null,
}
},
handleSubmit: function(event) {
if (typeof event !== 'undefined') {
event.preventDefault();
}
if (!this.refs.code.value) {
this.setState({
modal: 'missingCode',
});
} else if (!this.refs.email.value) {
this.setState({
modal: 'missingEmail',
});
}
this.setState({
submitting: true,
});
lbry.getUnusedAddress((address) => {
var code = this.refs.code.value;
var email = this.refs.email.value;
var xhr = new XMLHttpRequest;
xhr.addEventListener('load', () => {
var response = JSON.parse(xhr.responseText);
if (response.success) {
this.setState({
modal: 'referralInfo',
referralCredits: response.referralCredits,
});
} else {
this.setState({
submitting: false,
modal: 'lookupFailed',
failureReason: response.reason,
});
}
});
xhr.addEventListener('error', () => {
this.setState({
submitting: false,
modal: 'couldNotConnect',
});
});
xhr.open('POST', 'https://invites.lbry.io/check', true);
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
xhr.send('code=' + encodeURIComponent(code) + '&address=' + encodeURIComponent(address) +
'&email=' + encodeURIComponent(email));
});
},
closeModal: function() {
this.setState({
modal: null,
});
},
handleFinished: function() {
localStorage.setItem('claimCodeDone', true);
window.location = '?home';
},
render: function() {
return (
<main>
<form onSubmit={this.handleSubmit}>
<div className="card">
<h2>Check your referral credits</h2>
<section style={referralCodeContentStyle}>
<p>Have you referred others to LBRY? Enter your referral code and email address below to check how many credits you've earned!</p>
<p>As a reminder, your referral code is the same as your LBRY invitation code.</p>
</section>
<section>
<section><label style={referralCodeLabelStyle} htmlFor="code">Referral code</label><input name="code" ref="code" /></section>
<section><label style={referralCodeLabelStyle} htmlFor="email">Email</label><input name="email" ref="email" /></section>
</section>
<section>
<Link button="primary" label={this.state.submitting ? "Submitting..." : "Submit"}
disabled={this.state.submitting} onClick={this.handleSubmit} />
<input type='submit' className='hidden' />
</section>
</div>
</form>
<Modal isOpen={this.state.modal == 'referralInfo'} contentLabel="Credit earnings"
onConfirmed={this.handleFinished}>
{this.state.referralCredits > 0
? `You have earned ${response.referralCredits} credits from referrals. We will credit your account shortly. Thanks!`
: 'You have not earned any new referral credits since the last time you checked. Please check back in a week or two.'}
</Modal>
<Modal isOpen={this.state.modal == 'lookupFailed'} contentLabel={this.state.failureReason}
onConfirmed={this.closeModal}>
{this.state.failureReason}
</Modal>
<Modal isOpen={this.state.modal == 'couldNotConnect'} contentLabel="Couldn't confirm referral code"
onConfirmed={this.closeModal}>
LBRY couldn't connect to our servers to confirm your referral code. Please check your internet connection.
</Modal>
</main>
);
}
});
export default ReferralPage;

View file

@ -18,9 +18,6 @@ var ReportPage = React.createClass({
this._messageArea.value = '';
}
},
componentDidMount: function() {
document.title = "Report an Issue";
},
closeModal: function() {
this.setState({
modal: null,
@ -34,7 +31,7 @@ var ReportPage = React.createClass({
},
render: function() {
return (
<main className="page">
<main className="main--single-column">
<section className="card">
<h3>Report an Issue</h3>
<p>Please describe the problem you experienced and any information you think might be useful to us. Links to screenshots are great!</p>

126
ui/js/page/reward.js Normal file
View file

@ -0,0 +1,126 @@
import React from 'react';
import lbryio from '../lbryio.js';
import {Link} from '../component/link.js';
import Notice from '../component/notice.js';
import {CreditAmount} from '../component/common.js';
//
// const {shell} = require('electron');
// const querystring = require('querystring');
//
// const GITHUB_CLIENT_ID = '6baf581d32bad60519';
//
// const LinkGithubReward = React.createClass({
// propTypes: {
// onClaimed: React.PropTypes.func,
// },
// _launchLinkPage: function() {
// /* const githubAuthParams = {
// client_id: GITHUB_CLIENT_ID,
// redirect_uri: 'https://lbry.io/',
// scope: 'user:email,public_repo',
// allow_signup: false,
// }
// shell.openExternal('https://github.com/login/oauth/authorize?' + querystring.stringify(githubAuthParams)); */
// shell.openExternal('https://lbry.io');
// },
// handleConfirmClicked: function() {
// this.setState({
// confirming: true,
// });
//
// lbry.get_new_address().then((address) => {
// lbryio.call('reward', 'new', {
// reward_type: 'new_developer',
// access_token: '**access token here**',
// wallet_address: address,
// }, 'post').then((response) => {
// console.log('response:', response);
//
// this.props.onClaimed(); // This will trigger another API call to show that we succeeded
//
// this.setState({
// confirming: false,
// error: null,
// });
// }, (error) => {
// console.log('failed with error:', error);
// this.setState({
// confirming: false,
// error: error,
// });
// });
// });
// },
// getInitialState: function() {
// return {
// confirming: false,
// error: null,
// };
// },
// render: function() {
// return (
// <section>
// <p><Link button="alt" label="Link with GitHub" onClick={this._launchLinkPage} /></p>
// <section className="reward-page__details">
// <p>This will open a browser window where you can authorize GitHub to link your account to LBRY. This will record your email (no spam) and star the LBRY repo.</p>
// <p>Once you're finished, you may confirm you've linked the account to receive your reward.</p>
// </section>
// {this.state.error
// ? <Notice isError>
// {this.state.error.message}
// </Notice>
// : null}
//
// <Link button="primary" label={this.state.confirming ? 'Confirming...' : 'Confirm'}
// onClick={this.handleConfirmClicked} />
// </section>
// );
// }
// });
//
// const RewardPage = React.createClass({
// propTypes: {
// name: React.PropTypes.string.isRequired,
// },
// _getRewardType: function() {
// lbryio.call('reward_type', 'get', this.props.name).then((rewardType) => {
// this.setState({
// rewardType: rewardType,
// });
// });
// },
// getInitialState: function() {
// return {
// rewardType: null,
// };
// },
// componentWillMount: function() {
// this._getRewardType();
// },
// render: function() {
// if (!this.state.rewardType) {
// return null;
// }
//
// let Reward;
// if (this.props.name == 'link_github') {
// Reward = LinkGithubReward;
// }
//
// const {title, description, value} = this.state.rewardType;
// return (
// <main>
// <section className="card">
// <h2>{title}</h2>
// <CreditAmount amount={value} />
// <p>{this.state.rewardType.claimed
// ? <span class="empty">This reward has been claimed.</span>
// : description}</p>
// <Reward onClaimed={this._getRewardType} />
// </section>
// </main>
// );
// }
// });
//
// export default RewardPage;

74
ui/js/page/rewards.js Normal file
View file

@ -0,0 +1,74 @@
import React from 'react';
import lbry from '../lbry.js';
import lbryio from '../lbryio.js';
import {CreditAmount, Icon} from '../component/common.js';
import rewards from '../rewards.js';
import Modal from '../component/modal.js';
import {WalletNav} from './wallet.js';
import {RewardLink} from '../component/link.js';
const RewardTile = React.createClass({
propTypes: {
type: React.PropTypes.string.isRequired,
title: React.PropTypes.string.isRequired,
description: React.PropTypes.string.isRequired,
claimed: React.PropTypes.bool.isRequired,
value: React.PropTypes.number.isRequired,
onRewardClaim: React.PropTypes.func
},
render: function() {
return (
<section className="card">
<div className="card__inner">
<div className="card__title-primary">
<CreditAmount amount={this.props.value} />
<h3>{this.props.title}</h3>
</div>
<div className="card__actions">
{this.props.claimed
? <span><Icon icon="icon-check" /> Reward claimed.</span>
: <RewardLink {...this.props} />}
</div>
<div className="card__content">{this.props.description}</div>
</div>
</section>
);
}
});
var RewardsPage = React.createClass({
componentWillMount: function() {
this.loadRewards()
},
getInitialState: function() {
return {
userRewards: null,
failed: null
};
},
loadRewards: function() {
lbryio.call('reward', 'list', {}).then((userRewards) => {
this.setState({
userRewards: userRewards,
});
}, () => {
this.setState({failed: true })
});
},
render: function() {
return (
<main className="main--single-column">
<WalletNav viewingPage="rewards"/>
<div>
{!this.state.userRewards
? (this.state.failed ? <div className="empty">Failed to load rewards.</div> : '')
: this.state.userRewards.map(({RewardType, RewardTitle, RewardDescription, TransactionID, RewardAmount}) => {
return <RewardTile key={RewardType} onRewardClaim={this.loadRewards} type={RewardType} title={RewardTitle} description={RewardDescription} claimed={!!TransactionID} value={RewardAmount} />;
})}
</div>
</main>
);
}
});
export default RewardsPage;

165
ui/js/page/search.js Normal file
View file

@ -0,0 +1,165 @@
import React from 'react';
import lbry from '../lbry.js';
import lbryio from '../lbryio.js';
import lbryuri from '../lbryuri.js';
import lighthouse from '../lighthouse.js';
import {FileTile, FileTileStream} from '../component/file-tile.js';
import {Link} from '../component/link.js';
import {ToolTip} from '../component/tooltip.js';
import {BusyMessage} from '../component/common.js';
var SearchNoResults = React.createClass({
render: function() {
return <section>
<span className="empty">
No one has checked anything in for {this.props.query} yet.
<Link label="Be the first" href="?publish" />
</span>
</section>;
}
});
var SearchResultList = React.createClass({
render: function() {
var rows = [],
seenNames = {}; //fix this when the search API returns claim IDs
for (let {name, claim, claim_id, channel_name, channel_id, txid, nout} of this.props.results) {
const uri = lbryuri.build({
channelName: channel_name,
contentName: name,
claimId: channel_id || claim_id,
});
rows.push(
<FileTileStream key={uri} uri={uri} outpoint={txid + ':' + nout} metadata={claim.stream.metadata} contentType={claim.stream.source.contentType} />
);
}
return (
<div>{rows}</div>
);
}
});
let SearchResults = React.createClass({
propTypes: {
query: React.PropTypes.string.isRequired
},
_isMounted: false,
search: function(term) {
lighthouse.search(term).then(this.searchCallback);
if (!this.state.searching) {
this.setState({ searching: true })
}
},
componentWillMount: function () {
this._isMounted = true;
this.search(this.props.query);
},
componentWillReceiveProps: function (nextProps) {
if (nextProps.query != this.props.query) {
this.search(nextProps.query);
}
},
componentWillUnmount: function () {
this._isMounted = false;
},
getInitialState: function () {
return {
results: [],
searching: true
};
},
searchCallback: function (results) {
if (this._isMounted) //could have canceled while results were pending, in which case nothing to do
{
this.setState({
results: results,
searching: false //multiple searches can be out, we're only done if we receive one we actually care about
});
}
},
render: function () {
return this.state.searching ?
<BusyMessage message="Looking up the Dewey Decimals" /> :
(this.state.results && this.state.results.length ?
<SearchResultList results={this.state.results} /> :
<SearchNoResults query={this.props.query} />);
}
});
let SearchPage = React.createClass({
_isMounted: false,
propTypes: {
query: React.PropTypes.string.isRequired
},
isValidUri: function(query) {
try {
lbryuri.parse(query);
return true;
} catch (e) {
return false;
}
},
componentWillMount: function() {
this._isMounted = true;
lighthouse.search(this.props.query).then(this.searchCallback);
},
componentWillUnmount: function() {
this._isMounted = false;
},
getInitialState: function() {
return {
results: [],
searching: true
};
},
searchCallback: function(results) {
if (this._isMounted) //could have canceled while results were pending, in which case nothing to do
{
this.setState({
results: results,
searching: false //multiple searches can be out, we're only done if we receive one we actually care about
});
}
},
render: function() {
return (
<main className="main--single-column">
{ this.isValidUri(this.props.query) ?
<section className="section-spaced">
<h3 className="card-row__header">
Exact URL
<ToolTip label="?" body="This is the resolution of a LBRY URL and not controlled by LBRY Inc." className="tooltip--header"/>
</h3>
<FileTile uri={this.props.query} showEmpty={true} />
</section> : '' }
<section className="section-spaced">
<h3 className="card-row__header">
Search Results for {this.props.query}
<ToolTip label="?" body="These search results are provided by LBRY Inc." className="tooltip--header"/>
</h3>
<SearchResults query={this.props.query} />
</section>
</main>
);
}
});
export default SearchPage;

View file

@ -1,52 +1,63 @@
import React from 'react';
import {FormField, FormRow} from '../component/form.js';
import {SubHeader} from '../component/header.js';
import lbry from '../lbry.js';
var settingsRadioOptionStyles = {
display: 'block',
marginLeft: '13px'
}, settingsCheckBoxOptionStyles = {
display: 'block',
marginLeft: '13px'
}, settingsNumberFieldStyles = {
width: '40px'
}, downloadDirectoryLabelStyles = {
fontSize: '.9em',
marginLeft: '13px'
}, downloadDirectoryFieldStyles= {
width: '300px'
};
export let SettingsNav = React.createClass({
render: function() {
return <SubHeader modifier="constrained" viewingPage={this.props.viewingPage} links={{
'?settings': 'Settings',
'?help' : 'Help'
}} />;
}
});
var SettingsPage = React.createClass({
_onSettingSaveSuccess: function() {
// This is bad.
// document.dispatchEvent(new CustomEvent('globalNotice', {
// detail: {
// message: "Settings saved",
// },
// }))
},
setDaemonSetting: function(name, value) {
lbry.setDaemonSetting(name, value, this._onSettingSaveSuccess)
},
setClientSetting: function(name, value) {
lbry.setClientSetting(name, value)
this._onSettingSaveSuccess()
},
onRunOnStartChange: function (event) {
lbry.setDaemonSetting('run_on_startup', event.target.checked);
this.setDaemonSetting('run_on_startup', event.target.checked);
},
onShareDataChange: function (event) {
lbry.setDaemonSetting('share_debug_info', event.target.checked);
this.setDaemonSetting('share_usage_data', event.target.checked);
},
onDownloadDirChange: function(event) {
lbry.setDaemonSetting('download_directory', event.target.value);
this.setDaemonSetting('download_directory', event.target.value);
},
onMaxUploadPrefChange: function(isLimited) {
if (!isLimited) {
lbry.setDaemonSetting('max_upload', 0.0);
this.setDaemonSetting('max_upload', 0.0);
}
this.setState({
isMaxUpload: isLimited
});
},
onMaxUploadFieldChange: function(event) {
lbry.setDaemonSetting('max_upload', Number(event.target.value));
this.setDaemonSetting('max_upload', Number(event.target.value));
},
onMaxDownloadPrefChange: function(isLimited) {
if (!isLimited) {
lbry.setDaemonSetting('max_download', 0.0);
this.setDaemonSetting('max_download', 0.0);
}
this.setState({
isMaxDownload: isLimited
});
},
onMaxDownloadFieldChange: function(event) {
lbry.setDaemonSetting('max_download', Number(event.target.value));
this.setDaemonSetting('max_download', Number(event.target.value));
},
getInitialState: function() {
return {
@ -55,100 +66,144 @@ var SettingsPage = React.createClass({
showUnavailable: lbry.getClientSetting('showUnavailable'),
}
},
componentDidMount: function() {
document.title = "Settings";
},
componentWillMount: function() {
lbry.getDaemonSettings(function(settings) {
lbry.getDaemonSettings((settings) => {
this.setState({
daemonSettings: settings,
isMaxUpload: settings.max_upload != 0,
isMaxDownload: settings.max_download != 0
});
}.bind(this));
});
},
onShowNsfwChange: function(event) {
lbry.setClientSetting('showNsfw', event.target.checked);
},
onShowUnavailableChange: function(event) {
lbry.setClientSetting('showUnavailable', event.target.checked);
},
render: function() {
if (!this.state.daemonSettings) {
return null;
}
return (
<main>
/*
<section className="card">
<div className="card__content">
<h3>Run on Startup</h3>
<label style={settingsCheckBoxOptionStyles}>
<input type="checkbox" onChange={this.onRunOnStartChange} defaultChecked={this.state.daemonSettings.run_on_startup} /> Run LBRY automatically when I start my computer
</label>
</div>
<div className="card__content">
<FormRow type="checkbox"
onChange={this.onRunOnStartChange}
defaultChecked={this.state.daemonSettings.run_on_startup}
label="Run LBRY automatically when I start my computer" />
</div>
</section>
*/
return (
<main className="main--single-column">
<SettingsNav viewingPage="settings" />
<section className="card">
<div className="card__content">
<h3>Download Directory</h3>
<div className="help">Where would you like the files you download from LBRY to be saved?</div>
<input style={downloadDirectoryFieldStyles} type="text" name="download_directory" defaultValue={this.state.daemonSettings.download_directory} onChange={this.onDownloadDirChange}/>
</div>
<div className="card__content">
<FormRow type="text"
name="download_directory"
defaultValue={this.state.daemonSettings.download_directory}
helper="LBRY downloads will be saved here."
onChange={this.onDownloadDirChange} />
</div>
</section>
<section className="card">
<div className="card__content">
<h3>Bandwidth Limits</h3>
<div className="form-row">
<h4>Max Upload</h4>
<label style={settingsRadioOptionStyles}>
<input type="radio" name="max_upload_pref" onChange={this.onMaxUploadPrefChange.bind(this, false)} defaultChecked={!this.state.isMaxUpload}/> Unlimited
</label>
<label style={settingsRadioOptionStyles}>
<input type="radio" name="max_upload_pref" onChange={this.onMaxUploadPrefChange.bind(this, true)} defaultChecked={this.state.isMaxUpload}/> { this.state.isMaxUpload ? 'Up to' : 'Choose limit...' }
<span className={ this.state.isMaxUpload ? '' : 'hidden'}> <input type="number" min="0" step=".5" defaultValue={this.state.daemonSettings.max_upload} style={settingsNumberFieldStyles} onChange={this.onMaxUploadFieldChange}/> MB/s</span>
</label>
</div>
<div className="card__content">
<div className="form-row__label-row"><div className="form-field__label">Max Upload</div></div>
<FormRow type="radio"
name="max_upload_pref"
onChange={() => { this.onMaxUploadPrefChange(false) }}
defaultChecked={!this.state.isMaxUpload}
label="Unlimited" />
<div className="form-row">
<h4>Max Download</h4>
<label style={settingsRadioOptionStyles}>
<input type="radio" name="max_download_pref" onChange={this.onMaxDownloadPrefChange.bind(this, false)} defaultChecked={!this.state.isMaxDownload}/> Unlimited
</label>
<label style={settingsRadioOptionStyles}>
<input type="radio" name="max_download_pref" onChange={this.onMaxDownloadPrefChange.bind(this, true)} defaultChecked={this.state.isMaxDownload}/> { this.state.isMaxDownload ? 'Up to' : 'Choose limit...' }
<span className={ this.state.isMaxDownload ? '' : 'hidden'}> <input type="number" min="0" step=".5" defaultValue={this.state.daemonSettings.max_download} style={settingsNumberFieldStyles} onChange={this.onMaxDownloadFieldChange}/> MB/s</span>
</label>
<FormField type="radio"
name="max_upload_pref"
onChange={() => { this.onMaxUploadPrefChange(true) }}
defaultChecked={this.state.isMaxUpload}
label={ this.state.isMaxUpload ? 'Up to' : 'Choose limit...' } />
{ this.state.isMaxUpload ?
<FormField type="number"
min="0"
step=".5"
defaultValue={this.state.daemonSettings.max_upload}
placeholder="10"
className="form-field__input--inline"
onChange={this.onMaxUploadFieldChange}
/>
: ''
}
{ this.state.isMaxUpload ? <span className="form-field__label">MB/s</span> : '' }
</div>
</div>
<div className="card__content">
<div className="form-row__label-row"><div className="form-field__label">Max Download</div></div>
<FormRow label="Unlimited"
type="radio"
name="max_download_pref"
onChange={() => { this.onMaxDownloadPrefChange(false) }}
defaultChecked={!this.state.isMaxDownload} />
<div className="form-row">
<FormField type="radio"
name="max_download_pref"
onChange={() => { this.onMaxDownloadPrefChange(true) }}
defaultChecked={this.state.isMaxDownload}
label={ this.state.isMaxDownload ? 'Up to' : 'Choose limit...' } />
{ this.state.isMaxDownload ?
<FormField type="number"
min="0"
step=".5"
defaultValue={this.state.daemonSettings.max_download}
placeholder="10"
className="form-field__input--inline"
onChange={this.onMaxDownloadFieldChange}
/>
: ''
}
{ this.state.isMaxDownload ? <span className="form-field__label">MB/s</span> : '' }
</div>
</div>
</section>
<section className="card">
<div className="card__content">
<h3>Content</h3>
<div className="form-row">
<label style={settingsCheckBoxOptionStyles}>
<input type="checkbox" onChange={this.onShowNsfwChange} defaultChecked={this.state.showNsfw} /> Show NSFW content
</label>
<div className="help">
NSFW content may include nudity, intense sexuality, profanity, or other adult content.
By displaying NSFW content, you are affirming you are of legal age to view mature content in your country or jurisdiction.
</div>
<div className="card__content">
<FormRow type="checkbox"
onChange={this.onShowUnavailableChange}
defaultChecked={this.state.showUnavailable}
label="Show unavailable content in search results" />
</div>
<div className="card__content">
<FormRow label="Show NSFW content" type="checkbox"
onChange={this.onShowNsfwChange} defaultChecked={this.state.showNsfw}
helper="NSFW content may include nudity, intense sexuality, profanity, or other adult content. By displaying NSFW content, you are affirming you are of legal age to view mature content in your country or jurisdiction. " />
</div>
</section>
<section className="card">
<h3>Search</h3>
<div className="form-row">
<div className="help">
Would you like search results to include items that are not currently available for download?
</div>
<label style={settingsCheckBoxOptionStyles}>
<input type="checkbox" onChange={this.onShowUnavailableChange} defaultChecked={this.state.showUnavailable} />
Show unavailable content in search results
</label>
</div>
</section>
<section className="card">
<div className="card__content">
<h3>Share Diagnostic Data</h3>
<label style={settingsCheckBoxOptionStyles}>
<input type="checkbox" onChange={this.onShareDataChange} defaultChecked={this.state.daemonSettings.share_debug_info} />
Help make LBRY better by contributing diagnostic data about my usage
</label>
</div>
<div className="card__content">
<FormRow type="checkbox"
onChange={this.onShareDataChange}
defaultChecked={this.state.daemonSettings.share_usage_data}
label="Help make LBRY better by contributing diagnostic data about my usage" />
</div>
</section>
</main>
);
}
});
export default SettingsPage;

View file

@ -1,55 +1,32 @@
import React from 'react';
import lbry from '../lbry.js';
import lighthouse from '../lighthouse.js';
import {CreditAmount, Thumbnail} from '../component/common.js';
import lbryuri from '../lbryuri.js';
import {Video} from '../page/watch.js'
import {TruncatedText, Thumbnail, FilePrice, BusyMessage} from '../component/common.js';
import {FileActions} from '../component/file-actions.js';
import {Link} from '../component/link.js';
var formatItemImgStyle = {
maxWidth: '100%',
maxHeight: '100%',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
marginTop: '5px',
};
import UriIndicator from '../component/channel-indicator.js';
var FormatItem = React.createClass({
propTypes: {
claimInfo: React.PropTypes.object,
cost: React.PropTypes.number,
name: React.PropTypes.string,
metadata: React.PropTypes.object,
contentType: React.PropTypes.string,
uri: React.PropTypes.string,
outpoint: React.PropTypes.string,
costIncludesData: React.PropTypes.bool,
},
render: function() {
var claimInfo = this.props.claimInfo;
var thumbnail = claimInfo.thumbnail;
var title = claimInfo.title;
var description = claimInfo.description;
var author = claimInfo.author;
var language = claimInfo.language;
var license = claimInfo.license;
var fileContentType = (claimInfo.content_type || claimInfo['content-type']);
var mediaType = lbry.getMediaType(fileContentType);
var costIncludesData = this.props.costIncludesData;
var cost = this.props.cost || 0.0;
const {author, language, license} = this.props.metadata;
if (!this.props.contentType && [author, language, license].filter((val) => {return !!val; }).length === 0) {
return null;
}
return (
<div className="row-fluid">
<div className="span4">
<Thumbnail src={thumbnail} alt={'Photo for ' + title} style={formatItemImgStyle} />
</div>
<div className="span8">
<p>{description}</p>
<section>
<table className="table-standard">
<tbody>
<tr>
<td>Content-Type</td><td>{fileContentType}</td>
</tr>
<tr>
<td>Cost</td><td><CreditAmount amount={cost} isEstimate={!costIncludesData}/></td>
<td>Content-Type</td><td>{this.props.contentType}</td>
</tr>
<tr>
<td>Author</td><td>{author}</td>
@ -62,105 +39,246 @@ var FormatItem = React.createClass({
</tr>
</tbody>
</table>
</section>
<FileActions streamName={this.props.name} outpoint={this.props.outpoint} metadata={claimInfo} />
<section>
<Link href="https://lbry.io/dmca" label="report" className="button-text-help" />
</section>
</div>
</div>
);
}
});
var FormatsSection = React.createClass({
propTypes: {
claimInfo: React.PropTypes.object,
cost: React.PropTypes.number,
name: React.PropTypes.string,
costIncludesData: React.PropTypes.bool,
},
let ChannelPage = React.createClass({
render: function() {
var name = this.props.name;
var format = this.props.claimInfo;
var title = format.title;
if(format == null)
{
return (
<div>
<h2>Sorry, no results found for "{name}".</h2>
</div>);
}
return (
<div>
<div className="meta">lbry://{name}</div>
<h2>{title}</h2>
{/* In future, anticipate multiple formats, just a guess at what it could look like
// var formats = this.props.claimInfo.formats
// return (<tbody>{formats.map(function(format,i){ */}
<FormatItem claimInfo={format} cost={this.props.cost} name={this.props.name} costIncludesData={this.props.costIncludesData} />
{/* })}</tbody>); */}
</div>);
return <main className="main--single-column">
<section className="card">
<div className="card__inner">
<div className="card__title-identity"><h1>{this.props.title}</h1></div>
</div>
<div className="card__content">
<p>
This channel page is a stub.
</p>
</div>
</section>
</main>
}
});
var DetailPage = React.createClass({
let FilePage = React.createClass({
_isMounted: false,
propTypes: {
name: React.PropTypes.string,
uri: React.PropTypes.string,
},
getInitialState: function() {
return {
metadata: null,
cost: null,
costIncludesData: null,
nameLookupComplete: null,
isDownloaded: null,
};
},
componentWillUnmount: function() {
this._isMounted = false;
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.outpoint != this.props.outpoint || nextProps.uri != this.props.uri) {
this.loadCostAndFileState(nextProps.uri, nextProps.outpoint);
}
},
componentWillMount: function() {
document.title = 'lbry://' + this.props.name;
this._isMounted = true;
this.loadCostAndFileState(this.props.uri, this.props.outpoint);
},
lbry.claim_show({name: this.props.name}, ({name, txid, nout, value}) => {
loadCostAndFileState: function(uri, outpoint) {
lbry.file_list({outpoint: outpoint}).then((fileInfo) => {
if (this._isMounted) {
this.setState({
outpoint: txid + ':' + nout,
metadata: value,
nameLookupComplete: true,
isDownloaded: fileInfo.length > 0,
});
}
});
lbry.getCostInfoForName(this.props.name, ({cost, includesData}) => {
lbry.getCostInfo(uri).then(({cost, includesData}) => {
if (this._isMounted) {
this.setState({
cost: cost,
costIncludesData: includesData,
});
}
});
},
render: function() {
if (this.state.metadata == null) {
return null;
}
const name = this.props.name;
const costIncludesData = this.state.costIncludesData;
const metadata = this.state.metadata;
const cost = this.state.cost;
const outpoint = this.state.outpoint;
render: function() {
const metadata = this.props.metadata,
title = metadata ? this.props.metadata.title : this.props.uri,
uriIndicator = <UriIndicator uri={this.props.uri} hasSignature={this.props.hasSignature} signatureIsValid={this.props.signatureIsValid} />;
return (
<main>
<section className="card">
{this.state.nameLookupComplete ? (
<FormatsSection name={name} outpoint={outpoint} claimInfo={metadata} cost={cost} costIncludesData={costIncludesData} />
) : (
<div>
<h2>No content</h2>
There is no content available at the name <strong>lbry://{this.props.name}</strong>. If you reached this page from a link within the LBRY interface, please <Link href="?report" label="report a bug" />. Thanks!
</div>
)}
<main className="main--single-column">
<section className="show-page-media">
{ this.props.contentType && this.props.contentType.startsWith('video/') ?
<Video className="video-embedded" uri={this.props.uri} metadata={metadata} outpoint={this.props.outpoint} /> :
(metadata ? <Thumbnail src={metadata.thumbnail} /> : <Thumbnail />) }
</section>
</main>);
<section className="card">
<div className="card__inner">
<div className="card__title-identity">
{this.state.isDownloaded === false
? <span style={{float: "right"}}><FilePrice uri={this.props.uri} metadata={metadata} /></span>
: null}
<h1>{title}</h1>
<div className="card__subtitle">
{ this.props.channelUri ?
<Link href={"?show=" + this.props.channelUri }>{uriIndicator}</Link> :
uriIndicator}
</div>
<div className="card__actions">
<FileActions uri={this.props.uri} outpoint={this.props.outpoint} metadata={metadata} contentType={this.props.contentType} />
</div>
</div>
<div className="card__content card__subtext card__subtext card__subtext--allow-newlines">
{metadata.description}
</div>
</div>
{ metadata ?
<div className="card__content">
<FormatItem metadata={metadata} contentType={this.state.contentType} cost={this.state.cost} uri={this.props.uri} outpoint={this.props.outpoint} costIncludesData={this.state.costIncludesData} />
</div> : '' }
<div className="card__content">
<Link href="https://lbry.io/dmca" label="report" className="button-text-help" />
</div>
</section>
</main>
);
}
});
export default DetailPage;
let ShowPage = React.createClass({
_uri: null,
_isMounted: false,
propTypes: {
uri: React.PropTypes.string,
},
getInitialState: function() {
return {
outpoint: null,
metadata: null,
contentType: null,
hasSignature: false,
claimType: null,
signatureIsValid: false,
cost: null,
costIncludesData: null,
uriLookupComplete: null,
isFailed: false,
};
},
componentWillUnmount: function() {
this._isMounted = false;
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.uri != this.props.uri) {
this.setState(this.getInitialState());
this.loadUri(nextProps.uri);
}
},
componentWillMount: function() {
this._isMounted = true;
this.loadUri(this.props.uri);
},
loadUri: function(uri) {
this._uri = lbryuri.normalize(uri);
lbry.resolve({uri: this._uri}).then((resolveData) => {
const isChannel = resolveData && resolveData.claims_in_channel;
if (!this._isMounted) {
return;
}
if (resolveData) {
let newState = { uriLookupComplete: true }
if (!isChannel) {
let {claim: {txid: txid, nout: nout, has_signature: has_signature, signature_is_valid: signature_is_valid, value: {stream: {metadata: metadata, source: {contentType: contentType}}}}} = resolveData;
Object.assign(newState, {
claimType: "file",
metadata: metadata,
outpoint: txid + ':' + nout,
hasSignature: has_signature,
signatureIsValid: signature_is_valid,
contentType: contentType
});
lbry.setTitle(metadata.title ? metadata.title : this._uri)
} else {
let {certificate: {txid: txid, nout: nout, has_signature: has_signature}} = resolveData;
Object.assign(newState, {
claimType: "channel",
outpoint: txid + ':' + nout,
txid: txid,
metadata: {
title:resolveData.certificate.name
}
});
}
this.setState(newState);
} else {
this.setState(Object.assign({}, this.getInitialState(), {
uriLookupComplete: true,
isFailed: true
}));
}
});
},
render: function() {
const metadata = this.state.metadata,
title = metadata ? this.state.metadata.title : this._uri;
let innerContent = "";
if (!this.state.uriLookupComplete || this.state.isFailed) {
innerContent = <section className="card">
<div className="card__inner">
<div className="card__title-identity"><h1>{title}</h1></div>
</div>
<div className="card__content">
{ this.state.uriLookupComplete ?
<p>This location is not yet in use. { ' ' }<Link href="?publish" label="Put something here" />.</p> :
<BusyMessage message="Loading magic decentralized data..." />
}
</div>
</section>;
} else if (this.state.claimType == "channel") {
innerContent = <ChannelPage title={this._uri} />
} else {
let channelUriObj = lbryuri.parse(this._uri)
delete channelUriObj.path;
delete channelUriObj.contentName;
const channelUri = this.state.signatureIsValid && this.state.hasSignature && channelUriObj.isChannel ? lbryuri.build(channelUriObj, false) : null;
innerContent = <FilePage
uri={this._uri}
channelUri={channelUri}
outpoint={this.state.outpoint}
metadata={metadata}
contentType={this.state.contentType}
hasSignature={this.state.hasSignature}
signatureIsValid={this.state.signatureIsValid}
/>;
}
return <main className="main--single-column">{innerContent}</main>;
}
});
export default ShowPage;

View file

@ -5,12 +5,9 @@ var StartPage = React.createClass({
componentWillMount: function() {
lbry.stop();
},
componentDidMount: function() {
document.title = "LBRY is Closed";
},
render: function() {
return (
<main className="page">
<main className="main--single-column">
<h3>LBRY is Closed</h3>
<Link href="lbry://lbry" label="Click here to start LBRY" />
</main>

View file

@ -2,12 +2,10 @@ import React from 'react';
import lbry from '../lbry.js';
import {Link} from '../component/link.js';
import Modal from '../component/modal.js';
import {SubHeader} from '../component/header.js';
import {FormField, FormRow} from '../component/form.js';
import {Address, BusyMessage, CreditAmount} from '../component/common.js';
var addressRefreshButtonStyle = {
fontSize: '11pt',
};
var AddressSection = React.createClass({
_refreshAddress: function(event) {
if (typeof event !== 'undefined') {
@ -60,12 +58,20 @@ var AddressSection = React.createClass({
render: function() {
return (
<section className="card">
<div className="card__title-primary">
<h3>Wallet Address</h3>
<Address address={this.state.address} /> <Link text="Get new address" icon='icon-refresh' onClick={this._getNewAddress} style={addressRefreshButtonStyle} />
<input type='submit' className='hidden' />
</div>
<div className="card__content">
<Address address={this.state.address} />
</div>
<div className="card__actions">
<Link label="Get New Address" button="primary" icon='icon-refresh' onClick={this._getNewAddress} />
</div>
<div className="card__content">
<div className="help">
<p>Other LBRY users may send credits to you by entering this address on the "Send" page.</p>
You can generate a new address at any time, and any previous addresses will continue to work. Using multiple addresses can be helpful for keeping track of incoming payments from multiple sources.
<p>You can generate a new address at any time, and any previous addresses will continue to work. Using multiple addresses can be helpful for keeping track of incoming payments from multiple sources.</p>
</div>
</div>
</section>
);
@ -143,26 +149,25 @@ var SendToAddressSection = React.createClass({
return (
<section className="card">
<form onSubmit={this.handleSubmit}>
<div className="card__title-primary">
<h3>Send Credits</h3>
<div className="form-row">
<label htmlFor="amount">Amount</label>
<input id="amount" type="text" size="10" onChange={this.setAmount}></input>
</div>
<div className="form-row">
<label htmlFor="address">Recipient address</label>
<input id="address" type="text" size="60" onChange={this.setAddress}></input>
<div className="card__content">
<FormRow label="Amount" postfix="LBC" step="0.01" type="number" placeholder="1.23" size="10" onChange={this.setAmount} />
</div>
<div className="form-row form-row-submit">
<div className="card__content">
<FormRow label="Recipient Address" placeholder="bbFxRyXXXXXXXXXXXZD8nE7XTLUxYnddTs" type="text" size="60" onChange={this.setAddress} />
</div>
<div className="card__actions card__actions--form-submit">
<Link button="primary" label="Send" onClick={this.handleSubmit} disabled={!(parseFloat(this.state.amount) > 0.0) || this.state.address == ""} />
<input type='submit' className='hidden' />
</div>
{
this.state.results ?
<div className="form-row">
<div className="card__content">
<h4>Results</h4>
{this.state.results}
</div>
: ''
</div> : ''
}
</form>
<Modal isOpen={this.state.modal === 'insufficientBalance'} contentLabel="Insufficient balance"
@ -231,7 +236,10 @@ var TransactionList = React.createClass({
}
return (
<section className="card">
<div className="card__title-primary">
<h3>Transaction History</h3>
</div>
<div className="card__content">
{ this.state.transactionItems === null ? <BusyMessage message="Loading transactions" /> : '' }
{ this.state.transactionItems && rows.length === 0 ? <div className="empty">You have no transactions.</div> : '' }
{ this.state.transactionItems && rows.length > 0 ?
@ -250,11 +258,22 @@ var TransactionList = React.createClass({
</table>
: ''
}
</div>
</section>
);
}
});
export let WalletNav = React.createClass({
render: function() {
return <SubHeader modifier="constrained" viewingPage={this.props.viewingPage} links={{
'?wallet': 'Overview',
'?send': 'Send',
'?receive': 'Receive',
'?rewards': 'Rewards'
}} />;
}
});
var WalletPage = React.createClass({
_balanceSubscribeId: null,
@ -262,9 +281,6 @@ var WalletPage = React.createClass({
propTypes: {
viewingPage: React.PropTypes.string,
},
componentDidMount: function() {
document.title = "My Wallet";
},
/*
Below should be refactored so that balance is shared all of wallet page. Or even broader?
What is the proper React pattern for sharing a global state like balance?
@ -288,11 +304,16 @@ var WalletPage = React.createClass({
},
render: function() {
return (
<main className="page">
<main className="main--single-column">
<WalletNav viewingPage={this.props.viewingPage} />
<section className="card">
<div className="card__title-primary">
<h3>Balance</h3>
</div>
<div className="card__content">
{ this.state.balance === null ? <BusyMessage message="Checking balance" /> : ''}
{ this.state.balance !== null ? <CreditAmount amount={this.state.balance} precision={8} /> : '' }
</div>
</section>
{ this.props.viewingPage === 'wallet' ? <TransactionList /> : '' }
{ this.props.viewingPage === 'send' ? <SendToAddressSection /> : '' }

View file

@ -1,39 +1,136 @@
import React from 'react';
import {Icon} from '../component/common.js';
import {Icon, Thumbnail, FilePrice} from '../component/common.js';
import {Link} from '../component/link.js';
import lbry from '../lbry.js';
import Modal from '../component/modal.js';
import lbryio from '../lbryio.js';
import rewards from '../rewards.js';
import LoadScreen from '../component/load_screen.js'
const fs = require('fs');
const VideoStream = require('videostream');
export let WatchLink = React.createClass({
propTypes: {
uri: React.PropTypes.string,
metadata: React.PropTypes.object,
downloadStarted: React.PropTypes.bool,
onGet: React.PropTypes.func,
},
getInitialState: function() {
affirmedPurchase: false
},
play: function() {
lbry.get({uri: this.props.uri}).then((streamInfo) => {
if (streamInfo === null || typeof streamInfo !== 'object') {
this.setState({
modal: 'timedOut',
attemptingDownload: false,
});
}
var WatchPage = React.createClass({
lbryio.call('file', 'view', {
uri: this.props.uri,
outpoint: streamInfo.outpoint,
claimId: streamInfo.claim_id
}).catch(() => {})
});
if (this.props.onGet) {
this.props.onGet()
}
},
onWatchClick: function() {
this.setState({
loading: true
});
lbry.getCostInfo(this.props.uri).then(({cost}) => {
lbry.getBalance((balance) => {
if (cost > balance) {
this.setState({
modal: 'notEnoughCredits',
attemptingDownload: false,
});
} else if (cost <= 0.01) {
this.play()
} else {
lbry.file_list({outpoint: this.props.outpoint}).then((fileInfo) => {
if (fileInfo) { // Already downloaded
this.play();
} else {
this.setState({
modal: 'affirmPurchase'
});
}
});
}
});
});
},
getInitialState: function() {
return {
modal: null,
loading: false,
};
},
closeModal: function() {
this.setState({
loading: false,
modal: null,
});
},
render: function() {
return (<div>
<Link button={ this.props.button ? this.props.button : null }
disabled={this.state.loading}
label={this.props.label ? this.props.label : ""}
className={this.props.className}
icon="icon-play"
onClick={this.onWatchClick} />
<Modal contentLabel="Not enough credits" isOpen={this.state.modal == 'notEnoughCredits'} onConfirmed={this.closeModal}>
You don't have enough LBRY credits to pay for this stream.
</Modal>
<Modal type="confirm" isOpen={this.state.modal == 'affirmPurchase'}
contentLabel="Confirm Purchase" onConfirmed={this.play} onAborted={this.closeModal}>
Are you sure you'd like to buy <strong>{this.props.metadata.title}</strong> for <strong><FilePrice uri={this.props.uri} metadata={this.props.metadata} label={false} look="plain" /></strong> credits?
</Modal>
</div>);
}
});
export let Video = React.createClass({
_isMounted: false,
_controlsHideDelay: 3000, // Note: this needs to be shorter than the built-in delay in Electron, or Electron will hide the controls before us
_controlsHideTimeout: null,
_outpoint: null,
propTypes: {
name: React.PropTypes.string,
uri: React.PropTypes.string.isRequired,
metadata: React.PropTypes.object,
outpoint: React.PropTypes.string,
},
getInitialState: function() {
return {
downloadStarted: false,
readyToPlay: false,
loadStatusMessage: "Requesting stream",
isPlaying: false,
isPurchased: false,
loadStatusMessage: "Requesting stream... it may sit here for like 15-20 seconds in a really awkward way... we're working on it",
mimeType: null,
controlsShown: false,
};
},
componentDidMount: function() {
lbry.get({name: this.props.name}).then((fileInfo) => {
this._outpoint = fileInfo.outpoint;
onGet: function() {
lbry.get({uri: this.props.uri}).then((fileInfo) => {
this.updateLoadStatus();
});
this.setState({
isPlaying: true
})
},
handleBackClicked: function() {
history.back();
componentDidMount: function() {
if (this.props.autoplay) {
this.start()
}
},
handleMouseMove: function() {
if (this._controlsTimeout) {
@ -68,7 +165,7 @@ var WatchPage = React.createClass({
},
updateLoadStatus: function() {
lbry.file_list({
outpoint: this._outpoint,
outpoint: this.props.outpoint,
full_status: true,
}).then(([status]) => {
if (!status || status.written_bytes == 0) {
@ -93,6 +190,9 @@ var WatchPage = React.createClass({
return fs.createReadStream(status.download_path, opts)
}
};
rewards.claimNextPurchaseReward()
var elem = this.refs.video;
var videostream = VideoStream(mediaFile, elem);
elem.play();
@ -101,26 +201,15 @@ var WatchPage = React.createClass({
},
render: function() {
return (
!this.state.readyToPlay
? <LoadScreen message={'Loading video...'} details={this.state.loadStatusMessage} />
: <main className="video full-screen" onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
<video controls width="100%" height="100%" id="video" ref="video"></video>
{this.state.controlsShown
? <div className="video__overlay">
<div className="video__back">
<Link icon="icon-arrow-circle-o-left" className="video__back-link" onClick={this.handleBackClicked}/>
<div className="video__back-label">
<Icon icon="icon-caret-left" className="video__back-label-arrow" />
<div className="video__back-label-content">
Back to LBRY
<div className={"video " + this.props.className + (this.state.isPlaying && this.state.readyToPlay ? " video--active" : " video--hidden")}>{
this.state.isPlaying ?
!this.state.readyToPlay ?
<span>this is the world's worst loading screen and we shipped our software with it anyway... <br/><br/>{this.state.loadStatusMessage}</span> :
<video controls id="video" ref="video"></video> :
<div className="video__cover" style={{backgroundImage: 'url("' + this.props.metadata.thumbnail + '")'}}>
<WatchLink className="video__play-button" uri={this.props.uri} metadata={this.props.metadata} outpoint={this.props.outpoint} onGet={this.onGet} icon="icon-play"></WatchLink>
</div>
</div>
</div>
</div>
: null}
</main>
}</div>
);
}
});
export default WatchPage;
})

124
ui/js/rewards.js Normal file
View file

@ -0,0 +1,124 @@
import lbry from './lbry.js';
import lbryio from './lbryio.js';
function rewardMessage(type, amount) {
return {
new_developer: `You earned ${amount} for registering as a new developer.`,
new_user: `You earned ${amount} LBC new user reward.`,
confirm_email: `You earned ${amount} LBC for verifying your email address.`,
new_channel: `You earned ${amount} LBC for creating a publisher identity.`,
first_stream: `You earned ${amount} LBC for streaming your first video.`,
many_downloads: `You earned ${amount} LBC for downloading some of the things.`,
first_publish: `You earned ${amount} LBC for making your first publication.`,
}[type];
}
const rewards = {};
rewards.TYPE_NEW_DEVELOPER = "new_developer",
rewards.TYPE_NEW_USER = "new_user",
rewards.TYPE_CONFIRM_EMAIL = "confirm_email",
rewards.TYPE_FIRST_CHANNEL = "new_channel",
rewards.TYPE_FIRST_STREAM = "first_stream",
rewards.TYPE_MANY_DOWNLOADS = "many_downloads",
rewards.TYPE_FIRST_PUBLISH = "first_publish";
rewards.claimReward = function (type) {
function requestReward(resolve, reject, params) {
if (!lbryio.enabled) {
reject(new Error("Rewards are not enabled."))
return;
}
lbryio.call('reward', 'new', params, 'post').then(({RewardAmount}) => {
const
message = rewardMessage(type, RewardAmount),
result = {
type: type,
amount: RewardAmount,
message: message
};
// Display global notice
document.dispatchEvent(new CustomEvent('globalNotice', {
detail: {
message: message,
linkText: "Show All",
linkTarget: "?rewards",
isError: false,
},
}));
// Add more events here to display other places
resolve(result);
}, reject);
}
return new Promise((resolve, reject) => {
lbry.wallet_new_address().then((address) => {
const params = {
reward_type: type,
wallet_address: address,
};
switch (type) {
case rewards.TYPE_FIRST_CHANNEL:
lbry.claim_list_mine().then(function(claims) {
let claim = claims.find(function(claim) {
return claim.name.length && claim.name[0] == '@' && claim.txid.length
})
if (claim) {
params.transaction_id = claim.txid;
requestReward(resolve, reject, params)
} else {
reject(new Error("Please create a channel identity first."))
}
}).catch(reject)
break;
case rewards.TYPE_FIRST_PUBLISH:
lbry.claim_list_mine().then((claims) => {
let claim = claims.find(function(claim) {
return claim.name.length && claim.name[0] != '@' && claim.txid.length
})
if (claim) {
params.transaction_id = claim.txid
requestReward(resolve, reject, params)
} else {
reject(claims.length ?
new Error("Please publish something and wait for confirmation by the network to claim this reward.") :
new Error("Please publish something to claim this reward."))
}
}).catch(reject)
break;
case rewards.TYPE_FIRST_STREAM:
case rewards.TYPE_NEW_USER:
default:
requestReward(resolve, reject, params);
}
});
});
}
rewards.claimNextPurchaseReward = function() {
let types = {}
types[rewards.TYPE_FIRST_STREAM] = false
types[rewards.TYPE_MANY_DOWNLOADS] = false
lbryio.call('reward', 'list', {}).then((userRewards) => {
userRewards.forEach((reward) => {
if (types[reward.RewardType] === false && reward.TransactionID) {
types[reward.RewardType] = true
}
})
let unclaimedType = Object.keys(types).find((type) => {
return types[type] === false;
})
if (unclaimedType) {
rewards.claimReward(unclaimedType);
}
}, () => { });
}
export default rewards;

View file

@ -1,118 +0,0 @@
const CHANNEL_NAME_MIN_LEN = 4;
const CLAIM_ID_MAX_LEN = 40;
const uri = {};
/**
* Parses a LBRY name into its component parts. Throws errors with user-friendly
* messages for invalid names.
*
* Returns a dictionary with keys:
* - name (string)
* - properName (string; strips off @ for channels)
* - isChannel (boolean)
* - claimSequence (int, if present)
* - bidPosition (int, if present)
* - claimId (string, if present)
* - path (string, if persent)
*/
uri.parseLbryUri = function(lbryUri, requireProto=false) {
// Break into components. Empty sub-matches are converted to null
const componentsRegex = new RegExp(
'^((?:lbry:\/\/)?)' + // protocol
'([^:$#/]*)' + // name (stops at the first separator or end)
'([:$#]?)([^/]*)' + // modifier separator, modifier (stops at the first path separator or end)
'(/?)(.*)' // path separator, path
);
const [proto, name, modSep, modVal, pathSep, path] = componentsRegex.exec(lbryUri).slice(1).map(match => match || null);
// Validate protocol
if (requireProto && !proto) {
throw new Error('LBRY URIs must include a protocol prefix (lbry://).');
}
// Validate and process name
if (!name) {
throw new Error('URI does not include name.');
}
const isChannel = name[0] == '@';
const properName = isChannel ? name.substr(1) : name;
if (isChannel) {
if (!properName) {
throw new Error('No channel name after @.');
}
if (properName.length < CHANNEL_NAME_MIN_LEN) {
throw new Error(`Channel names must be at least ${CHANNEL_NAME_MIN_LEN} characters.`);
}
}
const nameBadChars = properName.match(/[^A-Za-z0-9-]/g);
if (nameBadChars) {
throw new Error(`Invalid character${nameBadChars.length == 1 ? '' : 's'} in name: ${nameBadChars.join(', ')}.`);
}
// Validate and process modifier (claim ID, bid position or claim sequence)
let claimId, claimSequence, bidPosition;
if (modSep) {
if (!modVal) {
throw new Error(`No modifier provided after separator ${modSep}.`);
}
if (modSep == '#') {
claimId = modVal;
} else if (modSep == ':') {
claimSequence = modVal;
} else if (modSep == '$') {
bidPosition = modVal;
}
}
if (claimId && (claimId.length > CLAIM_ID_MAX_LEN || !claimId.match(/^[0-9a-f]+$/))) {
throw new Error(`Invalid claim ID ${claimId}.`);
}
if (bidPosition && !bidPosition.match(/^-?[1-9][0-9]+$/)) {
throw new Error('Bid position must be a number.');
}
if (claimSequence && !claimSequence.match(/^-?[1-9][0-9]+$/)) {
throw new Error('Claim sequence must be a number.');
}
// Validate path
if (path) {
if (!isChannel) {
throw new Error('Only channel URIs may have a path.');
}
const pathBadChars = path.match(/[^A-Za-z0-9-]/g);
if (pathBadChars) {
throw new Error(`Invalid character${count == 1 ? '' : 's'} in path: ${nameBadChars.join(', ')}`);
}
} else if (pathSep) {
throw new Error('No path provided after /');
}
return {
name, properName, isChannel,
... claimSequence ? {claimSequence: parseInt(claimSequence)} : {},
... bidPosition ? {bidPosition: parseInt(bidPosition)} : {},
... claimId ? {claimId} : {},
... path ? {path} : {},
};
}
uri.buildLbryUri = function(uriObj, includeProto=true) {
const {name, claimId, claimSequence, bidPosition, path} = uriObj;
return (includeProto ? 'lbry://' : '') + name +
(claimId ? `#${claimId}` : '') +
(claimSequence ? `:${claimSequence}` : '') +
(bidPosition ? `\$${bidPosition}` : '') +
(path ? `/${path}` : '');
}
export default uri;

View file

@ -2,9 +2,9 @@
* Thin wrapper around localStorage.getItem(). Parses JSON and returns undefined if the value
* is not set yet.
*/
export function getLocal(key) {
export function getLocal(key, fallback=undefined) {
const itemRaw = localStorage.getItem(key);
return itemRaw === null ? undefined : JSON.parse(itemRaw);
return itemRaw === null ? fallback : JSON.parse(itemRaw);
}
/**
@ -13,3 +13,19 @@ export function getLocal(key) {
export function setLocal(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
/**
* Thin wrapper around localStorage.getItem(). Parses JSON and returns undefined if the value
* is not set yet.
*/
export function getSession(key, fallback=undefined) {
const itemRaw = sessionStorage.getItem(key);
return itemRaw === null ? fallback : JSON.parse(itemRaw);
}
/**
* Thin wrapper around localStorage.setItem(). Converts value to JSON.
*/
export function setSession(key, value) {
sessionStorage.setItem(key, JSON.stringify(value));
}

View file

@ -1,6 +1,6 @@
{
"name": "lbry-web-ui",
"version": "0.9.2rc15",
"version": "0.10.0",
"description": "LBRY UI",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@ -21,7 +21,6 @@
"babel-cli": "^6.11.4",
"babel-preset-es2015": "^6.13.2",
"babel-preset-react": "^6.11.1",
"clamp-js-main": "^0.11.1",
"mediaelement": "^2.23.4",
"node-sass": "^3.8.0",
"rc-progress": "^2.0.6",

View file

@ -1,246 +0,0 @@
@import "global";
html
{
height: 100%;
font-size: $font-size;
}
body
{
font-family: 'Source Sans Pro', sans-serif;
line-height: $font-line-height;
}
$drawer-width: 240px;
#drawer
{
width: $drawer-width;
position: fixed;
min-height: 100vh;
left: 0;
top: 0;
background: $color-bg;
z-index: 3;
.drawer-item
{
display: block;
padding: $spacing-vertical / 2;
font-size: 1.2em;
height: $spacing-vertical * 1.5;
.icon
{
margin-right: 6px;
}
.link-label
{
line-height: $spacing-vertical * 1.5;
}
.badge
{
float: right;
background: $color-money;
display: inline-block;
padding: 2px;
color: white;
margin-top: $spacing-vertical * 0.25 - 2;
border-radius: 2px;
}
}
.drawer-item-selected
{
background: $color-canvas;
color: $color-primary;
}
}
#drawer-handle
{
padding: $spacing-vertical / 2;
max-height: $height-header - $spacing-vertical;
text-align: center;
}
#window.drawer-closed
{
#drawer { display: none }
}
#window.drawer-open
{
#main-content { margin-left: $drawer-width; }
.open-drawer-link { display: none }
#header { padding-left: $drawer-width + $spacing-vertical / 2; }
}
#header
{
background: $color-primary;
color: white;
&.header-no-subnav {
height: $height-header;
}
&.header-with-subnav {
height: $height-header * 2;
}
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 2;
box-sizing: border-box;
h1 { font-size: 1.8em; line-height: $height-header - $spacing-vertical; display: inline-block; float: left; }
&.header-scrolled
{
box-shadow: $default-box-shadow;
}
}
.header-top-bar
{
padding: $spacing-vertical / 2;
}
.header-search
{
margin-left: 60px;
text-align: center;
input[type="search"] {
background: rgba(255, 255, 255, 0.3);
color: white;
width: 400px;
@include placeholder-color(#e8e8e8);
}
}
nav.sub-header
{
background: $color-primary;
text-transform: uppercase;
padding: $spacing-vertical / 2;
> a
{
$sub-header-selected-underline-height: 2px;
display: inline-block;
margin: 0 15px;
padding: 0 5px;
line-height: $height-header - $spacing-vertical - $sub-header-selected-underline-height;
color: #e8e8e8;
&:first-child
{
margin-left: 0;
}
&:last-child
{
margin-right: 0;
}
&.sub-header-selected
{
border-bottom: $sub-header-selected-underline-height solid #fff;
color: #fff;
}
&:hover
{
color: #fff;
}
}
}
#main-content
{
background: $color-canvas;
&.no-sub-nav
{
min-height: calc(100vh - 60px); //should be -$height-header, but I'm dumb I guess? It wouldn't work
main { margin-top: $height-header; }
}
&.with-sub-nav
{
min-height: calc(100vh - 120px); //should be -$height-header, but I'm dumb I guess? It wouldn't work
main { margin-top: $height-header * 2; }
}
main
{
padding: $spacing-vertical;
}
h2
{
margin-bottom: $spacing-vertical;
}
h3, h4
{
margin-bottom: $spacing-vertical / 2;
margin-top: $spacing-vertical;
&:first-child
{
margin-top: 0;
}
}
.meta
{
+ h2, + h3, + h4
{
margin-top: 0;
}
}
}
$header-icon-size: 1.5em;
.open-drawer-link, .close-drawer-link
{
display: inline-block;
font-size: $header-icon-size;
padding: 2px 6px 0 6px;
float: left;
}
.close-lbry-link
{
font-size: $header-icon-size;
float: right;
padding: 0 6px 0 18px;
}
.card {
margin-left: auto;
margin-right: auto;
max-width: 800px;
padding: $spacing-vertical;
background: $color-bg;
box-shadow: $default-box-shadow;
border-radius: 2px;
}
.card-obscured
{
position: relative;
}
.card-obscured .card-content {
-webkit-filter: blur($blur-intensity);
-moz-filter: blur($blur-intensity);
-o-filter: blur($blur-intensity);
-ms-filter: blur($blur-intensity);
filter: blur($blur-intensity);
}
.card-overlay {
position: absolute;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
padding: 20px;
background-color: rgba(128, 128, 128, 0.8);
color: #fff;
display: flex;
align-items: center;
font-weight: 600;
}
.card-series-submit
{
margin-left: auto;
margin-right: auto;
max-width: 800px;
padding: $spacing-vertical / 2;
}
.full-screen
{
width: 100%;
height: 100%;
}

View file

@ -6,17 +6,20 @@ $padding-button: 12px;
$padding-text-link: 4px;
$color-primary: #155B4A;
$color-primary-light: saturate(lighten($color-primary, 50%), 20%);
$color-light-alt: hsl(hue($color-primary), 15, 85);
$color-text-dark: #000;
$color-black-transparent: rgba(32,32,32,0.9);
$color-help: rgba(0,0,0,.6);
$color-notice: #921010;
$color-warning: #ffffff;
$color-notice: #8a6d3b;
$color-error: #a94442;
$color-load-screen-text: #c3c3c3;
$color-canvas: #f5f5f5;
$color-bg: #ffffff;
$color-bg-alt: #D9D9D9;
$color-money: #216C2A;
$color-meta-light: #505050;
$color-form-border: rgba(160,160,160,.5);
$font-size: 16px;
$font-line-height: 1.3333;
@ -25,10 +28,16 @@ $mobile-width-threshold: 801px;
$max-content-width: 1000px;
$max-text-width: 660px;
$width-page-constrained: 800px;
$height-header: $spacing-vertical * 2.5;
$height-button: $spacing-vertical * 1.5;
$height-video-embedded: $width-page-constrained * 9 / 16;
$default-box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
$box-shadow-layer: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
$box-shadow-focus: 2px 4px 4px 0 rgba(0,0,0,.14),2px 5px 3px -2px rgba(0,0,0,.2),2px 3px 7px 0 rgba(0,0,0,.12);
$transition-standard: .225s ease;
$blur-intensity: 8px;
@ -152,3 +161,34 @@ $blur-intensity: 8px;
height:1px;
overflow:hidden;
}
@mixin text-link($color: $color-primary, $hover-opacity: 0.70) {
.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: $hover-opacity;
transition: opacity $transition-standard;
text-decoration: underline;
.icon {
text-decoration: none;
}
}
color: $color;
cursor: pointer;
}

View file

@ -1,87 +0,0 @@
@import "global";
$gutter_fluid: 4;
[class*="span"] {
min-height: 1px;
max-width: 100%;
}
.span12 { width: 100%; }
.span11 { width: 91.666%; }
.span10 { width: 83.333%; }
.span9 { width: 75%; }
.span8 { width: 66.666%; }
.span7 { width: 58.333%; }
.span6 { width: 50%; }
.span5 { width: 41.666%; }
.span4 { width: 33.333%; }
.span3 { width: 25%; }
.span2 { width: 16.666%; }
.span1 { width: 8.333%; }
.row-fluid {
width: 100%;
> [class*="span"] {
float: left;
width: 100%;
margin-left: 1% * $gutter_fluid;
&:first-child
{
margin-left: 0;
}
}
$column_width: (100% - $gutter_fluid * 11) / 12;
> .span12 { width: $column_width * 12 + $gutter_fluid * 11; }
> .span11 { width: $column_width * 11 + $gutter_fluid * 10; }
> .span10 { width: $column_width * 10 + $gutter_fluid * 9; }
> .span9 { width: $column_width * 9 + $gutter_fluid * 8; }
> .span8 { width: $column_width * 8 + $gutter_fluid * 7; }
> .span7 { width: $column_width * 7 + $gutter_fluid * 6; }
> .span6 { width: $column_width * 6 + $gutter_fluid * 5; }
> .span5 { width: $column_width * 5 + $gutter_fluid * 4; }
> .span4 { width: $column_width * 4 + $gutter_fluid * 3; }
> .span3 { width: $column_width * 3 + $gutter_fluid * 2; }
> .span2 { width: $column_width * 2 + $gutter_fluid * 1; }
> .span1 { width: $column_width; }
}
.tile-fluid {
width: 100%;
> [class*="span"] {
float: left;
}
}
.column-fluid {
@include display-flex();
flex-wrap: wrap;
> [class*="span"] {
@include display-flex();
@include flex(1 0 auto);
overflow: hidden;
justify-content: center;
}
}
.row-fluid, .tile-fluid {
@include clearfix();
}
@media (max-width: $mobile-width-threshold) {
.row-fluid, .tile-fluid, .column-fluid {
width: 100%;
}
.pull-left, .pull-right
{
float: none;
}
[class*="span"] {
float: none !important;
width: 100% !important;
margin-left: 0 !important;
display: block !important;
}
}

View file

@ -1,35 +1,51 @@
@import "global";
@mixin text-link($color: $color-primary, $hover-opacity: 0.70) {
.icon
html
{
&:first-child {
padding-right: 5px;
height: 100%;
font-size: $font-size;
}
&:last-child:not(:only-child) {
padding-left: 5px;
}
}
&:not(.no-underline) {
text-decoration: underline;
.icon {
text-decoration: none;
}
}
&:hover
body
{
opacity: $hover-opacity;
transition: opacity .225s ease;
text-decoration: underline;
.icon {
text-decoration: none;
}
font-family: 'Source Sans Pro', sans-serif;
line-height: $font-line-height;
}
color: $color;
cursor: pointer;
#window
{
min-height: 100vh;
background: $color-canvas;
}
.badge
{
background: $color-money;
display: inline-block;
padding: 2px;
color: white;
border-radius: 2px;
}
.credit-amount--indicator
{
font-weight: bold;
color: $color-money;
}
#main-content
{
padding: $spacing-vertical;
margin-top: $height-header;
display: flex;
flex-direction: column;
main {
margin-left: auto;
margin-right: auto;
max-width: 100%;
}
main.main--single-column
{
width: $width-page-constrained;
}
}
.icon-fixed-width {
@ -38,26 +54,6 @@
text-align: center;
}
section
{
margin-bottom: $spacing-vertical;
&:last-child
{
margin-bottom: 0;
}
&:only-child {
/* If it's an only child, assume it's part of a React layout that will handle the last child condition on its own */
margin-bottom: $spacing-vertical;
}
}
main h1 {
font-size: 2.0em;
margin-bottom: $spacing-vertical;
margin-top: $spacing-vertical*2;
font-family: 'Raleway', sans-serif;
}
h2 {
font-size: 1.75em;
}
@ -76,11 +72,6 @@ sup, sub {
sup { top: -0.4em; }
sub { top: 0.4em; }
label {
cursor: default;
display: block;
}
code {
font: 0.8em Consolas, 'Lucida Console', 'Source Sans', monospace;
background-color: #eee;
@ -104,25 +95,11 @@ p
opacity: 0.7;
}
input[type="text"], input[type="search"], textarea
{
@include placeholder {
color: lighten($color-text-dark, 60%);
}
border: 2px solid rgba(160,160,160,.5);
padding-left: 5px;
padding-right: 5px;
box-sizing: border-box;
-webkit-appearance: none;
}
input[type="text"], input[type="search"]
{
line-height: $spacing-vertical - 4;
height: $spacing-vertical * 1.5;
}
.truncated-text {
display: inline-block;
//display: inline-block;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
}
.busy-indicator
@ -144,75 +121,6 @@ input[type="text"], input[type="search"]
}
}
.button-set-item {
position: relative;
display: inline-block;
+ .button-set-item
{
margin-left: $padding-button;
}
}
.button-block, .faux-button-block
{
display: inline-block;
height: $height-button;
line-height: $height-button;
text-decoration: none;
border: 0 none;
text-align: center;
border-radius: 2px;
text-transform: uppercase;
.icon
{
top: 0em;
}
.icon:first-child
{
padding-right: 5px;
}
.icon:last-child
{
padding-left: 5px;
}
}
.button-block
{
cursor: pointer;
}
.button__content {
margin: 0 $padding-button;
}
.button-primary
{
color: white;
background-color: $color-primary;
box-shadow: $default-box-shadow;
}
.button-alt
{
background-color: $color-bg-alt;
box-shadow: $default-box-shadow;
}
.button-text
{
@include text-link();
display: inline-block;
.button__content {
margin: 0 $padding-text-link;
}
}
.button-text-help
{
@include text-link(#aaa);
font-size: 0.8em;
}
.icon:only-child {
position: relative;
top: 0.16em;
@ -235,169 +143,17 @@ input[type="text"], input[type="search"]
font-style: italic;
}
.form-row
{
+ .form-row
{
margin-top: $spacing-vertical / 2;
}
.help
{
margin-top: $spacing-vertical / 2;
}
+ .form-row-submit
{
margin-top: $spacing-vertical;
}
}
.form-field-container {
display: inline-block;
}
.form-field--text {
width: 330px;
}
.form-field--text-number {
width: 50px;
}
.form-field-advice-container {
position: relative;
}
.form-field-advice {
position: absolute;
top: 0px;
left: 0px;
display: flex;
flex-direction: column;
white-space: nowrap;
transition: opacity 400ms ease-in;
}
.form-field-advice--fading {
opacity: 0;
}
.form-field-advice__arrow {
text-align: left;
padding-left: 18px;
font-size: 22px;
line-height: 0.3;
color: darken($color-primary, 5%);
}
.form-field-advice__content-container {
display: inline-block;
}
.form-field-advice__content {
display: inline-block;
padding: 5px;
border-radius: 2px;
background-color: darken($color-primary, 5%);
color: #fff;
}
.form-field-label {
width: 118px;
text-align: right;
vertical-align: top;
display: inline-block;
}
.sort-section {
display: block;
margin-bottom: 5px;
margin-bottom: $spacing-vertical * 2/3;
text-align: right;
line-height: 1;
font-size: 0.85em;
color: $color-help;
}
.modal-overlay {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
background-color: rgba(255, 255, 255, 0.74902);
z-index: 9999;
}
.modal {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 1px solid rgb(204, 204, 204);
background: rgb(255, 255, 255);
overflow: auto;
border-radius: 4px;
outline: none;
padding: 36px;
max-width: 250px;
}
.modal__header {
margin-bottom: 5px;
text-align: center;
}
.modal__buttons {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 15px;
}
.modal__button {
margin: 0px 6px;
}
.error-modal-overlay {
background: rgba(#000, .88);
}
.error-modal__content {
display: flex;
padding: 0px 8px 10px 10px;
}
.error-modal__warning-symbol {
margin-top: 6px;
margin-right: 7px;
}
.download-started-modal__file-path {
word-break: break-all;
}
.error-modal {
max-width: none;
width: 400px;
}
.error-modal__error-list { /*shitty hack/temp fix for long errors making modals unusable*/
border: 1px solid #eee;
padding: 8px;
list-style: none;
max-height: 400px;
max-width: 400px;
overflow-y: hidden;
section.section-spaced {
margin-bottom: $spacing-vertical;
}

View file

@ -25,12 +25,6 @@
transform: translate(0, 0);
}
.icon-mega
{
font-size: 200px;
line-height: 1;
}
/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
readers do not read off random characters that represent icons */
.icon-glass:before {

View file

@ -3,10 +3,14 @@ body, div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6, pre, code, form, fiel
margin:0;
padding:0;
}
input:focus, textarea:focus
:focus
{
outline: 0;
}
input::-webkit-search-cancel-button {
/* Remove default */
-webkit-appearance: none;
}
table
{
border-collapse: collapse;
@ -30,6 +34,7 @@ input, textarea, select
font-family:inherit;
font-size:inherit;
font-weight:inherit;
border: 0 none;
}
img {
width: auto\9;

View file

@ -1,15 +1,24 @@
@import "_reset";
@import "_grid";
@import "_icons";
@import "_mediaelement";
@import "_canvas";
@import "_gui";
@import "component/_table";
@import "component/_button.scss";
@import "component/_card.scss";
@import "component/_file-actions.scss";
@import "component/_file-tile.scss";
@import "component/_form-field.scss";
@import "component/_header.scss";
@import "component/_menu.scss";
@import "component/_tooltip.scss";
@import "component/_load-screen.scss";
@import "component/_channel-indicator.scss";
@import "component/_notice.scss";
@import "component/_modal.scss";
@import "component/_modal-page.scss";
@import "component/_snack-bar.scss";
@import "component/_video.scss";
@import "page/_developer.scss";
@import "page/_watch.scss";
@import "page/_reward.scss";
@import "page/_show.scss";

View file

@ -0,0 +1,87 @@
@import "../global";
$button-focus-shift: 12%;
.button-set-item {
position: relative;
display: inline-block;
+ .button-set-item
{
margin-left: $padding-button;
}
}
.button-block, .faux-button-block
{
display: inline-block;
height: $height-button;
line-height: $height-button;
text-decoration: none;
border: 0 none;
text-align: center;
border-radius: 2px;
text-transform: uppercase;
.icon
{
top: 0em;
}
.icon:first-child
{
padding-right: 5px;
}
.icon:last-child
{
padding-left: 5px;
}
.icon:only-child
{
padding-left: 0;
padding-right: 0;
}
}
.button-block
{
cursor: pointer;
}
.button__content {
margin: 0 $padding-button;
}
.button-primary
{
$color-button-text: white;
color: darken($color-button-text, $button-focus-shift * 0.5);
background-color: $color-primary;
box-shadow: $box-shadow-layer;
&:focus {
color: $color-button-text;
//box-shadow: $box-shadow-focus;
background-color: mix(black, $color-primary, $button-focus-shift)
}
}
.button-alt
{
background-color: $color-bg-alt;
box-shadow: $box-shadow-layer;
}
.button-text
{
@include text-link();
display: inline-block;
.button__content {
margin: 0 $padding-text-link;
}
}
.button-text-help
{
@include text-link(#aaa);
font-size: 0.8em;
}
.button--flat
{
box-shadow: none !important;
}

View file

@ -0,0 +1,151 @@
@import "../global";
$padding-card-horizontal: $spacing-vertical * 2/3;
.card {
margin-left: auto;
margin-right: auto;
max-width: $width-page-constrained;
background: $color-bg;
box-shadow: $box-shadow-layer;
border-radius: 2px;
margin-bottom: $spacing-vertical * 2/3;
overflow: auto;
}
.card--obscured
{
position: relative;
}
.card--obscured .card__inner {
-webkit-filter: blur($blur-intensity);
-moz-filter: blur($blur-intensity);
-o-filter: blur($blur-intensity);
-ms-filter: blur($blur-intensity);
filter: blur($blur-intensity);
}
.card__title-primary {
padding: 0 $padding-card-horizontal;
margin-top: $spacing-vertical;
}
.card__title-identity {
padding: 0 $padding-card-horizontal;
margin-top: $spacing-vertical * 1/3;
margin-bottom: $spacing-vertical * 1/3;
}
.card__actions {
padding: 0 $padding-card-horizontal;
}
.card__actions {
margin-top: $spacing-vertical * 2/3;
}
.card__actions--bottom {
margin-top: $spacing-vertical * 1/3;
margin-bottom: $spacing-vertical * 1/3;
}
.card__actions--form-submit {
margin-top: $spacing-vertical;
margin-bottom: $spacing-vertical * 2/3;
}
.card__content {
margin-top: $spacing-vertical * 2/3;
margin-bottom: $spacing-vertical * 2/3;
padding: 0 $padding-card-horizontal;
}
.card__subtext {
color: #444;
margin-top: 12px;
font-size: 0.9em;
margin-top: $spacing-vertical * 2/3;
margin-bottom: $spacing-vertical * 2/3;
padding: 0 $padding-card-horizontal;
}
.card__subtext--allow-newlines {
white-space: pre-wrap;
}
.card__subtext--two-lines {
height: $font-size * 0.9 * $font-line-height * 2;
}
.card-overlay {
position: absolute;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
padding: 20px;
background-color: rgba(128, 128, 128, 0.8);
color: #fff;
display: flex;
align-items: center;
font-weight: 600;
}
$card-link-scaling: 1.1;
.card__link {
display: block;
}
.card--link:hover {
position: relative;
z-index: 1;
box-shadow: $box-shadow-focus;
transform: scale($card-link-scaling);
transform-origin: 50% 50%;
overflow-x: visible;
overflow-y: visible;
}
.card__media {
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
}
$width-card-small: $spacing-vertical * 12;
$height-card-small: $spacing-vertical * 15;
.card--small {
width: $width-card-small;
overflow-x: hidden;
white-space: normal;
}
.card--small .card__media {
height: $width-card-small * 9 / 16;
}
.card__subtitle {
color: $color-help;
font-size: 0.85em;
line-height: $font-line-height * 1 / 0.85;
}
.card-series-submit
{
margin-left: auto;
margin-right: auto;
max-width: $width-page-constrained;
padding: $spacing-vertical / 2;
}
.card-row {
> .card {
vertical-align: top;
display: inline-block;
margin-right: $spacing-vertical / 3;
}
+ .card-row {
margin-top: $spacing-vertical * 1/3;
}
}
.card-row--small {
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
/*hacky way to give space for hover */
padding-left: 20px;
margin-left: -20px;
padding-right: 20px;
margin-right: -20px;
}
.card-row__header {
margin-bottom: $spacing-vertical / 3;
}

View file

@ -1,5 +1,5 @@
@import "../global";
.channel-indicator__icon--invalid {
color: #b01c2e;
color: $color-error;
}

View file

@ -1,31 +1,26 @@
@import "../global";
$height-file-tile: $spacing-vertical * 6;
.file-tile__row {
height: $spacing-vertical * 7;
}
.file-tile__row--unavailable {
opacity: 0.5;
}
.file-tile__thumbnail {
max-width: 100%;
max-height: $spacing-vertical * 7;
display: block;
margin-left: auto;
margin-right: auto;
}
.file-tile__title {
font-weight: bold;
}
.file-tile__cost {
overflow: hidden;
height: $height-file-tile;
.credit-amount {
float: right;
}
.file-tile__description {
color: #444;
margin-top: 12px;
font-size: 0.9em;
//also a hack
.card__media {
height: $height-file-tile;
max-width: $height-file-tile;
width: $height-file-tile;
margin-right: $spacing-vertical / 2;
float: left;
}
//basically everything here is a hack now
.file-tile__content {
padding-top: $spacing-vertical * 1/3;
margin-left: $height-file-tile + $spacing-vertical / 2;
}
.card__title-primary {
margin-top: 0;
}
}

View file

@ -0,0 +1,158 @@
@import "../global";
$width-input-border: 2px;
$width-input-text: 330px;
.form-row-submit
{
margin-top: $spacing-vertical;
}
.form-row-submit--with-footer
{
margin-bottom: $spacing-vertical;
}
.form-row__label-row {
margin-top: $spacing-vertical * 5/6;
margin-bottom: $spacing-vertical * 1/6;
line-height: 1;
font-size: 0.9 * $font-size;
}
.form-row__label-row--prefix {
float: left;
margin-right: 5px;
}
input[type="text"].input-copyable {
border: 1px solid $color-form-border;
line-height: 1;
padding-top: $spacing-vertical * 1/3;
padding-bottom: $spacing-vertical * 1/3;
width: $width-input-text;
padding-left: 5px;
padding-right: 5px;
width: 100%;
&:focus { border-color: black; }
}
.form-field {
display: inline-block;
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
}
select {
transition: outline $transition-standard;
cursor: pointer;
box-sizing: border-box;
padding-left: 5px;
padding-right: 5px;
height: $spacing-vertical;
&:focus {
outline: $width-input-border solid $color-primary;
}
}
textarea,
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="search"],
input[type="date"] {
@include placeholder {
color: lighten($color-text-dark, 60%);
}
transition: all $transition-standard;
cursor: pointer;
padding-left: 1px;
padding-right: 1px;
box-sizing: border-box;
-webkit-appearance: none;
&[readonly] {
background-color: #bbb;
}
}
input[type="text"],
input[type="password"],
input[type="email"],
input[type="number"],
input[type="search"],
input[type="date"] {
border-bottom: $width-input-border solid $color-form-border;
line-height: 1;
padding-top: $spacing-vertical * 1/3;
padding-bottom: $spacing-vertical * 1/3;
&.form-field__input--error {
border-color: $color-error;
}
&.form-field__input--inline {
padding-top: 0;
padding-bottom: 0;
border-bottom-width: 1px;
margin-left: 8px;
margin-right: 8px;
}
}
textarea:focus,
input[type="text"]:focus,
input[type="password"]:focus,
input[type="email"]:focus,
input[type="number"]:focus,
input[type="search"]:focus,
input[type="date"]:focus {
border-color: $color-primary;
}
textarea {
border: $width-input-border solid $color-form-border;
}
}
.form-field__label {
&[for] { cursor: pointer; }
> input[type="checkbox"], input[type="radio"] {
margin-right: 6px;
}
}
.form-field__label--error {
color: $color-error;
}
.form-field__input-text {
width: $width-input-text;
}
.form-field__prefix {
margin-right: 4px;
}
.form-field__postfix {
margin-left: 4px;
}
.form-field__input-number {
width: 70px;
text-align: right;
}
.form-field__input-textarea {
width: $width-input-text;
}
.form-field__error, .form-field__helper {
margin-top: $spacing-vertical * 1/3;
font-size: 0.8em;
transition: opacity $transition-standard;
}
.form-field__error {
color: $color-error;
}
.form-field__helper {
color: $color-help;
}

View file

@ -0,0 +1,94 @@
@import "../global";
$color-header: #666;
$color-header-active: darken($color-header, 20%);
#header
{
color: $color-header;
background: #fff;
display: flex;
position: fixed;
box-shadow: $box-shadow-layer;
top: 0;
left: 0;
width: 100%;
z-index: 2;
padding: $spacing-vertical / 2;
box-sizing: border-box;
}
.header__item {
flex: 0 0 content;
padding-left: $spacing-vertical / 4;
padding-right: $spacing-vertical / 4;
}
.header__item--wunderbar {
flex-grow: 1;
}
.wunderbar
{
position: relative;
.icon {
position: absolute;
left: 10px;
top: $spacing-vertical / 2 - 4px; //hacked
}
}
.wunderbar--active .icon-search { color: $color-primary; }
.wunderbar__input {
background: rgba(255, 255, 255, 0.7);
width: 100%;
color: $color-header;
height: $spacing-vertical * 1.5;
line-height: $spacing-vertical * 1.5;
padding-left: 38px;
padding-right: 5px;
border: 1px solid $color-text-dark;
@include border-radius(2px);
border: 1px solid #ccc;
&:focus {
color: $color-header-active;
box-shadow: $box-shadow-focus;
border-color: $color-primary;
}
}
nav.sub-header
{
text-transform: uppercase;
padding: 0 0 $spacing-vertical;
&.sub-header--constrained {
max-width: $width-page-constrained;
margin-left: auto;
margin-right: auto;
}
> a
{
$sub-header-selected-underline-height: 2px;
display: inline-block;
margin: 0 15px;
padding: 0 5px;
line-height: $height-header - $spacing-vertical - $sub-header-selected-underline-height;
color: $color-header;
&:first-child
{
margin-left: 0;
}
&:last-child
{
margin-right: 0;
}
&.sub-header-selected
{
border-bottom: $sub-header-selected-underline-height solid $color-header-active;
color: $color-header-active;
}
&:hover
{
color: $color-header-active;
}
}
}

View file

@ -23,7 +23,7 @@
}
.load-screen__details--warning {
color: $color-warning;
color: white;
}
.load-screen__cancel-link {

View file

@ -10,7 +10,7 @@ $border-radius-menu: 2px;
position: absolute;
white-space: nowrap;
background-color: white;
box-shadow: $default-box-shadow;
box-shadow: $box-shadow-layer;
border-radius: $border-radius-menu;
padding-top: ($spacing-vertical / 5) 0px;
z-index: 1;

View file

@ -0,0 +1,54 @@
@import "../global";
.modal-page {
position: fixed;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 1px solid rgb(204, 204, 204);
background: rgb(255, 255, 255);
overflow: auto;
}
.modal-page--full {
left: 0;
right: 0;
top: 0;
bottom: 0;
.modal-page__content {
max-width: 500px;
}
}
/*
.modal-page {
position: fixed;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 1px solid rgb(204, 204, 204);
background: rgb(255, 255, 255);
overflow: auto;
border-radius: 4px;
outline: none;
padding: 36px;
top: 25px;
left: 25px;
right: 25px;
bottom: 25px;
}
*/
.modal-page__content {
h1, h2 {
margin-bottom: $spacing-vertical / 2;
}
h3, h4 {
margin-bottom: $spacing-vertical / 4;
}
}

View file

@ -0,0 +1,81 @@
@import "../global";
.modal-overlay {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
background-color: rgba(255, 255, 255, 0.74902);
z-index: 9999;
}
.modal-overlay--clear {
background-color: transparent;
}
.modal {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 1px solid rgb(204, 204, 204);
background: rgb(255, 255, 255);
overflow: auto;
border-radius: 4px;
padding: $spacing-vertical;
box-shadow: $box-shadow-layer;
max-width: 400px;
}
.modal__header {
margin-bottom: 5px;
text-align: center;
}
.modal__buttons {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 15px;
}
.modal__button {
margin: 0px 6px;
}
.error-modal-overlay {
background: rgba(#000, .88);
}
.error-modal__content {
display: flex;
padding: 0px 8px 10px 10px;
}
.error-modal__warning-symbol {
margin-top: 6px;
margin-right: 7px;
}
.download-started-modal__file-path {
word-break: break-all;
}
.error-modal {
max-width: none;
width: 400px;
}
.error-modal__error-list { /*shitty hack/temp fix for long errors making modals unusable*/
border: 1px solid #eee;
padding: 8px;
list-style: none;
max-height: 400px;
max-width: 400px;
overflow-y: hidden;
}

View file

@ -0,0 +1,18 @@
@import "../global";
.notice {
padding: 10px 20px;
border: 1px solid #000;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
border-radius: 5px;
color: #468847;
background-color: #dff0d8;
border-color: #d6e9c6;
}
.notice--error {
color: #b94a48;
background-color: #f2dede;
border-color: #eed3d7;
}

View file

@ -0,0 +1,42 @@
@import "../global";
$padding-snack-horizontal: $spacing-vertical;
.snack-bar {
$height-snack: $spacing-vertical * 2;
$padding-snack-vertical: $spacing-vertical / 4;
line-height: $height-snack - $padding-snack-vertical * 2;
padding: $padding-snack-vertical $padding-snack-horizontal;
position: fixed;
top: $spacing-vertical;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
min-width: 300px;
max-width: 500px;
background: $color-black-transparent;
color: #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 2px;
transition: all $transition-standard;
z-index: 10000; /*hack to get it over react modal */
}
.snack-bar__action {
display: inline-block;
text-transform: uppercase;
color: $color-primary-light;
margin: 0px 0px 0px $padding-snack-horizontal;
min-width: min-content;
&:hover {
text-decoration: underline;
}
}

View file

@ -15,6 +15,7 @@
z-index: 1;
left: 50%;
margin-left: $tooltip-body-width * -1 / 2;
white-space: normal;
box-sizing: border-box;
padding: $spacing-vertical / 2;
@ -24,7 +25,7 @@
background-color: $color-bg;
font-size: $font-size * 7/8;
line-height: $font-line-height;
box-shadow: $default-box-shadow;
box-shadow: $box-shadow-layer;
}
.tooltip--header .tooltip__link {

View file

@ -0,0 +1,56 @@
video {
object-fit: contain;
box-sizing: border-box;
max-height: 100%;
max-width: 100%;
}
.video {
background: #000;
color: white;
}
.video-embedded {
max-width: $width-page-constrained;
max-height: $height-video-embedded;
height: $height-video-embedded;
video {
height: 100%;
}
&.video--hidden {
height: $height-video-embedded;
}
&.video--active {
/*background: none;*/
}
}
.video__cover {
text-align: center;
height: 100%;
width: 100%;
background-size: auto 100%;
background-position: center center;
background-repeat: no-repeat;
position: relative;
.video__play-button { @include absolute-center(); }
}
.video__play-button {
position: absolute;
width: 100%;
height: 100%;
cursor: pointer;
display: none;
font-size: $spacing-vertical * 3;
color: white;
z-index: 1;
background: $color-black-transparent;
opacity: 0.6;
left: 0;
top: 0;
&:hover {
opacity: 1;
transition: opacity $transition-standard;
}
}

View file

@ -0,0 +1,5 @@
@import "../global";
.reward-page__details {
background-color: lighten($color-canvas, 1.5%);
}

9
ui/scss/page/_show.scss Normal file
View file

@ -0,0 +1,9 @@
@import "../global";
.show-page-media {
text-align: center;
margin-bottom: $spacing-vertical;
img {
max-width: 100%;
}
}

View file

@ -1,6 +1,3 @@
.video {
background: #000;
}
.video__overlay {
position: absolute;
@ -23,7 +20,7 @@
}
.video__back-label {
opacity: 0;
opacity: 0.5;
transition: opacity 100ms ease-in;
}