Restart with full Vue project
This commit is contained in:
commit
9b5d7228a1
|
@ -0,0 +1,2 @@
|
||||||
|
*node_modules
|
||||||
|
*vscode
|
|
@ -0,0 +1,3 @@
|
||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not ie <= 8
|
|
@ -0,0 +1,9 @@
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"name": "mixit",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.18.0",
|
||||||
|
"chart.js": "^2.8.0",
|
||||||
|
"vue": "^2.6.10",
|
||||||
|
"vue-chartjs": "^3.4.2",
|
||||||
|
"vue-class-component": "^6.0.0",
|
||||||
|
"vue-property-decorator": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/cli-plugin-typescript": "^3.0.3",
|
||||||
|
"@vue/cli-service": "^3.0.3",
|
||||||
|
"babel-plugin-transform-decorators": "^6.24.1",
|
||||||
|
"pug": "^2.0.3",
|
||||||
|
"pug-plain-loader": "^1.0.0",
|
||||||
|
"sass-loader": "^7.0.1",
|
||||||
|
"tslint-config-prettier": "^1.15.0",
|
||||||
|
"tslint-plugin-prettier": "^1.3.0",
|
||||||
|
"typescript": "^3.0.0",
|
||||||
|
"core-js": "^2.6.5",
|
||||||
|
"css-loader": "^0.28.11",
|
||||||
|
"eslint": "^5.16.0",
|
||||||
|
"eslint-plugin-vue": "^5.2.2",
|
||||||
|
"file-loader": "^1.1.11",
|
||||||
|
"friendly-errors-webpack-plugin": "^1.7.0",
|
||||||
|
"less": "^3.0.2",
|
||||||
|
"less-loader": "^4.1.0",
|
||||||
|
"node-sass": "^4.11.0",
|
||||||
|
"optimize-css-assets-webpack-plugin": "^4.0.0",
|
||||||
|
"url-loader": "^1.0.1",
|
||||||
|
"vue-loader": "^15.4.2",
|
||||||
|
"vue-style-loader": "^4.1.2",
|
||||||
|
"vue-template-compiler": "^2.6.10"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Mixit</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but mixit doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,367 @@
|
||||||
|
<template lang="pug">
|
||||||
|
#app
|
||||||
|
#errors(v-show="errors")
|
||||||
|
.error(v-for="(error, key) in errors" @click="removeError(key)") {{ error }}
|
||||||
|
|
||||||
|
#content
|
||||||
|
#services
|
||||||
|
.tile(v-if="tiles" v-for="tile in tiles" :style="tile.grid")
|
||||||
|
component(:is="tile.service.type" :emit="tile.emiter" :auth="tile.service.auth" :options="tile.options")
|
||||||
|
|
||||||
|
button#showManager(@click="toggleManager") {{ managerButton }}
|
||||||
|
#manager(v-show="showManager")
|
||||||
|
.newService
|
||||||
|
//TODO: change to select
|
||||||
|
input(v-model="newService" @keyup.enter="addService" placeholder="service")
|
||||||
|
#layout-select
|
||||||
|
.layout(v-for="(layout, id) in layouts.get().data")
|
||||||
|
template(v-if="layouts.get().selectedId == id")
|
||||||
|
input(:value="layout.name" @keyup.ctrl.delete="removeSelectedLayout()"
|
||||||
|
@keyup.enter="renameSelectedLayout($event.target.value)")
|
||||||
|
button(v-else @click="selectLayout(id)") {{ layout.name }}
|
||||||
|
.layout
|
||||||
|
button(@click="addLayout") +
|
||||||
|
.showService
|
||||||
|
select(@change="showService($event.target.value)")
|
||||||
|
option(selected disabled) ---
|
||||||
|
option(v-for="(service, key) in services.get()" :value="key")
|
||||||
|
| {{ service.name || service.type }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from 'vue-property-decorator'
|
||||||
|
import { Selectable } from './helpers/lists/Selectable'
|
||||||
|
import LocalStorageHandler from './helpers/storage/LocalStorageHandler'
|
||||||
|
import DiscordVue from './services/discord/Discord.vue'
|
||||||
|
import MastodonVue from './services/mastodon/Mastodon.vue'
|
||||||
|
import NextcloudNewsVue from './services/nextcloud/NextcloudNews.vue'
|
||||||
|
import OpenWeatherMapVue from './services/openweathermap/OpenWeatherMap.vue'
|
||||||
|
import { Auth, Layout, Rect, Service, serviceKey, tileKey } from './types/App'
|
||||||
|
import * as Events from './types/Events'
|
||||||
|
|
||||||
|
const layoutsStorage = 'layouts'
|
||||||
|
const servicesStorage = 'services'
|
||||||
|
|
||||||
|
function saveAuth(auth: Auth) {
|
||||||
|
const res: any = {}
|
||||||
|
for (const entry of auth.entries()) {
|
||||||
|
res[entry[0]] = entry[1]
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
mastodon: MastodonVue,
|
||||||
|
'nextcloud-news': NextcloudNewsVue,
|
||||||
|
openweathermap: OpenWeatherMapVue,
|
||||||
|
discord: DiscordVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class App extends Vue {
|
||||||
|
showManager = false
|
||||||
|
|
||||||
|
layouts = new LocalStorageHandler(layoutsStorage,
|
||||||
|
new Selectable<Layout>([{ name: 'main', tiles: [] }]),
|
||||||
|
data => new Selectable<Layout>(data), l => l.data)
|
||||||
|
|
||||||
|
services = new LocalStorageHandler<Service[]>(servicesStorage, [],
|
||||||
|
(data: any[]) => data.map(s => ({ ...s, auth: new Auth(Object.entries(s.auth)) })),
|
||||||
|
data => data.map(s => ({ ...s, auth: saveAuth(s.auth) })))
|
||||||
|
newService = ''
|
||||||
|
|
||||||
|
errors: string[] = []
|
||||||
|
bus = new Vue()
|
||||||
|
|
||||||
|
get managerButton() {
|
||||||
|
return this.showManager ? '▼' : '▲'
|
||||||
|
}
|
||||||
|
|
||||||
|
get tiles() {
|
||||||
|
const layout = this.layouts.get().selected
|
||||||
|
if(layout) {
|
||||||
|
return layout.tiles.map((tile, key: tileKey) => {
|
||||||
|
const service = this.loadService(key, tile.service)
|
||||||
|
if(service) {
|
||||||
|
return {
|
||||||
|
...tile, service, serviceId: tile.service,
|
||||||
|
grid: this.gridPos(tile.position), emiter: this.makeEmiter(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.layouts.load()
|
||||||
|
this.services.load()
|
||||||
|
|
||||||
|
new Map<string, (event: Events.Message) => void>([
|
||||||
|
[ Events.ErrorEvent, this.onError ],
|
||||||
|
[ Events.SaveOptionsEvent, this.onSaveOptions ],
|
||||||
|
[ Events.SaveOptionEvent, this.onSaveOption ],
|
||||||
|
[ Events.MoveTileEvent, this.onMoveTile ],
|
||||||
|
[ Events.RemoveTileEvent, this.onRemoveTile ],
|
||||||
|
[ Events.SaveServiceEvent, this.onSaveService ],
|
||||||
|
[ Events.RemoveServiceEvent, this.onRemoveService ]
|
||||||
|
]).forEach((handler, name) => this.bus.$on(name, handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI
|
||||||
|
toggleManager() {
|
||||||
|
this.showManager = !this.showManager
|
||||||
|
}
|
||||||
|
selectLayout(id: number) {
|
||||||
|
this.layouts.get().select(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layouts
|
||||||
|
addLayout() {
|
||||||
|
this.layouts.edit(l => {
|
||||||
|
l.data.push({ name: 'layout' + l.data.length, tiles: [] })
|
||||||
|
return l
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renameSelectedLayout(name: string) {
|
||||||
|
this.layouts.edit(data => {
|
||||||
|
if(data.selected) {
|
||||||
|
data.selected.name = name
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
removeSelectedLayout() {
|
||||||
|
this.layouts.edit(data => data.remove())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiles
|
||||||
|
showService(id: serviceKey) {
|
||||||
|
this.layouts.edit(data => {
|
||||||
|
if(data.selected) {
|
||||||
|
data.selected.tiles.push({
|
||||||
|
service: id, position: {}, options: {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSaveOption({ key, msg }: Events.SaveOptionMessage) {
|
||||||
|
this.layouts.edit(data => {
|
||||||
|
if(data.selected) {
|
||||||
|
this.$set(data.selected.tiles[key].options, msg.key, msg.value)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSaveOptions({ key, msg }: Events.SaveOptionsMessage) {
|
||||||
|
this.layouts.edit(data => {
|
||||||
|
if(data.selected) {
|
||||||
|
let options = data.selected.tiles[key].options
|
||||||
|
options = Object.assign({}, options, msg)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMoveTile({ key, msg }: Events.MoveTileMessage) {
|
||||||
|
this.layouts.edit(data => {
|
||||||
|
if(data.selected){
|
||||||
|
const position = data.selected.tiles[key].position
|
||||||
|
this.$set(position, msg.type, Math.max(1,
|
||||||
|
(position[msg.type] || 1) + msg.direction
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onRemoveTile({ key }: Events.RemoveTileMessage) {
|
||||||
|
this.layouts.edit(data => {
|
||||||
|
if(data.selected) {
|
||||||
|
data.selected.tiles.splice(key, 1)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services
|
||||||
|
getServiceId(key: number) {
|
||||||
|
const tile = this.tiles[key]
|
||||||
|
if(tile) {
|
||||||
|
return tile.serviceId
|
||||||
|
} else {
|
||||||
|
throw new Error('tile not found')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addService() {
|
||||||
|
if (this.newService) {
|
||||||
|
this.services.edit(data => {
|
||||||
|
data.push({ type: this.newService, name: this.newService, auth: new Auth() })
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
this.showService(this.services.get().length - 1)
|
||||||
|
this.newService = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSaveService({ key, msg }: Events.SaveServiceMessage) {
|
||||||
|
const service = this.loadService(key, this.getServiceId(key))
|
||||||
|
if(service){
|
||||||
|
service.name = msg.name
|
||||||
|
service.auth = msg.auth
|
||||||
|
this.services.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onRemoveService({ key }: Events.RemoveServiceMessage) {
|
||||||
|
this.services.edit(data => {
|
||||||
|
data.splice(this.getServiceId(key), 1)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
this.onRemoveTile({ key, msg: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
onError({ msg }: Events.ErrorMessage) {
|
||||||
|
this.errors.push(msg.toString())
|
||||||
|
}
|
||||||
|
removeError(id: number) {
|
||||||
|
this.errors.splice(id, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
private gridPos(position: Rect) {
|
||||||
|
return {
|
||||||
|
'grid-row': `${position.x || 1} / span ${position.h || 2}`,
|
||||||
|
'grid-column': `${position.y || 1} / span ${position.w || 2}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private makeEmiter(key: tileKey) {
|
||||||
|
const bus = this.bus
|
||||||
|
return (name: string, msg: any) => {
|
||||||
|
bus.$emit(name, { key, msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private loadService(key: tileKey, id: serviceKey) {
|
||||||
|
const ser = this.services.get()[id]
|
||||||
|
if (ser){
|
||||||
|
return ser
|
||||||
|
} else {
|
||||||
|
this.onRemoveTile({ key, msg: undefined })
|
||||||
|
this.errors.push('Removing missing service')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@import 'common.sass'
|
||||||
|
|
||||||
|
*
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
font-family: Verdana, Geneva, sans-serif
|
||||||
|
scrollbar-width: thin
|
||||||
|
|
||||||
|
body
|
||||||
|
background-color: $backColor
|
||||||
|
color: $foreColor
|
||||||
|
|
||||||
|
a
|
||||||
|
text-decoration: none
|
||||||
|
color: $noneColor
|
||||||
|
|
||||||
|
input, select, button
|
||||||
|
background-color: $backColor
|
||||||
|
color: $foreColor
|
||||||
|
border: 1px solid $halfColor
|
||||||
|
|
||||||
|
.icon
|
||||||
|
width: 1em
|
||||||
|
height: 1em
|
||||||
|
vertical-align: middle
|
||||||
|
|
||||||
|
.text-icon
|
||||||
|
font-weight: bold
|
||||||
|
font-size: 1.2em
|
||||||
|
|
||||||
|
.note
|
||||||
|
font-size: .7em
|
||||||
|
vertical-align: text-top
|
||||||
|
|
||||||
|
.letter
|
||||||
|
margin: 0 .5em
|
||||||
|
|
||||||
|
.colored
|
||||||
|
color: orange
|
||||||
|
|
||||||
|
#errors
|
||||||
|
position: absolute
|
||||||
|
.error
|
||||||
|
@include tile
|
||||||
|
|
||||||
|
#content
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
height: 100vh
|
||||||
|
|
||||||
|
#showManager
|
||||||
|
position: absolute
|
||||||
|
bottom: 0
|
||||||
|
|
||||||
|
#manager
|
||||||
|
background-color: $tileColor
|
||||||
|
border-radius: $borderRadius
|
||||||
|
padding-left: 1em
|
||||||
|
height: 1.3em
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
|
||||||
|
#layout-select
|
||||||
|
display: flex
|
||||||
|
|
||||||
|
#services
|
||||||
|
flex: 1
|
||||||
|
overflow: hidden
|
||||||
|
display: grid
|
||||||
|
grid-gap: .2em
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr))
|
||||||
|
grid-template-rows: repeat(4, minmax(0, 1fr))
|
||||||
|
justify-items: stretch
|
||||||
|
.tile
|
||||||
|
overflow: auto
|
||||||
|
& > div
|
||||||
|
height: 100%
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
.service-header
|
||||||
|
.title, .settings
|
||||||
|
@include tile
|
||||||
|
.title
|
||||||
|
font-size: large
|
||||||
|
text-align: center
|
||||||
|
font-weight: bold
|
||||||
|
.settings .position
|
||||||
|
float: right
|
||||||
|
width: 1.2em
|
||||||
|
.service-content
|
||||||
|
overflow: hidden
|
||||||
|
.service-loader
|
||||||
|
display: inline-block
|
||||||
|
width: 64px
|
||||||
|
height: 64px
|
||||||
|
&:after
|
||||||
|
content: " "
|
||||||
|
display: block
|
||||||
|
width: 46px
|
||||||
|
height: 46px
|
||||||
|
margin: 1px
|
||||||
|
border-radius: 50%
|
||||||
|
border: 5px solid $noneColor
|
||||||
|
border-color: $noneColor transparent $noneColor transparent
|
||||||
|
animation: service-loader 1.2s linear infinite
|
||||||
|
@keyframes service-loader
|
||||||
|
0%
|
||||||
|
transform: rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: rotate(360deg)
|
||||||
|
</style>
|
|
@ -0,0 +1,15 @@
|
||||||
|
$avatarSize: 3em
|
||||||
|
$borderRadius: .3em
|
||||||
|
|
||||||
|
$backColor: #333
|
||||||
|
$tileColor: #222
|
||||||
|
$darkColor: #111
|
||||||
|
$halfColor: #999
|
||||||
|
$noneColor: #aaa
|
||||||
|
$foreColor: #eee
|
||||||
|
|
||||||
|
@mixin tile
|
||||||
|
margin: $borderRadius
|
||||||
|
background-color: $tileColor
|
||||||
|
border-radius: $borderRadius
|
||||||
|
padding: $borderRadius
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Component, Prop } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import { Auth } from '@/types/App'
|
||||||
|
import ServiceEmiter from './ServiceEmiter'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class ServiceClient<O> extends ServiceEmiter {
|
||||||
|
|
||||||
|
@Prop(Map)
|
||||||
|
readonly auth!: Auth
|
||||||
|
|
||||||
|
@Prop(Object)
|
||||||
|
readonly options!: O
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { AxiosPromise } from 'axios'
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import { ServiceData } from '@/types/App'
|
||||||
|
import * as Events from '@/types/Events'
|
||||||
|
|
||||||
|
@Component({})
|
||||||
|
export default class ServiceEmiter extends Vue {
|
||||||
|
@Prop(Function)
|
||||||
|
readonly emit!: (name: string, msg: any) => void
|
||||||
|
|
||||||
|
emitError(err: string) {
|
||||||
|
this.emit(Events.ErrorEvent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOptions(options: object) {
|
||||||
|
this.emit(Events.SaveOptionsEvent, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOption(key: string, value: any) {
|
||||||
|
this.saveOptionCouple({ key, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOptionCouple(couple: Events.Option) {
|
||||||
|
this.emit(Events.SaveOptionEvent, couple)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveService(service: ServiceData) {
|
||||||
|
this.emit(Events.SaveServiceEvent, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
catchEmit(req: AxiosPromise) {
|
||||||
|
return req.catch(err => {
|
||||||
|
this.emitError(err)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.service-header
|
||||||
|
.title(@click="showSettings = !showSettings")
|
||||||
|
slot(name="title")
|
||||||
|
.settings(v-show="showSettings")
|
||||||
|
input.position(
|
||||||
|
@keyup.up.ctrl.exact="onMove('x', -1)" @keyup.down.ctrl.exact="onMove('x', 1)"
|
||||||
|
@keyup.left.ctrl.exact="onMove('y', -1)" @keyup.right.ctrl.exact="onMove('y', 1)"
|
||||||
|
@keyup.up.alt.exact="onMove('h', -1)" @keyup.down.alt.exact="onMove('h', 1)"
|
||||||
|
@keyup.left.alt.exact="onMove('w', -1)" @keyup.right.alt.exact="onMove('w', 1)"
|
||||||
|
@keyup.delete.ctrl.exact="onRemove" @keyup.delete.alt.exact="onRemoveService"
|
||||||
|
)
|
||||||
|
slot(name="settings")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Mixins } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import * as Events from '../types/Events'
|
||||||
|
import ServiceEmiter from './ServiceEmiter'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class ServiceHeader extends Mixins(ServiceEmiter) {
|
||||||
|
|
||||||
|
showSettings = false
|
||||||
|
|
||||||
|
onMove(type: Events.MoveType, direction: Events.MoveDirection) {
|
||||||
|
this.emit(Events.MoveTileEvent, { type, direction })
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemove() {
|
||||||
|
this.emit(Events.RemoveTileEvent, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemoveService() {
|
||||||
|
this.emit(Events.RemoveServiceEvent, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class ShowMediaMixin extends Vue {
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, default: true })
|
||||||
|
readonly showMedia!: boolean
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Component, Emit, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class BaseSetting extends Vue {
|
||||||
|
|
||||||
|
@Prop(String)
|
||||||
|
readonly id!: string
|
||||||
|
|
||||||
|
@Prop(String)
|
||||||
|
readonly title!: string
|
||||||
|
|
||||||
|
@Emit('change')
|
||||||
|
sendChange(value: any) {
|
||||||
|
return { key: this.id, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
<template lang="pug">
|
||||||
|
extends model
|
||||||
|
|
||||||
|
block input
|
||||||
|
input(:id="id" type="checkbox" :checked="value" @change.stop="sendChange($event.target.checked)")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import BaseSettingVue from './BaseSetting'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class SettingBoolean extends BaseSettingVue {
|
||||||
|
|
||||||
|
@Prop(Boolean)
|
||||||
|
readonly value!: boolean
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,23 @@
|
||||||
|
<template lang="pug">
|
||||||
|
extends model
|
||||||
|
|
||||||
|
block input
|
||||||
|
input(:id="id" type="number" step="1" :value="value" @keyup.enter="sendChange(parseInt($event.target.value))")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import BaseSettingVue from './BaseSetting'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class SettingInt extends BaseSettingVue {
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
})
|
||||||
|
readonly value!: number
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<template lang="pug">
|
||||||
|
extends model
|
||||||
|
|
||||||
|
block input
|
||||||
|
input(:id="id" type="text" :value="value" @keyup.enter="sendChange($event.target.value)")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import BaseSettingVue from './BaseSetting'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class SettingString extends BaseSettingVue {
|
||||||
|
|
||||||
|
@Prop(String)
|
||||||
|
readonly value!: string
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,3 @@
|
||||||
|
p.setting
|
||||||
|
label(:for="id") {{ title }}:
|
||||||
|
block input
|
|
@ -0,0 +1,24 @@
|
||||||
|
<template lang="pug">
|
||||||
|
div.loadable-block
|
||||||
|
slot(name="success" v-if="loadable.isSuccess") {{ get }}
|
||||||
|
slot(name="error" v-else-if="loadable.error") {{ loadable.error }}
|
||||||
|
slot(name="loading" v-else)
|
||||||
|
.service-loader
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ErrorLoadable from '../../helpers/loadable/ErrorLoadable'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class LoadableBlock<T, E> extends Vue {
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
readonly loadable!: ErrorLoadable<T, E>
|
||||||
|
|
||||||
|
get get() {
|
||||||
|
return this.loadable.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,23 @@
|
||||||
|
<template lang="pug">
|
||||||
|
span.loadable-inline
|
||||||
|
slot(name="success" v-if="loadable.isSuccess") {{ get }}
|
||||||
|
slot(name="error" v-else-if="loadable.error") {{ loadable.error }}
|
||||||
|
slot(name="loading" v-else) Loading...
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ErrorLoadable from '../../helpers/loadable/ErrorLoadable'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class LoadableInline<T, E> extends Vue {
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
readonly loadable!: ErrorLoadable<T, E>
|
||||||
|
|
||||||
|
get get() {
|
||||||
|
return this.loadable.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { AxiosPromise, AxiosResponse } from 'axios'
|
||||||
|
import { Component } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import AxiosLoadable from '@/helpers/loadable/AxiosLoadable'
|
||||||
|
import { unsafeAxiosMapper } from '@/helpers/unsafeAxiosMapper'
|
||||||
|
import { Auth } from '@/types/App'
|
||||||
|
import ConnectedService from './ConnectedService'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class AccountService<T, E, U = T> extends ConnectedService<T, E> {
|
||||||
|
|
||||||
|
account = new AxiosLoadable<T, E>()
|
||||||
|
|
||||||
|
get connector() {
|
||||||
|
return this.account
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.account.load(this.catchEmit(this.getAccount(this.auth)), this.mapAccount)
|
||||||
|
}
|
||||||
|
mapAccount(res: AxiosResponse<U>) {
|
||||||
|
return unsafeAxiosMapper<T, U>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth(auth: Auth) {
|
||||||
|
return this.getAccount(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
mapServiceName(res: AxiosResponse<U>, newAuth: Auth) {
|
||||||
|
return `${this.serviceName} ${this.mapAccount(res)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccount(auth: Auth): AxiosPromise<U> {
|
||||||
|
throw this.mustBeDefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Component, Prop, Watch } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import { Auth } from '@/types/App'
|
||||||
|
import SettingBooleanVue from '../input/SettingBoolean.vue'
|
||||||
|
import SettingIntVue from '../input/SettingInt.vue'
|
||||||
|
import SettingStringVue from '../input/SettingString.vue'
|
||||||
|
import ServiceEmiter from '../ServiceEmiter'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
'setting-string': SettingStringVue,
|
||||||
|
'setting-int': SettingIntVue,
|
||||||
|
'setting-boolean': SettingBooleanVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class BaseService extends ServiceEmiter {
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: Map,
|
||||||
|
default() {
|
||||||
|
return new Auth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
readonly auth!: Auth
|
||||||
|
|
||||||
|
@Prop({ type: Object, default: {} })
|
||||||
|
readonly options!: object
|
||||||
|
|
||||||
|
@Watch('auth')
|
||||||
|
onAuthChanged() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
get mustBeDefined() {
|
||||||
|
return new Error('Must be defined')
|
||||||
|
}
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
throw this.mustBeDefined
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { AxiosPromise, AxiosResponse } from 'axios'
|
||||||
|
import { Component } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ErrorLoadable from '@/helpers/loadable/ErrorLoadable'
|
||||||
|
import { Auth } from '@/types/App'
|
||||||
|
import LoadableBlockVue from '../loadable/LoadableBlock.vue'
|
||||||
|
import LoadableInlineVue from '../loadable/LoadableInline.vue'
|
||||||
|
import BaseService from './BaseService'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
'loadable-inline': LoadableInlineVue,
|
||||||
|
'loadable-block': LoadableBlockVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class ConnectedService<T, E> extends BaseService {
|
||||||
|
|
||||||
|
get isSetup(): boolean {
|
||||||
|
throw this.mustBeDefined
|
||||||
|
}
|
||||||
|
get connector(): ErrorLoadable<T, E | string> {
|
||||||
|
throw this.mustBeDefined
|
||||||
|
}
|
||||||
|
get serviceName(): string {
|
||||||
|
throw this.mustBeDefined
|
||||||
|
}
|
||||||
|
|
||||||
|
newAuth = {}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.isSetup) {
|
||||||
|
this.load()
|
||||||
|
} else {
|
||||||
|
this.connector.fail('First connection')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeAuth() {
|
||||||
|
const auth = new Auth(Object.entries(this.newAuth))
|
||||||
|
this.catchEmit(this.checkAuth(auth)).then(res =>
|
||||||
|
this.saveService({ name: this.mapServiceName(res, auth), auth }))
|
||||||
|
}
|
||||||
|
|
||||||
|
mapServiceName(res: AxiosResponse, newAuth: Auth) {
|
||||||
|
return this.serviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
throw this.mustBeDefined
|
||||||
|
}
|
||||||
|
checkAuth(auth: Auth): AxiosPromise {
|
||||||
|
throw this.mustBeDefined
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
<template lang="pug">
|
||||||
|
span {{ timeSince }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Mixins, Prop } from 'vue-property-decorator'
|
||||||
|
import TimedMixin from './TimedMixin'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class FromNow extends Mixins(TimedMixin) {
|
||||||
|
|
||||||
|
protected static toNumber(date: Date | number | string) {
|
||||||
|
return Number(new Date(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Prop([Date, Number, String])
|
||||||
|
readonly date!: Date | number | string
|
||||||
|
|
||||||
|
get timeSince() {
|
||||||
|
const seconds = Math.floor((FromNow.toNumber(this.now) - FromNow.toNumber(this.date)) / 1000)
|
||||||
|
|
||||||
|
let interval = Math.floor(seconds / 31536000)
|
||||||
|
|
||||||
|
if (interval > 1) {
|
||||||
|
return interval + ' years'
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 2592000)
|
||||||
|
if (interval > 1) {
|
||||||
|
return interval + ' months'
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 86400)
|
||||||
|
if (interval > 1) {
|
||||||
|
return interval + ' days'
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 3600)
|
||||||
|
if (interval > 1) {
|
||||||
|
return interval + ' hours'
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 60)
|
||||||
|
if (interval > 1) {
|
||||||
|
return interval + ' minutes'
|
||||||
|
}
|
||||||
|
return Math.floor(seconds) + ' seconds'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class TimedMixin extends Vue {
|
||||||
|
|
||||||
|
@Prop([Number, Date])
|
||||||
|
readonly now!: number | Date
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Component, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class TimerMixin extends Vue {
|
||||||
|
|
||||||
|
now = Date.now()
|
||||||
|
|
||||||
|
created() {
|
||||||
|
const self = this
|
||||||
|
setInterval(() => {
|
||||||
|
self.now = Date.now()
|
||||||
|
}, 30 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
export default abstract class Lists {
|
||||||
|
|
||||||
|
static last<T>(list: T[]) {
|
||||||
|
return list[list.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
static getIndex<T>(list: T[], where: (val: T) => boolean) {
|
||||||
|
for (let i = list.length - 1; i >= 0; i--){
|
||||||
|
if (where(list[i])) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.length
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeAt<T>(list: T[], id: number) {
|
||||||
|
list.splice(id, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeFrom<T>(list: T[], start: number) {
|
||||||
|
list.splice(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeFirst<T>(list: T[], where: (val: T) => boolean) {
|
||||||
|
this.removeAt(list, this.getIndex(list, where))
|
||||||
|
}
|
||||||
|
|
||||||
|
static removeFirstBy<T, U>(list: T[], mapper: (val: T) => U, val: U) {
|
||||||
|
this.removeFirst(list, e => mapper(e) === val)
|
||||||
|
}
|
||||||
|
|
||||||
|
static pushAll<T>(list: T[], elems: T[]) {
|
||||||
|
list.push.apply(list, elems)
|
||||||
|
}
|
||||||
|
|
||||||
|
static clear<T>(list: T[]) {
|
||||||
|
list.splice(0, list.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
static for<T>(list: T[], action: (val: T, id: number, list: T[]) => void) {
|
||||||
|
for (let i = 0; i < list.length; i++){
|
||||||
|
action(list[i], i, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
export class Selectable<T> {
|
||||||
|
|
||||||
|
protected selectedId = 0
|
||||||
|
|
||||||
|
constructor(public data: T[]){ }
|
||||||
|
|
||||||
|
get selected(): T | undefined {
|
||||||
|
return this.data[this.selectedId]
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedKey() {
|
||||||
|
return this.selectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
select(key: number) {
|
||||||
|
this.selectedId = key
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(key: number) {
|
||||||
|
return this.selectedId === key
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.data.splice(this.selectedId, 1)
|
||||||
|
this.selectedId = 0
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { AxiosPromise, AxiosResponse } from 'axios'
|
||||||
|
import { unsafeAxiosMapper } from '../unsafeAxiosMapper'
|
||||||
|
import ErrorLoadable from './ErrorLoadable'
|
||||||
|
|
||||||
|
export default class AxiosLoadable<T, E> extends ErrorLoadable<T, E> {
|
||||||
|
|
||||||
|
load<U>(promise: AxiosPromise<U>, then: (res: AxiosResponse<U>) => T = res => unsafeAxiosMapper<T, U>(res), reset = true) {
|
||||||
|
if (reset) {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then(res => this.success(then(res)))
|
||||||
|
.catch(err => {
|
||||||
|
this.fail(err)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { AxiosPromise, AxiosResponse } from 'axios'
|
||||||
|
import AxiosLoadable from './AxiosLoadable'
|
||||||
|
|
||||||
|
export default class AxiosLoadableMore<T, E> extends AxiosLoadable<T, E> {
|
||||||
|
|
||||||
|
protected loadingMore = false
|
||||||
|
|
||||||
|
get isLoadingMore() {
|
||||||
|
return this.loadingMore
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
super.reset()
|
||||||
|
this.loadingMore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMore<U>(promise: AxiosPromise<U>, then: (res: AxiosResponse<U>, data: T) => void) {
|
||||||
|
this.loadingMore = true
|
||||||
|
promise.then(res => {
|
||||||
|
if (this.data) {
|
||||||
|
then(res, this.data)
|
||||||
|
}
|
||||||
|
this.loadingMore = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import Loadable from './Loadable'
|
||||||
|
|
||||||
|
export default class ErrorLoadable<T, E> extends Loadable<T> {
|
||||||
|
|
||||||
|
protected error?: E
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
super.reset()
|
||||||
|
this.error = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSuccess() {
|
||||||
|
return this.loaded && this.error === undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
get display() {
|
||||||
|
return this.loaded ? (this.error ? this.error.toString() : (this.data ? this.data.toString() : 'Empty')) : 'Loading...'
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(error: E) {
|
||||||
|
this.loaded = true
|
||||||
|
this.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
|
||||||
|
|
||||||
|
export default class Loadable<T> {
|
||||||
|
|
||||||
|
protected loaded = false
|
||||||
|
protected data?: T
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.loaded = false
|
||||||
|
this.data = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
|
||||||
|
with(call: (data: T) => void) {
|
||||||
|
if (this.data) {
|
||||||
|
call(this.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map<U>(mapper: (data: T) => U, or: U) {
|
||||||
|
return this.data ? mapper(this.data) : or
|
||||||
|
}
|
||||||
|
|
||||||
|
get display() {
|
||||||
|
return this.loaded ? (this.data ? this.data.toString() : 'Empty') : 'Loading...'
|
||||||
|
}
|
||||||
|
|
||||||
|
success(data: T) {
|
||||||
|
this.loaded = true
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import WebStorageHandler from './WebStorageHandler'
|
||||||
|
|
||||||
|
export default class LocalStorageHandler<T> extends WebStorageHandler<T> {
|
||||||
|
constructor(key: string, data: T, loader?: (value: any) => T, saver?: (me: T) => any) {
|
||||||
|
super(window.localStorage, key, data, loader, saver)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
export default class WebStorageHandler<T> { // TODO: extends loadable
|
||||||
|
constructor(protected storage: Storage, protected key: string, protected data: T,
|
||||||
|
protected loader: (value: any) => T = (v => v), protected saver: (me: T) => any = (v => v)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
|
||||||
|
set(value: T) {
|
||||||
|
this.data = value
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(mapper: (data: T) => T) {
|
||||||
|
this.set(mapper(this.get()))
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
const data = this.storage.getItem(this.key)
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
this.data = this.loader(JSON.parse(data))
|
||||||
|
} catch (e) {
|
||||||
|
this.storage.removeItem(this.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
this.storage.setItem(this.key, JSON.stringify(this.saver(this.data)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
export function unsafeAxiosMapper<T, U = T>(res: AxiosResponse<U>) {
|
||||||
|
return res.data as unknown as T
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import Vue from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
Vue.config.productionTip = false;
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
render: h => h(App)
|
||||||
|
}).$mount('#app');
|
|
@ -0,0 +1,44 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.client(@scroll.passive="onScroll")
|
||||||
|
loadable-block.list(:loadable="guilds")
|
||||||
|
template(#success)
|
||||||
|
guild(v-for="guild in guilds.get().data" :key="guild.id" :guild="guild" :showMedia="options.showMedia")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios, { AxiosResponse } from 'axios'
|
||||||
|
import { Component, Mixins } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import LoadableBlockVue from '../../components/loadable/LoadableBlock.vue'
|
||||||
|
import ServiceClient from '../../components/ServiceClient'
|
||||||
|
import TimerMixin from '../../components/time/TimerMixin'
|
||||||
|
import { Selectable } from '../../helpers/lists/Selectable'
|
||||||
|
import AxiosLoadable from '../../helpers/loadable/AxiosLoadable'
|
||||||
|
import { AUTH, getRest } from './Discord.vue'
|
||||||
|
import GuildVue from './Guild.vue'
|
||||||
|
import { Guild, Options } from './Types'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
guild: GuildVue,
|
||||||
|
loadableBlock: LoadableBlockVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient, TimerMixin) {
|
||||||
|
|
||||||
|
rest = getRest(this.auth, this.options.timeout)
|
||||||
|
|
||||||
|
guilds = new AxiosLoadable<Selectable<Guild>, object>()
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.guilds.load(
|
||||||
|
this.get('/users/@me/guilds'),
|
||||||
|
res => new Selectable(res.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path: string, options = {}) {
|
||||||
|
return this.catchEmit(this.rest.get(path, { params: { limit: this.options.buffer, ...options } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,68 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.discord
|
||||||
|
service-header(:emit="emit")
|
||||||
|
template(#title) {{ serviceName }}: {{ account.display }}
|
||||||
|
template(#settings)
|
||||||
|
setting-boolean(:id="'reconnect'" :title="'Reconnect'" :value="params.reconnect" @change="saveOptionCouple")
|
||||||
|
setting-int(:id="'buffer'" :title="'Buffer size'" :value="params.buffer" @change="saveOptionCouple")
|
||||||
|
setting-boolean(:id="'showMedia'" :title="'Show medias'" :value="params.showMedia" @change="saveOptionCouple")
|
||||||
|
loadable-block.service-content(:loadable="account")
|
||||||
|
template(#success)
|
||||||
|
client(:auth="auth" :options="params" :emit="emit")
|
||||||
|
template(#error)
|
||||||
|
form(@submit.prevent="makeAuth")
|
||||||
|
p
|
||||||
|
label(for="token") Token:
|
||||||
|
input#token(v-model="newAuth.token" required)
|
||||||
|
p
|
||||||
|
input(type="submit" value="Connect")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios, { AxiosResponse } from 'axios'
|
||||||
|
import { Component, Mixins } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import AccountService from '../../components/service/AccountService';
|
||||||
|
import ServiceHeaderVue from '../../components/ServiceHeader.vue'
|
||||||
|
import { Auth } from '../../types/App'
|
||||||
|
import ClientVue from './Client.vue'
|
||||||
|
import { Account, Options } from './Types'
|
||||||
|
|
||||||
|
export const AUTH = { TOKEN: 'token' }
|
||||||
|
export const CDN = 'https://cdn.discordapp.com'
|
||||||
|
export function getRest(auth: Auth, timeout: number) {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: 'https://discordapp.com/api/', timeout,
|
||||||
|
headers: { Authorization: auth.get(AUTH.TOKEN) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
client: ClientVue,
|
||||||
|
'service-header': ServiceHeaderVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class Discord extends Mixins<AccountService<string, object, Account>>(AccountService) { // TODO: Use oauth
|
||||||
|
|
||||||
|
get params(): Options {
|
||||||
|
return { timeout: 5000, reconnect: false, buffer: 20, showMedia: true, ...this.options }
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetup() {
|
||||||
|
return this.auth && this.auth.has(AUTH.TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
get serviceName() {
|
||||||
|
return 'Discord'
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccount(auth: Auth) {
|
||||||
|
return getRest(auth, this.params.timeout).get('/users/@me')
|
||||||
|
}
|
||||||
|
|
||||||
|
mapAccount(res: AxiosResponse<Account>) {
|
||||||
|
return res.data.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,25 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.guild
|
||||||
|
| {{ guild.name }}
|
||||||
|
img(v-if="showMedia && guild.icon" :src="`${CDN}/icons/${guild.id}/${guild.icon}.png?size=16`")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ShowMediaMixin from '../../components/ShowMediaMixin'
|
||||||
|
import { CDN } from './Discord.vue'
|
||||||
|
import { Guild as IGuild } from './Types'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class Guild extends ShowMediaMixin {
|
||||||
|
|
||||||
|
@Prop(Object)
|
||||||
|
readonly guild!: IGuild
|
||||||
|
|
||||||
|
get CDN() {
|
||||||
|
return CDN
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,16 @@
|
||||||
|
export interface Account {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guild {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
timeout: number
|
||||||
|
reconnect: boolean
|
||||||
|
buffer: number
|
||||||
|
showMedia: boolean
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
<template lang="pug">
|
||||||
|
a.account(target="_blank" :href="account.url")
|
||||||
|
.avatar(v-if="showMedia" :style="avatarStyle(account.avatar_static)")
|
||||||
|
.name(v-html="parseEmojis(account.display_name, account.emojis, showMedia)")
|
||||||
|
.acct @{{ account.acct }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Mixins, Prop } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ShowMediaMixin from '../../components/ShowMediaMixin'
|
||||||
|
import { ParseEmojisMixin } from './ParseEmojisMixin'
|
||||||
|
import { Account as IAccount } from './Types'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class Account extends Mixins(ParseEmojisMixin, ShowMediaMixin) {
|
||||||
|
|
||||||
|
@Prop(Object)
|
||||||
|
readonly account!: IAccount
|
||||||
|
|
||||||
|
avatarStyle(avatar: string) {
|
||||||
|
return {
|
||||||
|
'background-image': `url(${avatar})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,215 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.client(@scroll.passive="onScroll")
|
||||||
|
.statues
|
||||||
|
.header(v-if="hasNotifications") Accueil
|
||||||
|
loadable-block.list(:loadable="statues")
|
||||||
|
template(#success)
|
||||||
|
template(v-for="status in statues.get()")
|
||||||
|
status(v-if="showStatus(status)" :key="status.id" :status="status" :now="now" :showMedia="options.showMedia" @mark="onStatusMark")
|
||||||
|
.status(v-show="statues.loadingMore")
|
||||||
|
.service-loader
|
||||||
|
.notifications(v-if="hasNotifications")
|
||||||
|
.header
|
||||||
|
| Notifications
|
||||||
|
span.date(@click.stop.prevent="onNotificationsClear") ❌
|
||||||
|
.list
|
||||||
|
notification(v-for="notification in notifications.get()" :key="notification.id" :notification="notification" :now="now"
|
||||||
|
:showMedia="options.showMedia" @dismiss="onNotificationDismiss" @mark="onStatusMark")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios, { AxiosResponse } from 'axios'
|
||||||
|
import { Component, Mixins } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import LoadableBlockVue from '../../components/loadable/LoadableBlock.vue'
|
||||||
|
import ServiceClient from '../../components/ServiceClient'
|
||||||
|
import TimerMixin from '../../components/time/TimerMixin'
|
||||||
|
import Lists from '../../helpers/lists/Lists'
|
||||||
|
import AxiosLodable from '../../helpers/loadable/AxiosLoadable'
|
||||||
|
import AxiosLodableMore from '../../helpers/loadable/AxiosLoadableMore'
|
||||||
|
import { AUTH, getRest } from './Mastodon.vue'
|
||||||
|
import NotificationVue from './Notification.vue'
|
||||||
|
import StatusVue from './Status.vue'
|
||||||
|
import { MarkMessage, Notification, Options, Status } from './Types'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
status: StatusVue,
|
||||||
|
notification: NotificationVue,
|
||||||
|
loadableBlock: LoadableBlockVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient, TimerMixin) {
|
||||||
|
|
||||||
|
rest = getRest(this.auth, this.options.timeout)
|
||||||
|
|
||||||
|
statues = new AxiosLodableMore<Status[], object>()
|
||||||
|
notifications = new AxiosLodable<Notification[], object>()
|
||||||
|
|
||||||
|
get hasNotifications() {
|
||||||
|
if(!this.notifications.isSuccess) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const not = this.notifications.get()
|
||||||
|
if(not){
|
||||||
|
return not.length > 0
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.statues.load(this.getTimeline())
|
||||||
|
this.notifications.load(this.get('/notifications'))
|
||||||
|
this.setupStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path: string, options = {}) {
|
||||||
|
return this.catchEmit(this.rest.get(path, { params: { limit: this.options.buffer, ...options } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
post(path: string, options = {}) {
|
||||||
|
return this.catchEmit(this.rest.post(path, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeline(options = {}) {
|
||||||
|
return this.get('/timelines/home', options)
|
||||||
|
}
|
||||||
|
|
||||||
|
onScroll(event: any) {
|
||||||
|
if(!this.statues.isLoadingMore && event.target.scrollHeight - event.target.clientHeight - event.target.scrollTop - 100 < 0) {
|
||||||
|
this.statues.loadMore(
|
||||||
|
this.getTimeline({ max_id: this.statues.map(s => Lists.last(s).id , 0) }),
|
||||||
|
(res, statues) => Lists.pushAll(statues, res.data)
|
||||||
|
)
|
||||||
|
} else if(event.target.scrollTop < 20) {
|
||||||
|
this.statues.with(s => Lists.removeFrom(s, this.options.buffer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus(status: Status) {
|
||||||
|
return (!status.in_reply_to_id || this.options.reply) && (!status.reblog || this.options.reblog)
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusMark(action: MarkMessage) {
|
||||||
|
this.post(`/statuses/${action.id}/${action.type}`)
|
||||||
|
.then(() => action.callback())
|
||||||
|
}
|
||||||
|
|
||||||
|
onNotificationDismiss(id: number) {
|
||||||
|
this.post('/notifications/dismiss', { id })
|
||||||
|
.then(() => this.notifications.with(
|
||||||
|
ns => Lists.removeFirstBy(ns, n => n.id, id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
onNotificationsClear() {
|
||||||
|
this.post('/notifications/clear')
|
||||||
|
.then(() => this.notifications.with(
|
||||||
|
ns => Lists.clear(ns)))
|
||||||
|
}
|
||||||
|
|
||||||
|
setupStream() {
|
||||||
|
const ws = new WebSocket(
|
||||||
|
`wss://${this.auth.get(AUTH.SERVER)}/api/v1/streaming?access_token=${this.auth.get(AUTH.TOKEN)}&stream=user`
|
||||||
|
)
|
||||||
|
ws.onmessage = event => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
const payload = JSON.parse(data.payload)
|
||||||
|
switch (data.event) {
|
||||||
|
case 'update':
|
||||||
|
this.statues.with(s => s.unshift(payload))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'notification':
|
||||||
|
this.notifications.with(n => n.unshift(payload))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'delete':
|
||||||
|
this.statues.with(st => Lists.removeFirstBy(st, s => s.id, payload.id))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onerror = ev => this.emitError(ev.type)
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.emitError(
|
||||||
|
'Mastodon stream disconnected !' +
|
||||||
|
(this.options.reconnect ? ' Reconnecting...' : '')
|
||||||
|
)
|
||||||
|
if (this.options.reconnect) {
|
||||||
|
setTimeout(() => this.setupStream(), this.options.timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@import '../../common.sass'
|
||||||
|
|
||||||
|
.mastodon
|
||||||
|
.client
|
||||||
|
display: flex
|
||||||
|
height: 100%
|
||||||
|
overflow-y: auto
|
||||||
|
.header
|
||||||
|
@include tile
|
||||||
|
.list
|
||||||
|
& > div
|
||||||
|
@include tile
|
||||||
|
.statues
|
||||||
|
flex: 1
|
||||||
|
.notifications
|
||||||
|
max-width: 33%
|
||||||
|
|
||||||
|
.account
|
||||||
|
.name
|
||||||
|
margin: 0 $borderRadius
|
||||||
|
color: $foreColor
|
||||||
|
.avatar
|
||||||
|
float: left
|
||||||
|
border-radius: $borderRadius
|
||||||
|
width: $avatarSize
|
||||||
|
height: $avatarSize
|
||||||
|
background-size: $avatarSize $avatarSize
|
||||||
|
div
|
||||||
|
display: inline-block
|
||||||
|
|
||||||
|
.status, .notification
|
||||||
|
min-height: $avatarSize
|
||||||
|
.content
|
||||||
|
margin: .5em .5em .5em 1em
|
||||||
|
&.avatared
|
||||||
|
margin-left: .5em + $avatarSize
|
||||||
|
.reblog
|
||||||
|
font-size: .8em
|
||||||
|
.spoiler
|
||||||
|
margin-bottom: .5em
|
||||||
|
.media
|
||||||
|
margin: .5em
|
||||||
|
position: relative
|
||||||
|
display: inline-block
|
||||||
|
& > *
|
||||||
|
max-height: 10em
|
||||||
|
max-width: 100%
|
||||||
|
.gif
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
height: 100%
|
||||||
|
width: 100%
|
||||||
|
background-color: #00000044
|
||||||
|
color: white
|
||||||
|
padding: .5em
|
||||||
|
.meta
|
||||||
|
margin-left: 1em + $avatarSize
|
||||||
|
font-size: .8em
|
||||||
|
a
|
||||||
|
margin: 0 .5em
|
||||||
|
|
||||||
|
.date, .dismiss
|
||||||
|
float: right
|
||||||
|
</style>
|
|
@ -0,0 +1,83 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.mastodon
|
||||||
|
service-header(:emit="emit")
|
||||||
|
template(#title)
|
||||||
|
| {{ serviceName }}:
|
||||||
|
loadable-inline(:loadable="account")
|
||||||
|
template(#success)
|
||||||
|
span(v-html="parseEmojis(account.data.display_name, account.data.emojis) + '@' + server", params.showMedia)
|
||||||
|
template(#settings)
|
||||||
|
setting-boolean(:id="'reconnect'" :title="'Reconnect'" :value="params.reconnect" @change="saveOptionCouple")
|
||||||
|
setting-boolean(:id="'reblog'" :title="'Show reblogs'" :value="params.reblog" @change="saveOptionCouple")
|
||||||
|
setting-boolean(:id="'reply'" :title="'Show replies'" :value="params.reply" @change="saveOptionCouple")
|
||||||
|
setting-int(:id="'buffer'" :title="'Buffer size'" :value="params.buffer" @change="saveOptionCouple")
|
||||||
|
setting-boolean(:id="'showMedia'" :title="'Show medias'" :value="params.showMedia" @change="saveOptionCouple")
|
||||||
|
loadable-block.service-content(:loadable="account")
|
||||||
|
template(#success)
|
||||||
|
client(:auth="auth" :options="params" :emit="emit")
|
||||||
|
template(#error)
|
||||||
|
form(@submit.prevent="makeAuth")
|
||||||
|
p
|
||||||
|
label(for="server") Server:
|
||||||
|
input#server(v-model="newAuth.server" required)
|
||||||
|
p
|
||||||
|
label(for="token") Token:
|
||||||
|
input#token(v-model="newAuth.token" required)
|
||||||
|
p
|
||||||
|
input(type="submit" value="Connect")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios, { AxiosResponse } from 'axios'
|
||||||
|
import { Component, Mixins } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import AccountService from '../../components/service/AccountService'
|
||||||
|
import ServiceHeaderVue from '../../components/ServiceHeader.vue'
|
||||||
|
import { Auth } from '../../types/App'
|
||||||
|
import ClientVue from './Client.vue'
|
||||||
|
import { ParseEmojisMixin } from './ParseEmojisMixin'
|
||||||
|
import { Account, Options } from './Types'
|
||||||
|
|
||||||
|
export const AUTH = { SERVER: 'server', TOKEN: 'token' }
|
||||||
|
export function getRest(auth: Auth, timeout: number) {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: `https://${auth.get(AUTH.SERVER)}/api/v1/`, timeout,
|
||||||
|
headers: { Authorization: 'Bearer ' + auth.get(AUTH.TOKEN) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
client: ClientVue,
|
||||||
|
'service-header': ServiceHeaderVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class Mastodon extends Mixins<AccountService<Account, object>>(AccountService, ParseEmojisMixin) { // TODO: Use oauth
|
||||||
|
|
||||||
|
get server() {
|
||||||
|
return this.auth.get(AUTH.SERVER)
|
||||||
|
}
|
||||||
|
|
||||||
|
get params(): Options {
|
||||||
|
return { timeout: 5000, reconnect: false, buffer: 20, reblog: true, reply: false,
|
||||||
|
showMedia: true, ...this.options }
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetup() {
|
||||||
|
return this.auth && this.auth.has(AUTH.SERVER) && this.auth.has(AUTH.TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
get serviceName() {
|
||||||
|
return 'Mastodon'
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccount(auth: Auth) {
|
||||||
|
return getRest(auth, this.params.timeout)
|
||||||
|
.get<Account>('/accounts/verify_credentials')
|
||||||
|
}
|
||||||
|
|
||||||
|
mapServiceName(res: AxiosResponse<Account>, newAuth: Auth) {
|
||||||
|
return `${this.serviceName} ${this.mapAccount(res).acct}@${newAuth.get(AUTH.SERVER)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,52 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.notification
|
||||||
|
account(:account="notification.account" :showMedia="showMedia")
|
||||||
|
|
||||||
|
span.colored.text-icon.letter(v-if="notification.type == 'mention'") ✉
|
||||||
|
span.colored.text-icon.letter(v-if="notification.type == 'reblog'") ⟳
|
||||||
|
span.colored.text-icon.letter(v-if="notification.type == 'favourite'") ⚝
|
||||||
|
|
||||||
|
from-now.date(:date="notification.created_at" :now="now")
|
||||||
|
|
||||||
|
.content
|
||||||
|
template(v-if="notification.type == 'follow'") Vous suit
|
||||||
|
status.reblog(v-else-if="notification.status" :status="notification.status" :now="now"
|
||||||
|
:showMedia="showMedia" :withAccount="notification.type != 'mention'" @mark="passMark")
|
||||||
|
|
||||||
|
a.date(@click.stop.prevent="makeDismiss" style="margin-top: -1em") ❌
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ShowMediaMixin from '../../components/ShowMediaMixin'
|
||||||
|
import FromNowVue from '../../components/time/FromNow.vue'
|
||||||
|
import TimedMixin from '../../components/time/TimedMixin'
|
||||||
|
import AccountVue from './Account.vue'
|
||||||
|
import StatusVue from './Status.vue'
|
||||||
|
import { MarkMessage, Notification as INotification } from './Types'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
fromNow: FromNowVue,
|
||||||
|
account: AccountVue,
|
||||||
|
status: StatusVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class Notification extends Mixins(TimedMixin, ShowMediaMixin) {
|
||||||
|
|
||||||
|
@Prop(Object)
|
||||||
|
readonly notification!: INotification
|
||||||
|
|
||||||
|
@Emit('dismiss')
|
||||||
|
makeDismiss() {
|
||||||
|
return this.notification.id
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit('mark')
|
||||||
|
passMark(action: MarkMessage) {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Component, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import { Emoji } from './Types'
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export class ParseEmojisMixin extends Vue {
|
||||||
|
|
||||||
|
parseEmojis(text: string, emojis: Emoji[], show = true) {
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
text = text.split(`:${emoji.shortcode}:`).join(
|
||||||
|
show ? `<img draggable="false" class="icon" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.static_url}">` : emoji.shortcode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.status
|
||||||
|
account(v-if="withAccount" :account="status.account" :showMedia="showMedia")
|
||||||
|
|
||||||
|
span.text-icon.letter(v-if="status.reblog") ⟳
|
||||||
|
|
||||||
|
a.date(target="_blank" :href="status.uri")
|
||||||
|
from-now(:date="status.created_at" :now="now")
|
||||||
|
|
||||||
|
.content(:class="{ avatared: showMedia }")
|
||||||
|
template(v-if="!status.reblog")
|
||||||
|
.spoiler(v-if="status.spoiler_text" @click.stop.prevent="status.sensitive = !status.sensitive").
|
||||||
|
{{ status.spoiler_text || 'Spoiler' }} {{ status.sensitive ? '→' : '↓' }}
|
||||||
|
div(v-if="!status.spoiler_text || !status.sensitive")
|
||||||
|
.text(v-html="parseEmojis(status.content, status.emojis, showMedia)")
|
||||||
|
a.media(v-for="media in status.media_attachments" :href="media.url" target="_blank")
|
||||||
|
template(v-if="showMedia")
|
||||||
|
img(v-if="media.type == 'image' || media.type == 'gifv'" :src="media.preview_url" :alt="media.description" :title="media.description")
|
||||||
|
template(v-else) Wrong type
|
||||||
|
.gif(v-if="media.type == 'gifv'") GIF
|
||||||
|
template(v-else) Hidden media
|
||||||
|
status.reblog(v-else :status="status.reblog" :now="now" :showMedia="showMedia")
|
||||||
|
|
||||||
|
.meta(v-if="!status.reblog")
|
||||||
|
a.replies(@click.stop.prevent="makeReply(status)")
|
||||||
|
span.text-icon ✉
|
||||||
|
| {{ status.replies_count }}
|
||||||
|
a.reblogs(:class="{ colored: status.reblogged }" @click.stop.prevent="makeReblog(status)")
|
||||||
|
span.text-icon ⟳
|
||||||
|
| {{ status.reblogs_count }}
|
||||||
|
a.favourites(:class="{ colored: status.favourited }" @click.stop.prevent="makeFav(status)")
|
||||||
|
span.text-icon ⚝
|
||||||
|
| {{ status.favourites_count }}
|
||||||
|
a.fil(v-if="status.in_reply_to_id" @click.stop.prevent="showReply(status.in_reply_to_id)")
|
||||||
|
| Voir le fil
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Emit, Mixins, Prop } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ShowMediaMixin from '../../components/ShowMediaMixin'
|
||||||
|
import FromNowVue from '../../components/time/FromNow.vue'
|
||||||
|
import TimedMixin from '../../components/time/TimedMixin'
|
||||||
|
import AccountVue from './Account.vue'
|
||||||
|
import { ParseEmojisMixin } from './ParseEmojisMixin'
|
||||||
|
import { MarkMessage, Status as IStatus } from './Types'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
account: AccountVue,
|
||||||
|
fromNow: FromNowVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class Status extends Mixins(TimedMixin, ParseEmojisMixin, ShowMediaMixin) {
|
||||||
|
|
||||||
|
@Prop(Object)
|
||||||
|
readonly status!: IStatus
|
||||||
|
|
||||||
|
@Prop({ type: Boolean, default: true })
|
||||||
|
readonly withAccount!: boolean
|
||||||
|
|
||||||
|
showReply(statusId: number) {
|
||||||
|
throw statusId // TODO:
|
||||||
|
}
|
||||||
|
|
||||||
|
makeReply(status: IStatus) {
|
||||||
|
throw status.id // TODO:
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit('mark')
|
||||||
|
emitMark(status: IStatus, action: 'reblog' | 'favourite', callback: CallableFunction, undo = false): MarkMessage {
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
type: (undo ? 'un' : '') + action,
|
||||||
|
callback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeReblog(status: IStatus) {
|
||||||
|
this.emitMark(status, 'reblog', () => {
|
||||||
|
status.reblogs_count += (status.reblogged ? -1 : 1)
|
||||||
|
status.reblogged = !status.reblogged
|
||||||
|
}, status.reblogged)
|
||||||
|
}
|
||||||
|
|
||||||
|
makeFav(status: IStatus) {
|
||||||
|
this.emitMark(status, 'favourite', () => {
|
||||||
|
status.favourites_count += (status.favourited ? -1 : 1)
|
||||||
|
status.favourited = !status.favourited
|
||||||
|
}, status.favourited)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,59 @@
|
||||||
|
export interface Emoji {
|
||||||
|
shortcode: string
|
||||||
|
static_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
acct: string
|
||||||
|
display_name: string
|
||||||
|
emojis: Emoji[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
timeout: number
|
||||||
|
reconnect: boolean
|
||||||
|
buffer: number
|
||||||
|
reblog: boolean
|
||||||
|
reply: boolean
|
||||||
|
showMedia: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
id: number
|
||||||
|
uri: string
|
||||||
|
account: Account
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
emojis: Emoji[]
|
||||||
|
favourited: boolean
|
||||||
|
favourites_count: number
|
||||||
|
media_attachments: Media[]
|
||||||
|
sensitive: boolean
|
||||||
|
reblogged: boolean
|
||||||
|
reblogs_count: number
|
||||||
|
replies_count: number
|
||||||
|
in_reply_to_id?: number
|
||||||
|
reblog?: Status
|
||||||
|
spoiler_text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Media {
|
||||||
|
description: string
|
||||||
|
url: string
|
||||||
|
preview_url: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number
|
||||||
|
account: Account
|
||||||
|
type: string
|
||||||
|
created_at: string
|
||||||
|
status?: Status
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkMessage {
|
||||||
|
id: number,
|
||||||
|
type: string,
|
||||||
|
callback: CallableFunction
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.nextcloud-news(v-show="showService")
|
||||||
|
service-header(:emit="emit")
|
||||||
|
template(#title)
|
||||||
|
| {{ serviceName }}
|
||||||
|
span.note(v-if="hasNews") ({{ news.get().length }})
|
||||||
|
template(#settings)
|
||||||
|
setting-int(:id="'update'" :title="'Update interval'" :value="params.update" @change="saveOptionCouple")
|
||||||
|
setting-int(:id="'buffer'" :title="'Buffer size'" :value="params.buffer" @change="saveOptionCouple")
|
||||||
|
setting-boolean(:id="'showEmpty'" :title="'Show empty'" :value="params.showEmpty" @change="saveOptionCouple")
|
||||||
|
loadable-block.unreaded(:loadable="news")
|
||||||
|
template(#success)
|
||||||
|
.news(v-for="line in news.get()")
|
||||||
|
a(:href="line.url" target="_blank")
|
||||||
|
from-now.date(:date="line.pubDate * 1000" :now="now")
|
||||||
|
span.read(@click.stop="makeRead(line.id)") 👁
|
||||||
|
span.title(@click.stop="line.open = !line.open") {{ line.author }} ─ {{ line.title }}
|
||||||
|
.content(v-if="line.open && line.body") {{ line.body }}
|
||||||
|
template(#error)
|
||||||
|
form(@submit.prevent="makeAuth")
|
||||||
|
p
|
||||||
|
label(for="server") Server:
|
||||||
|
input#server(v-model="newAuth.server" required)
|
||||||
|
p
|
||||||
|
label(for="username") Username:
|
||||||
|
input#username(v-model="newAuth.username" required)
|
||||||
|
p
|
||||||
|
label(for="token") Token:
|
||||||
|
input#token(v-model="newAuth.token" required)
|
||||||
|
p
|
||||||
|
input(type="submit" value="Connect")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios, { AxiosInstance, AxiosResponse } from 'axios'
|
||||||
|
import { Component, Mixins } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ConnectedService from '../../components/service/ConnectedService'
|
||||||
|
import ServiceHeaderVue from '../../components/ServiceHeader.vue'
|
||||||
|
import FromNowVue from '../../components/time/FromNow.vue'
|
||||||
|
import TimerMixin from '../../components/time/TimerMixin'
|
||||||
|
import Lists from '../../helpers/lists/Lists'
|
||||||
|
import AxiosLoadable from '../../helpers/loadable/AxiosLoadable'
|
||||||
|
import { Auth } from '../../types/App'
|
||||||
|
|
||||||
|
interface News {
|
||||||
|
id: number
|
||||||
|
url: string
|
||||||
|
pubDate: number
|
||||||
|
author: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH = { SERVER: 'server', USERNAME: 'username', TOKEN: 'token' }
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
'service-header': ServiceHeaderVue,
|
||||||
|
fromNow: FromNowVue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class NextcloudNews extends Mixins<ConnectedService<object, object>>(ConnectedService, TimerMixin) { // TODO: handle unread
|
||||||
|
|
||||||
|
rest!: AxiosInstance // NOTE: set in this.init()
|
||||||
|
news = new AxiosLoadable<News[], object>()
|
||||||
|
|
||||||
|
get params() {
|
||||||
|
return { timeout: 5000, buffer: -1, update: 5 * 60, showEmpty: true, ...this.options }
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetup() {
|
||||||
|
return this.auth && this.auth.has(AUTH.SERVER) && this.auth.has(AUTH.USERNAME) && this.auth.has(AUTH.TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
get connector() {
|
||||||
|
return this.news
|
||||||
|
}
|
||||||
|
|
||||||
|
get serviceName() {
|
||||||
|
return 'Nextcloud News'
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasNews() {
|
||||||
|
if(!this.news.isSuccess) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const news = this.news.get()
|
||||||
|
if(news){
|
||||||
|
return news.length > 0
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get showService() {
|
||||||
|
return this.params.showEmpty || this.hasNews || !this.isSetup
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData() {
|
||||||
|
this.news.load<{ items: News[] }>(
|
||||||
|
this.catchEmit(this.rest.get<News[]>('/items', { params: { batchSize: this.params.buffer, type: 3, getRead: false } })),
|
||||||
|
res => res.data.items.map(n => {
|
||||||
|
n.open = false
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeNews(id: number) {
|
||||||
|
this.news.with(ns => Lists.removeFirstBy(ns, n => n.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
makeRead(id: number) {
|
||||||
|
this.catchEmit(this.rest.put(`/items/${id}/read`))
|
||||||
|
.then(() => this.removeNews(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.rest = this.getRest(this.auth)
|
||||||
|
|
||||||
|
this.loadData()
|
||||||
|
|
||||||
|
if(this.params.update > 0) {
|
||||||
|
setInterval(this.loadData, this.params.update * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth(auth: Auth){
|
||||||
|
return this.getRest(auth).get('/folders')
|
||||||
|
}
|
||||||
|
|
||||||
|
getRest(auth: Auth) {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: `https://${auth.get(AUTH.SERVER)}/index.php/apps/news/api/v1-2/`,
|
||||||
|
headers: { Authorization: 'Basic ' + btoa(auth.get(AUTH.USERNAME) + ':' + auth.get(AUTH.TOKEN)) },
|
||||||
|
timeout: this.params.timeout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@import '../../common.sass'
|
||||||
|
|
||||||
|
.nextcloud-news
|
||||||
|
.unreaded
|
||||||
|
overflow-y: auto
|
||||||
|
.news
|
||||||
|
@include tile
|
||||||
|
.date
|
||||||
|
float: right
|
||||||
|
.read
|
||||||
|
margin-right: .5em
|
||||||
|
.content
|
||||||
|
padding: $borderRadius
|
||||||
|
</style>
|
|
@ -0,0 +1,73 @@
|
||||||
|
import moment from 'moment'
|
||||||
|
import { Bar, mixins } from 'vue-chartjs'
|
||||||
|
const { reactiveProp } = mixins
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: Bar,
|
||||||
|
mixins: [reactiveProp],
|
||||||
|
// tslint:disable-next-line: no-empty
|
||||||
|
renderChart(a: any, b: any) { },
|
||||||
|
chartData: undefined,
|
||||||
|
mounted () {
|
||||||
|
this.renderChart(this.chartData, {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
fontColor: 'white'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
xAxes: [{
|
||||||
|
type: 'time',
|
||||||
|
distribution: 'series',
|
||||||
|
ticks: {
|
||||||
|
fontColor: 'white',
|
||||||
|
source: 'data',
|
||||||
|
autoSkip: true,
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkipPadding: 5
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
displayFormats: {
|
||||||
|
hour: 'HH[h]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
yAxes: [{
|
||||||
|
id: 'y-axis-temp',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
ticks: {
|
||||||
|
fontColor: 'white'
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
id: 'y-axis-rain',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
ticks: {
|
||||||
|
fontColor: 'white',
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: 1
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
tooltips: {
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
title(tooltipItem: any, myData: any) {
|
||||||
|
const item = myData.datasets[tooltipItem[0].datasetIndex].data[tooltipItem[0].index]
|
||||||
|
return moment(item.x || item.t).format('HH[h]')
|
||||||
|
},
|
||||||
|
label(tooltipItem: any, myData: any) {
|
||||||
|
let label = myData.datasets[tooltipItem.datasetIndex].label || ''
|
||||||
|
if (label) {
|
||||||
|
label += ': '
|
||||||
|
}
|
||||||
|
label += tooltipItem.value
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,237 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.openweathermap
|
||||||
|
service-header(:emit="emit")
|
||||||
|
template(#title) {{ serviceName }}
|
||||||
|
template(#settings)
|
||||||
|
setting-int(:id="'update'" :title="'Update interval'" :value="params.update" @change="saveOptionCouple")
|
||||||
|
setting-int(:id="'forecastLimit'" :title="'Forecast limit'" :value="params.forecastLimit" @change="saveOptionCouple")
|
||||||
|
p.setting
|
||||||
|
button(@click="showAdd = true") Add city
|
||||||
|
loadable-block(:loadable="weathers")
|
||||||
|
template(#success)
|
||||||
|
.list
|
||||||
|
weather(v-for="(city, id) in weathers.get().data" :key="id" :selected="weathers.get().selectedId == id"
|
||||||
|
:city="city" @select="makeSelect(id)" @remove="removeCity(id)")
|
||||||
|
input.weather(v-show="showAdd" placeholder="city id" @keyup.enter="addCity(parseInt($event.target.value))")
|
||||||
|
loadable-block(:loadable="forecast").forecast
|
||||||
|
template(#success)
|
||||||
|
chart.chart(:chartData="forecastChart")
|
||||||
|
template(#error)
|
||||||
|
form(@submit.prevent="makeAuth")
|
||||||
|
p
|
||||||
|
label(for="token") Token:
|
||||||
|
input#token(v-model="newAuth.token" required)
|
||||||
|
p
|
||||||
|
input(type="submit" value="Connect")
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import axios, { AxiosInstance, AxiosResponse } from 'axios'
|
||||||
|
import { Component } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
import ConnectedService from '../../components/service/ConnectedService'
|
||||||
|
import ServiceHeaderVue from '../../components/ServiceHeader.vue'
|
||||||
|
import Lists from '../../helpers/lists/Lists'
|
||||||
|
import { Selectable } from '../../helpers/lists/Selectable'
|
||||||
|
import AxiosLoadable from '../../helpers/loadable/AxiosLoadable'
|
||||||
|
import { Auth } from '../../types/App'
|
||||||
|
import Chart from './Chart'
|
||||||
|
import WeatherVue, { IWeather } from './Weather.vue'
|
||||||
|
|
||||||
|
interface Forecast {
|
||||||
|
dt: number
|
||||||
|
main: {
|
||||||
|
temp: number
|
||||||
|
}
|
||||||
|
rain?: {
|
||||||
|
'3h': number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH = { TOKEN: 'token' }
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
'service-header': ServiceHeaderVue,
|
||||||
|
weather: WeatherVue,
|
||||||
|
chart: Chart
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export default class OpenWeatherMap extends ConnectedService<object, object> {
|
||||||
|
|
||||||
|
rest!: AxiosInstance // NOTE: set in this.init()
|
||||||
|
weathers = new AxiosLoadable<Selectable<IWeather>, object>()
|
||||||
|
forecast = new AxiosLoadable<Forecast[], string>()
|
||||||
|
showAdd = false
|
||||||
|
|
||||||
|
get params() {
|
||||||
|
return { cities: [] as Array<{ id: number }>, timeout: 5000, update: 10 * 60, lang: 'fr',
|
||||||
|
forecastLimit: 9, ...this.options }
|
||||||
|
}
|
||||||
|
|
||||||
|
get isSetup() {
|
||||||
|
return this.auth && this.auth.has(AUTH.TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
get serviceName() {
|
||||||
|
return 'OpenWeatherMap'
|
||||||
|
}
|
||||||
|
|
||||||
|
get connector() {
|
||||||
|
return this.weathers
|
||||||
|
}
|
||||||
|
|
||||||
|
get forecastChart() {
|
||||||
|
return this.forecast.map<object>(fs => ({
|
||||||
|
datasets: [{
|
||||||
|
type: 'line',
|
||||||
|
label: 'Temperature',
|
||||||
|
yAxisID: 'y-axis-temp',
|
||||||
|
borderColor: 'white',
|
||||||
|
borderWidth: 1,
|
||||||
|
fill: false,
|
||||||
|
data: fs.map(line => ({
|
||||||
|
x: line.dt * 1000, y: line.main.temp
|
||||||
|
}))
|
||||||
|
},{
|
||||||
|
type: 'bar',
|
||||||
|
label: 'Percipitation',
|
||||||
|
yAxisID: 'y-axis-rain',
|
||||||
|
borderColor: '#DDDDDD',
|
||||||
|
backgroundColor: '#DDDDDD33',
|
||||||
|
borderWidth: 1,
|
||||||
|
data: fs.filter(f => 'rain' in f && '3h' in f.rain!).map(line => ({
|
||||||
|
x: line.dt * 1000, y: line.rain!['3h']
|
||||||
|
}))
|
||||||
|
}]
|
||||||
|
}), unescape)
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasWeathers() {
|
||||||
|
if(!this.weathers.isSuccess) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = this.weathers.get()
|
||||||
|
if(ws){
|
||||||
|
return ws.data.length > 0
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.$watch('options.cities', this.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
makeSelect(id: number) {
|
||||||
|
this.weathers.with(w => w.select(id))
|
||||||
|
this.loadForecast()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData() {
|
||||||
|
this.weathers.with(w => Lists.for(w.data,
|
||||||
|
(weather, i) => this.getWeather({ id: weather.id })
|
||||||
|
.then(res => this.$set(w, i, res.data))
|
||||||
|
))
|
||||||
|
this.loadForecast()
|
||||||
|
}
|
||||||
|
|
||||||
|
getWeather(params: { id: number }) {
|
||||||
|
return this.catchEmit(this.rest.get('weather', { params }))
|
||||||
|
}
|
||||||
|
|
||||||
|
loadForecast() {
|
||||||
|
const selected = this.weathers.map(w => w.selected, undefined)
|
||||||
|
if(selected) {
|
||||||
|
this.forecast.load<{ list: Forecast[] }>(
|
||||||
|
this.catchEmit(this.rest.get('forecast', { params: {
|
||||||
|
id: selected.id, cnt: this.params.forecastLimit
|
||||||
|
}})),
|
||||||
|
res => res.data.list
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.forecast.fail('Any selection')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dt: number) {
|
||||||
|
const date = new Date(dt * 1000)
|
||||||
|
return `${date.toLocaleDateString()} ${date.getHours()}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
addCity(id: number) {
|
||||||
|
this.params.cities.push({ id })
|
||||||
|
this.saveOption('cities', this.params.cities)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCity(key: number) {
|
||||||
|
Lists.removeAt(this.params.cities, key)
|
||||||
|
this.saveOption('cities', this.params.cities)
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.rest = this.getRest(this.auth)
|
||||||
|
this.showAdd = this.params.cities.length === 0
|
||||||
|
|
||||||
|
if(this.params.cities.length > 0) {
|
||||||
|
axios.all(this.params.cities.map(city => this.getWeather(city)))
|
||||||
|
.then(axios.spread((...ress) =>
|
||||||
|
this.weathers.success(new Selectable(ress.map(r => r.data)))))
|
||||||
|
.then(this.loadForecast)
|
||||||
|
.catch(this.weathers.fail)
|
||||||
|
|
||||||
|
if(this.params.update > 0) {
|
||||||
|
setInterval(this.updateData, this.params.update * 1000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.weathers.success(new Selectable([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth(auth: Auth) {
|
||||||
|
return this.getRest(auth).get('/weather?q=London')
|
||||||
|
}
|
||||||
|
|
||||||
|
getRest(auth: Auth) {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: 'https://api.openweathermap.org/data/2.5/',
|
||||||
|
params: {
|
||||||
|
appid: auth.get(AUTH.TOKEN), units: 'metric', lang: this.params.lang
|
||||||
|
},
|
||||||
|
timeout: this.params.timeout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@import '../../common.sass'
|
||||||
|
|
||||||
|
.openweathermap
|
||||||
|
.loadable-block
|
||||||
|
overflow: hidden
|
||||||
|
display: flex
|
||||||
|
flex: 1
|
||||||
|
flex-direction: column
|
||||||
|
.list
|
||||||
|
display: flex
|
||||||
|
flex-wrap: wrap
|
||||||
|
.weather, .forecast
|
||||||
|
flex: 1
|
||||||
|
@include tile
|
||||||
|
.forecast
|
||||||
|
flex: 1
|
||||||
|
overflow: hidden
|
||||||
|
height: 100%
|
||||||
|
.chart
|
||||||
|
position: relative
|
||||||
|
height: 100%
|
||||||
|
.ic
|
||||||
|
overflow: hidden
|
||||||
|
height: 30px
|
||||||
|
display: inline-block
|
||||||
|
img
|
||||||
|
margin-top: -10px
|
||||||
|
</style>
|
|
@ -0,0 +1,78 @@
|
||||||
|
<template lang="pug">
|
||||||
|
.weather(:class="{ selected }" @click.stop.prevent="$emit('select')")
|
||||||
|
.main(v-for="main in city.weather")
|
||||||
|
p {{ main.description }}
|
||||||
|
.ic
|
||||||
|
img(:src="`https://openweathermap.org/img/w/${main.icon}.png`" :alt="main.main")
|
||||||
|
span.remove(@click.stop.prevent="$emit('remove')") ❌
|
||||||
|
.header
|
||||||
|
| {{ city.name }}
|
||||||
|
img.icon(:src="`https://openweathermap.org/images/flags/${city.sys.country.toLowerCase()}.png`" :alt="city.sys.country" :title="city.sys.country")
|
||||||
|
.data
|
||||||
|
| {{ city.main.temp }}°C ─ {{ city.main.humidity }}%
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||||
|
|
||||||
|
export interface IWeather {
|
||||||
|
id: number
|
||||||
|
main: {
|
||||||
|
temp: number
|
||||||
|
humidity: number
|
||||||
|
}
|
||||||
|
weather: Array<{
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
main: string
|
||||||
|
}>
|
||||||
|
name: string
|
||||||
|
sys: {
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export default class Weather extends Vue {
|
||||||
|
|
||||||
|
@Prop(Object)
|
||||||
|
readonly city!: IWeather
|
||||||
|
|
||||||
|
@Prop(Boolean)
|
||||||
|
readonly selected!: boolean
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@import '../../common.sass'
|
||||||
|
|
||||||
|
.weather
|
||||||
|
min-width: 17em
|
||||||
|
border: 1px solid $tileColor
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: auto auto
|
||||||
|
grid-template-rows: 1.2em auto
|
||||||
|
grid-template-areas: "header main" "data remove"
|
||||||
|
&.selected
|
||||||
|
border-color: $halfColor
|
||||||
|
.header
|
||||||
|
grid-area: header
|
||||||
|
font-size: 1.2em
|
||||||
|
.data
|
||||||
|
grid-area: data
|
||||||
|
margin-top: .5em
|
||||||
|
.main
|
||||||
|
grid-area: main
|
||||||
|
justify-self: right
|
||||||
|
p
|
||||||
|
margin: $borderRadius
|
||||||
|
display: inline
|
||||||
|
vertical-align: top
|
||||||
|
.remove
|
||||||
|
grid-area: remove
|
||||||
|
justify-self: right
|
||||||
|
align-self: end
|
||||||
|
font-size: .8em
|
||||||
|
</style>
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import Vue, { VNode } from 'vue';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
// tslint:disable no-empty-interface
|
||||||
|
interface Element extends VNode {}
|
||||||
|
// tslint:disable no-empty-interface
|
||||||
|
interface ElementClass extends Vue {}
|
||||||
|
interface IntrinsicElements {
|
||||||
|
[elem: string]: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module '*.vue' {
|
||||||
|
import Vue from 'vue';
|
||||||
|
export default Vue;
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
export type serviceKey = number
|
||||||
|
export type tileKey = number
|
||||||
|
|
||||||
|
export class Auth extends Map<string, string> { }
|
||||||
|
export interface ServiceData {
|
||||||
|
name: string
|
||||||
|
auth: Auth
|
||||||
|
}
|
||||||
|
export interface Service extends ServiceData {
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Layout {
|
||||||
|
name: string
|
||||||
|
tiles: Tile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rect {
|
||||||
|
x?: number, y?: number
|
||||||
|
h?: number, w?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
service: serviceKey
|
||||||
|
position: Rect
|
||||||
|
options: object
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { ServiceData, tileKey } from './App';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
key: tileKey,
|
||||||
|
msg: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveOptionEvent = 'saveOption'
|
||||||
|
export interface Option {
|
||||||
|
key: string
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
export interface SaveOptionMessage extends Message {
|
||||||
|
msg: Option
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveOptionsEvent = 'saveOptions'
|
||||||
|
export interface SaveOptionsMessage extends Message {
|
||||||
|
msg: object
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoveTileEvent = 'move'
|
||||||
|
export type MoveType = 'x' | 'y' | 'h' | 'w'
|
||||||
|
export type MoveDirection = 1 | -1
|
||||||
|
export interface Move {
|
||||||
|
type: MoveType
|
||||||
|
direction: MoveDirection
|
||||||
|
}
|
||||||
|
export interface MoveTileMessage extends Message {
|
||||||
|
msg: Move
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RemoveTileEvent = 'remove'
|
||||||
|
export interface RemoveTileMessage extends Message {
|
||||||
|
msg: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveServiceEvent = 'saveService'
|
||||||
|
export interface SaveServiceMessage extends Message {
|
||||||
|
msg: ServiceData
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RemoveServiceEvent = 'removeService'
|
||||||
|
export interface RemoveServiceMessage extends Message {
|
||||||
|
msg: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorEvent = 'error'
|
||||||
|
export interface ErrorMessage extends Message { }
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es6",
|
||||||
|
"module": "esnext",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"importHelpers": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": ["webpack-env"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
|
||||||
|
},
|
||||||
|
"include": [ "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"defaultSeverity": "warning",
|
||||||
|
"extends": ["tslint:recommended", "tslint-config-prettier", "tslint-plugin-prettier"],
|
||||||
|
"linterOptions": {
|
||||||
|
"exclude": [
|
||||||
|
"node_modules/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"trailing-comma": false,
|
||||||
|
"quotemark": [true, "single"],
|
||||||
|
"indent": [true, "spaces", 2],
|
||||||
|
"member-access": false,
|
||||||
|
"object-literal-sort-keys": false,
|
||||||
|
"arrow-parens": false,
|
||||||
|
"no-empty-interface": false,
|
||||||
|
"interface-name": false,
|
||||||
|
"semicolon": false
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue