Commit 965ee1dc authored by Eliot Berriot's avatar Eliot Berriot

Merge branch 'release/0.3'

parents 05e6db24 1b615675
Pipeline #199 passed with stages
in 42 seconds
# scp-browser
> A client-side SCP-wiki.net browser
A client-side SCP-wiki.net browser: http://eliotberriot.pages.eliotberriot.com/scp-browser/
# Database
Database is built from http://scp-wiki.net:
```bash
pip3 install --user -r requirements.txt # or in a virtualenv if you prefer
python3 fetch_database.py
python3 convert_data.py
```
# Front-end
## Build Setup
``` bash
......
......@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<title>scp-browser</title>
<title>SCP Browser</title>
</head>
<body>
<div id="app"></div>
......
<template>
<div id="app">
<div class="ui secondary pointing menu">
<div class="ui container">
<router-link class="ui item" active-class="active" to="/browse">Browse</router-link>
</div>
</div>
<router-view/>
</div>
</template>
......
<template>
<div>
<div v-if="scpData.loading" class="ui active inverted page dimmer">
<div class="ui center text loader">
<p>Downloading SCP data (this should take &lt;30s depending on your internet connection)...</p>
<p>We will cache the data to ensure you don't see this step ever again.</p>
<p>Many D-class were harmed during this page load.</p>
</div>
</div>
<div v-else class="ui grid">
<data-loader :loading="scpData.loading"></data-loader>
<div v-if="!scpData.loading" class="ui grid">
<div class="three wide column">
<h2>Filters</h2>
<div class="ui form">
<button class="ui fluid basic icon button" @click="scpData.initFilters()">
Reset filters
<i class="x icon"></i>
</button>
<div class="ui segments">
<div class="ui segment">
<h3 class="ui header">Ordering</h3>
<div class="ui field">
<select v-model="sortField" class="ui fluid dropdown">
<option v-for="field in sortFields" :value="field.id">{{ field.label }}</option>
<select v-model="scpData.sortField" class="ui fluid dropdown">
<option v-for="field in scpData.sortFields" :value="field.id">{{ field.label }}</option>
</select>
</div>
<div class="ui inline field">
......@@ -24,7 +22,7 @@
type="checkbox"
id="ordering-direction"
class="hidden"
v-model="sortAscending">
v-model="scpData.sortAscending">
<label for="ordering-direction">Sort in ascending order </label>
</div>
</div>
......@@ -36,7 +34,7 @@
type="text"
id="search"
placeholder="666, Red ice, clown, shadow..."
v-model="filtersById.search.query">
v-model="scpData.filtersById.search.query">
</div>
</div>
<div class='ui segment'>
......@@ -46,17 +44,23 @@
type="checkbox"
@change="toggleAll('objectClass')"
id="objectclass-toggle-all"
:checked="filtersById.objectClass.choices.length === filtersById.objectClass.selected.length">
:checked="scpData.filtersById.objectClass.choices.length === scpData.filtersById.objectClass.selected.length">
<label for="objectclass-toggle-all">Select all</label>
</div>
<div class="choices">
<div class="inline field" v-for="choice in filtersById.objectClass.choices">
<input type="checkbox" :id="'objectclass' + choice.id" :value="choice.id" v-model="filtersById.objectClass.selected">
<span :class="['ui', getObjectClassColor(choice.id), 'empty', 'circular', 'label']"></span>
<div class="inline field" v-for="choice in scpData.filtersById.objectClass.choices">
<input type="checkbox" :id="'objectclass' + choice.id" :value="choice.id" v-model="scpData.filtersById.objectClass.selected">
<span :class="['ui', scpData.getObjectClassColor(choice.id), 'empty', 'circular', 'label']"></span>
<label :for="'objectclass' + choice.id">{{ choice.label }}</label>
</div>
</div>
</div>
<div class='ui segment'>
<h3 class="ui header">Tags</h3>
<select v-model="scpData.filtersById.tags.selected" class="ui fluid tags dropdown" multiple>
<option v-for="tag in scpData.filtersById.tags.choices" :value="tag.id">{{ tag.label }}</option>
</select>
</div>
</div>
</div>
</div>
......@@ -72,7 +76,7 @@
</div>
<div class="meta">
<span>
<i :class="['ui', 'warning circle', getObjectClassColor(scp.object_class), 'icon']"></i>
<i :class="['ui', 'warning circle', scpData.getObjectClassColor(scp.object_class), 'icon']"></i>
{{ scpData.data.object_classes[scp.object_class].label }}
</span>
<span>
......@@ -86,10 +90,17 @@
<p></p>
</div>
</div>
<a class="ui bottom attached button" target="_blank" :href="scp.url">
<i class="external link icon"></i>
View on SCP-wiki.net
</a>
<div class="ui two bottom attached basic buttons">
<router-link class="ui button" :to="{name: 'detail', params: {id: scp.id }}">
<i class="plus icon"></i>
Detail
</router-link>
<a class="ui button" target="_blank" :href="scp.url">
<i class="external link icon"></i>
SCP-wiki.net
</a>
</div>
</div>
</div>
</div>
......@@ -99,92 +110,17 @@
<script>
import scpData from '@/scp-data'
import fuzzysearch from 'fuzzysearch'
import $ from 'jquery'
import DataLoader from './DataLoader'
export default {
components: {
DataLoader
},
data () {
let self = this
let d = {
scpData,
searchQuery: '',
sortAscending: true,
sortField: 'id',
sortFields: [
{
id: 'comments',
label: 'Comments'
},
{
id: 'id',
label: 'SCP Number'
},
{
id: 'last_edition_date',
label: 'Last edition date'
},
{
id: 'rating',
label: 'Rating'
},
{
id: 'revisions',
label: 'Revisions'
}
],
filters: [
{
id: 'objectClass',
choices: [],
selected: [],
init: function (scps) {
this.choices = Object.keys(self.scpData.data.object_classes).map(e => {
return self.scpData.data.object_classes[e]
})
this.selected = this.choices.map(e => {
return e.id
})
},
apply: function () {
return this.selected.length !== this.choices.length
},
match: function (scp) {
for (var i = 0; i < this.selected.length; i++) {
let s = this.selected[i]
if (scp.object_class === s) {
return true
}
}
}
},
{
id: 'search',
query: '',
searchFields: ['name', 'object_class'],
init: function (scps) {},
apply: function () {
return this.query.length > 0
},
match: function (scp) {
let id = parseInt(this.query, '10')
if (!isNaN(id) & scp.id === id) {
return true
}
let length = this.searchFields.length
for (var i = 0; i < length; i++) {
let f = this.searchFields[i]
if (fuzzysearch(this.query, scp[f].toLowerCase())) {
return true
}
}
}
}
]
scpData
}
d.filtersById = {}
d.filters.forEach(e => {
d.filtersById[e.id] = e
})
return d
},
mounted: function () {
......@@ -193,34 +129,8 @@ export default {
$(this.$el).find('.ui.dropdown').dropdown()
},
methods: {
getObjectClassColor: function (objectClass) {
let colors = {
'null': 'black',
'safe': 'teal',
'euclid': 'orange',
'keter': 'red',
'thaumiel': 'purple',
'pending': 'yellow',
'neutralized': 'green',
'hiemal': 'blue'
}
let color = colors[objectClass]
if (!color) {
color = 'grey'
}
return color
},
matchFilters: function (scp, filters) {
for (var i = 0; i < filters.length; i++) {
let filter = filters[i]
if (!filter.match(scp)) {
return false
}
}
return true
},
toggleAll: function (filter) {
let f = this.filtersById[filter]
let f = this.scpData.filtersById[filter]
if (f.selected.length === f.choices.length) {
f.selected = []
} else {
......@@ -230,34 +140,17 @@ export default {
},
computed: {
filteredScps: function () {
let self = this
let candidates = this.scpData.data.scps
let filters = this.filters.filter(e => {
return e.apply()
})
let filtered = candidates.filter(e => {
return self.matchFilters(e, filters)
})
let sorted = filtered.sort((a, b) => {
return a[self.sortField] - b[self.sortField]
})
if (!self.sortAscending) {
sorted.reverse()
}
return sorted
return this.scpData.filteredScps()
},
scps: function () {
if (this.scpData) {
return this.scpData.data.scps
}
return []
selectedTags: function () {
return this.scpData.filtersById.tags.selected
}
},
watch: {
scps: function (v) {
this.filters.forEach(e => {
e.init(v)
})
selectedTags: function (v) {
if (v.length === 0) {
$(this.$el).find('.ui.dropdown.tags').dropdown('set exactly', [])
}
}
}
}
......@@ -272,7 +165,7 @@ export default {
background-position: center center !important;
}
.no-image {
background-image: url('../assets/scp-logo.png') !important;
background-image: url('../assets/scp-logo.jpg') !important;
}
</style>
<template>
<div v-if="loading" class="ui active inverted page dimmer">
<div class="ui center text loader">
<p>Downloading SCP data (this should take &lt;30s depending on your internet connection)...</p>
<p>We will cache the data to ensure you don't see this step ever again.</p>
<p>Many D-class were harmed during this page load.</p>
</div>
</div>
</template>
<script>
export default {
props: ['loading']
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
<template>
<div class="ui container">
<data-loader :loading="scpData.loading"></data-loader>
<div v-if="scp" class="ui stackable grid">
<div class="four wide column">
<div class="ui vertical menu">
<router-link to="/browse" class="item">
<i class="block list icon"></i>
Back to list
</router-link>
<a class="link item" :href="scp.url" target="_blank">
<i class="external link icon"></i>
View on SCP-wiki.net
</a>
<div class="ui item">
{{ position + 1 }} on {{ scpData.filteredScps().length }} results
<div class="menu">
<router-link v-if="previousScp" :to="{name: 'detail', params: {id: previousScp.id }}" class="item ellipsis">
<i class="left arrow icon"></i>
#{{ previousScp.id }} {{ previousScp.name }}
</router-link>
<router-link v-if="nextScp" :to="{name: 'detail', params: {id: nextScp.id }}" class="item ellipsis">
<i class="right arrow icon"></i>
#{{ nextScp.id }} {{ nextScp.name }}
</router-link>
</div>
</div>
<div class="ui item">
Metadata
<div class="menu">
<a @click="browse({objectClass: [scp.object_class]})" class="item ellipsis">
<span :class="['ui', 'tiny', scpData.getObjectClassColor(scp.object_class), 'label']">
{{ scpData.data.object_classes[scp.object_class].label }}
</span>
Object class
</a>
<a class="item ellipsis">
<i class="yellow star icon"></i>
Rated {{ prettyRating }}
</a>
<a class="item ellipsis">
<i class="black comments icon"></i>
{{ scp.comments }} comments
</a>
<a class="item ellipsis">
<i class="black book icon"></i>
{{ scp.revisions }} revisions
</a>
</div>
</div>
<div class="ui item">
<i class="ui tag icon"></i>
Tags
<div class="menu">
<a v-for="tag in scp.tags" @click="browse({tags: [tag]})" class="item">
{{ tag }}
<span class="ui circular tiny label">{{ scpData.data.tags[tag] }}</span>
</a>
</div>
</div>
</div>
</div>
<div class="eight wide column">
<div class="ui fluid text container">
<h1>SCP #{{ scp.id }} - {{ scp.name }}</h1>
<template v-if="getField('procedures')">
<h2 class="ui header">Special Containment Procedure</h2>
<p v-for="paragraph in getField('procedures')">{{ paragraph }}</p>
</template>
<template v-if="getField('description')">
<h2 class="ui header">Description</h2>
<p v-for="paragraph in getField('description')">{{ paragraph }}</p>
</template>
</div>
</div>
<div class="four wide right floated column">
<h2>Gallery</h2>
<img v-if="coverImage" class="ui fluid bordered image" :src="coverImage.src" :alt="coverImage.caption" :title="coverImage.caption" />
<img v-else class="ui fluid bordered image" src="../assets/scp-logo.jpg" alt="No image available" title="No image available" />
<p v-if="coverImage && coverImage.caption">{{ coverImage.caption }}</p>
</div>
</div>
</div>
</template>
<script>
import scpData from '@/scp-data'
import DataLoader from './DataLoader'
export default {
props: {
id: {required: true}
},
components: {
DataLoader
},
beforeRouteUpdate (to, from, next) {
this.realId = parseInt(to.params.id, '10')
next()
},
data () {
let d = {
realId: parseInt(this.id, '10'),
scpData: scpData
}
return d
},
mounted: function () {
this.scpData.load()
},
methods: {
browse: function (filterData) {
this.scpData.initFilters(filterData)
this.$router.push('/browse')
},
getField: function (name) {
let candidates = this.scp.text_fields.filter(e => {
return e.code === name
})
if (candidates.length > 0) {
return candidates[0].paragraphs
}
}
},
computed: {
scp: function () {
let self = this
return this.scpData.data.scps.filter(s => {
return s.id === self.realId
})[0]
},
coverImage: function () {
let i = this.scp.images[0]
return i
},
position: function () {
let filtered = this.scpData.filteredScps()
for (var i = 0; i < filtered.length; i++) {
let candidate = filtered[i]
if (candidate.id === this.scp.id) {
return i
}
}
},
previousScp: function () {
let filtered = this.scpData.filteredScps()
if (this.position > 0) {
return filtered[this.position - 1]
}
},
nextScp: function () {
let filtered = this.scpData.filteredScps()
if (this.position < filtered.length - 1) {
return filtered[this.position + 1]
}
},
prettyRating: function () {
if (this.scp.rating > 0) {
return '+' + this.scp.rating
}
return this.scp.rating
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
......@@ -2,6 +2,7 @@ import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import Browse from '@/components/Browse'
import Detail from '@/components/Detail'
Vue.use(Router)
......@@ -16,6 +17,12 @@ export default new Router({
path: '/browse',
name: 'Browse',
component: Browse
},
{
path: '/browse/:id',
name: 'detail',
component: Detail,
props: true
}
]
})
......@@ -2,6 +2,7 @@ import $ from 'jquery'
import Vue from 'vue'
import LZString from 'lz-string'
import Worker from 'worker-loader!./worker.js'
import fuzzysearch from 'fuzzysearch'
const cacheWorker = new Worker()
cacheWorker.onmessage = (event) => {
......@@ -19,6 +20,121 @@ var scpData = {
scps: [],
object_classes: {}
},
searchQuery: '',
sortAscending: true,
sortField: 'id',
sortFields: [
{
id: 'comments',
label: 'Comments'
},
{
id: 'id',
label: 'SCP Number'
},
{
id: 'last_edition_date',
label: 'Last edition date'
},
{
id: 'rating',
label: 'Rating'
},
{
id: 'revisions',
label: 'Revisions'
}
],
filters: [
{
id: 'objectClass',
choices: [],
selected: [],
init: function (self, initialData) {
this.choices = Object.keys(self.data.object_classes).map(e => {
return self.data.object_classes[e]
})
if (initialData) {
this.selected = initialData
} else {
this.selected = this.choices.map(e => {
return e.id
})
}
},
apply: function () {
return this.selected.length !== this.choices.length
},
match: function (scp) {
for (var i = 0; i < this.selected.length; i++) {
let s = this.selected[i]
if (scp.object_class === s) {
return true
}
}
}
},
{
id: 'tags',
choices: [],
selected: [],
init: function (self, initialData) {
this.choices = Object.keys(self.data.tags).map(e => {
return {
id: e,
label: e
}
})
if (initialData) {
this.selected = initialData
} else {
this.selected = []
}
},
apply: function () {
let s = this.selected.length
return s > 0 && s.length !== this.choices.length
},
match: function (scp) {
for (var i = 0; i < this.selected.length; i++) {
let s = this.selected[i]
if (scp.tags.indexOf(s) < 0) {
return false
}
}
return true
}
},
{
id: 'search',
query: '',
searchFields: ['name', 'object_class'],
init: function (self, initialData) {
if (initialData) {
this.query = initialData
} else {
this.query = ''
}
$(self.$el).dropdown('refresh')
},
apply: function () {
return this.query.length > 0
},
match: function (scp) {
let id = parseInt(this.query, '10')
if (!isNaN(id) & scp.id === id) {
return true
}
let length = this.searchFields.length
for (var i = 0; i < length; i++) {
let f = this.searchFields[i]
if (fuzzysearch(this.query, scp[f].toLowerCase())) {
return true
}
}
}
}
],
toCache: function () {
cacheWorker.postMessage({ action: 'cache', data: this.data })
},
......@@ -28,8 +144,15 @@ var scpData = {
var restored = JSON.parse(LZString.decompress(dataRow))
return restored
},
load (component) {
load (callback) {
let self = this
if (!callback) {
callback = function () {}
}
if (this.alreadyLoaded()) {
return callback()
}
let url = 'http://eliotberriot.pages.eliotberriot.com/scp-browser/data.json'
console.log('fetching data (this may take some time)...')
this.loading = true
......@@ -37,7 +160,8 @@ var scpData = {
this.data = this.restoreCache()
this.loading = false
console.log('restored from cache')
return
this.initFilters()
return callback()
} catch (e) {
console.log('Cannot restore from cache', e)
}
......@@ -53,6 +177,8 @@ var scpData = {
let r = JSON.parse(response)
self.data = r
self.loading = false
self.initFilters()
callback()
self.toCache()
},
error: response => {
......@@ -60,11 +186,69 @@ var scpData = {
self.loading = false
}
})
},
getObjectClassColor: function (objectClass) {
let colors = {
'null': 'black',
'safe': 'teal',
'euclid': 'orange',
'keter': 'red',
'thaumiel': 'purple',
'pending': 'yellow',
'neutralized': 'green',
'hiemal': 'blue'
}
let color = colors[objectClass]
if (!color) {
color = 'grey'
}
return color
},
alreadyLoaded () {
return this.data.scps.length > 0
},
matchFilters: function (scp, filters) {
<