$color-apprentice: #dd0093;
$color-guru: #882d9e;
$color-master: #294ddb;
$color-enlightened: #0093dd;
$color-kanji: #f100a1;
$color-vocabulary: #a100f1;
$spin-time: 1.25s;
$color-cycle-time: 1.25s;
@keyframes spin {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }
}
@keyframes spin-double {
0% { transform: rotateZ(0deg); }
50% { transform: rotateZ(180deg); }
100% { transform: rotateZ(360deg); }
}
@keyframes color-cycle {
0%, 100% {
border-color: rgba($color-apprentice, 0.2);
border-top-color: $color-apprentice;
}
25% {
border-color: rgba($color-guru, 0.2);
border-top-color: $color-guru;
}
50% {
border-color: rgba($color-master, 0.2);
border-top-color: $color-master;
}
75% {
border-color: rgba($color-enlightened, 0.2);
border-top-color: $color-enlightened;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
text-shadow: 0 0 0 rgba(black, 0.2);
}
50% {
opacity: 0.6;
text-shadow: 0 0 1px rgba(black, 0.8);
}
}
.loading {
border-radius: 50%;
width: 100%;
height: 100%;
border-width: 0.15em;
border-style: solid;
animation: spin $spin-time infinite linear,
color-cycle ($color-cycle-time * 4) infinite cubic-bezier(1, 0, 0, 1) ($color-cycle-time * -0.25);
}
.loading-outer {
display: inline-block;
font-size: 80px;
width: 1em;
height: 1em;
animation: spin-double ($color-cycle-time * 2) infinite ease;
}
.loading-kanji .heading {
color: $color-kanji;
}
.loading-vocabulary .heading {
color: $color-vocabulary;
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
.table tr:hover {
background-color: transparent;
}
.table th, .table td {
text-align: center;
}
.table td {
vertical-align: middle;
padding: 0.5rem 0.75rem;
}
.table td.item {
background-repeat: repeat-x;
font-size: 24px;
a {
color: white;
display: block;
margin: -0.5rem -0.75rem;
padding: 0.5rem 0.75rem;
}
&.item-vocabulary {
background-color: $color-vocabulary;
&:hover {
background-color: lighten($color-vocabulary, 10%);
}
}
&.item-kanji {
background-color: $color-kanji;
&:hover {
background-color: lighten($color-kanji, 10%);
}
}
}
.sort-column {
cursor: pointer;
&:hover {
background-color: #fafafa;
}
}
.srs {
color: #fff;
&.srs-apprentice {
background-color: $color-apprentice;
}
&.srs-guru {
background-color: $color-guru;
}
&.srs-master {
background-color: $color-master;
}
&.srs-enlightened {
background-color: $color-enlightened;
}
}
View Compiled
class App extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
apiKey: props.apiKey,
kanji: props.kanji,
vocabulary: props.vocabulary
};
this.handleApiEntry = this.handleApiEntry.bind(this);
this.loadData = this.loadData.bind(this);
this.resetData = this.resetData.bind(this);
}
render() {
const { kanji, vocabulary, apiKey } = this.state;
return (
<div>
<header>
<nav className="navbar">
<div className="navbar-brand">
<span className="navbar-item"><strong>WaniKani Leech Detector</strong></span>
<a className="navbar-item" href="javascript:void(0)" onClick={this.resetData}>Reset Data</a>
</div>
</nav>
</header>
<main>
{
(kanji && vocabulary) ? <Main kanji={kanji} vocabulary={vocabulary} /> :
apiKey ? <Loading kanji={kanji == null} vocabulary={vocabulary == null} /> :
<ApiEntry onEnter={this.handleApiEntry} />
}
</main>
<footer className="footer">
<div className="container">
<div className="content has-text-centered">
<p>
<strong>WaniKani Leech Detector</strong> by <a href="http://github.com/smrq" target="_blank">smrq</a>. The source code is licensed <a href="http://opensource.org/licenses/mit-license.php" target="_blank">MIT</a>.
</p>
<p>
Forked from <a href="https://community.wanikani.com/t/Leech-Detector/18227" target="_blank">Leech Detector</a> by <a href="https://community.wanikani.com/u/StellaTerra" target="_blank">StellaTerra</a>.
</p>
</div>
</div>
</footer>
</div>
);
}
componentDidMount() {
if (this.state.apiKey && !(this.state.kanji && this.state.vocabulary)) {
this.loadData();
}
}
handleApiEntry(apiKey) {
storeLocalData('wanikani_leech_apikey', apiKey);
this.setState({ apiKey }, this.loadData);
}
loadData() {
const { apiKey } = this.state;
fetchResource(apiKey, 'vocabulary', 1, data => {
const vocabulary = scoreData('vocabulary', data.general);
storeLocalData('wanikani_leech_vocabulary', vocabulary, 1000*60*5);
this.setState({ vocabulary });
});
fetchResource(apiKey, 'kanji', 1, data => {
const kanji = scoreData('kanji', data);
storeLocalData('wanikani_leech_kanji', kanji, 1000*60*5);
this.setState({ kanji });
});
}
resetData() {
localStorage.removeItem('wanikani_leech_vocabulary');
localStorage.removeItem('wanikani_leech_kanji');
localStorage.removeItem('wanikani_leech_apikey');
this.setState({
apiKey: null,
kanji: null,
vocabulary: null
});
}
}
class ApiEntry extends React.PureComponent {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
render() {
return (
<section className="section is-medium">
<div className="container">
<h1 className="title">WaniKani Leech Detector</h1>
<h2 className="subtitle">Get started by entering your API Version 1 Key.</h2>
<form onSubmit={this.handleSubmit}>
<div className="columns">
<div className="column is-half">
<div className="field" >
<div className="field has-addons">
<div className="control is-expanded">
<input className="input" type="text" name="apiKey" placeholder="546573744b6579506c7349676e6f7265" />
</div>
<div className="control">
<button className="button is-primary" type="submit">Submit</button>
</div>
</div>
<p className="help">You can find your API key in your <a href="https://www.wanikani.com/settings/account#public-api-key" target="_blank">WaniKani Account Settings</a>.</p>
</div>
</div>
</div>
</form>
</div>
</section>
);
}
handleSubmit(event) {
event.preventDefault();
this.props.onEnter(event.currentTarget.apiKey.value);
}
}
function Loading(props) {
const { kanji, vocabulary } = props;
return (
<section className="section is-medium">
<div className="container has-text-centered">
<nav className="level">
<div className="level-item loading-kanji">
<div>
<p className="heading">Kanji</p>
<p className={kanji ? "title pulse" : "title"}>{kanji ? 'Loading' : 'Done'}</p>
</div>
</div>
<div className="level-item">
<div className="loading-outer">
<div className="loading" />
</div>
</div>
<div className="level-item loading-vocabulary">
<div>
<p className="heading">Vocabulary</p>
<p className={vocabulary ? "title pulse" : "title"}>{vocabulary ? 'Loading' : 'Done'}</p>
</div>
</div>
</nav>
</div>
</section>
);
}
class Main extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
sort: 'score',
sortDescending: true,
count: 50,
includeBurned: false
};
this.handleSortSrs = this.handleSortSrs.bind(this);
this.handleSortWrongCount = this.handleSortWrongCount.bind(this);
this.handleSortScore = this.handleSortScore.bind(this);
this.handleSortStreak = this.handleSortStreak.bind(this);
this.handleSortBy = this.handleSortBy.bind(this);
this.handleShowMore = this.handleShowMore.bind(this);
this.handleIncludeBurnedChange = this.handleIncludeBurnedChange.bind(this);
}
render() {
const { kanji, vocabulary } = this.props;
const { sort, sortDescending, count, includeBurned } = this.state;
let items = [...kanji, ...vocabulary];
if (!includeBurned) {
items = items.filter(item => !item.burned);
}
sortItems(items, sort, sortDescending);
const limitedItems = items.slice(0, count);
return (
<section className="section">
<div className="container">
<p>
<label>
<input type="checkbox" checked={includeBurned} onChange={this.handleIncludeBurnedChange} /> Include burned items
</label>
</p>
<table className="table">
<thead>
<tr>
<th>Item</th>
<th>Type</th>
<th className='sort-column' onClick={this.handleSortSrs}>SRS</th>
<th className='sort-column' onClick={this.handleSortWrongCount}>Wrong</th>
<th className='sort-column' onClick={this.handleSortStreak}>Streak</th>
<th className='sort-column' onClick={this.handleSortScore}>Score</th>
</tr>
</thead>
<tbody>
{limitedItems.map(item => (
<tr>
<td className={`item item-${item.itemType}`}>
<a href={`https://www.wanikani.com/${item.itemType}/${item.name}`} target="_blank">
{item.name}
</a>
</td>
<td>{item.type}</td>
<td className={`srs srs-${srsLevelName(item.srs)}`}>{item.srs}</td>
<td className="wrong-count">{item.wrongCount}</td>
<td className="streak">{item.streak}</td>
<td className="score">{item.score.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
{items.length > limitedItems.length && (
<p>
<a href="javascript:void(0)" onClick={this.handleShowMore}>Show more</a>
</p>
)}
</div>
</section>
);
}
handleSortSrs() {
this.handleSortBy('srs');
}
handleSortWrongCount() {
this.handleSortBy('wrongCount');
}
handleSortScore() {
this.handleSortBy('score');
}
handleSortStreak() {
this.handleSortBy('streak');
}
handleSortBy(sort) {
this.setState(state => ({
sort,
sortDescending: state.sort === sort ? !state.sortDescending : true,
count: 50
}));
}
handleShowMore() {
this.setState(state => ({ count: state.count + 50 }));
}
handleIncludeBurnedChange() {
this.setState(state => ({ includeBurned: !state.includeBurned }));
}
}
function fetchResource(apiKey, resource, failDelay, callback) {
fetch(`https://www.wanikani.com/api/user/${apiKey}/${resource}`)
.then(checkResponse)
.then(parseJson)
.then(unpackJson)
.then(callback)
.catch(error => {
console.error(error);
console.warn(`Failed to fetch ${resource}, retrying in ${failDelay} seconds.`);
setTimeout(
() => fetchResource(apiKey, resource, failDelay * 2, callback),
failDelay * 1000);
});
}
function checkResponse(response) {
if (response.ok) {
return response;
}
throw new Error('Network response failed.');
}
function parseJson(response) {
return response.json();
}
function unpackJson(json) {
if (json.error) {
throw new Error(json.error);
}
return json.requested_information;
}
function scoreData(itemType, data) {
const result = [];
data
.filter(item => item.user_specific != null)
.forEach(item => {
const name = item.character;
const details = item.user_specific;
if (details.meaning_incorrect > 0) {
const score = details.meaning_incorrect / details.meaning_current_streak;
result.push({
itemType,
name,
type: 'Meaning',
srs: details.srs_numeric,
burned: details.burned,
wrongCount: details.meaning_incorrect,
streak: details.meaning_current_streak,
score,
});
}
if (details.reading_incorrect > 0) {
const score = details.reading_incorrect / details.reading_current_streak;
result.push({
itemType,
name,
type: 'Reading',
srs: details.srs_numeric,
burned: details.burned,
wrongCount: details.reading_incorrect,
streak: details.reading_current_streak,
score
});
}
});
return result;
}
function sortItems(items, sort, sortDescending) {
const sortFunction = getSortFunction(sort);
const sortDirectionFunction = sortDescending ? descending : ascending;
if (sortFunction) {
items.sort(sortDirectionFunction(sortFunction));
}
}
function getSortFunction(sort) {
switch (sort) {
case 'score':
return (a, b) => byScore(a, b) || bySrs(a, b);
case 'srs':
return (a, b) => bySrs(a, b) || byScore(a, b);
case 'streak':
return (a, b) => byStreak(b, a) || byScore(a, b) || bySrs(a, b);
case 'wrongCount':
return (a, b) => byWrongCount(a, b) || byScore(a, b) || bySrs(a, b);
default:
return;
}
}
function descending(sort) {
return sort;
}
function ascending(sort) {
return (a, b) => sort(b, a);
}
function byScore(a, b) {
const aScore = a.wrongCount / a.streak;
const bScore = b.wrongCount / b.streak;
return bScore - aScore;
}
function byWrongCount(a, b) {
return b.wrongCount - a.wrongCount;
}
function bySrs(a, b) {
return b.srs - a.srs;
}
function byStreak(a, b) {
return b.streak - a.streak;
}
function srsLevelName(srsNumber) {
switch (srsNumber) {
case 9:
return 'burned';
case 8:
return 'enlightened';
case 7:
return 'master';
case 6:
case 5:
return 'guru';
case 4:
case 3:
case 2:
case 1:
return 'apprentice';
default:
throw new Error(`Invalid SRS level: ${srsNumber}`);
}
}
function storeLocalData(key, data, expiration) {
localStorage.setItem(key, JSON.stringify({
data,
expires: expiration && (Date.now() + expiration)
}));
}
function retrieveLocalData(key) {
let stored = localStorage.getItem(key);
if (!stored) {
return null;
}
stored = JSON.parse(stored);
if (stored.expires && Date.now() > stored.expires) {
localStorage.removeItem(key);
return null;
}
return stored.data;
}
const apiKey = retrieveLocalData('wanikani_leech_apikey');
const kanji = retrieveLocalData('wanikani_leech_kanji');
const vocabulary = retrieveLocalData('wanikani_leech_vocabulary');
ReactDOM.render(
<App apiKey={apiKey} kanji={kanji} vocabulary={vocabulary} />,
document.getElementById('app'));
View Compiled