summaryrefslogtreecommitdiff
path: root/app/client
diff options
context:
space:
mode:
Diffstat (limited to 'app/client')
-rw-r--r--app/client/common/fileList.component.js68
-rw-r--r--app/client/modules/samplernn/index.js3
-rw-r--r--app/client/modules/samplernn/samplernn.actions.js109
-rw-r--r--app/client/modules/samplernn/samplernn.datasets.js60
-rw-r--r--app/client/modules/samplernn/samplernn.loss.js10
-rw-r--r--app/client/modules/samplernn/samplernn.results.js108
-rw-r--r--app/client/util/index.js159
-rw-r--r--app/client/util/sort.js32
8 files changed, 413 insertions, 136 deletions
diff --git a/app/client/common/fileList.component.js b/app/client/common/fileList.component.js
index 13eb300..c16928f 100644
--- a/app/client/common/fileList.component.js
+++ b/app/client/common/fileList.component.js
@@ -5,7 +5,7 @@ import { Link } from 'react-router-dom';
import moment from 'moment'
import * as util from '../util'
-const defaultFields = new Set(['date', 'size'])
+const defaultFields = new Set(['name', 'date', 'size'])
export const FileList = props => {
const {
@@ -17,19 +17,19 @@ export const FileList = props => {
fileListClassName='filelist',
rowClassName='row file'
} = props
- const { mapFn, sortFn } = orderByFn(orderBy)
+ const { mapFn, sortFn } = util.sort.orderByFn(orderBy)
const fileList = (files || [])
.map(mapFn)
.sort(sortFn)
- .map(pair =>
- <FileRow
+ .map(pair => {
+ return <FileRow
file={pair[1]}
fields={fieldSet(fields)}
className={rowClassName}
linkFiles
onClick
/>
- )
+ })
return (
<div className={'rows ' + className}>
<div class='row heading'>
@@ -45,39 +45,6 @@ export const FileList = props => {
)
}
-const numericSort = {
- asc: (a,b) => a[0] - b[0],
- desc: (a,b) => b[0] - a[0],
-}
-const stringSort = {
- asc: (a,b) => a[0].localeCompare(b[0]),
- desc: (a,b) => b[0].localeCompare(a[0]),
-}
-export const orderByFn = (s='name asc') => {
- const [field='name', direction='asc'] = s.split(' ')
- let mapFn, sortFn
- switch (field) {
- case 'epoch':
- mapFn = a => [a.epoch || a.epochs, a]
- sortFn = numericSort[direction]
- break
- case 'size':
- mapFn = a => [a.size, a]
- sortFn = numericSort[direction]
- break
- case 'date':
- mapFn = a => [+new Date(a.date || a.created_at), a]
- sortFn = numericSort[direction]
- break
- case 'name':
- default:
- mapFn = a => [a.id || a.name, a]
- sortFn = stringSort[direction]
- break
- }
- return { mapFn, sortFn }
-}
-
export const fieldSet = fields => {
if (fields) {
if (fields instanceof Set) {
@@ -94,23 +61,29 @@ export const FileRow = props => {
const size = util.hush_size(file.size)
const date = file.date || file.created_at
+ const epoch = file.epoch || file.epochs || 0
return (
<div class={className} key={file.name}>
- <div className="filename" title={file.name || file.url}>
- {file.persisted === false
- ? <span>{file.name || file.url}</span>
- : (linkFiles && file.url)
- ? <a target='_blank' href={file.url}>{file.name || file.url}</a>
- : <span class='link' onClick={() => onClick(file)}>{file.name || file.url}</span>
- }
- </div>
+ {fields.has('name') &&
+ <div className="filename" title={file.name || file.url}>
+ {file.persisted === false
+ ? <span className='unpersisted'>{file.name || file.url}</span>
+ : (linkFiles && file.url)
+ ? <a target='_blank' href={file.url}>{file.name || file.url}</a>
+ : <span class='link' onClick={() => onClick(file)}>{file.name || file.url}</span>
+ }
+ </div>
+ }
{fields.has('age') &&
<div className={"age " + util.carbon_date(date)}>{util.get_age(date)}</div>
}
{fields.has('username') &&
<div className={"username"}>{username}</div>
}
+ {fields.has('epoch') &&
+ <div className={"epoch " + util.hush_null(epoch)[0]}>{epoch > 0 ? 'ep. ' + epoch : ''}</div>
+ }
{fields.has('date') &&
<div className={"date " + util.carbon_date(date)}>{moment(date).format("YYYY-MM-DD")}</div>
}
@@ -120,9 +93,6 @@ export const FileRow = props => {
{fields.has('size') &&
<div className={"size " + size[0]}>{size[1]}</div>
}
- {fields.has('epoch') &&
- <div className="epoch">{file.epoch > 0 ? 'epoch ' + file.epoch : ' '}</div>
- }
{(fields.has('activity') || fields.has('module')) &&
<div className='activity'>
{fields.has('activity') && file.activity}
diff --git a/app/client/modules/samplernn/index.js b/app/client/modules/samplernn/index.js
index 7066942..e1c41d5 100644
--- a/app/client/modules/samplernn/index.js
+++ b/app/client/modules/samplernn/index.js
@@ -1,6 +1,7 @@
import { h, Component } from 'preact'
import { Route, Link } from 'react-router-dom'
import SampleRNNDatasets from './samplernn.datasets'
+import SampleRNNResults from './samplernn.results'
import SampleRNNInspect from './samplernn.inspect'
import SampleRNNLoss from './samplernn.loss'
@@ -9,8 +10,10 @@ function router () {
<div>
<Route exact path='/samplernn/graph/' component={SampleRNNLoss} />
<Route exact path='/samplernn/inspect/' component={SampleRNNInspect} />
+ <Route exact path='/samplernn/datasets/' component={SampleRNNDatasets} />
<Route exact path='/samplernn/datasets/:id/' component={SampleRNNDatasets} />
<Route exact path='/samplernn/datasets/new/' component={SampleRNNDatasets} />
+ <Route exact path='/samplernn/results/' component={SampleRNNResults} />
</div>
)
}
diff --git a/app/client/modules/samplernn/samplernn.actions.js b/app/client/modules/samplernn/samplernn.actions.js
index 1a60719..e8f8251 100644
--- a/app/client/modules/samplernn/samplernn.actions.js
+++ b/app/client/modules/samplernn/samplernn.actions.js
@@ -12,16 +12,38 @@ export const load_directories = (id) => (dispatch) => {
actions.socket.list_directory({ module: 'samplernn', dir: 'datasets' }),
actions.socket.list_directory({ module: 'samplernn', dir: 'results' }),
actions.socket.list_directory({ module: 'samplernn', dir: 'output' }),
+ load_loss()(dispatch),
]).then(res => {
// console.log(res)
- const [folders, files, tasks, datasets, results, output] = res
+ const [folders, files, tasks, datasets, results, output, lossReport] = res
- const empty_dataset = (name) => ({
- name,
- input: [],
- checkpoints: [],
- output: [],
- })
+ const unsortedFolder = {
+ id: 0,
+ name: 'unsorted',
+ datasets: [],
+ }
+
+ const datasetLookup = {}
+
+ const get_dataset = (name, folder=unsortedFolder, date) => {
+ const dataset = datasetLookup[name] || empty_dataset(name, folder)
+ if (date) {
+ dataset.date = dataset.date ? Math.max(+new Date(date), dataset.date) : +new Date(date)
+ }
+ return dataset
+ }
+
+ const empty_dataset = (name, folder=unsortedFolder) => {
+ const dataset = {
+ name,
+ input: [],
+ checkpoints: [],
+ output: [],
+ }
+ datasetLookup[dataset.name] = dataset
+ folder.datasets.push(dataset)
+ return dataset
+ }
// take all of the folders and put them in a lookup
const folderLookup = folders.reduce((folderLookup, folder) => {
@@ -29,11 +51,7 @@ export const load_directories = (id) => (dispatch) => {
folder.datasets = []
return folderLookup
}, {
- 'unsorted': {
- id: 0,
- name: 'unsorted',
- datasets: [],
- }
+ unsorted: unsortedFolder
})
// prepare the files by splitting into two groups
@@ -41,61 +59,34 @@ export const load_directories = (id) => (dispatch) => {
const ungeneratedFiles = files.filter(file => !file.generated)
// build the initial dataset lookup table using the ungenerated files
- const datasetLookup = ungeneratedFiles.reduce((datasetLookup, file) => {
- file.checkpoints = []
+ ungeneratedFiles.reduce((datasetLookup, file) => {
if (! file.name) {
file.name = (file.opt || {}).token || file.url
- // datasetLookup[] = file
}
const name = (file.name || 'unsorted').split('.')[0]
- if (! datasetLookup[name]) {
- const dataset = empty_dataset(name)
- datasetLookup[dataset.name] = dataset
- dataset.input.push(file)
- folderLookup[file.folder_id].datasets.push(dataset)
- }
- else {
- datasetLookup[name].input.push(file)
- }
+ const dataset = get_dataset(name, folderLookup[file.folder_id], unsortedFolder, file.date)
+ dataset.input.push(file)
return datasetLookup
- }, {
- unsorted: empty_dataset('unsorted')
- })
+ }, datasetLookup)
// go over the generated files and add addl datasets (if the files were deleted)
generatedFiles.map(file => {
const pair = file.name.split('.')[0].split('-')
- let dataset = datasetLookup[pair[0]]
- if (!dataset) {
- dataset = empty_dataset(pair[0])
- datasetLookup[dataset.name] = dataset
- folderLookup[file.folder_id].datasets.push(dataset)
- }
+ const dataset = get_dataset(pair[0], folderLookup[file.folder_id], unsortedFolder, file.date)
dataset.output.push(file)
file.epoch = file.epoch || pair[1]
})
- // console.log(datasets)
const flatDatasets = datasets.filter(s => s.name.match(/(wav|aiff?|flac|mp3)$/) && !s.dir)
const builtDatasets = datasets.filter(s => s.dir)
builtDatasets.forEach(dir => {
- let dataset = datasetLookup[dir.name]
- if (! dataset) {
- dataset = empty_dataset(dir.name)
- datasetLookup[dataset.name] = dataset
- folderLookup.unsorted.datasets.push(dataset)
- }
+ const dataset = get_dataset(dir.name)
dataset.isBuilt = true
})
flatDatasets.forEach(file => {
const name = file.name.split('.')[0]
- let dataset = datasetLookup[name]
- if (! dataset) {
- dataset = empty_dataset(name)
- datasetLookup[dataset.name] = dataset
- folderLookup.unsorted.datasets.push(dataset)
- }
+ const dataset = get_dataset(name, unsortedFolder, file.date)
file.persisted = false
dataset.input.push(file)
})
@@ -107,14 +98,15 @@ export const load_directories = (id) => (dispatch) => {
.map(s => s.split(':'))
.filter(b => b.length && b[1])
.reduce((a,b) => (a[b[0]] = b[1]) && a, {})
- checkpoint.name = checkpoint.dataset || checkpoint.exp
+ checkpoint.name = checkpoint.name || checkpoint.dataset || checkpoint.exp
checkpoint.date = s.date
checkpoint.dir = s
- let dataset = datasetLookup[checkpoint.dataset]
- if (! dataset) {
- dataset = empty_dataset(checkpoint.dataset)
- datasetLookup[dataset.name] = dataset
- folderLookup.unsorted.datasets.push(dataset)
+ checkpoint.persisted = false
+ const dataset = get_dataset(checkpoint.name, unsortedFolder, checkpoint.date)
+ const loss = lossReport[checkpoint.name]
+ if (loss) {
+ dataset.epoch = checkpoint.epoch = loss.length
+ checkpoint.training_loss = loss
}
dataset.checkpoints.push(checkpoint)
return checkpoint
@@ -122,14 +114,10 @@ export const load_directories = (id) => (dispatch) => {
output.map(file => {
const pair = file.name.split('.')[0].split('-')
- let dataset = datasetLookup[pair[0]]
- if (!dataset) {
- dataset = empty_dataset(pair[0])
- datasetLookup[dataset.name] = dataset
- folderLookup.unsorted.datasets.push(dataset)
- }
+ const dataset = get_dataset(pair[0], unsortedFolder, file.date)
file.persisted = false
- file.epoch = file.epoch || pair[1].replace(/^\D+/, '')
+ file.epoch = parseInt(file.epoch || pair[1].replace(/^\D+/, '')) || 0
+ dataset.epoch = Math.max(file.epoch, dataset.epoch || 0)
dataset.output.push(file)
})
@@ -156,7 +144,7 @@ export const load_directories = (id) => (dispatch) => {
}
export const load_loss = () => dispatch => {
- actions.socket.run_script({ module: 'samplernn', activity: 'report' })
+ return actions.socket.run_script({ module: 'samplernn', activity: 'report' })
.then(report => {
const lossReport = {}
report.stdout.split('\n\n').filter(a=>!!a).forEach(data => {
@@ -175,6 +163,7 @@ export const load_loss = () => dispatch => {
type: types.samplernn.load_loss,
lossReport
})
+ return lossReport
})
}
diff --git a/app/client/modules/samplernn/samplernn.datasets.js b/app/client/modules/samplernn/samplernn.datasets.js
index b6a12cc..aa729a5 100644
--- a/app/client/modules/samplernn/samplernn.datasets.js
+++ b/app/client/modules/samplernn/samplernn.datasets.js
@@ -1,6 +1,7 @@
import { h, Component } from 'preact'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
+import * as util from '../../util'
import * as samplernnActions from './samplernn.actions'
@@ -18,11 +19,15 @@ class SampleRNNDatasets extends Component {
super()
this.fileOptions = this.fileOptions.bind(this)
this.pickFile = this.pickFile.bind(this)
- let id = props.match.params.id
+ let id = props.match.params.id || localStorage.getItem('samplernn.last_id')
if (! id && props.location.pathname.match(/\/new\//)) {
id = 'new'
+ } else if (id) {
+ localStorage.setItem('samplernn.last_id', id)
+ }
+ if (id && (! props.samplernn.folder || props.samplernn.folder.id !== id)) {
+ props.actions.load_directories(id)
}
- props.actions.load_directories(id)
}
pickFile(file){
console.log('pick', file)
@@ -50,28 +55,45 @@ class SampleRNNDatasets extends Component {
</div>
)
}
- fetchURL(url) {
- }
render(){
const { samplernn } = this.props
const folder = samplernn.folder
if (!folder.name) return
- console.log(folder)
- const datasets = folder.datasets.map(dataset =>{
- console.log(dataset)
+ const { mapFn, sortFn } = util.sort.orderByFn('date desc')
+ const datasets = folder.datasets.map(mapFn).sort(sortFn).map(pair => {
+ const dataset = pair[1]
return (
<div className='row dataset'>
<div className='col'>
- {!!dataset.input.length && <FileList files={dataset.input} className='input_files' fileListClassName='' rowClassName='input_file' />}
+ {!!dataset.input.length &&
+ <FileList
+ files={dataset.input}
+ className='input_files'
+ fileListClassName=''
+ rowClassName='input_file'
+ />
+ }
</div>
<div className='col quiet'>
- <div>{dataset.isBuilt ? 'has dataset' : 'not built'}</div>
+ <div>{dataset.isBuilt ? 'cached' : ''}</div>
</div>
<div className='col checkpoint'>
- {!!dataset.checkpoints.length && <FileRow file={dataset.checkpoints[0]} />}
+ {!!dataset.checkpoints.length &&
+ <FileRow
+ file={dataset.checkpoints[0]}
+ fields={'name date epoch'}
+ className='row checkpoint'
+ />
+ }
</div>
<div className='col'>
- {!!dataset.output.length && <FileList files={dataset.output} orderBy='epoch desc' />}
+ {!!dataset.output.length &&
+ <FileList
+ files={dataset.output}
+ orderBy='epoch desc'
+ fields={'name date epoch size'}
+ />
+ }
</div>
</div>
)
@@ -79,12 +101,12 @@ class SampleRNNDatasets extends Component {
return (
<div className='app'>
<div className='heading'>
- <h3>SampleRNN</h3>
+ <h2>SampleRNN</h2>
</div>
<div class='rows params datasets'>
<div class='row dataset'>
<div class='col'>input</div>
- <div class='col'>dataset</div>
+ <div class='col'></div>
<div class='col'>checkpoint</div>
<div class='col'>output</div>
</div>
@@ -95,18 +117,6 @@ class SampleRNNDatasets extends Component {
}
}
-// <Dataset
-// canRename
-// canUpload
-// canAddURL
-// canDeleteFile
-// linkFiles
-// fileOptions={this.fileOptions}
-// onPick={this.handlePick}
-// folder={samplernn.folder}
-// files={samplernn.folder.files}
-// />
-
const mapStateToProps = state => ({
samplernn: state.module.samplernn,
runner: state.system.runner,
diff --git a/app/client/modules/samplernn/samplernn.loss.js b/app/client/modules/samplernn/samplernn.loss.js
index 3900c31..04d16c8 100644
--- a/app/client/modules/samplernn/samplernn.loss.js
+++ b/app/client/modules/samplernn/samplernn.loss.js
@@ -95,9 +95,15 @@ class SampleRNNLoss extends Component {
// if ( (ii % 1) < 0.1) {
// ctx.strokeStyle = 'rgba(255,255,255,1.0)'
ctx.lineWidth = 2
+ ctx.setLineDash([4, 4])
ctx.stroke()
+ ctx.stroke()
ctx.stroke()
- ctx.stroke()
+ ctx.setLineDash([0,0])
+ const fontSize = 12
+ ctx.font = 'italic ' + (fontSize * devicePixelRatio) + 'px "Georgia"'
+ ctx.fill = 'black'
+ ctx.fillText(ii.toFixed(1), w-50, (h-Y) + fontSize + (10 * devicePixelRatio))
// }
}
ctx.lineWidth = 1
@@ -106,7 +112,7 @@ class SampleRNNLoss extends Component {
const loss = lossReport[key]
const vf = parseFloat(loss[loss.length-1].training_loss) || 0
const vg = parseFloat(loss[0].training_loss) || 5
- console.log(vf)
+ // console.log(vf)
const vv = 1 - norm(vf, scaleMin, scaleMax/2)
ctx.lineWidth = (1-norm(vf, scaleMin, scaleMax)) * 5
ctx.strokeStyle = 'rgba(' + [randrange(30,190), randrange(30,150), randrange(60,120)].join(',') + ',' + 0.8+ ')'
diff --git a/app/client/modules/samplernn/samplernn.results.js b/app/client/modules/samplernn/samplernn.results.js
new file mode 100644
index 0000000..518864b
--- /dev/null
+++ b/app/client/modules/samplernn/samplernn.results.js
@@ -0,0 +1,108 @@
+import { h, Component } from 'preact'
+import { bindActionCreators } from 'redux'
+import { connect } from 'react-redux'
+import * as util from '../../util'
+
+import * as samplernnActions from './samplernn.actions'
+
+import Dataset from '../../dataset/dataset.component'
+
+import Group from '../../common/group.component'
+import Slider from '../../common/slider.component'
+import Select from '../../common/select.component'
+import Button from '../../common/button.component'
+import { FileList, FileRow } from '../../common/fileList.component'
+import TextInput from '../../common/textInput.component'
+
+class SampleRNNResults extends Component {
+ constructor(props){
+ super()
+ this.fileOptions = this.fileOptions.bind(this)
+ this.pickFile = this.pickFile.bind(this)
+ let id = props.match.params.id || localStorage.getItem('samplernn.last_id')
+ if (!props.samplernn.data) props.actions.load_directories()
+ }
+ pickFile(file){
+ console.log('pick', file)
+ }
+ fileOptions(file){
+ if (file.activity === 'url' && !file.dataset) {
+ if (this.props.runner.cpu.status !== 'IDLE') {
+ return (
+ <div className='gray'>
+ fetching...
+ </div>
+ )
+ } else {
+ return (
+ <div className='link' onClick={() => this.fetchURL(file.url)}>
+ fetch
+ </div>
+ )
+ }
+ }
+ return (
+ <div>
+ <div className='link' onClick={() => this.train(file)}>train</div>
+ {file.epoch == 0 && <div className='epochs'>{file.epochs} ep.</div>}
+ </div>
+ )
+ }
+ render(){
+ if (this.props.samplernn.loading) return <span>Loading</span>
+ console.log(this.props.samplernn.data)
+ const { folderLookup } = this.props.samplernn.data
+ // const { folderLookup } = samplernn
+ console.log(this.props)
+
+ const renders = Object.keys(folderLookup).sort(util.sort.stringSort.asc).map(key => {
+ const folder = folderLookup[key]
+ let { mapFn, sortFn } = util.sort.orderByFn('epoch desc')
+ console.log(mapFn, sortFn)
+ const datasetPairs = folder.datasets.map(mapFn).sort(sortFn)
+ const bestRenders = datasetPairs
+ .map(pair => pair[1])
+ .filter(dataset => dataset.output.length)
+ .map(dataset => {
+ const { output } = dataset
+ return output.map(mapFn).sort(sortFn)[0][1]
+ })
+ console.log(bestRenders.map(r => r.epoch))
+ return (
+ <div className='col'>
+ <h3>{key}</h3>
+ {!!bestRenders.length &&
+ <FileList
+ files={bestRenders}
+ orderBy='epoch desc'
+ fields={'name date epoch size'}
+ />
+ }
+ </div>
+ )
+ })
+
+ return (
+ <div className='app'>
+ <div className='heading'>
+ <h2>SampleRNN</h2>
+ </div>
+ <div class='rows params renders'>
+ {renders}
+ </div>
+ </div>
+ )
+ }
+}
+
+const mapStateToProps = state => ({
+ samplernn: state.module.samplernn,
+ runner: state.system.runner,
+ task: state.task,
+})
+
+const mapDispatchToProps = (dispatch, ownProps) => ({
+ actions: bindActionCreators(samplernnActions, dispatch),
+})
+
+export default connect(mapStateToProps, mapDispatchToProps)(SampleRNNResults)
diff --git a/app/client/util/index.js b/app/client/util/index.js
new file mode 100644
index 0000000..6ae1f64
--- /dev/null
+++ b/app/client/util/index.js
@@ -0,0 +1,159 @@
+import * as sort from './sort'
+
+export {
+ sort
+}
+
+export const is_iphone = !!((navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i)))
+export const is_ipad = !!(navigator.userAgent.match(/iPad/i))
+export const is_android = !!(navigator.userAgent.match(/Android/i))
+export const is_mobile = is_iphone || is_ipad || is_android
+export const is_desktop = ! is_mobile;
+
+const htmlClassList = document.body.parentNode.classList
+htmlClassList.add(is_desktop ? 'desktop' : 'mobile')
+htmlClassList.remove('loading')
+
+// window.debug = false
+
+export function clamp(n,a,b) { return n<a?a:n<b?n:b }
+export function norm(n,a,b) { return (n-a) / (b-a) }
+export function lerp(n,a,b) { return (b-a)*n+a }
+export function mix(n,a,b) { return a*(1-n)+b*n }
+export function randint(n) { return Math.floor(Math.random()*n) }
+export function randrange(a,b){ return Math.random() * (b-a) + a }
+
+document.body.style.backgroundImage = 'linear-gradient(' + (randint(40)+40) + 'deg, #fde, #ffe)'
+
+export function timeInSeconds(n){
+ return (n / 10).toFixed(1) + ' s.'
+}
+export function gerund(s){
+ return s.replace(/e?$/, 'ing')
+}
+export function commatize (n, radix) {
+ radix = radix || 1024
+ var nums = [], i, counter = 0, r = Math.floor
+ if (n > radix) {
+ n /= radix
+ nums.unshift(r((n * 10) % 10))
+ nums.unshift(".")
+ }
+ do {
+ i = n % 10
+ n = r(n / 10)
+ if (n && ! (++counter % 3))
+ { i = ' ' + r(i) }
+ nums.unshift(r(i))
+ }
+ while (n)
+ return nums.join("")
+}
+export function carbon_date (date, no_bold) {
+ var span = (+new Date() - new Date(date)) / 1000, color
+ if (! no_bold && span < 86400) // modified today
+ { color = "new" }
+ else if (span < 604800) // modifed this week
+ { color = "recent" }
+ else if (span < 1209600) // modifed 2 weeks ago
+ { color = "med" }
+ else if (span < 3024000) // modifed 5 weeks ago
+ { color = "old" }
+ else if (span < 12315200) // modifed 6 months ago
+ { color = "older" }
+ else
+ { color = "quiet" }
+ return color
+}
+export function hush_views (n, bias, no_bold) {
+ var txt = commatize(n, 1000)
+ bias = bias || 1
+ n = n || 0
+ if (n < 30) { return["quiet", n + " v."] }
+ if (n < 200) { return ["quiet", txt + " v."] }
+ else if (n < 500) { return ["quiet", txt + " v."] }
+ else if (n < 1000) { return ["old", txt + " v."] }
+ else if (n < 5000) { return ["med", txt + " kv."] }
+ else if (no_bold || n < 10000) { return ["recent", txt + " kv."] }
+ else { return ["new", txt + " kv."] }
+}
+export function hush_threads (n, bias, no_bold) {
+ var txt = commatize(n, 1000)
+ bias = bias || 1
+ n = n || 0
+ if (n < 10) { return["quiet", n + " t."] }
+ else if (n < 25) { return ["old", txt + " t."] }
+ else if (n < 50) { return ["med", txt + " t."] }
+ else if (no_bold || n < 100) { return ["recent", txt + " t."] }
+ else { return ["new", txt + " t."] }
+}
+export function hush_size (n, bias, no_bold) {
+ var txt = commatize(Math.round(n / 1024))
+ bias = 1 || bias
+ n = n || 0
+ if (! n) { return ['', ''] }
+ if (n < 1000) {
+ return ["quiet", n + " b."]
+ }
+ if (n < 1000000) {
+ return ["quiet", txt + " kb."]
+ }
+ else if (n < (20000000/bias)) {
+ return ["quiet", txt + " mb."]
+ }
+ else if (n < (50000000/bias)) {
+ return ["old", txt + " mb."]
+ }
+ else if (n < (80000000/bias)) {
+ return ["med", txt + " mb."]
+ }
+ else if (no_bold || n < (170000000/bias)) {
+ return ["recent", txt + " mb."]
+ }
+ else {
+ return ["new", txt + " mb."]
+ }
+}
+export function hush_null (n, unit, no_bold) {
+ var s = unit ? n + " " + unit + "." : n
+ if (n < 3) {
+ return ["quiet", s]
+ }
+ else if (n < 6) {
+ return ["older", s]
+ }
+ else if (n < 10) {
+ return ["old", s]
+ }
+ else if (n < 16) {
+ return ["med", s]
+ }
+ else if (no_bold || n < 21) {
+ return ["recent", s]
+ }
+ else {
+ return ["new", s]
+ }
+}
+export function get_age (t) {
+ var age = Math.abs(+Date.now() - new Date(t))/1000
+ var r = Math.floor
+ var m
+ if (age < 5) { return "now" }
+ if (age < 60) { return r(age) + "s" }
+ age /= 60
+ if (age < 60) { return r(age) + "m" }
+ m = r(age % 60)
+ age /= 60
+ if (m > 0 && age < 2) { return r(age) + "h" + m + "m" }
+ if (age < 24) { return r(age) + "h" }
+ age /= 24
+ if (age < 7) { return r(age) + "d" }
+ age /= 7
+ if (age < 12) { return r(age) + "w" }
+ age /= 4
+ if (age < 12) { return r(age) + "m" }
+ age /= 12
+ return r(age) + "y"
+}
+export function courtesy_s (n, s) { return n == 1 ? "" : (s || "s") }
diff --git a/app/client/util/sort.js b/app/client/util/sort.js
new file mode 100644
index 0000000..cc1b462
--- /dev/null
+++ b/app/client/util/sort.js
@@ -0,0 +1,32 @@
+export const numericSort = {
+ asc: (a,b) => a[0] - b[0],
+ desc: (a,b) => b[0] - a[0],
+}
+export const stringSort = {
+ asc: (a,b) => a[0].localeCompare(b[0]),
+ desc: (a,b) => b[0].localeCompare(a[0]),
+}
+export const orderByFn = (s='name asc') => {
+ const [field='name', direction='asc'] = s.split(' ')
+ let mapFn, sortFn
+ switch (field) {
+ case 'epoch':
+ mapFn = a => [parseInt(a.epoch || a.epochs) || 0, a]
+ sortFn = numericSort[direction]
+ break
+ case 'size':
+ mapFn = a => [a.size, a]
+ sortFn = numericSort[direction]
+ break
+ case 'date':
+ mapFn = a => [+new Date(a.date || a.created_at), a]
+ sortFn = numericSort[direction]
+ break
+ case 'name':
+ default:
+ mapFn = a => [a.id || a.name, a]
+ sortFn = stringSort[direction]
+ break
+ }
+ return { mapFn, sortFn }
+}