モバイル版でもデッキを使えるように (#4366)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Fix bug

* wip

* Update notifications.vue

* Update user-menu.vue

* deck settings

* indicate
This commit is contained in:
syuilo
2019-02-25 19:45:00 +09:00
committed by GitHub
parent 3654f247c4
commit c0a60260c2
53 changed files with 711 additions and 666 deletions

View File

@@ -64,6 +64,10 @@ export default Vue.extend({
this.hukidasi = false;
}
if (top < 0) {
top = 0;
}
popover.style.left = left + 'px';
popover.style.top = top + 'px';

View File

@@ -1,5 +1,5 @@
<template>
<div class="puqkfets" :class="{ mini }">
<div class="puqkfets" :class="{ mini: narrow }">
<mk-avatar class="avatar" :user="note.user"/>
<fa icon="retweet"/>
<i18n path="@.renoted-by" tag="span">
@@ -30,13 +30,13 @@ export default Vue.extend({
note: {
type: Object,
required: true
},
mini: {
type: Boolean,
required: false,
}
},
inject: {
narrow: {
default: false
}
}
},
});
</script>
@@ -44,25 +44,20 @@ export default Vue.extend({
.puqkfets
display flex
align-items center
padding 16px 32px 8px 32px
padding 8px 16px
line-height 28px
white-space pre
color var(--renoteText)
background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%)
&.mini
&:not(.mini)
padding 8px 16px
@media (min-width 500px)
padding 16px
padding 8px 16px
@media (min-width 600px)
padding 16px 32px
> .avatar
@media (min-width 500px)
width 28px
height 28px
padding 16px 32px 8px 32px
> .avatar
flex-shrink 0

View File

@@ -8,7 +8,7 @@
</blockquote>
</div>
<div v-else class="mk-url-preview">
<a :class="{ mini, compact }" :href="url" target="_blank" :title="url" v-if="!fetching">
<a :class="{ mini: narrow, compact }" :href="url" target="_blank" :title="url" v-if="!fetching">
<div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"></div>
<article>
<header>
@@ -125,10 +125,10 @@ export default Vue.extend({
required: false,
default: false
},
},
mini: {
type: Boolean,
required: false,
inject: {
narrow: {
default: false
}
},
@@ -350,7 +350,7 @@ export default Vue.extend({
&.compact
> .thumbnail
position: absolute
position absolute
width 56px
height 100%
@@ -368,7 +368,7 @@ export default Vue.extend({
&.compact
> article
> header h1, p, footer
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
overflow hidden
white-space nowrap
text-overflow ellipsis
</style>

View File

@@ -66,10 +66,11 @@ export default Vue.extend({
},
async pushList() {
const t = this.$t('select-list'); // なぜか後で参照すると null になるので最初にメモリに確保しておく
const lists = await this.$root.api('users/lists/list');
const { canceled, result: listId } = await this.$root.dialog({
type: null,
title: this.$t('select-list'),
title: t,
select: {
items: lists.map(list => ({
value: list.id, text: list.title

View File

@@ -0,0 +1,49 @@
<template>
<x-widgets-column v-if="column.type == 'widgets'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-notifications-column v-else-if="column.type == 'notifications'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'home'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'local'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
<x-direct-column v-else-if="column.type == 'direct'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XTlColumn from './deck.tl-column.vue';
import XNotificationsColumn from './deck.notifications-column.vue';
import XWidgetsColumn from './deck.widgets-column.vue';
import XMentionsColumn from './deck.mentions-column.vue';
import XDirectColumn from './deck.direct-column.vue';
export default Vue.extend({
components: {
XTlColumn,
XNotificationsColumn,
XWidgetsColumn,
XMentionsColumn,
XDirectColumn
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: false,
default: false
}
},
methods: {
focus() {
this.$children[0].focus();
}
}
});
</script>

View File

@@ -0,0 +1,426 @@
<template>
<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs" :class="{ naked, narrow, active, isStacked, draghover, dragging, dropready }"
@dragover.prevent.stop="onDragover"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
v-hotkey="keymap">
<header :class="{ indicate: count > 0 }"
draggable="true"
@click="goTop"
@dragstart="onDragstart"
@dragend="onDragend"
@contextmenu.prevent.stop="onContextmenu">
<button class="toggleActive" @click="toggleActive" v-if="isStacked">
<template v-if="active"><fa icon="angle-up"/></template>
<template v-else><fa icon="angle-down"/></template>
</button>
<span><slot name="header"></slot></span>
<span class="count" v-if="count > 0">({{ count }})</span>
<button v-if="!isTemporaryColumn" class="menu" ref="menu" @click.stop="showMenu"><fa icon="caret-down"/></button>
<button v-else class="close" @click.stop="close"><fa icon="times"/></button>
</header>
<div ref="body" v-show="active">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Menu from '../../../common/views/components/menu.vue';
import { countIf } from '../../../../../prelude/array';
import { faArrowUp, faArrowDown } from '@fortawesome/free-solid-svg-icons';
import { faWindowMaximize } from '@fortawesome/free-regular-svg-icons';
export default Vue.extend({
i18n: i18n('deck'),
props: {
column: {
type: Object,
required: false,
default: null
},
isStacked: {
type: Boolean,
required: false,
default: false
},
name: {
type: String,
required: false
},
menu: {
type: Array,
required: false,
default: null
},
naked: {
type: Boolean,
required: false,
default: false
},
narrow: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
count: 0,
active: true,
dragging: false,
draghover: false,
dropready: false,
faArrowUp, faArrowDown
};
},
computed: {
isTemporaryColumn(): boolean {
return this.column == null;
},
keymap(): any {
return {
'shift+up': () => this.$parent.$emit('parentFocus', 'up'),
'shift+down': () => this.$parent.$emit('parentFocus', 'down'),
'shift+left': () => this.$parent.$emit('parentFocus', 'left'),
'shift+right': () => this.$parent.$emit('parentFocus', 'right'),
};
}
},
inject: {
getColumnVm: { from: 'getColumnVm' }
},
watch: {
active(v) {
if (v && this.isScrollTop()) {
this.$emit('top');
}
},
dragging(v) {
this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd');
}
},
provide() {
return {
column: this,
isScrollTop: this.isScrollTop,
count: v => this.count = v,
inDeck: !this.naked
};
},
mounted() {
this.$refs.body.addEventListener('scroll', this.onScroll, { passive: true });
if (!this.isTemporaryColumn) {
this.$root.$on('deck.column.dragStart', this.onOtherDragStart);
this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd);
}
},
beforeDestroy() {
this.$refs.body.removeEventListener('scroll', this.onScroll);
if (!this.isTemporaryColumn) {
this.$root.$off('deck.column.dragStart', this.onOtherDragStart);
this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd);
}
},
methods: {
onOtherDragStart() {
this.dropready = true;
},
onOtherDragEnd() {
this.dropready = false;
},
toggleActive() {
if (!this.isStacked) return;
const vms = this.$store.state.device.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id));
if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return;
this.active = !this.active;
},
isScrollTop() {
return this.active && this.$refs.body.scrollTop == 0;
},
onScroll() {
if (this.isScrollTop()) {
this.$emit('top');
}
if (this.$store.state.settings.fetchOnScroll !== false) {
const current = this.$refs.body.scrollTop + this.$refs.body.clientHeight;
if (current > this.$refs.body.scrollHeight - 1) this.$emit('bottom');
}
},
getMenu() {
const items = [{
icon: 'pencil-alt',
text: this.$t('rename'),
action: () => {
this.$root.dialog({
title: this.$t('rename'),
input: {
default: this.name,
allowEmpty: false
}
}).then(({ canceled, result: name }) => {
if (canceled) return;
this.$store.commit('device/renameDeckColumn', { id: this.column.id, name });
});
}
}, null, {
icon: 'arrow-left',
text: this.$t('swap-left'),
action: () => {
this.$store.commit('device/swapLeftDeckColumn', this.column.id);
}
}, {
icon: 'arrow-right',
text: this.$t('swap-right'),
action: () => {
this.$store.commit('device/swapRightDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: faArrowUp,
text: this.$t('swap-up'),
action: () => {
this.$store.commit('device/swapUpDeckColumn', this.column.id);
}
} : undefined, this.isStacked ? {
icon: faArrowDown,
text: this.$t('swap-down'),
action: () => {
this.$store.commit('device/swapDownDeckColumn', this.column.id);
}
} : undefined, null, {
icon: ['far', 'window-restore'],
text: this.$t('stack-left'),
action: () => {
this.$store.commit('device/stackLeftDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: faWindowMaximize,
text: this.$t('pop-right'),
action: () => {
this.$store.commit('device/popRightDeckColumn', this.column.id);
}
} : undefined, null, {
icon: ['far', 'trash-alt'],
text: this.$t('remove'),
action: () => {
this.$store.commit('device/removeDeckColumn', this.column.id);
}
}];
if (this.menu) {
items.unshift(null);
for (const i of this.menu.reverse()) {
items.unshift(i);
}
}
return items;
},
onContextmenu(e) {
if (this.isTemporaryColumn) return;
this.$contextmenu(e, this.getMenu());
},
showMenu() {
this.$root.new(Menu, {
source: this.$refs.menu,
items: this.getMenu()
});
},
close() {
this.$router.push('/');
},
goTop() {
this.$refs.body.scrollTo({
top: 0,
behavior: 'smooth'
});
},
onDragstart(e) {
// テンポラリカラムはドラッグさせない
if (this.isTemporaryColumn) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('mk-deck-column', this.column.id);
this.dragging = true;
},
onDragend(e) {
this.dragging = false;
},
onDragover(e) {
// テンポラリカラムにはドロップさせない
if (this.isTemporaryColumn) {
e.dataTransfer.dropEffect = 'none';
return;
}
// 自分自身がドラッグされている場合
if (this.dragging) {
// 自分自身にはドロップさせない
e.dataTransfer.dropEffect = 'none';
return;
}
const isDeckColumn = e.dataTransfer.types[0] == 'mk-deck-column';
e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
if (!this.dragging && isDeckColumn) this.draghover = true;
},
onDragleave() {
this.draghover = false;
},
onDrop(e) {
this.draghover = false;
this.$root.$emit('deck.column.dragEnd');
const id = e.dataTransfer.getData('mk-deck-column');
if (id != null && id != '') {
this.$store.commit('device/swapDeckColumn', {
a: this.column.id,
b: id
});
}
}
}
});
</script>
<style lang="stylus" scoped>
.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs
$header-height = 42px
height 100%
background var(--face)
border-radius var(--round)
box-shadow var(--shadow)
overflow hidden
&.draghover
box-shadow 0 0 0 2px var(--primaryAlpha08)
&:after
content ""
display block
position absolute
z-index 1000
top 0
left 0
width 100%
height 100%
background var(--primaryAlpha02)
&.dragging
box-shadow 0 0 0 2px var(--primaryAlpha04)
&.dropready
*
pointer-events none
&:not(.active)
flex-basis $header-height
min-height $header-height
&:not(.isStacked).narrow
width 285px
min-width 285px
flex-grow 0 !important
&.naked
background var(--deckAcrylicColumnBg)
> header
background transparent
box-shadow none
> button
color var(--text)
> header
display flex
z-index 2
line-height $header-height
padding 0 16px
font-size 14px
color var(--faceHeaderText)
background var(--faceHeader)
box-shadow 0 var(--lineWidth) rgba(#000, 0.15)
cursor pointer
&, *
user-select none
*:not(button)
pointer-events none
&.indicate
box-shadow 0 3px 0 0 var(--primary)
> span
[data-icon]
margin-right 8px
> .count
margin-left 4px
opacity 0.5
> .toggleActive
> .menu
> .close
padding 0
width $header-height
line-height $header-height
font-size 16px
color var(--faceTextButton)
&:hover
color var(--faceTextButtonHover)
&:active
color var(--faceTextButtonActive)
> .toggleActive
margin-left -16px
> .menu
> .close
margin-left auto
margin-right -16px
> div
height "calc(100% - %s)" % $header-height
overflow auto
overflow-x hidden
-webkit-overflow-scrolling touch
</style>

View File

@@ -0,0 +1,46 @@
<template>
<x-column :name="name" :column="column" :is-stacked="isStacked">
<template #header><fa :icon="['far', 'envelope']"/>{{ name }}</template>
<x-direct/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XDirect from './deck.direct.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XDirect
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.direct');
}
},
methods: {
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View File

@@ -0,0 +1,65 @@
<template>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
data() {
return {
connection: null,
makePromise: cursor => this.$root.api('notes/mentions', {
limit: fetchLimit + 1,
untilId: cursor ? cursor : undefined,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
visibility: 'specified'
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
return {
notes: notes,
cursor: notes[notes.length - 1].id
};
} else {
return {
notes: notes,
cursor: null
};
}
})
};
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('mention', this.onNote);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onNote(note) {
// Prepend a note
if (note.visibility == 'specified') {
(this.$refs.timeline as any).prepend(note);
}
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View File

@@ -0,0 +1,34 @@
<template>
<x-column>
<template #header>
<fa :icon="faHashtag"/>{{ $t('@.explore') }}
</template>
<div>
<x-explore v-bind="$attrs"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XExplore from '../../../common/views/pages/explore.vue';
import { faHashtag } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XExplore,
},
data() {
return {
faHashtag
};
}
});
</script>

View File

@@ -0,0 +1,58 @@
<template>
<x-column>
<template #header>
<fa :icon="['fa', 'star']"/>{{ $t('favorites') }}
</template>
<div>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XNotes,
},
data() {
return {
makePromise: cursor => this.$root.api('i/favorites', {
limit: fetchLimit + 1,
untilId: cursor ? cursor : undefined,
}).then(notes => {
notes = notes.map(x => x.note);
if (notes.length == fetchLimit + 1) {
notes.pop();
return {
notes: notes,
cursor: notes[notes.length - 1].id
};
} else {
return {
notes: notes,
cursor: null
};
}
})
};
},
methods: {
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View File

@@ -0,0 +1,46 @@
<template>
<x-column>
<template #header>
<fa :icon="faNewspaper"/>{{ $t('@.featured-notes') }}
</template>
<div>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XNotes from './deck.notes.vue';
import { faNewspaper } from '@fortawesome/free-solid-svg-icons';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XNotes,
},
data() {
return {
faNewspaper,
makePromise: cursor => this.$root.api('notes/featured', {
limit: 20,
}).then(notes => {
notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return notes;
})
};
},
methods: {
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View File

@@ -0,0 +1,119 @@
<template>
<x-column>
<template #header>
<fa icon="hashtag"/><span>{{ tag }}</span>
</template>
<div class="xroyrflcmhhtmlwmyiwpfqiirqokfueb">
<div ref="chart" class="chart"></div>
<x-hashtag-tl :tag-tl="tagTl" class="tl"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import XColumn from './deck.column.vue';
import XHashtagTl from './deck.hashtag-tl.vue';
import ApexCharts from 'apexcharts';
export default Vue.extend({
components: {
XColumn,
XHashtagTl
},
computed: {
tag(): string {
return this.$route.params.tag;
},
tagTl(): any {
return {
query: [[this.tag]]
};
}
},
watch: {
$route: 'fetch'
},
created() {
this.fetch();
},
methods: {
fetch() {
this.$root.api('charts/hashtag', {
tag: this.tag,
span: 'hour',
limit: 24
}).then(stats => {
const local = [];
const remote = [];
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
const h = now.getHours();
for (let i = 0; i < 24; i++) {
const x = new Date(y, m, d, h - i);
local.push([x, stats.local.count[i]]);
remote.push([x, stats.remote.count[i]]);
}
const chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'area',
height: 70,
sparkline: {
enabled: true
},
},
grid: {
clipMarkers: false,
padding: {
top: 16,
right: 16,
bottom: 16,
left: 16
}
},
stroke: {
curve: 'straight',
width: 2
},
series: [{
name: 'Local',
data: local
}, {
name: 'Remote',
data: remote
}],
xaxis: {
type: 'datetime',
}
});
chart.render();
});
}
}
});
</script>
<style lang="stylus" scoped>
.xroyrflcmhhtmlwmyiwpfqiirqokfueb
background var(--deckColumnBg)
> .chart
margin-bottom 16px
background var(--face)
> .tl
background var(--face)
</style>

View File

@@ -0,0 +1,85 @@
<template>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
props: {
tagTl: {
type: Object,
required: true
},
mediaOnly: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
connection: null,
makePromise: cursor => this.$root.api('notes/search_by_tag', {
limit: fetchLimit + 1,
untilId: cursor ? cursor : undefined,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
query: this.tagTl.query
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
return {
notes: notes,
cursor: notes[notes.length - 1].id
};
} else {
return {
notes: notes,
cursor: null
};
}
})
};
},
watch: {
mediaOnly() {
this.$refs.timeline.reload();
}
},
mounted() {
if (this.connection) this.connection.close();
this.connection = this.$root.stream.connectToChannel('hashtag', {
q: this.tagTl.query
});
this.connection.on('note', this.onNote);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onNote(note) {
if (this.mediaOnly && note.files.length == 0) return;
(this.$refs.timeline as any).prepend(note);
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View File

@@ -0,0 +1,95 @@
<template>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
props: {
list: {
type: Object,
required: true
},
mediaOnly: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
connection: null,
makePromise: cursor => this.$root.api('notes/user-list-timeline', {
listId: this.list.id,
limit: fetchLimit + 1,
untilId: cursor ? cursor : undefined,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
return {
notes: notes,
cursor: notes[notes.length - 1].id
};
} else {
return {
notes: notes,
cursor: null
};
}
})
};
},
watch: {
mediaOnly() {
this.$refs.timeline.reload();
}
},
mounted() {
if (this.connection) this.connection.dispose();
this.connection = this.$root.stream.connectToChannel('userList', {
listId: this.list.id
});
this.connection.on('note', this.onNote);
this.connection.on('userAdded', this.onUserAdded);
this.connection.on('userRemoved', this.onUserRemoved);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onNote(note) {
if (this.mediaOnly && note.files.length == 0) return;
(this.$refs.timeline as any).prepend(note);
},
onUserAdded() {
this.$refs.timeline.reload();
},
onUserRemoved() {
this.$refs.timeline.reload();
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View File

@@ -0,0 +1,46 @@
<template>
<x-column :name="name" :column="column" :is-stacked="isStacked">
<template #header><fa icon="at"/>{{ name }}</template>
<x-mentions ref="tl"/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XMentions from './deck.mentions.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XMentions
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.mentions');
}
},
methods: {
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View File

@@ -0,0 +1,61 @@
<template>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
const fetchLimit = 10;
export default Vue.extend({
components: {
XNotes
},
data() {
return {
connection: null,
makePromise: cursor => this.$root.api('notes/mentions', {
limit: fetchLimit + 1,
untilId: cursor ? cursor : undefined,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
return {
notes: notes,
cursor: notes[notes.length - 1].id
};
} else {
return {
notes: notes,
cursor: null
};
}
})
};
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('mention', this.onNote);
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onNote(note) {
(this.$refs.timeline as any).prepend(note);
},
focus() {
this.$refs.timeline.focus();
}
}
});
</script>

View File

@@ -0,0 +1,75 @@
<template>
<x-column>
<template #header>
<fa :icon="['far', 'comment-alt']"/><mk-user-name :user="note.user" v-if="note"/>
</template>
<div class="rvtscbadixhhbsczoorqoaygovdeecsx" v-if="note">
<div class="is-remote" v-if="note.user.host != null">
<details>
<summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-post') }}</summary>
<a :href="note.url || note.uri" target="_blank">{{ $t('@.view-on-remote') }}</a>
</details>
</div>
<mk-note :note="note" :detail="true"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XNotes from './deck.notes.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XNotes,
},
data() {
return {
note: null,
fetching: true
};
},
watch: {
$route: 'fetch'
},
created() {
this.fetch();
},
methods: {
fetch() {
this.fetching = true;
this.$root.api('notes/show', {
noteId: this.$route.params.note
}).then(note => {
this.note = note;
this.fetching = false;
});
}
}
});
</script>
<style lang="stylus" scoped>
.rvtscbadixhhbsczoorqoaygovdeecsx
> .is-remote
padding 8px 16px
font-size 12px
&.is-remote
color var(--remoteInfoFg)
background var(--remoteInfoBg)
> a
font-weight bold
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu">
<div class="empty" v-if="notes.length == 0 && !fetching && inited">{{ $t('@.no-notes') }}</div>
<mk-error v-if="!fetching && !inited" @retry="init()"/>
<div class="placeholder" v-if="fetching">
<template v-for="i in 10">
<mk-note-skeleton :key="i"/>
</template>
</div>
<!-- トランジションを有効にするとなぜかメモリリークする -->
<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition notes" ref="notes" tag="div">
<template v-for="(note, i) in _notes">
<mk-note
:note="note"
:key="note.id"
@update:note="onNoteUpdated(i, $event)"
:compact="true"
/>
<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
<span><fa icon="angle-up"/>{{ note._datetext }}</span>
<span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span>
</p>
</template>
</component>
<footer v-if="cursor != null">
<button @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $t('@.load-more') }}</template>
<template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>
</button>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import shouldMuteNote from '../../../common/scripts/should-mute-note';
const displayLimit = 20;
export default Vue.extend({
i18n: i18n(),
inject: ['column', 'isScrollTop', 'count'],
props: {
makePromise: {
required: true
}
},
data() {
return {
rootEl: null,
notes: [],
queue: [],
fetching: true,
moreFetching: false,
inited: false,
cursor: null
};
},
computed: {
_notes(): any[] {
return (this.notes as any).map(note => {
const date = new Date(note.createdAt).getDate();
const month = new Date(note.createdAt).getMonth() + 1;
note._date = date;
note._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
return note;
});
}
},
watch: {
queue(q) {
this.count(q.length);
},
makePromise() {
this.init();
}
},
created() {
this.column.$on('top', this.onTop);
this.column.$on('bottom', this.onBottom);
this.init();
},
beforeDestroy() {
this.column.$off('top', this.onTop);
this.column.$off('bottom', this.onBottom);
},
methods: {
focus() {
(this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus();
},
onNoteUpdated(i, note) {
Vue.set((this as any).notes, i, note);
},
reload() {
this.init();
},
init() {
this.queue = [];
this.notes = [];
this.fetching = true;
this.makePromise().then(x => {
if (Array.isArray(x)) {
this.notes = x;
} else {
this.notes = x.notes;
this.cursor = x.cursor;
}
this.inited = true;
this.fetching = false;
this.$emit('inited');
}, e => {
this.fetching = false;
});
},
more() {
if (this.cursor == null || this.moreFetching) return;
this.moreFetching = true;
this.makePromise(this.cursor).then(x => {
this.notes = this.notes.concat(x.notes);
this.cursor = x.cursor;
this.moreFetching = false;
}, e => {
this.moreFetching = false;
});
},
prepend(note, silent = false) {
// 弾く
if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return;
// タブが非表示ならタイトルで通知
if (document.hidden) {
this.$store.commit('pushBehindNote', note);
}
if (this.isScrollTop()) {
// Prepend the note
this.notes.unshift(note);
// オーバーフローしたら古い投稿は捨てる
if (this.notes.length >= displayLimit) {
this.notes = this.notes.slice(0, displayLimit);
}
} else {
this.queue.push(note);
}
},
append(note) {
this.notes.push(note);
},
releaseQueue() {
for (const n of this.queue) {
this.prepend(n, true);
}
this.queue = [];
},
onTop() {
this.releaseQueue();
},
onBottom() {
this.more();
}
}
});
</script>
<style lang="stylus" scoped>
.eamppglmnmimdhrlzhplwpvyeaqmmhxu
.transition
.mk-notes-enter
.mk-notes-leave-to
opacity 0
transform translateY(-30px)
> *
transition transform .3s ease, opacity .3s ease
> .empty
padding 16px
text-align center
color var(--text)
> .placeholder
padding 16px
opacity 0.3
> .notes
> .date
display block
margin 0
line-height 28px
font-size 12px
text-align center
color var(--dateDividerFg)
background var(--dateDividerBg)
border-bottom solid var(--lineWidth) var(--faceDivider)
span
margin 0 16px
[data-icon]
margin-right 8px
> footer
> button
display block
margin 0
padding 16px
width 100%
text-align center
color #ccc
background var(--face)
border-top solid var(--lineWidth) var(--faceDivider)
border-bottom-left-radius 6px
border-bottom-right-radius 6px
&:hover
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05)
&:active
box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1)
</style>

View File

@@ -0,0 +1,189 @@
<template>
<div class="dsfykdcjpuwfvpefwufddclpjhzktmpw">
<div class="notification reaction" v-if="notification.type == 'reaction'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<mk-reaction-icon :reaction="notification.reaction"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
<router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<fa icon="quote-left"/>
<mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/>
<fa icon="quote-right"/>
</router-link>
</div>
</div>
<div class="notification renote" v-if="notification.type == 'renote'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="retweet"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
<router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)">
<fa icon="quote-left"/>
<mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/>
<fa icon="quote-right"/>
</router-link>
</div>
</div>
<div class="notification follow" v-if="notification.type == 'follow'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="user-plus"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
</div>
</div>
<div class="notification followRequest" v-if="notification.type == 'receiveFollowRequest'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="user-clock"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
</div>
</div>
<div class="notification poll_vote" v-if="notification.type == 'poll_vote'">
<mk-avatar class="avatar" :user="notification.user"/>
<div>
<header>
<fa icon="chart-pie"/>
<router-link :to="notification.user | userPage">
<mk-user-name :user="notification.user"/>
</router-link>
<mk-time :time="notification.createdAt"/>
</header>
<router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)">
<fa icon="quote-left"/>
<mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/>
<fa icon="quote-right"/>
</router-link>
</div>
</div>
<template v-if="notification.type == 'quote'">
<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
</template>
<template v-if="notification.type == 'reply'">
<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
</template>
<template v-if="notification.type == 'mention'">
<mk-note :note="notification.note" @update:note="onNoteUpdated"/>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import getNoteSummary from '../../../../../misc/get-note-summary';
export default Vue.extend({
props: ['notification'],
data() {
return {
getNoteSummary
};
},
methods: {
onNoteUpdated(note) {
switch (this.notification.type) {
case 'quote':
case 'reply':
case 'mention':
Vue.set(this.notification, 'note', note);
break;
}
}
}
});
</script>
<style lang="stylus" scoped>
.dsfykdcjpuwfvpefwufddclpjhzktmpw
> .notification
padding 16px
font-size 12px
overflow-wrap break-word
&:after
content ""
display block
clear both
> .avatar
display block
float left
width 36px
height 36px
border-radius 6px
> div
float right
width calc(100% - 36px)
padding-left 8px
> header
display flex
align-items baseline
white-space nowrap
[data-icon], .mk-reaction-icon
margin-right 4px
> .mk-time
margin-left auto
color var(--noteHeaderInfo)
font-size 0.9em
> .note-preview
color var(--noteText)
> .note-ref
color var(--noteText)
display inline-block
width: 100%
overflow hidden
white-space nowrap
text-overflow ellipsis
[data-icon]
font-size 1em
font-weight normal
font-style normal
display inline-block
margin-right 3px
&.renote
> div > header [data-icon]
color #77B255
&.follow
> div > header [data-icon]
color #53c7ce
&.receiveFollowRequest
> div > header [data-icon]
color #888
</style>

View File

@@ -0,0 +1,40 @@
<template>
<x-column :name="name" :column="column" :is-stacked="isStacked">
<template #header><fa :icon="['far', 'bell']"/>{{ name }}</template>
<x-notifications/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XNotifications from './deck.notifications.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XNotifications
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.notifications');
}
},
});
</script>

View File

@@ -0,0 +1,223 @@
<template>
<div class="oxynyeqmfvracxnglgulyqfgqxnxmehl">
<div class="placeholder" v-if="fetching">
<template v-for="i in 10">
<mk-note-skeleton :key="i"/>
</template>
</div>
<!-- トランジションを有効にするとなぜかメモリリークする -->
<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div">
<template v-for="(notification, i) in _notifications">
<x-notification class="notification" :notification="notification" :key="notification.id"/>
<p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'">
<span><fa icon="angle-up"/>{{ notification._datetext }}</span>
<span><fa icon="angle-down"/>{{ _notifications[i + 1]._datetext }}</span>
</p>
</template>
</component>
<button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications">
<template v-if="fetchingMoreNotifications"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreNotifications ? this.$t('@.loading') : this.$t('@.load-more') }}
</button>
<p class="empty" v-if="notifications.length == 0 && !fetching">{{ $t('empty') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XNotification from './deck.notification.vue';
const displayLimit = 20;
export default Vue.extend({
i18n: i18n(),
components: {
XNotification
},
inject: ['column', 'isScrollTop', 'count'],
data() {
return {
fetching: true,
fetchingMoreNotifications: false,
notifications: [],
queue: [],
moreNotifications: false,
connection: null
};
},
computed: {
_notifications(): any[] {
return (this.notifications as any).map(notification => {
const date = new Date(notification.createdAt).getDate();
const month = new Date(notification.createdAt).getMonth() + 1;
notification._date = date;
notification._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString());
return notification;
});
}
},
watch: {
queue(q) {
this.count(q.length);
}
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('notification', this.onNotification);
this.column.$on('top', this.onTop);
this.column.$on('bottom', this.onBottom);
const max = 10;
this.$root.api('i/notifications', {
limit: max + 1
}).then(notifications => {
if (notifications.length == max + 1) {
this.moreNotifications = true;
notifications.pop();
}
this.notifications = notifications;
this.fetching = false;
});
},
beforeDestroy() {
this.connection.dispose();
this.column.$off('top', this.onTop);
this.column.$off('bottom', this.onBottom);
},
methods: {
fetchMoreNotifications() {
this.fetchingMoreNotifications = true;
const max = 20;
this.$root.api('i/notifications', {
limit: max + 1,
untilId: this.notifications[this.notifications.length - 1].id
}).then(notifications => {
if (notifications.length == max + 1) {
this.moreNotifications = true;
notifications.pop();
} else {
this.moreNotifications = false;
}
this.notifications = this.notifications.concat(notifications);
this.fetchingMoreNotifications = false;
});
},
onNotification(notification) {
// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
this.$root.stream.send('readNotification', {
id: notification.id
});
this.prepend(notification);
},
prepend(notification) {
if (this.isScrollTop()) {
// Prepend the notification
this.notifications.unshift(notification);
// オーバーフローしたら古い通知は捨てる
if (this.notifications.length >= displayLimit) {
this.notifications = this.notifications.slice(0, displayLimit);
}
} else {
this.queue.push(notification);
}
},
releaseQueue() {
for (const n of this.queue) {
this.prepend(n);
}
this.queue = [];
},
onTop() {
this.releaseQueue();
},
onBottom() {
this.fetchMoreNotifications();
}
}
});
</script>
<style lang="stylus" scoped>
.oxynyeqmfvracxnglgulyqfgqxnxmehl
.transition
.mk-notifications-enter
.mk-notifications-leave-to
opacity 0
transform translateY(-30px)
> *
transition transform .3s ease, opacity .3s ease
> .placeholder
padding 16px
opacity 0.3
> .notifications
> .notification:not(:last-child)
border-bottom solid var(--lineWidth) var(--faceDivider)
> .date
display block
margin 0
line-height 28px
text-align center
font-size 12px
color var(--dateDividerFg)
background var(--dateDividerBg)
border-bottom solid var(--lineWidth) var(--faceDivider)
span
margin 0 16px
[data-icon]
margin-right 8px
> .more
display block
width 100%
padding 16px
color var(--text)
border-top solid var(--lineWidth) rgba(#000, 0.05)
&:hover
background rgba(#000, 0.025)
&:active
background rgba(#000, 0.05)
&.fetching
cursor wait
> [data-icon]
margin-right 4px
> .empty
margin 0
padding 16px
text-align center
color var(--text)
</style>

View File

@@ -0,0 +1,61 @@
<template>
<x-column>
<template #header>
<fa icon="search"/><span>{{ q }}</span>
</template>
<div>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import XColumn from './deck.column.vue';
import XNotes from './deck.notes.vue';
const limit = 20;
export default Vue.extend({
components: {
XColumn,
XNotes
},
data() {
return {
makePromise: cursor => this.$root.api('notes/search', {
limit: limit + 1,
offset: cursor ? cursor : undefined,
query: this.q
}).then(notes => {
if (notes.length == limit + 1) {
notes.pop();
return {
notes: notes,
cursor: cursor ? cursor + limit : limit
};
} else {
return {
notes: notes,
cursor: null
};
}
})
};
},
computed: {
q(): string {
return this.$route.query.q;
}
},
watch: {
$route() {
this.$refs.timeline.reload();
}
},
});
</script>

View File

@@ -0,0 +1,101 @@
<template>
<x-column :menu="menu" :name="name" :column="column" :is-stacked="isStacked">
<template #header>
<fa v-if="column.type == 'home'" icon="home"/>
<fa v-if="column.type == 'local'" :icon="['far', 'comments']"/>
<fa v-if="column.type == 'hybrid'" icon="share-alt"/>
<fa v-if="column.type == 'global'" icon="globe"/>
<fa v-if="column.type == 'list'" icon="list"/>
<fa v-if="column.type == 'hashtag'" icon="hashtag"/>
<span>{{ name }}</span>
</template>
<div class="editor" style="padding:12px" v-if="edit">
<ui-switch v-model="column.isMediaOnly" @change="onChangeSettings">{{ $t('is-media-only') }}</ui-switch>
</div>
<x-list-tl v-if="column.type == 'list'"
:list="column.list"
:media-only="column.isMediaOnly"
ref="tl"
/>
<x-hashtag-tl v-else-if="column.type == 'hashtag'"
:tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)"
:media-only="column.isMediaOnly"
ref="tl"
/>
<x-tl v-else
:src="column.type"
:media-only="column.isMediaOnly"
ref="tl"
/>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import XTl from './deck.tl.vue';
import XListTl from './deck.list-tl.vue';
import XHashtagTl from './deck.hashtag-tl.vue';
export default Vue.extend({
i18n: i18n('deck/deck.tl-column.vue'),
components: {
XColumn,
XTl,
XListTl,
XHashtagTl
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
edit: false,
menu: [{
icon: 'cog',
text: this.$t('edit'),
action: () => {
this.edit = !this.edit;
}
}]
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
switch (this.column.type) {
case 'home': return this.$t('@deck.home');
case 'local': return this.$t('@deck.local');
case 'hybrid': return this.$t('@deck.hybrid');
case 'global': return this.$t('@deck.global');
case 'list': return this.column.list.title;
case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title;
}
}
},
methods: {
onChangeSettings(v) {
this.$store.commit('device/updateDeckColumn', this.column);
},
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div class="iwaalbte" v-if="disabled">
<p>
<fa :icon="faMinusCircle"/>
{{ $t('disabled-timeline.title') }}
</p>
<p class="desc">{{ $t('disabled-timeline.description') }}</p>
</div>
<x-notes v-else ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</template>
<script lang="ts">
import Vue from 'vue';
import XNotes from './deck.notes.vue';
import { faMinusCircle } from '@fortawesome/free-solid-svg-icons';
import i18n from '../../../i18n';
const fetchLimit = 10;
export default Vue.extend({
i18n: i18n('deck'),
components: {
XNotes
},
props: {
src: {
type: String,
required: false,
default: 'home'
},
mediaOnly: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
connection: null,
disabled: false,
faMinusCircle,
makePromise: null
};
},
computed: {
stream(): any {
switch (this.src) {
case 'home': return this.$root.stream.useSharedConnection('homeTimeline');
case 'local': return this.$root.stream.useSharedConnection('localTimeline');
case 'hybrid': return this.$root.stream.useSharedConnection('hybridTimeline');
case 'global': return this.$root.stream.useSharedConnection('globalTimeline');
}
},
endpoint(): string {
switch (this.src) {
case 'home': return 'notes/timeline';
case 'local': return 'notes/local-timeline';
case 'hybrid': return 'notes/hybrid-timeline';
case 'global': return 'notes/global-timeline';
}
},
},
watch: {
mediaOnly() {
(this.$refs.timeline as any).reload();
}
},
created() {
this.makePromise = cursor => this.$root.api(this.endpoint, {
limit: fetchLimit + 1,
untilId: cursor ? cursor : undefined,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
return {
notes: notes,
cursor: notes[notes.length - 1].id
};
} else {
return {
notes: notes,
cursor: null
};
}
});
},
mounted() {
this.connection = this.stream;
this.connection.on('note', this.onNote);
if (this.src == 'home') {
this.connection.on('follow', this.onChangeFollowing);
this.connection.on('unfollow', this.onChangeFollowing);
}
this.$root.getMeta().then(meta => {
this.disabled = !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin && (
meta.disableLocalTimeline && ['local', 'hybrid'].includes(this.src) ||
meta.disableGlobalTimeline && ['global'].includes(this.src));
});
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onNote(note) {
if (this.mediaOnly && note.files.length == 0) return;
(this.$refs.timeline as any).prepend(note);
},
onChangeFollowing() {
(this.$refs.timeline as any).reload();
},
focus() {
(this.$refs.timeline as any).focus();
}
}
});
</script>
<style lang="stylus" scoped>
.iwaalbte
color var(--text)
text-align center
> p
margin 16px
&.desc
font-size 14px
</style>

View File

@@ -0,0 +1,231 @@
<template>
<div>
<ui-container v-if="user.pinnedNotes && user.pinnedNotes.length > 0" :body-togglable="true">
<template #header><fa icon="thumbtack"/> {{ $t('pinned-notes') }}</template>
<div>
<mk-note v-for="n in user.pinnedNotes" :key="n.id" :note="n"/>
</div>
</ui-container>
<ui-container v-if="images.length > 0" :body-togglable="true"
:expanded="$store.state.device.expandUsersPhotos"
@toggle="expanded => $store.commit('device/set', { key: 'expandUsersPhotos', value: expanded })">
<template #header><fa :icon="['far', 'images']"/> {{ $t('images') }}</template>
<div class="sainvnaq">
<router-link v-for="image in images"
:style="`background-image: url(${image.thumbnailUrl})`"
:key="`${image.id}:${image._note.id}`"
:to="image._note | notePage"
:title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`"
></router-link>
</div>
</ui-container>
<ui-container :body-togglable="true"
:expanded="$store.state.device.expandUsersActivity"
@toggle="expanded => $store.commit('device/set', { key: 'expandUsersActivity', value: expanded })">
<template #header><fa :icon="['far', 'chart-bar']"/> {{ $t('activity') }}</template>
<div>
<div ref="chart"></div>
</div>
</ui-container>
<ui-container>
<template #header><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</template>
<div>
<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/>
</div>
</ui-container>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XNotes from './deck.notes.vue';
import { concat } from '../../../../../prelude/array';
import ApexCharts from 'apexcharts';
const fetchLimit = 10;
export default Vue.extend({
i18n: i18n('deck/deck.user-column.vue'),
components: {
XNotes,
},
props: {
user: {
type: Object,
required: true
}
},
data() {
return {
withFiles: false,
images: [],
makePromise: null,
chart: null as ApexCharts
};
},
watch: {
user() {
this.fetch();
this.genPromiseMaker();
}
},
created() {
this.fetch();
this.genPromiseMaker();
},
methods: {
genPromiseMaker() {
this.makePromise = cursor => this.$root.api('users/notes', {
userId: this.user.id,
limit: fetchLimit + 1,
untilId: cursor ? cursor : undefined,
withFiles: this.withFiles,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
return {
notes: notes,
cursor: notes[notes.length - 1].id
};
} else {
return {
notes: notes,
cursor: null
};
}
});
},
fetch() {
const image = [
'image/jpeg',
'image/png',
'image/gif'
];
this.$root.api('users/notes', {
userId: this.user.id,
fileType: image,
excludeNsfw: !this.$store.state.device.alwaysShowNsfw,
limit: 9,
untilDate: new Date().getTime() + 1000 * 86400 * 365
}).then(notes => {
for (const note of notes) {
for (const file of note.files) {
file._note = note;
}
}
const files = concat(notes.map((n: any): any[] => n.files));
this.images = files.filter(f => image.includes(f.type)).slice(0, 9);
});
this.$root.api('charts/user/notes', {
userId: this.user.id,
span: 'day',
limit: 21
}).then(stats => {
const normal = [];
const reply = [];
const renote = [];
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
for (let i = 0; i < 21; i++) {
const x = new Date(y, m, d - i);
normal.push([
x,
stats.diffs.normal[i]
]);
reply.push([
x,
stats.diffs.reply[i]
]);
renote.push([
x,
stats.diffs.renote[i]
]);
}
if (this.chart) this.chart.destroy();
this.chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'bar',
stacked: true,
height: 100,
sparkline: {
enabled: true
},
},
plotOptions: {
bar: {
columnWidth: '90%'
}
},
grid: {
clipMarkers: false,
padding: {
top: 16,
right: 16,
bottom: 16,
left: 16
}
},
tooltip: {
shared: true,
intersect: false
},
series: [{
name: 'Normal',
data: normal
}, {
name: 'Reply',
data: reply
}, {
name: 'Renote',
data: renote
}],
xaxis: {
type: 'datetime',
crosshairs: {
width: 1,
opacity: 1
}
}
});
this.chart.render();
});
},
}
});
</script>
<style lang="stylus" scoped>
.sainvnaq
display grid
grid-template-columns 1fr 1fr 1fr
gap 8px
padding 16px
> *
height 70px
background-position center center
background-size cover
background-clip content-box
border-radius 4px
</style>

View File

@@ -0,0 +1,263 @@
<template>
<x-column>
<template #header>
<fa icon="user"/><mk-user-name :user="user" v-if="user"/>
</template>
<div class="zubukjlciycdsyynicqrnlsmdwmymzqu" v-if="user">
<div class="is-remote" v-if="user.host != null">
<details>
<summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}</summary>
<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a>
</details>
</div>
<header :style="bannerStyle">
<div>
<button class="menu" @click="menu" ref="menu"><fa icon="ellipsis-h"/></button>
<mk-follow-button v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" class="follow" mini/>
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
<router-link class="name" :to="user | userPage()">
<mk-user-name :user="user"/>
</router-link>
<span class="acct">@{{ user | acct }} <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/></span>
<span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span>
</div>
</header>
<div class="info">
<div class="description">
<mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</div>
<div class="fields" v-if="user.fields">
<dl class="field" v-for="(field, i) in user.fields" :key="i">
<dt class="name">
<mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/>
</dt>
<dd class="value">
<mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</dd>
</dl>
</div>
<div class="counts">
<div>
<router-link :to="user | userPage()">
<b>{{ user.notesCount | number }}</b>
<span>{{ $t('posts') }}</span>
</router-link>
</div>
<div>
<router-link :to="user | userPage('following')">
<b>{{ user.followingCount | number }}</b>
<span>{{ $t('following') }}</span>
</router-link>
</div>
<div>
<router-link :to="user | userPage('followers')">
<b>{{ user.followersCount | number }}</b>
<span>{{ $t('followers') }}</span>
</router-link>
</div>
</div>
</div>
<router-view :user="user"></router-view>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import parseAcct from '../../../../../misc/acct/parse';
import XColumn from './deck.column.vue';
import XUserMenu from '../../../common/views/components/user-menu.vue';
export default Vue.extend({
i18n: i18n('deck/deck.user-column.vue'),
components: {
XColumn,
},
data() {
return {
user: null,
fetching: true,
};
},
computed: {
bannerStyle(): any {
if (this.user == null) return {};
if (this.user.bannerUrl == null) return {};
return {
backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null,
backgroundImage: `url(${ this.user.bannerUrl })`
};
},
},
watch: {
$route: 'fetch'
},
created() {
this.fetch();
},
methods: {
fetch() {
this.fetching = true;
this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
this.user = user;
this.fetching = false;
});
},
menu() {
this.$root.new(XUserMenu, {
source: this.$refs.menu,
user: this.user
});
}
}
});
</script>
<style lang="stylus" scoped>
.zubukjlciycdsyynicqrnlsmdwmymzqu
background var(--deckColumnBg)
> .is-remote
padding 8px 16px
font-size 12px
&.is-remote
color var(--remoteInfoFg)
background var(--remoteInfoBg)
> a
font-weight bold
> header
overflow hidden
background-size cover
background-position center
> div
padding 32px
background rgba(#000, 0.5)
color #fff
text-align center
> .menu
position absolute
top 8px
left 8px
padding 8px
font-size 16px
text-shadow 0 0 8px #000
> .follow
position absolute
top 16px
right 16px
> .avatar
display block
width 64px
height 64px
margin 0 auto
> .name
display block
margin-top 8px
font-weight bold
text-shadow 0 0 8px #000
color #fff
> .acct
display block
font-size 14px
opacity 0.7
text-shadow 0 0 8px #000
> .locked
opacity 0.8
> .followed
display inline-block
font-size 12px
background rgba(0, 0, 0, 0.5)
opacity 0.7
margin-top: 2px
padding 4px
border-radius 4px
> .info
padding 16px
font-size 12px
color var(--text)
text-align center
background var(--face)
&:before
content ""
display blcok
position absolute
top -32px
left 0
right 0
width 0px
margin 0 auto
border-top solid 16px transparent
border-left solid 16px transparent
border-right solid 16px transparent
border-bottom solid 16px var(--face)
> .fields
margin-top 8px
> .field
display flex
padding 0
margin 0
align-items center
> .name
padding 4px
margin 4px
width 30%
overflow hidden
white-space nowrap
text-overflow ellipsis
font-weight bold
> .value
padding 4px
margin 4px
width 70%
overflow hidden
white-space nowrap
text-overflow ellipsis
> .counts
display grid
grid-template-columns 2fr 2fr 2fr
margin-top 8px
border-top solid var(--lineWidth) var(--faceDivider)
> div
padding 8px 8px 0 8px
text-align center
> a
color var(--text)
> b
display block
font-size 110%
> span
display block
font-size 80%
opacity 0.7
</style>

View File

@@ -0,0 +1,398 @@
<template>
<mk-ui :class="$style.root">
<div class="qlvquzbjribqcaozciifydkngcwtyzje" ref="body" :style="style" :class="`${$store.state.device.deckColumnAlign} ${$store.state.device.deckColumnWidth}`" v-hotkey.global="keymap">
<template v-for="ids in layout">
<div v-if="ids.length > 1" class="folder">
<template v-for="id, i in ids">
<x-column-core :ref="id" :key="id" :column="columns.find(c => c.id == id)" :is-stacked="true" @parentFocus="moveFocus(id, $event)"/>
</template>
</div>
<x-column-core v-else :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id == ids[0])" @parentFocus="moveFocus(ids[0], $event)"/>
</template>
<router-view></router-view>
<button ref="add" @click="add" :title="$t('@deck.add-column')"><fa icon="plus"/></button>
</div>
</mk-ui>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumnCore from './deck.column-core.vue';
import Menu from '../../../common/views/components/menu.vue';
import * as uuid from 'uuid';
export default Vue.extend({
i18n: i18n('deck'),
components: {
XColumnCore
},
computed: {
columns(): any[] {
if (this.$store.state.device.deck == null) return [];
return this.$store.state.device.deck.columns;
},
layout(): any[] {
if (this.$store.state.device.deck == null) return [];
if (this.$store.state.device.deck.layout == null) return this.$store.state.device.deck.columns.map(c => [c.id]);
return this.$store.state.device.deck.layout;
},
style(): any {
return {
height: `calc(100vh - ${this.$store.state.uiHeaderHeight}px)`
};
},
keymap(): any {
return {
't': this.focus
};
}
},
watch: {
$route() {
if (this.$route.name == 'index') return;
this.$nextTick(() => {
this.$refs.body.scrollTo({
left: this.$refs.body.scrollWidth - this.$refs.body.clientWidth,
behavior: 'smooth'
});
});
}
},
provide() {
return {
getColumnVm: this.getColumnVm,
narrow: true
};
},
created() {
if (this.$store.state.device.deck == null) {
const deck = {
columns: [/*{
type: 'widgets',
widgets: []
}, */{
id: uuid(),
type: 'home',
name: null,
}, {
id: uuid(),
type: 'notifications',
name: null,
}, {
id: uuid(),
type: 'local',
name: null,
}, {
id: uuid(),
type: 'global',
name: null,
}]
};
deck.layout = deck.columns.map(c => [c.id]);
this.$store.commit('device/set', {
key: 'deck',
value: deck
});
}
// 互換性のため
if (this.$store.state.device.deck != null && this.$store.state.device.deck.layout == null) {
this.$store.commit('device/set', {
key: 'deck',
value: Object.assign({}, this.$store.state.device.deck, {
layout: this.$store.state.device.deck.columns.map(c => [c.id])
})
});
}
},
mounted() {
document.title = this.$root.instanceName;
document.documentElement.style.overflow = 'hidden';
},
beforeDestroy() {
document.documentElement.style.overflow = 'auto';
},
methods: {
getColumnVm(id) {
return this.$refs[id][0];
},
add() {
this.$root.new(Menu, {
source: this.$refs.add,
items: [{
icon: 'home',
text: this.$t('@deck.home'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'home'
});
}
}, {
icon: ['far', 'comments'],
text: this.$t('@deck.local'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'local'
});
}
}, {
icon: 'share-alt',
text: this.$t('@deck.hybrid'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'hybrid'
});
}
}, {
icon: 'globe',
text: this.$t('@deck.global'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'global'
});
}
}, {
icon: 'at',
text: this.$t('@deck.mentions'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'mentions'
});
}
}, {
icon: ['far', 'envelope'],
text: this.$t('@deck.direct'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'direct'
});
}
}, {
icon: 'list',
text: this.$t('@deck.list'),
action: async () => {
const lists = await this.$root.api('users/lists/list');
const { canceled, result: listId } = await this.$root.dialog({
type: null,
title: this.$t('@deck.select-list'),
select: {
items: lists.map(list => ({
value: list.id, text: list.title
}))
},
showCancelButton: true
});
if (canceled) return;
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'list',
list: lists.find(l => l.id === listId)
});
}
}, {
icon: 'hashtag',
text: this.$t('@deck.hashtag'),
action: () => {
this.$root.dialog({
title: this.$t('enter-hashtag-tl-title'),
input: true
}).then(({ canceled, result: title }) => {
if (canceled) return;
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'hashtag',
tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id
});
});
}
}, {
icon: ['far', 'bell'],
text: this.$t('@deck.notifications'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'notifications'
});
}
}, {
icon: 'calculator',
text: this.$t('@deck.widgets'),
action: () => {
this.$store.commit('device/addDeckColumn', {
id: uuid(),
type: 'widgets',
widgets: []
});
}
}]
});
},
focus() {
// Flatten array of arrays
const ids = [].concat.apply([], this.layout);
const firstTl = ids.find(id => this.isTlColumn(id));
if (firstTl) {
this.$refs[firstTl][0].focus();
}
},
moveFocus(id, direction) {
let targetColumn;
if (direction == 'right') {
const currentColumnIndex = this.layout.findIndex(ids => ids.includes(id));
this.layout.some((ids, i) => {
if (i <= currentColumnIndex) return false;
const tl = ids.find(id => this.isTlColumn(id));
if (tl) {
targetColumn = tl;
return true;
}
});
} else if (direction == 'left') {
const currentColumnIndex = [...this.layout].reverse().findIndex(ids => ids.includes(id));
[...this.layout].reverse().some((ids, i) => {
if (i <= currentColumnIndex) return false;
const tl = ids.find(id => this.isTlColumn(id));
if (tl) {
targetColumn = tl;
return true;
}
});
} else if (direction == 'down') {
const currentColumn = this.layout.find(ids => ids.includes(id));
const currentIndex = currentColumn.indexOf(id);
currentColumn.some((_id, i) => {
if (i <= currentIndex) return false;
if (this.isTlColumn(_id)) {
targetColumn = _id;
return true;
}
});
} else if (direction == 'up') {
const currentColumn = [...this.layout.find(ids => ids.includes(id))].reverse();
const currentIndex = currentColumn.indexOf(id);
currentColumn.some((_id, i) => {
if (i <= currentIndex) return false;
if (this.isTlColumn(_id)) {
targetColumn = _id;
return true;
}
});
}
if (targetColumn) {
this.$refs[targetColumn][0].focus();
}
},
isTlColumn(id) {
const column = this.columns.find(c => c.id === id);
return ['home', 'local', 'hybrid', 'global', 'list', 'hashtag', 'mentions', 'direct'].includes(column.type);
}
}
});
</script>
<style lang="stylus" module>
.root
height 100vh
</style>
<style lang="stylus" scoped>
.qlvquzbjribqcaozciifydkngcwtyzje
display flex
flex 1
padding 16px 0 16px 16px
overflow auto
overflow-y hidden
-webkit-overflow-scrolling touch
> div
margin-right 8px
width 330px
min-width 330px
&:last-of-type
margin-right 0
&.folder
display flex
flex-direction column
> *:not(:last-child)
margin-bottom 8px
&.narrow
> div
width 303px
min-width 303px
&.narrower
> div
width 316.5px
min-width 316.5px
&.wider
> div
width 343.5px
min-width 343.5px
&.wide
> div
width 357px
min-width 357px
&.center
> *
&:first-child
margin-left auto
&:last-child
margin-right auto
&.:not(.flexible)
> *
flex-grow 0
flex-shrink 0
&.flexible
> *
flex-grow 1
flex-shrink 0
> button
padding 0 16px
color var(--faceTextButton)
flex-grow 0 !important
&:hover
color var(--faceTextButtonHover)
&:active
color var(--faceTextButtonActive)
</style>

View File

@@ -0,0 +1,172 @@
<template>
<x-column :menu="menu" :naked="true" :narrow="true" :name="name" :column="column" :is-stacked="isStacked" class="wtdtxvecapixsepjtcupubtsmometobz">
<template #header><fa icon="calculator"/>{{ name }}</template>
<div class="gqpwvtwtprsbmnssnbicggtwqhmylhnq">
<template v-if="edit">
<header>
<select v-model="widgetAdderSelected" @change="addWidget">
<option value="profile">{{ $t('@.widgets.profile') }}</option>
<option value="analog-clock">{{ $t('@.widgets.analog-clock') }}</option>
<option value="calendar">{{ $t('@.widgets.calendar') }}</option>
<option value="timemachine">{{ $t('@.widgets.timemachine') }}</option>
<option value="activity">{{ $t('@.widgets.activity') }}</option>
<option value="rss">{{ $t('@.widgets.rss') }}</option>
<option value="trends">{{ $t('@.widgets.trends') }}</option>
<option value="photo-stream">{{ $t('@.widgets.photo-stream') }}</option>
<option value="slideshow">{{ $t('@.widgets.slideshow') }}</option>
<option value="version">{{ $t('@.widgets.version') }}</option>
<option value="broadcast">{{ $t('@.widgets.broadcast') }}</option>
<option value="notifications">{{ $t('@.widgets.notifications') }}</option>
<option value="users">{{ $t('@.widgets.users') }}</option>
<option value="polls">{{ $t('@.widgets.polls') }}</option>
<option value="post-form">{{ $t('@.widgets.post-form') }}</option>
<option value="messaging">{{ $t('@.widgets.messaging') }}</option>
<option value="memo">{{ $t('@.widgets.memo') }}</option>
<option value="hashtags">{{ $t('@.widgets.hashtags') }}</option>
<option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option>
<option value="server">{{ $t('@.widgets.server') }}</option>
<option value="nav">{{ $t('@.widgets.nav') }}</option>
<option value="tips">{{ $t('@.widgets.tips') }}</option>
</select>
</header>
<x-draggable
:list="column.widgets"
:options="{ animation: 150 }"
@sort="onWidgetSort"
>
<div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="widgetFunc(widget.id)">
<button class="remove" @click="removeWidget(widget)"><fa icon="times"/></button>
<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="deck" :column="column"/>
</div>
</x-draggable>
</template>
<template v-else>
<component class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="deck" :column="column"/>
</template>
</div>
</x-column>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XColumn from './deck.column.vue';
import * as XDraggable from 'vuedraggable';
import * as uuid from 'uuid';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XDraggable
},
props: {
column: {
type: Object,
required: true
},
isStacked: {
type: Boolean,
required: true
}
},
data() {
return {
edit: false,
menu: null,
widgetAdderSelected: null
}
},
computed: {
name(): string {
if (this.column.name) return this.column.name;
return this.$t('@deck.widgets');
}
},
created() {
this.menu = [{
icon: 'cog',
text: this.$t('edit'),
action: () => {
this.edit = !this.edit;
}
}];
},
methods: {
widgetFunc(id) {
const w = this.$refs[id][0];
if (w.func) w.func();
},
onWidgetSort() {
this.saveWidgets();
},
addWidget() {
this.$store.commit('device/addDeckWidget', {
id: this.column.id,
widget: {
name: this.widgetAdderSelected,
id: uuid(),
data: {}
}
});
this.widgetAdderSelected = null;
},
removeWidget(widget) {
this.$store.commit('device/removeDeckWidget', {
id: this.column.id,
widget
});
},
saveWidgets() {
this.$store.commit('device/updateDeckColumn', this.column);
}
}
});
</script>
<style lang="stylus" scoped>
.wtdtxvecapixsepjtcupubtsmometobz
.gqpwvtwtprsbmnssnbicggtwqhmylhnq
> header
padding 16px
> *
width 100%
padding 4px
.widget, .customize-container
margin 8px
&:first-of-type
margin-top 0
.customize-container
cursor move
> *:not(.remove)
pointer-events none
> .remove
position absolute
z-index 1
top 8px
right 8px
width 32px
height 32px
color #fff
background rgba(#000, 0.7)
border-radius 4px
</style>