Compare commits
1 Commits
Author | SHA1 | Date |
---|---|---|
May B. | ab895a7d02 |
12
package.json
12
package.json
|
@ -21,6 +21,12 @@
|
|||
"@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",
|
||||
|
@ -31,12 +37,6 @@
|
|||
"less-loader": "^4.1.0",
|
||||
"node-sass": "^4.11.0",
|
||||
"optimize-css-assets-webpack-plugin": "^4.0.0",
|
||||
"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",
|
||||
"url-loader": "^1.0.1",
|
||||
"vue-loader": "^15.4.2",
|
||||
"vue-style-loader": "^4.1.2",
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<noscript>
|
||||
<strong>We're sorry but mixit doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<main id="app"></main>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
177
src/App.vue
177
src/App.vue
|
@ -31,75 +31,35 @@
|
|||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
import { Selectable } from './helpers/lists/Selectable'
|
||||
import LocalStorageHandler from './helpers/storage/LocalStorageHandler'
|
||||
import Discord from './services/discord/Discord.vue'
|
||||
import Mastodon from './services/mastodon/Mastodon.vue'
|
||||
import NextcloudNews from './services/nextcloud/NextcloudNews.vue'
|
||||
import OpenWeatherMap from './services/openweathermap/OpenWeatherMap.vue'
|
||||
import { ErrorsModule, ServicesModule, LayoutsModule } from './store'
|
||||
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, NextcloudNews, openweathermap: OpenWeatherMap, Discord } })
|
||||
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)
|
||||
}
|
||||
return LayoutsModule.tiles.map((tile, key: tileKey) => {
|
||||
const service = ServicesModule.get(tile.service)
|
||||
if(service) {
|
||||
return {
|
||||
...tile, service, serviceId: tile.service,
|
||||
grid: this.gridPos(tile.position)
|
||||
}
|
||||
})
|
||||
} 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))
|
||||
} else {
|
||||
LayoutsModule.removeTile(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// UI
|
||||
|
@ -107,117 +67,44 @@ export default class App extends Vue {
|
|||
this.showManager = !this.showManager
|
||||
}
|
||||
selectLayout(id: number) {
|
||||
this.layouts.get().select(id)
|
||||
LayoutsModule.select(id)
|
||||
}
|
||||
|
||||
// Layouts
|
||||
addLayout() {
|
||||
this.layouts.edit(l => {
|
||||
l.data.push({ name: 'layout' + l.data.length, tiles: [] })
|
||||
return l
|
||||
})
|
||||
LayoutsModule.add({ name: 'new layout', tiles: [] })
|
||||
}
|
||||
renameSelectedLayout(name: string) {
|
||||
this.layouts.edit(data => {
|
||||
if(data.selected) {
|
||||
data.selected.name = name
|
||||
}
|
||||
return data
|
||||
})
|
||||
LayoutsModule.setName(name)
|
||||
}
|
||||
removeSelectedLayout() {
|
||||
this.layouts.edit(data => data.remove())
|
||||
LayoutsModule.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
|
||||
LayoutsModule.addTile({
|
||||
service: id, position: {}, options: {}
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
const id = ServicesModule.add({
|
||||
type: this.newService, name: this.newService, auth: new Auth()
|
||||
})
|
||||
this.showService(this.services.get().length - 1)
|
||||
this.showService(id)
|
||||
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())
|
||||
get errors() {
|
||||
return ErrorsModule.errors
|
||||
}
|
||||
removeError(id: number) {
|
||||
this.errors.splice(id, 1)
|
||||
ErrorsModule.remove(id)
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
@ -227,21 +114,6 @@ export default class App extends Vue {
|
|||
'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>
|
||||
|
@ -263,7 +135,7 @@ a, .osef
|
|||
text-decoration: none
|
||||
color: $noneColor
|
||||
|
||||
input, select, button, textarea
|
||||
input, select, button
|
||||
background-color: $backColor
|
||||
color: $foreColor
|
||||
border: 1px solid $halfColor
|
||||
|
@ -340,7 +212,6 @@ input, select, button, textarea
|
|||
float: right
|
||||
width: 1.2em
|
||||
.service-content
|
||||
flex-grow: 1
|
||||
overflow: hidden
|
||||
.service-loader
|
||||
display: inline-block
|
||||
|
|
|
@ -19,11 +19,7 @@ export default class FromNowMixin extends Vue {
|
|||
}
|
||||
|
||||
fromNow(date: Date | number | string) {
|
||||
const now = FromNowMixin.toNumber(TimeModule.now)
|
||||
const target = FromNowMixin.toNumber(date)
|
||||
|
||||
const prefix = target > now ? 'in ' : ''
|
||||
const milliseconds = Math.floor(Math.abs(target - now))
|
||||
const milliseconds = Math.floor(FromNowMixin.toNumber(TimeModule.now) - FromNowMixin.toNumber(date))
|
||||
|
||||
let cur = 0
|
||||
let divider = 1
|
||||
|
@ -32,11 +28,10 @@ export default class FromNowMixin extends Vue {
|
|||
divider *= time[1]
|
||||
const next = Math.floor(milliseconds / divider)
|
||||
if(next <= 0) {
|
||||
return `${prefix}${cur} ${name}${cur > 1 ? 's' : ''}`
|
||||
return `${cur} ${name}${cur > 1 ? 's' : ''}`
|
||||
}
|
||||
name = time[0]
|
||||
cur = next
|
||||
}
|
||||
return `${prefix}a long time`
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
|
||||
export interface IEmit {
|
||||
$emit: (name: string, arg: any) => void
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class LocalBusMixin<O, B extends O & IEmit> extends Vue {
|
||||
|
||||
@Prop(Object)
|
||||
readonly bus!: B
|
||||
|
||||
}
|
|
@ -5,37 +5,37 @@ import LoadableBlock from './loadable/LoadableBlock.vue'
|
|||
import LoadableInline from './loadable/LoadableInline.vue'
|
||||
import SuccessLoadable from './loadable/SuccessLoadableBlock.vue'
|
||||
|
||||
import { ServiceData } from '@/types/App'
|
||||
import * as Events from '@/types/Events'
|
||||
import { ErrorsModule, LayoutsModule, ServicesModule } from '@/store'
|
||||
import { Options, ServiceData } from '@/types/App'
|
||||
|
||||
@Component({ components: { LoadableBlock, LoadableInline, SuccessLoadable } })
|
||||
export default class ServiceEmiter extends Vue {
|
||||
@Prop(Function)
|
||||
readonly emit!: (name: string, msg: any) => void
|
||||
@Prop(Number)
|
||||
readonly tileKey!: number
|
||||
|
||||
emitError(err: string) {
|
||||
this.emit(Events.ErrorEvent, err)
|
||||
addError(error: any) {
|
||||
ErrorsModule.add(error.toString())
|
||||
}
|
||||
|
||||
saveOptions(options: object) {
|
||||
this.emit(Events.SaveOptionsEvent, options)
|
||||
saveOptions(options: Options) {
|
||||
LayoutsModule.setTileOptions(this.tileKey, options)
|
||||
}
|
||||
|
||||
saveOption(key: string, value: any) {
|
||||
this.saveOptionCouple({ key, value })
|
||||
LayoutsModule.setTileOption(this.tileKey, key, value)
|
||||
}
|
||||
|
||||
saveOptionCouple(couple: Events.Option) {
|
||||
this.emit(Events.SaveOptionEvent, couple)
|
||||
saveOptionCouple({ key, value }: { key: string, value: any }) {
|
||||
this.saveOption(key, value)
|
||||
}
|
||||
|
||||
saveService(service: ServiceData) {
|
||||
this.emit(Events.SaveServiceEvent, service)
|
||||
saveService({ name, auth }: ServiceData) {
|
||||
ServicesModule.set(LayoutsModule.getTile(this.tileKey).service, name, auth)
|
||||
}
|
||||
|
||||
catchEmit<T>(req: AxiosPromise<T>) {
|
||||
catchError(req: AxiosPromise) {
|
||||
return req.catch(err => {
|
||||
this.emitError(err)
|
||||
this.addError(err)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
|
|
@ -16,24 +16,25 @@
|
|||
<script lang="ts">
|
||||
import { Component, Mixins } from 'vue-property-decorator'
|
||||
|
||||
import * as Events from '../types/Events'
|
||||
import ServiceEmiter from './ServiceEmiter'
|
||||
import { MoveType, MoveDirection } from '@/types/App';
|
||||
import { LayoutsModule, ServicesModule } from '../store';
|
||||
|
||||
@Component
|
||||
export default class ServiceHeader extends Mixins(ServiceEmiter) {
|
||||
|
||||
showSettings = false
|
||||
|
||||
onMove(type: Events.MoveType, direction: Events.MoveDirection) {
|
||||
this.emit(Events.MoveTileEvent, { type, direction })
|
||||
onMove(type: MoveType, direction: MoveDirection) {
|
||||
LayoutsModule.moveTile(this.tileKey, type, direction)
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
this.emit(Events.RemoveTileEvent, {})
|
||||
LayoutsModule.removeTile(this.tileKey)
|
||||
}
|
||||
|
||||
onRemoveService() {
|
||||
this.emit(Events.RemoveServiceEvent, {})
|
||||
ServicesModule.remove(LayoutsModule.getTile(this.tileKey).service)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
<template lang="pug">
|
||||
extends model
|
||||
|
||||
block input
|
||||
input(:id="id" ref="input" type="checkbox" :checked="value" @change.stop="handleChange")
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
|
||||
import BaseSetting from './BaseSetting'
|
||||
|
||||
@Component
|
||||
export default class SettingHeritBoolean extends BaseSetting {
|
||||
|
||||
@Prop(Boolean)
|
||||
readonly value!: boolean | undefined
|
||||
|
||||
mounted () {
|
||||
const input: any = this.$refs.input
|
||||
input.indeterminate = this.value === undefined
|
||||
}
|
||||
|
||||
handleChange() {
|
||||
this.sendChange(this.value === false ? undefined : !this.value)
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
|
@ -1,24 +0,0 @@
|
|||
<template lang="pug">
|
||||
extends model
|
||||
|
||||
block input
|
||||
select(:id="id" :value="value" @change="sendChange($event.target.value)")
|
||||
option(v-for="option in options" :value="option") {{ option }}
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
|
||||
import BaseSetting from './BaseSetting'
|
||||
|
||||
@Component
|
||||
export default class SettingSelect extends BaseSetting {
|
||||
|
||||
@Prop(String)
|
||||
readonly value!: string
|
||||
|
||||
@Prop(Array)
|
||||
readonly options!: string[]
|
||||
|
||||
}
|
||||
</script>
|
|
@ -2,7 +2,7 @@ import { AxiosPromise, AxiosResponse } from 'axios'
|
|||
import { Component } from 'vue-property-decorator'
|
||||
|
||||
import AxiosLoadable from '@/helpers/loadable/AxiosLoadable'
|
||||
import { unsafeAxiosMapper } from '@/helpers/unsafeAxiosMapper'
|
||||
import { unsafeAxiosMapper } from '@/helpers/unsafeMapper'
|
||||
import { Auth } from '@/types/App'
|
||||
import ConnectedService from './ConnectedService'
|
||||
|
||||
|
@ -16,7 +16,7 @@ export default class AccountService<T, E, U = T> extends ConnectedService<T, E>
|
|||
}
|
||||
|
||||
load() {
|
||||
this.account.load(this.catchEmit(this.getAccount(this.auth)), this.mapAccount)
|
||||
this.account.load(this.catchError(this.getAccount(this.auth)), this.mapAccount)
|
||||
}
|
||||
mapAccount(res: AxiosResponse<U>) {
|
||||
return unsafeAxiosMapper<T, U>(res)
|
||||
|
|
|
@ -2,14 +2,12 @@ import { Component, Prop, Watch } from 'vue-property-decorator'
|
|||
|
||||
import { Auth } from '@/types/App'
|
||||
import SettingBoolean from '../input/SettingBoolean.vue'
|
||||
import SettingHeritBoolean from '../input/SettingHeritBoolean.vue'
|
||||
import SettingInt from '../input/SettingInt.vue'
|
||||
import SettingSelect from '../input/SettingSelect.vue'
|
||||
import SettingString from '../input/SettingString.vue'
|
||||
import ServiceEmiter from '../ServiceEmiter'
|
||||
import ServiceHeader from '../ServiceHeader.vue'
|
||||
|
||||
@Component({ components: { ServiceHeader, SettingString, SettingInt, SettingBoolean, SettingHeritBoolean, SettingSelect } })
|
||||
@Component({ components: { ServiceHeader, SettingString, SettingInt, SettingBoolean } })
|
||||
export default class BaseService extends ServiceEmiter {
|
||||
|
||||
@Prop({
|
||||
|
|
|
@ -30,7 +30,7 @@ export default class ConnectedService<T, E> extends BaseService {
|
|||
|
||||
makeAuth() {
|
||||
const auth = new Auth(Object.entries(this.newAuth))
|
||||
this.catchEmit(this.checkAuth(auth)).then(res =>
|
||||
this.catchError(this.checkAuth(auth)).then(res =>
|
||||
this.saveService({ name: this.mapServiceName(res, auth), auth }))
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
export default abstract class Lists {
|
||||
|
||||
static last<T>(list: T[]) {
|
||||
|
@ -15,18 +13,6 @@ export default abstract class Lists {
|
|||
return list.length
|
||||
}
|
||||
|
||||
static setAt<T>(list: T[], id: number, val: T) {
|
||||
return Vue.set(list, id, val)
|
||||
}
|
||||
|
||||
static setFirst<T>(list: T[], where: (val: T) => boolean, val: T) {
|
||||
return this.setAt(list, this.getIndex(list, where), val)
|
||||
}
|
||||
|
||||
static setFirstBy<T, U>(list: T[], mapper: (val: T) => U, key: U, val: T) {
|
||||
return this.setFirst(list, e => mapper(e) === key, val)
|
||||
}
|
||||
|
||||
static removeAt<T>(list: T[], id: number) {
|
||||
list.splice(id, 1)
|
||||
}
|
||||
|
@ -57,12 +43,4 @@ export default abstract class Lists {
|
|||
}
|
||||
}
|
||||
|
||||
static sort<T, U>(list: T[], mapper: (val: T) => U, comparer: (a: U, b: U) => number) {
|
||||
return list.sort((a, b) => comparer(mapper(a), mapper(b)))
|
||||
}
|
||||
|
||||
static stringCompare(a: string, b: string) {
|
||||
return ('' + a).localeCompare(b)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { AxiosPromise, AxiosResponse } from 'axios'
|
||||
import { unsafeAxiosMapper } from '../unsafeAxiosMapper'
|
||||
import { unsafeAxiosMapper } from '../unsafeMapper'
|
||||
import ErrorLoadable from './ErrorLoadable'
|
||||
|
||||
export default class AxiosLoadable<T, E> extends ErrorLoadable<T, E> {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Loadable from './Loadable'
|
||||
import { unsafeMapper } from '../unsafeMapper';
|
||||
|
||||
export default class ErrorLoadable<T, E> extends Loadable<T> {
|
||||
|
||||
|
@ -9,10 +10,6 @@ export default class ErrorLoadable<T, E> extends Loadable<T> {
|
|||
this.error = undefined
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== undefined
|
||||
}
|
||||
|
||||
get isSuccess() {
|
||||
return this.loaded && this.error === undefined
|
||||
}
|
||||
|
|
|
@ -9,10 +9,6 @@ export default class Loadable<T> {
|
|||
this.reset()
|
||||
}
|
||||
|
||||
get isLoaded() {
|
||||
return this.loaded
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.loaded = false
|
||||
this.data = undefined
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { unsafeMapper } from '../unsafeMapper'
|
||||
import ErrorLoadable from './ErrorLoadable'
|
||||
|
||||
export default class PromiseLoadable<T, E> extends ErrorLoadable<T, E> {
|
||||
|
||||
load<U>(promise: Promise<U>, then: (res: U) => T = res => unsafeMapper<T, U>(res), reset = true) {
|
||||
if (reset) {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
return promise
|
||||
.then(res => this.success(then(res)))
|
||||
.catch(err => {
|
||||
this.fail(err)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
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,31 @@
|
|||
export default class StorageHandler<T> implements IStorageHandler<T> {
|
||||
|
||||
constructor(protected storage: Storage, protected key: string, protected fallback: T) { }
|
||||
|
||||
loadSync(): T {
|
||||
const data = this.storage.getItem(this.key)
|
||||
if (data) {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (e) {
|
||||
this.storage.removeItem(this.key)
|
||||
}
|
||||
}
|
||||
return this.fallback;
|
||||
}
|
||||
|
||||
async load() {
|
||||
return this.loadSync()
|
||||
}
|
||||
|
||||
save(data: T) {
|
||||
return this.storage.setItem(this.key, JSON.stringify(data))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export interface IStorageHandler<T> {
|
||||
load(): Promise<T>
|
||||
loadSync(): T
|
||||
save(data: T): void
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
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)))
|
||||
}
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
import { AxiosResponse } from 'axios'
|
||||
|
||||
export function unsafeMapper<T, U = T>(res: U) {
|
||||
return res as unknown as T
|
||||
}
|
||||
export function unsafeAxiosMapper<T, U = T>(res: AxiosResponse<U>) {
|
||||
return res.data as unknown as T
|
||||
}
|
|
@ -83,7 +83,7 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
|
|||
}
|
||||
|
||||
get(path: string, options = {}) {
|
||||
return this.catchEmit(this.rest.get(path, { params: { limit: this.options.buffer, ...options } }))
|
||||
return this.catchError(this.rest.get(path, { params: { limit: this.options.buffer, ...options } }))
|
||||
}
|
||||
|
||||
getMessages(channel: string, options = {}) {
|
||||
|
|
|
@ -8,20 +8,16 @@ a.account(target="_blank" :href="account.url")
|
|||
<script lang="ts">
|
||||
import { Component, Mixins, Prop } from 'vue-property-decorator'
|
||||
|
||||
import BusMixin from './BusMixin'
|
||||
import ShowMediaMixin from '@/components/ShowMediaMixin'
|
||||
import { ParseEmojisMixin } from './ParseEmojisMixin'
|
||||
import { Account as IAccount } from './Types'
|
||||
|
||||
@Component
|
||||
export default class Account extends Mixins(ParseEmojisMixin, BusMixin) {
|
||||
export default class Account extends Mixins(ParseEmojisMixin, ShowMediaMixin) {
|
||||
|
||||
@Prop(Object)
|
||||
readonly account!: IAccount
|
||||
|
||||
get showMedia() {
|
||||
return this.bus.showMedia
|
||||
}
|
||||
|
||||
avatarStyle(avatar: string) {
|
||||
return {
|
||||
'background-image': `url(${avatar})`
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import { Component } from 'vue-property-decorator'
|
||||
|
||||
import LocalBusMixin from '@/components/LocalBusMixin'
|
||||
import { BusOptions } from './Types'
|
||||
|
||||
@Component
|
||||
export default class BusMixin extends LocalBusMixin<BusOptions, any> { }
|
||||
|
||||
export const LocalEvents = {
|
||||
Mark: 'mark',
|
||||
Vote: 'vote',
|
||||
Context: 'context'
|
||||
}
|
|
@ -1,106 +1,41 @@
|
|||
<template lang="pug">
|
||||
.client
|
||||
.statues(v-show="!hasContext" @scroll.passive="onScroll")
|
||||
.client(@scroll.passive="onScroll")
|
||||
.statues
|
||||
.header(v-if="hasNotifications") Accueil
|
||||
success-loadable.list(:loadable="statues")
|
||||
template(v-for="status in statues.get()")
|
||||
status(v-if="showStatus(status)" :key="status.id" :status="status" :bus="bus")
|
||||
status(v-if="showStatus(status)" :key="status.id" :status="status" :showMedia="options.showMedia" @mark="onStatusMark")
|
||||
.status(v-show="statues.loadingMore")
|
||||
.service-loader
|
||||
|
||||
.context(v-if="hasContext")
|
||||
.header(@click="closeContext")
|
||||
| Context
|
||||
span.date(@click.stop.prevent="closeContext") {{ icons.close }}
|
||||
.list
|
||||
.ancestors
|
||||
template(v-if="targetContext.isSuccess")
|
||||
status(v-for="status in targetContext.get().ancestors" :key="status.id" :status="status" :bus="bus")
|
||||
.service-loader(v-else)
|
||||
status.selected(:status="targetStatus" :bus="bus")
|
||||
.descendants
|
||||
template(v-if="targetContext.isSuccess")
|
||||
status(v-for="status in targetContext.get().descendants" :key="status.id" :status="status" :bus="bus")
|
||||
.service-loader(v-else)
|
||||
|
||||
.notifications(v-if="hasNotifications")
|
||||
.header
|
||||
| Notifications
|
||||
span.date(@click.stop.prevent="onNotificationsClear") {{ icons.close }}
|
||||
span.date(@click.stop.prevent="onNotificationsClear") ❌
|
||||
.list
|
||||
notification(v-for="notification in notifications.get()" :key="notification.id" :notification="notification"
|
||||
@dismiss="onNotificationDismiss" :bus="bus")
|
||||
|
||||
.compose-toggle(@click="showCompose = !showCompose") {{ icons.compose }}
|
||||
.emoji-list(v-if="options.showMedia" v-show="showCompose && showEmojis")
|
||||
img.emoji(v-for="emoji in emojis.get()" @click="addEmoji(emoji.shortcode)" :src="emoji.static_url" :alt="emoji.shortcode" :title="emoji.shortcode")
|
||||
.compose(v-show="showCompose")
|
||||
textarea.content(v-model="compose.status" placeholder="message")
|
||||
.options
|
||||
.emojis
|
||||
button(v-if="options.showMedia" @click="showEmojis = !showEmojis") ☺
|
||||
select(v-else @change="addEmoji($event.target.value)")
|
||||
option(v-for="emoji in emojis.get()" :value="emoji.shortcode") {{ emoji.shortcode }}
|
||||
.sens
|
||||
label.note(for="sensitive") Sensitive:
|
||||
input(id="sensitive" v-model="compose.sensitive" type="checkbox")
|
||||
.cw
|
||||
input(v-show="compose.sensitive" v-model="compose.spoiler_text" placeholder="content warning")
|
||||
.visibility
|
||||
select(v-model="compose.visibility")
|
||||
option(v-for="(icon, value) in visibilities" :value="value") {{ icon }}
|
||||
span.note {{ compose.visibility }}
|
||||
button(@click="sendStatus") Toot
|
||||
:showMedia="options.showMedia" @dismiss="onNotificationDismiss" @mark="onStatusMark")
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios, { AxiosResponse } from 'axios'
|
||||
import { Component, Mixins, Prop, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Component, Mixins } from 'vue-property-decorator'
|
||||
|
||||
import LocalBusMixin from '@/components/LocalBusMixin'
|
||||
import ServiceClient from '@/components/ServiceClient'
|
||||
import Lists from '@/helpers/lists/Lists'
|
||||
import AxiosLoadable from '@/helpers/loadable/AxiosLoadable'
|
||||
import AxiosLoadableMore from '@/helpers/loadable/AxiosLoadableMore'
|
||||
import { LocalEvents } from './BusMixin'
|
||||
import { Icons, Visibility } from './Icons'
|
||||
import { AUTH, getHeaders, getRest } from './Mastodon.vue'
|
||||
import AxiosLodable from '@/helpers/loadable/AxiosLoadable'
|
||||
import AxiosLodableMore from '@/helpers/loadable/AxiosLoadableMore'
|
||||
import { AUTH, getRest } from './Mastodon.vue'
|
||||
import Notification from './Notification.vue'
|
||||
import Status from './Status.vue'
|
||||
import { BusOptions, Context, Emoji, MarkStatus, Notification as INotification, Options, Poll, PollVote, Status as IStatus, StatusPost, TimelineType } from './Types'
|
||||
|
||||
const STREAMS = {
|
||||
home: 'user',
|
||||
local: 'public:local',
|
||||
public: 'public'
|
||||
}
|
||||
import { MarkMessage, Notification as INotification, Options, Status as IStatus } from './Types'
|
||||
|
||||
@Component({ components: { Status, Notification } })
|
||||
export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient) {
|
||||
|
||||
rest = getRest(this.auth, this.options.timeout)
|
||||
|
||||
statues = new AxiosLoadableMore<IStatus[], object>()
|
||||
notifications = new AxiosLoadable<INotification[], object>()
|
||||
emojis = new AxiosLoadable<Emoji[], object>()
|
||||
stream?: WebSocket = undefined
|
||||
|
||||
targetStatus: IStatus | null = null
|
||||
targetContext = new AxiosLoadable<Context, object>()
|
||||
|
||||
showCompose = false
|
||||
compose: StatusPost = {
|
||||
status: '',
|
||||
visibility: 'unlisted',
|
||||
sensitive: false,
|
||||
spoiler_text: ''
|
||||
}
|
||||
showEmojis = false // MAYBE: show tabs with unicode emoticons
|
||||
|
||||
bus = new Vue({ data: {
|
||||
showMedia: this.options.showMedia,
|
||||
showCounts: this.options.showCounts
|
||||
} })
|
||||
statues = new AxiosLodableMore<IStatus[], object>()
|
||||
notifications = new AxiosLodable<INotification[], object>()
|
||||
|
||||
get hasNotifications() {
|
||||
if(!this.notifications.isSuccess) {
|
||||
|
@ -115,77 +50,22 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
|
|||
}
|
||||
}
|
||||
|
||||
get hasContext() {
|
||||
return this.targetStatus && !this.targetContext.hasError
|
||||
}
|
||||
|
||||
get visibilities() {
|
||||
return Visibility
|
||||
}
|
||||
|
||||
get icons() {
|
||||
return Icons
|
||||
}
|
||||
|
||||
created() {
|
||||
new Map<string, (arg: any) => void>([
|
||||
[ LocalEvents.Mark, this.onStatusMark ],
|
||||
[ LocalEvents.Vote, this.onPollVote ],
|
||||
[ LocalEvents.Context, this.onHandleContext ]
|
||||
]).forEach((handler, name) => this.bus.$on(name, handler))
|
||||
|
||||
this.notifications.load(this.get<INotification[]>('/notifications'))
|
||||
this.emojis.load(this.get<Emoji[]>('/custom_emojis'), res => Lists.sort(res.data, e => e.shortcode, Lists.stringCompare))
|
||||
}
|
||||
|
||||
@Watch('options', { deep: true })
|
||||
change(o: any) {
|
||||
Object.keys(this.bus.$data).forEach(key => this.bus.$data[key] = o[key])
|
||||
}
|
||||
|
||||
@Watch('options.timeline', { immediate: true })
|
||||
init() {
|
||||
this.statues.load(this.getTimeline())
|
||||
this.notifications.load(this.get('/notifications'))
|
||||
this.setupStream()
|
||||
}
|
||||
|
||||
get<T>(path: string, options = {}) {
|
||||
return this.catchEmit(this.rest.get<T>(path, { params: { limit: this.options.buffer, ...options } }))
|
||||
get(path: string, options = {}) {
|
||||
return this.catchError(this.rest.get(path, { params: { limit: this.options.buffer, ...options } }))
|
||||
}
|
||||
|
||||
post<T>(path: string, options = {}) {
|
||||
return this.catchEmit(this.rest.post<T>(path, options))
|
||||
post(path: string, options = {}) {
|
||||
return this.catchError(this.rest.post(path, options))
|
||||
}
|
||||
|
||||
addEmoji(code: string) {
|
||||
this.compose.status += `:${code}:`
|
||||
}
|
||||
|
||||
sendStatus() {
|
||||
if(this.compose.status) {
|
||||
const post: StatusPost = {
|
||||
status: this.compose.status,
|
||||
visibility: this.compose.visibility
|
||||
}
|
||||
if(this.compose.sensitive) {
|
||||
post.sensitive = true
|
||||
if(this.compose.spoiler_text) {
|
||||
post.spoiler_text = this.compose.spoiler_text
|
||||
}
|
||||
}
|
||||
if(this.targetStatus) {
|
||||
post.in_reply_to_id = this.targetStatus.id
|
||||
}
|
||||
this.post('/statuses', post)
|
||||
this.compose.status = ''
|
||||
}
|
||||
}
|
||||
|
||||
getTimeline(options: any = {}) {
|
||||
if (this.options.timeline === 'local') {
|
||||
options.local = true
|
||||
}
|
||||
return this.get<IStatus[]>(`/timelines/${this.options.timeline === 'home' ? 'home' : 'public'}`, options)
|
||||
getTimeline(options = {}) {
|
||||
return this.get('/timelines/home', options)
|
||||
}
|
||||
|
||||
onScroll(event: any) {
|
||||
|
@ -200,35 +80,9 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
|
|||
return (!status.in_reply_to_id || this.options.reply) && (!status.reblog || this.options.reblog)
|
||||
}
|
||||
|
||||
onStatusMark(action: MarkStatus) {
|
||||
this.post<IStatus>(`/statuses/${action.id}/${action.type}`)
|
||||
.then(res => this.statues.with(
|
||||
sts => Lists.setFirstBy(sts, st => st.id, action.id, res.data)
|
||||
))
|
||||
}
|
||||
|
||||
onPollVote(action: PollVote) {
|
||||
this.post<Poll>(`/polls/${action.poll}/votes`, { choices: action.choices })
|
||||
.then(res => this.statues.with(
|
||||
sts => sts.find(st => st.id === action.id)!.poll = res.data
|
||||
))
|
||||
}
|
||||
|
||||
onHandleContext(status: IStatus) {
|
||||
if(this.targetStatus && this.targetStatus.id === status.id) {
|
||||
this.closeContext()
|
||||
return
|
||||
}
|
||||
|
||||
this.statues.with(sts => {
|
||||
this.targetStatus = status
|
||||
this.targetContext.load(this.get(`/statuses/${status.id}/context`), undefined, true)
|
||||
})
|
||||
}
|
||||
|
||||
closeContext() {
|
||||
this.targetStatus = null
|
||||
this.targetContext.reset()
|
||||
onStatusMark(action: MarkMessage) {
|
||||
this.post(`/statuses/${action.id}/${action.type}`)
|
||||
.then(() => action.callback())
|
||||
}
|
||||
|
||||
onNotificationDismiss(id: number) {
|
||||
|
@ -244,43 +98,36 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
|
|||
}
|
||||
|
||||
setupStream() {
|
||||
if(this.stream) {
|
||||
this.stream.close()
|
||||
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
|
||||
}
|
||||
}
|
||||
this.get<{ version: string }>('/instance').then(res => {
|
||||
const oldAuth = res.data.version < '2.8.4' ? `access_token=${this.auth.get(AUTH.TOKEN)}&` : ''
|
||||
this.stream = new WebSocket(
|
||||
`wss://${this.auth.get(AUTH.SERVER)}/api/v1/streaming?${oldAuth}stream=${STREAMS[this.options.timeline]}`,
|
||||
this.auth.get(AUTH.TOKEN)
|
||||
ws.onerror = ev => this.addError(ev.type)
|
||||
ws.onclose = () => {
|
||||
this.addError(
|
||||
'Mastodon stream disconnected !' +
|
||||
(this.options.reconnect ? ' Reconnecting...' : '')
|
||||
)
|
||||
this.stream.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
|
||||
}
|
||||
if (this.options.reconnect) {
|
||||
setTimeout(() => this.setupStream(), this.options.timeout)
|
||||
}
|
||||
this.stream.onerror = ev => this.emitError(ev.type)
|
||||
this.stream.onclose = () => {
|
||||
this.emitError(
|
||||
'Mastodon stream disconnected !' +
|
||||
(this.options.reconnect ? ' Reconnecting...' : '')
|
||||
)
|
||||
if (this.options.reconnect) {
|
||||
setTimeout(() => this.setupStream(), this.options.timeout)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -292,51 +139,16 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
|
|||
.mastodon
|
||||
.client
|
||||
display: flex
|
||||
flex-direction: column
|
||||
height: 100%
|
||||
overflow: hidden
|
||||
position: relative
|
||||
.header, .emoji-list
|
||||
@include main-tile
|
||||
overflow-y: auto
|
||||
.header
|
||||
margin-bottom: 0
|
||||
@include main-tile
|
||||
.list
|
||||
@include group-tile
|
||||
flex-grow: 1
|
||||
.statues, .notifications, .context, .emoji-list
|
||||
flex-grow: 1
|
||||
display: flex
|
||||
flex-direction: column
|
||||
overflow-y: scroll
|
||||
height: 100%
|
||||
.ancestors, .descendants
|
||||
.status
|
||||
font-size: .9em
|
||||
padding: $borderRadius
|
||||
@include tile
|
||||
.compose-toggle
|
||||
position: absolute
|
||||
bottom: .5em
|
||||
right: 1.5em
|
||||
background-color: $backColor
|
||||
border: 1px solid $darkColor
|
||||
border-radius: 100%
|
||||
height: 2em
|
||||
width: 2em
|
||||
text-align: center
|
||||
line-height: 2em
|
||||
.emoji-list
|
||||
img
|
||||
width: 2em
|
||||
height: 2em
|
||||
.compose
|
||||
@include main-tile
|
||||
display: flex
|
||||
min-height: 5em
|
||||
textarea
|
||||
flex: 1
|
||||
.options
|
||||
margin-right: 1em
|
||||
.statues
|
||||
flex: 1
|
||||
.notifications
|
||||
max-width: 33%
|
||||
|
||||
.account
|
||||
.name
|
||||
|
@ -379,17 +191,9 @@ export default class Client extends Mixins<ServiceClient<Options>>(ServiceClient
|
|||
background-color: #00000044
|
||||
color: white
|
||||
padding: .5em
|
||||
.card, .poll
|
||||
@include tile
|
||||
padding: .2em
|
||||
display: block
|
||||
.provider
|
||||
float: right
|
||||
.meta
|
||||
margin-left: 1em + $avatarSize
|
||||
font-size: .8em
|
||||
.fil
|
||||
float: right
|
||||
a
|
||||
margin: 0 .5em
|
||||
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
export const Visibility = {
|
||||
public: '◍',
|
||||
unlisted: '👁',
|
||||
private: '⚿',
|
||||
direct: '✉'
|
||||
}
|
||||
|
||||
export const Notification = {
|
||||
mention: '✉',
|
||||
reblog: '⟳',
|
||||
favourite: '⚝',
|
||||
follow: '👁',
|
||||
poll: '✓'
|
||||
}
|
||||
|
||||
export const Icons = {
|
||||
compose: '🖉',
|
||||
close: '❌',
|
||||
}
|
||||
|
||||
export const Context = {
|
||||
full: '⮃',
|
||||
up: '⮥',
|
||||
down: '⮦',
|
||||
no: '⭿'
|
||||
}
|
|
@ -5,15 +5,13 @@
|
|||
| {{ serviceName }}:
|
||||
loadable-inline(:loadable="account")
|
||||
template(#success)
|
||||
span(v-html="parseEmojis(account.data.display_name, account.data.emojis, params.showMedia) + '@' + server")
|
||||
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")
|
||||
setting-boolean(:id="'showCounts'" :title="'Show counts'" :value="params.showCounts" @change="saveOptionCouple")
|
||||
setting-select(:id="'timeline'" :title="'Timeline'" :value="params.timeline" @change="saveOptionCouple" :options="['home', 'local', 'public']")
|
||||
loadable-block.service-content(:loadable="account")
|
||||
template(#success)
|
||||
client(:auth="auth" :options="params" :emit="emit")
|
||||
|
@ -40,13 +38,10 @@ import { ParseEmojisMixin } from './ParseEmojisMixin'
|
|||
import { Account, Options } from './Types'
|
||||
|
||||
export const AUTH = { SERVER: 'server', TOKEN: 'token' }
|
||||
export function getHeaders(auth: Auth) {
|
||||
return { headers: { Authorization: 'Bearer ' + auth.get(AUTH.TOKEN) } }
|
||||
}
|
||||
export function getRest(auth: Auth, timeout: number) {
|
||||
return axios.create({
|
||||
baseURL: `https://${auth.get(AUTH.SERVER)}/api/v1/`, timeout,
|
||||
...getHeaders(auth)
|
||||
headers: { Authorization: 'Bearer ' + auth.get(AUTH.TOKEN) },
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -59,7 +54,7 @@ export default class Mastodon extends Mixins<AccountService<Account, object>>(Ac
|
|||
|
||||
get params(): Options {
|
||||
return { timeout: 5000, reconnect: false, buffer: 20, reblog: true, reply: false,
|
||||
showMedia: true, showCounts: true, timeline: 'home', ...this.options }
|
||||
showMedia: true, ...this.options }
|
||||
}
|
||||
|
||||
get isSetup() {
|
||||
|
|
|
@ -1,48 +1,45 @@
|
|||
<template lang="pug">
|
||||
.notification
|
||||
account(:account="notification.account" :showMedia="showMedia")
|
||||
|
||||
account(:account="notification.account" :bus="bus")
|
||||
a.date
|
||||
| {{ fromNow(notification.created_at) }}
|
||||
span(@click.stop.prevent="makeDismiss") {{ closeIcon }}
|
||||
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'") ⚝
|
||||
|
||||
span.colored.text-icon.letter {{ notificationTypeIcon }}
|
||||
span.date {{ fromNow(notification.created_at) }}
|
||||
|
||||
.content
|
||||
template(v-if="notification.type == 'follow'") Vous suit
|
||||
status.reblog(v-else-if="notification.status" :status="notification.status"
|
||||
:withAccount="notification.type != 'mention'" :bus="bus")
|
||||
: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 FromNowMixin from '@/components/FromNowMixin'
|
||||
import ShowMediaMixin from '@/components/ShowMediaMixin'
|
||||
import Account from './Account.vue'
|
||||
import BusMixin from './BusMixin'
|
||||
import { Icons, Notification as NotificationIcons } from './Icons'
|
||||
import Status from './Status.vue'
|
||||
import { MarkStatus, Notification as INotification, PollVote, Status as IStatus } from './Types'
|
||||
import { MarkMessage, Notification as INotification } from './Types'
|
||||
|
||||
@Component({ components: { Account, Status } })
|
||||
export default class Notification extends Mixins(FromNowMixin, BusMixin) {
|
||||
export default class Notification extends Mixins(ShowMediaMixin, FromNowMixin) {
|
||||
|
||||
@Prop(Object)
|
||||
readonly notification!: INotification
|
||||
|
||||
get notificationTypeIcon() {
|
||||
return NotificationIcons[this.notification.type] || '?'
|
||||
}
|
||||
|
||||
get closeIcon() {
|
||||
return Icons.close
|
||||
}
|
||||
|
||||
@Emit('dismiss')
|
||||
makeDismiss() {
|
||||
return this.notification.id
|
||||
}
|
||||
|
||||
@Emit('mark')
|
||||
passMark(action: MarkMessage) {
|
||||
return action
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -6,12 +6,10 @@ import { Emoji } from './Types'
|
|||
export class ParseEmojisMixin extends Vue {
|
||||
|
||||
parseEmojis(text: string, emojis: Emoji[], show = true) {
|
||||
if (show) {
|
||||
for (const emoji of emojis) {
|
||||
text = text.split(`:${emoji.shortcode}:`).join(
|
||||
`<img draggable="false" class="icon" alt="${emoji.shortcode}" title="${emoji.shortcode}" src="${emoji.static_url}">`
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<template lang="pug">
|
||||
.status
|
||||
account(v-if="withAccount" :account="status.account" :bus="bus")
|
||||
account(v-if="withAccount" :account="status.account" :showMedia="showMedia")
|
||||
|
||||
span.text-icon.letter(v-if="status.reblog") {{ reblogIcon }}
|
||||
span.text-icon.letter(v-if="status.reblog") ⟳
|
||||
|
||||
a.date(target="_blank" :href="status.uri") {{ fromNow(status.created_at) }}
|
||||
|
||||
|
@ -12,56 +12,39 @@
|
|||
{{ status.spoiler_text || 'Spoiler' }} {{ status.sensitive ? '→' : '↓' }}
|
||||
div(v-if="!status.spoiler_text || !status.sensitive")
|
||||
.text(v-html="parseEmojis(status.content, status.emojis, showMedia)")
|
||||
|
||||
.poll(v-if="status.poll")
|
||||
.date {{ fromNow(status.poll.expires_at) }}
|
||||
form.options(@submit.prevent="makeVote($event.target.elements)")
|
||||
.option(v-for="option in status.poll.options")
|
||||
input(v-if="!status.poll.expired && !status.poll.voted" :type="status.poll.multiple ? 'checkbox' : 'radio'" :id="status.poll.id + option.title" :value="option.title" :name="status.poll.id")
|
||||
label(:for="status.poll.id + option.title")
|
||||
| {{ option.title }}
|
||||
span.note {{ option.votes_count }}
|
||||
button(v-if="status.poll.voted") voted
|
||||
button(v-else-if="status.poll.expired") expired
|
||||
input(v-else type="submit" value="vote")
|
||||
|
||||
a.media(v-for="media in status.media_attachments" :href="media.url" target="_blank")
|
||||
template(v-if="bus.showMedia")
|
||||
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 {{ media.description }}
|
||||
|
||||
a.card(v-if="status.card" :href="status.card.url" target="_blank")
|
||||
a.provider(v-if="status.card.provider_name" :src="status.card.provider_url" target="_blank") {{ status.card.provider_name }}
|
||||
.title {{ status.card.title }}
|
||||
.descr {{ status.card.description }}
|
||||
template(v-if="status.card.image")
|
||||
img(v-if="showMedia" :src="status.card.image")
|
||||
a(v-else-if="status.card.type == 'photo'" :src="status.card.image" target="_blank") Hidden media
|
||||
|
||||
status.reblog(v-else :status="status.reblog" :bus="bus")
|
||||
template(v-else) Hidden media
|
||||
status.reblog(v-else :status="status.reblog" :showMedia="showMedia")
|
||||
|
||||
.meta(v-if="!status.reblog")
|
||||
status-meta(v-for="meta in metas" :key="meta.name" :meta="meta" :bus="bus")
|
||||
a {{ statusVisibilityIcon }}
|
||||
a.fil(@click.stop.prevent="showContext")
|
||||
| {{ contextIcon }}
|
||||
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 FromNowMixin from '@/components/FromNowMixin'
|
||||
import ShowMediaMixin from '@/components/ShowMediaMixin'
|
||||
import Account from './Account.vue'
|
||||
import BusMixin, { LocalEvents } from './BusMixin'
|
||||
import { Context, Notification, Visibility } from './Icons'
|
||||
import { ParseEmojisMixin } from './ParseEmojisMixin'
|
||||
import StatusMeta from './StatusMeta.vue'
|
||||
import { Card, MarkStatus, MarkStatusType, Poll, PollVote, Status as IStatus } from './Types'
|
||||
import { MarkMessage, Status as IStatus } from './Types'
|
||||
|
||||
@Component({ components: { Account, StatusMeta } })
|
||||
export default class Status extends Mixins(ParseEmojisMixin, FromNowMixin, BusMixin) {
|
||||
@Component({ components: { Account } })
|
||||
export default class Status extends Mixins(ParseEmojisMixin, ShowMediaMixin, FromNowMixin) {
|
||||
|
||||
@Prop(Object)
|
||||
readonly status!: IStatus
|
||||
|
@ -69,61 +52,35 @@ export default class Status extends Mixins(ParseEmojisMixin, FromNowMixin, BusMi
|
|||
@Prop({ type: Boolean, default: true })
|
||||
readonly withAccount!: boolean
|
||||
|
||||
get statusVisibilityIcon() {
|
||||
return Visibility[this.status.visibility] || '?'
|
||||
showReply(statusId: number) {
|
||||
throw statusId // TODO:
|
||||
}
|
||||
|
||||
get reblogIcon() {
|
||||
return Notification.reblog
|
||||
makeReply(status: IStatus) {
|
||||
throw status.id // TODO:
|
||||
}
|
||||
|
||||
get contextIcon() {
|
||||
if(this.status.in_reply_to_id) {
|
||||
return Context[this.status.replies_count ? 'full' : 'up']
|
||||
} else {
|
||||
return Context[this.status.replies_count ? 'down' : 'no']
|
||||
@Emit('mark')
|
||||
emitMark(status: IStatus, action: 'reblog' | 'favourite', callback: CallableFunction, undo = false): MarkMessage {
|
||||
return {
|
||||
id: status.id,
|
||||
type: (undo ? 'un' : '') + action,
|
||||
callback
|
||||
}
|
||||
}
|
||||
|
||||
get showMedia() {
|
||||
return this.bus.$data.showMedia
|
||||
makeReblog(status: IStatus) {
|
||||
this.emitMark(status, 'reblog', () => {
|
||||
status.reblogs_count += (status.reblogged ? -1 : 1)
|
||||
status.reblogged = !status.reblogged
|
||||
}, status.reblogged)
|
||||
}
|
||||
|
||||
get metas() {
|
||||
return [
|
||||
{ name: 'reply', click: this.makeReply, active: false, icon: Notification.mention, count: this.status.replies_count },
|
||||
{ name: 'reblog', click: this.makeReblog, active: this.status.reblogged, icon: Notification.reblog, count: this.status.reblogs_count },
|
||||
{ name: 'fav', click: this.makeFav, active: this.status.favourited, icon: Notification.favourite, count: this.status.favourites_count }
|
||||
]
|
||||
}
|
||||
|
||||
makeVote(elements: HTMLInputElement[]) {
|
||||
const choices = Object.values(elements).filter(e => e.checked).map(e => e.value)
|
||||
if(choices.length > 0) {
|
||||
this.bus.$emit(LocalEvents.Vote, { id: this.status.id, poll: this.status.poll!.id, choices })
|
||||
}
|
||||
}
|
||||
|
||||
makeMark(action: string, undo: boolean) {
|
||||
this.bus.$emit(LocalEvents.Mark, {
|
||||
id: this.status.id, type: (undo ? 'un' : '') + action as MarkStatusType
|
||||
})
|
||||
}
|
||||
|
||||
makeReblog() {
|
||||
this.makeMark('reblog', this.status.reblogged)
|
||||
}
|
||||
|
||||
makeFav() {
|
||||
this.makeMark('favourite', this.status.favourited)
|
||||
}
|
||||
|
||||
showContext() {
|
||||
this.bus.$emit(LocalEvents.Context, this.status)
|
||||
}
|
||||
|
||||
makeReply() {
|
||||
throw this // TODO:
|
||||
makeFav(status: IStatus) {
|
||||
this.emitMark(status, 'favourite', () => {
|
||||
status.favourites_count += (status.favourited ? -1 : 1)
|
||||
status.favourited = !status.favourited
|
||||
}, status.favourited)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
<template lang="pug">
|
||||
a(:class="{ colored: meta.active }" @click.stop.prevent="meta.click")
|
||||
span.text-icon {{ meta.icon }}
|
||||
template(v-if="showCounts") {{ meta.count }}
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Prop } from 'vue-property-decorator'
|
||||
|
||||
import BusMixin from './BusMixin'
|
||||
|
||||
@Component
|
||||
export default class StatusMeta extends Mixins(BusMixin) {
|
||||
|
||||
@Prop(Object)
|
||||
readonly meta!: object
|
||||
|
||||
get showCounts() {
|
||||
return this.bus.$data.showCounts
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
|
@ -9,31 +9,13 @@ export interface Account {
|
|||
emojis: Emoji[]
|
||||
}
|
||||
|
||||
export type TimelineType = 'home' | 'local' | 'public'
|
||||
|
||||
export interface BusOptions {
|
||||
showMedia: boolean
|
||||
showCounts: boolean
|
||||
}
|
||||
|
||||
export interface Options extends BusOptions {
|
||||
export interface Options {
|
||||
timeout: number
|
||||
reconnect: boolean
|
||||
buffer: number
|
||||
reblog: boolean
|
||||
reply: boolean
|
||||
timeline: TimelineType
|
||||
}
|
||||
|
||||
export type VisibilityType = 'public' | 'unlisted' | 'private' | 'direct'
|
||||
|
||||
export interface StatusPost {
|
||||
status: string
|
||||
in_reply_to_id?: number
|
||||
media_ids?: number[] // TODO:
|
||||
sensitive?: boolean
|
||||
spoiler_text?: string
|
||||
visibility: VisibilityType
|
||||
showMedia: boolean
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
|
@ -53,42 +35,6 @@ export interface Status {
|
|||
in_reply_to_id?: number
|
||||
reblog?: Status
|
||||
spoiler_text?: string
|
||||
card?: Card
|
||||
poll?: Poll
|
||||
visibility: VisibilityType
|
||||
}
|
||||
|
||||
export type CardType = 'link' | 'photo' | 'video' | 'rich'
|
||||
export interface Card {
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
image?: string
|
||||
type: CardType
|
||||
author_name?: string
|
||||
author_url?: string
|
||||
provider_name?: string
|
||||
provider_url?: string
|
||||
}
|
||||
|
||||
export interface PollOption {
|
||||
title: string
|
||||
votes_count?: number
|
||||
}
|
||||
export interface Poll {
|
||||
id: string
|
||||
expires_at?: string
|
||||
expired: boolean
|
||||
multiple: boolean
|
||||
votes_count: number
|
||||
options: PollOption[]
|
||||
voted?: boolean
|
||||
}
|
||||
|
||||
export interface PollVote {
|
||||
id: number
|
||||
poll: string
|
||||
choices: string[]
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
|
@ -98,22 +44,16 @@ export interface Media {
|
|||
type: string
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
ancestors: Status[]
|
||||
descendants: Status[]
|
||||
}
|
||||
|
||||
export type NotificationType = 'follow' | 'mention' | 'reblog' | 'favourite' | 'poll'
|
||||
export interface Notification {
|
||||
id: number
|
||||
account: Account
|
||||
type: NotificationType
|
||||
type: string
|
||||
created_at: string
|
||||
status?: Status
|
||||
}
|
||||
|
||||
export type MarkStatusType = 'reblog' | 'unreblog' | 'favourite' | 'unfavourite'
|
||||
export interface MarkStatus {
|
||||
export interface MarkMessage {
|
||||
id: number,
|
||||
type: MarkStatusType
|
||||
type: string,
|
||||
callback: CallableFunction
|
||||
}
|
|
@ -94,7 +94,7 @@ export default class NextcloudNews extends Mixins<ConnectedService<object, objec
|
|||
|
||||
loadData() {
|
||||
this.news.load<{ items: News[] }>(
|
||||
this.catchEmit(this.rest.get<{ items: News[] }>('/items', { params: { batchSize: this.params.buffer, type: 3, getRead: false } })),
|
||||
this.catchError(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
|
||||
|
@ -107,7 +107,7 @@ export default class NextcloudNews extends Mixins<ConnectedService<object, objec
|
|||
}
|
||||
|
||||
makeRead(id: number) {
|
||||
this.catchEmit(this.rest.put(`/items/${id}/read`))
|
||||
this.catchError(this.rest.put(`/items/${id}/read`))
|
||||
.then(() => this.removeNews(id))
|
||||
}
|
||||
|
||||
|
|
|
@ -53,11 +53,10 @@ export default {
|
|||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
callbacks: {
|
||||
title(tooltipItems: any, myData: any) {
|
||||
const item = myData.datasets[tooltipItems[0].datasetIndex].data[tooltipItems[0].index]
|
||||
return moment(item.x || item.t).format('HH[h]') + (item.d ? ` - ${item.d}` : '')
|
||||
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 || ''
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios'
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { Component } from 'vue-property-decorator'
|
||||
|
||||
import ConnectedService from '@/components/service/ConnectedService'
|
||||
|
@ -44,9 +44,6 @@ interface Forecast {
|
|||
rain?: {
|
||||
'3h': number
|
||||
}
|
||||
weather: Array<{
|
||||
description: string
|
||||
}>,
|
||||
}
|
||||
|
||||
const AUTH = { TOKEN: 'token' }
|
||||
|
@ -86,9 +83,9 @@ export default class OpenWeatherMap extends ConnectedService<object, object> {
|
|||
borderWidth: 1,
|
||||
fill: false,
|
||||
data: fs.map(line => ({
|
||||
x: line.dt * 1000, y: line.main.temp, d: line.weather[0].description
|
||||
x: line.dt * 1000, y: line.main.temp
|
||||
}))
|
||||
}, {
|
||||
},{
|
||||
type: 'bar',
|
||||
label: 'Percipitation',
|
||||
yAxisID: 'y-axis-rain',
|
||||
|
@ -132,17 +129,21 @@ export default class OpenWeatherMap extends ConnectedService<object, object> {
|
|||
this.loadForecast()
|
||||
}
|
||||
|
||||
get(path: string, params: AxiosRequestConfig) {
|
||||
return this.catchError(this.rest.get(path, params))
|
||||
}
|
||||
|
||||
getWeather(params: { id: number }) {
|
||||
return this.catchEmit(this.rest.get('weather', { params }))
|
||||
return this.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: {
|
||||
this.get('forecast', { params: {
|
||||
id: selected.id, cnt: this.params.forecastLimit
|
||||
}})),
|
||||
}}),
|
||||
res => res.data.list
|
||||
)
|
||||
} else {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { Selectable } from '@/helpers/lists/Selectable'
|
||||
import ErrorLoadable from '@/helpers/loadable/ErrorLoadable'
|
||||
import { Layout, Service } from '@/types/App'
|
||||
import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators'
|
||||
|
||||
export interface AppState {
|
||||
layouts: ErrorLoadable<Selectable<Layout>, string>
|
||||
services: ErrorLoadable<Service[], string>
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
@Module({ name: 'app', namespaced: true })
|
||||
export default class App extends VuexModule implements AppState {
|
||||
layouts = new ErrorLoadable<Selectable<Layout>, string>()
|
||||
services = new ErrorLoadable<Service[], string>()
|
||||
errors: string[] = []
|
||||
|
||||
addError(error: string) {
|
||||
this.errors.push(error)
|
||||
}
|
||||
removeError(id: number) {
|
||||
this.errors.splice(id, 1)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Module, Mutation, VuexModule } from 'vuex-module-decorators'
|
||||
|
||||
export interface ErrorsState {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
@Module({ name: 'errors', namespaced: true })
|
||||
export default class Errors extends VuexModule implements ErrorsState {
|
||||
errors: string[] = []
|
||||
|
||||
@Mutation
|
||||
add(error: string) {
|
||||
this.errors.push(error)
|
||||
}
|
||||
|
||||
@Mutation
|
||||
remove(id: number) {
|
||||
this.errors.splice(id, 1)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
import { Selectable } from '@/helpers/lists/Selectable'
|
||||
import PromiseLoadable from '@/helpers/loadable/PromiseLoadable'
|
||||
import StorageHandler from '@/helpers/storage/StorageHandler'
|
||||
import { Layout, Tile, Options, MoveType, MoveDirection } from '@/types/App'
|
||||
import { Module, Mutation, VuexModule } from 'vuex-module-decorators'
|
||||
|
||||
export interface LayoutsState {
|
||||
layouts: PromiseLoadable<Selectable<Layout>, string>
|
||||
}
|
||||
|
||||
@Module({ name: 'services', namespaced: true })
|
||||
export default class Layouts extends VuexModule implements LayoutsState {
|
||||
layouts = new PromiseLoadable<Selectable<Layout>, string>() // TODO: create increment id storage
|
||||
private storage = new StorageHandler<Layout[]>(window.localStorage, 'services', [{ name: 'main', tiles: [] }])
|
||||
|
||||
get data() {
|
||||
return this.layouts.get()
|
||||
}
|
||||
get current() {
|
||||
return this.layouts.map(l => l.selected, undefined)
|
||||
}
|
||||
get tiles() {
|
||||
return this.current ? this.current.tiles : []
|
||||
}
|
||||
|
||||
getTile(id: number) {
|
||||
return this.tiles[id]
|
||||
}
|
||||
|
||||
@Mutation
|
||||
load() {
|
||||
this.layouts.load(this.storage.load(),
|
||||
data => new Selectable<Layout>(data))
|
||||
}
|
||||
|
||||
save() {
|
||||
this.storage.save(this.layouts.map(l => l.data, []))
|
||||
}
|
||||
|
||||
@Mutation
|
||||
select(id: number) {
|
||||
this.layouts.with(l => l.select(id))
|
||||
}
|
||||
|
||||
@Mutation
|
||||
remove() {
|
||||
this.layouts.with(l => l.remove())
|
||||
this.save()
|
||||
}
|
||||
|
||||
@Mutation
|
||||
removeTile(id: number) {
|
||||
const layout = this.current
|
||||
if (layout) {
|
||||
layout.tiles.splice(id, 1)
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation
|
||||
add(layout: Layout) {
|
||||
this.layouts.with(l => l.data.push(layout))
|
||||
this.save()
|
||||
}
|
||||
|
||||
@Mutation
|
||||
addTile(tile: Tile) {
|
||||
const layout = this.current
|
||||
if (layout) {
|
||||
layout.tiles.push(tile)
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation
|
||||
setName(name: string) {
|
||||
const layout = this.current
|
||||
if (layout) {
|
||||
layout.name = name
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation
|
||||
setTileOption(id: number, key: string, value: any) {
|
||||
const tile = this.getTile(id)
|
||||
if (tile) {
|
||||
tile.options[key] = value
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation
|
||||
setTileOptions(id: number, options: Options) {
|
||||
const tile = this.getTile(id)
|
||||
if (tile) {
|
||||
tile.options = options
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation
|
||||
moveTile(id: number, type: MoveType, direction: MoveDirection) {
|
||||
const tile = this.getTile(id)
|
||||
if (tile) {
|
||||
tile.position[type] = Math.max(1,
|
||||
(tile.position[type] || 1) + direction
|
||||
)
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import PromiseLoadable from '@/helpers/loadable/PromiseLoadable'
|
||||
import StorageHandler from '@/helpers/storage/StorageHandler'
|
||||
import { Auth, Service } from '@/types/App'
|
||||
import { Module, Mutation, VuexModule } from 'vuex-module-decorators'
|
||||
|
||||
export interface ServicesState {
|
||||
services: PromiseLoadable<Service[], string>
|
||||
}
|
||||
|
||||
@Module({ name: 'services', namespaced: true })
|
||||
export default class Services extends VuexModule implements ServicesState {
|
||||
services = new PromiseLoadable<Service[], string>() // TODO: create increment id storage
|
||||
private storage = new StorageHandler<Service[]>(window.localStorage, 'services', [])
|
||||
|
||||
get data() {
|
||||
return this.services.get()
|
||||
}
|
||||
|
||||
get(id: number) {
|
||||
return this.services.map(data => data[id], undefined)
|
||||
}
|
||||
|
||||
@Mutation
|
||||
load() {
|
||||
this.services.load(this.storage.load(),
|
||||
data => data.map(s => ({ ...s, auth: new Auth(Object.entries(s.auth)) }))
|
||||
)
|
||||
}
|
||||
|
||||
save() {
|
||||
function saveAuth(auth: Auth) {
|
||||
const res: any = {}
|
||||
for (const entry of auth.entries()) {
|
||||
res[entry[0]] = entry[1]
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
this.storage.save(this.services.map(data => data.map(
|
||||
(s: Service) => ({ ...s, auth: saveAuth(s.auth) })
|
||||
), []))
|
||||
}
|
||||
|
||||
@Mutation
|
||||
remove(id: number) {
|
||||
this.services.with(data => data.splice(id, 1))
|
||||
this.save()
|
||||
}
|
||||
|
||||
@Mutation
|
||||
add(service: Service): number {
|
||||
this.services.with(data => data.push(service))
|
||||
this.save()
|
||||
return this.services.map(data => data.length - 1, -1)
|
||||
}
|
||||
|
||||
@Mutation
|
||||
set(id: number, name: string, auth: Auth) {
|
||||
const service = this.get(id)
|
||||
if (service) {
|
||||
service.name = name
|
||||
service.auth = auth
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -9,14 +9,14 @@ export default class Time extends VuexModule implements TimeState {
|
|||
now = new Date
|
||||
|
||||
@Mutation
|
||||
updateTime() {
|
||||
update() {
|
||||
this.now = new Date
|
||||
}
|
||||
|
||||
@Action
|
||||
start() {
|
||||
setInterval(() => {
|
||||
this.updateTime()
|
||||
this.update()
|
||||
}, 15 * 1000)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,21 +1,39 @@
|
|||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import { getModule } from 'vuex-module-decorators'
|
||||
|
||||
import Errors from './Errors'
|
||||
import Layouts from './Layouts'
|
||||
import Services from './Services'
|
||||
import Time from './Time'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export interface State {
|
||||
errors: Errors
|
||||
layouts: Layouts
|
||||
services: Services
|
||||
time: Time
|
||||
}
|
||||
|
||||
const store = new Vuex.Store<State>({
|
||||
strict: process.env.NODE_ENV !== 'production',
|
||||
modules: {
|
||||
errors: Errors,
|
||||
layouts: Layouts,
|
||||
services: Services,
|
||||
time: Time
|
||||
}
|
||||
})
|
||||
|
||||
export const ErrorsModule = getModule(Errors, store)
|
||||
|
||||
export const LayoutsModule = getModule(Layouts, store)
|
||||
LayoutsModule.load()
|
||||
|
||||
export const ServicesModule = getModule(Services, store)
|
||||
ServicesModule.load()
|
||||
|
||||
export const TimeModule = getModule(Time, store)
|
||||
TimeModule.start()
|
||||
|
||||
|
|
|
@ -15,13 +15,16 @@ export interface Layout {
|
|||
tiles: Tile[]
|
||||
}
|
||||
|
||||
export type MoveType = 'x' | 'y' | 'h' | 'w'
|
||||
export type MoveDirection = 1 | -1
|
||||
export interface Rect {
|
||||
x?: number, y?: number
|
||||
h?: number, w?: number
|
||||
}
|
||||
|
||||
export interface Options { [index: string]: { option: any } }
|
||||
export interface Tile {
|
||||
service: serviceKey
|
||||
position: Rect
|
||||
options: object
|
||||
options: Options
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
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 { }
|
|
@ -1,4 +1,6 @@
|
|||
module.exports = {
|
||||
/* ... other settings */
|
||||
transpileDependencies: ["vuex-module-decorators"]
|
||||
transpileDependencies: [
|
||||
'vuex-module-decorators',
|
||||
'vuex-persist'
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue