summaryrefslogtreecommitdiff
path: root/src/client/components/deck
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-07-11 10:13:11 +0900
committerGitHub <noreply@github.com>2020-07-11 10:13:11 +0900
commitcf3fc97202588e835ade5d6ab1a3c087e46958ad (patch)
treeb3fe472b455bf913a47df4d41b1363c7122bf1d9 /src/client/components/deck
parentタイムライン上でTwitterウィジットを展開できるようにな... (diff)
downloadmisskey-cf3fc97202588e835ade5d6ab1a3c087e46958ad.tar.gz
misskey-cf3fc97202588e835ade5d6ab1a3c087e46958ad.tar.bz2
misskey-cf3fc97202588e835ade5d6ab1a3c087e46958ad.zip
Deck (#6504)
* wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip
Diffstat (limited to 'src/client/components/deck')
-rw-r--r--src/client/components/deck/antenna-column.vue80
-rw-r--r--src/client/components/deck/column-core.vue50
-rw-r--r--src/client/components/deck/column.vue426
-rw-r--r--src/client/components/deck/direct-column.vue39
-rw-r--r--src/client/components/deck/list-column.vue87
-rw-r--r--src/client/components/deck/mentions-column.vue39
-rw-r--r--src/client/components/deck/notifications-column.vue69
-rw-r--r--src/client/components/deck/tl-column.vue141
-rw-r--r--src/client/components/deck/widgets-column.vue151
9 files changed, 1082 insertions, 0 deletions
diff --git a/src/client/components/deck/antenna-column.vue b/src/client/components/deck/antenna-column.vue
new file mode 100644
index 0000000000..83fe14f2cc
--- /dev/null
+++ b/src/client/components/deck/antenna-column.vue
@@ -0,0 +1,80 @@
+<template>
+<x-column :menu="menu" :column="column" :is-stacked="isStacked">
+ <template #header>
+ <fa :icon="faSatellite"/><span style="margin-left: 8px;">{{ column.name }}</span>
+ </template>
+
+ <x-timeline ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSatellite, faCog } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XTimeline from '../timeline.vue';
+
+export default Vue.extend({
+ components: {
+ XColumn,
+ XTimeline,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ menu: null,
+ faSatellite
+ };
+ },
+
+ watch: {
+ mediaOnly() {
+ (this.$refs.timeline as any).reload();
+ }
+ },
+
+ created() {
+ this.menu = [{
+ icon: faCog,
+ text: this.$t('antenna'),
+ action: async () => {
+ const antennas = await this.$root.api('antennas/list');
+ this.$root.dialog({
+ title: this.$t('antenna'),
+ type: null,
+ select: {
+ items: antennas.map(x => ({
+ value: x, text: x.name
+ }))
+ },
+ showCancelButton: true
+ }).then(({ canceled, result: antenna }) => {
+ if (canceled) return;
+ this.column.antennaId = antenna.id;
+ this.$store.commit('deviceUser/updateDeckColumn', this.column);
+ });
+ }
+ }];
+ },
+
+ methods: {
+ focus() {
+ (this.$refs.timeline as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/src/client/components/deck/column-core.vue b/src/client/components/deck/column-core.vue
new file mode 100644
index 0000000000..44f19e7eda
--- /dev/null
+++ b/src/client/components/deck/column-core.vue
@@ -0,0 +1,50 @@
+<template>
+<!-- TODO: リファクタの余地がありそう -->
+<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 === 'tl'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<x-list-column v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<x-antenna-column v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<!-- TODO: <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 './tl-column.vue';
+import XAntennaColumn from './antenna-column.vue';
+import XListColumn from './list-column.vue';
+import XNotificationsColumn from './notifications-column.vue';
+import XWidgetsColumn from './widgets-column.vue';
+import XMentionsColumn from './mentions-column.vue';
+import XDirectColumn from './direct-column.vue';
+
+export default Vue.extend({
+ components: {
+ XTlColumn,
+ XAntennaColumn,
+ XListColumn,
+ XNotificationsColumn,
+ XWidgetsColumn,
+ XMentionsColumn,
+ XDirectColumn
+ },
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: false,
+ default: false
+ }
+ },
+ methods: {
+ focus() {
+ this.$children[0].focus();
+ }
+ }
+});
+</script>
diff --git a/src/client/components/deck/column.vue b/src/client/components/deck/column.vue
new file mode 100644
index 0000000000..f7620e5749
--- /dev/null
+++ b/src/client/components/deck/column.vue
@@ -0,0 +1,426 @@
+<template>
+<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+<section class="dnpfarvg _panel _narrow_" :class="{ naked, paged: isMainColumn, _close_: !isMainColumn, active, isStacked, draghover, dragging, dropready }"
+ @dragover.prevent.stop="onDragover"
+ @dragleave="onDragleave"
+ @drop.prevent.stop="onDrop"
+ v-hotkey="keymap"
+ :style="{ width: `${width}px` }"
+>
+ <header :class="{ indicated }"
+ draggable="true"
+ @click="goTop"
+ @dragstart="onDragstart"
+ @dragend="onDragend"
+ @contextmenu.prevent.stop="onContextmenu"
+ >
+ <button class="toggleActive _button" @click="toggleActive" v-if="isStacked">
+ <template v-if="active"><fa :icon="faAngleUp"/></template>
+ <template v-else><fa :icon="faAngleDown"/></template>
+ </button>
+ <div class="action">
+ <slot name="action"></slot>
+ </div>
+ <span class="header"><slot name="header"></slot></span>
+ <button v-if="!isMainColumn" class="menu _button" ref="menu" @click.stop="showMenu"><fa :icon="faCaretDown"/></button>
+ <button v-else-if="$route.name !== 'index'" class="close _button" @click.stop="close"><fa :icon="faTimes"/></button>
+ </header>
+ <div ref="body" v-show="active">
+ <slot></slot>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes, faArrowRight, faArrowLeft, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
+import { faWindowMaximize, faTrashAlt, faWindowRestore } from '@fortawesome/free-regular-svg-icons';
+
+export default Vue.extend({
+ props: {
+ column: {
+ type: Object,
+ required: false,
+ default: null
+ },
+ isStacked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ menu: {
+ type: Array,
+ required: false,
+ default: null
+ },
+ naked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ indicated: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ data() {
+ return {
+ active: true,
+ dragging: false,
+ draghover: false,
+ dropready: false,
+ faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes,
+ };
+ },
+
+ computed: {
+ isMainColumn(): boolean {
+ return this.column == null;
+ },
+
+ width(): number {
+ return this.isMainColumn ? 350 : this.column.width;
+ },
+
+ 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'),
+ };
+ }
+ },
+
+ watch: {
+ active(v) {
+ this.$emit('change-active-state', v);
+ },
+
+ dragging(v) {
+ this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd');
+ }
+ },
+
+ mounted() {
+ if (!this.isMainColumn) {
+ this.$root.$on('deck.column.dragStart', this.onOtherDragStart);
+ this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd);
+ }
+ },
+
+ beforeDestroy() {
+ if (!this.isMainColumn) {
+ 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;
+ this.active = !this.active;
+ },
+
+ getMenu() {
+ const items = [{
+ icon: faPencilAlt,
+ text: this.$t('rename'),
+ action: () => {
+ this.$root.dialog({
+ title: this.$t('rename'),
+ input: {
+ default: this.column.name,
+ allowEmpty: false
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ this.$store.commit('deviceUser/renameDeckColumn', { id: this.column.id, name });
+ });
+ }
+ }, null, {
+ icon: faArrowLeft,
+ text: this.$t('swap-left'),
+ action: () => {
+ this.$store.commit('deviceUser/swapLeftDeckColumn', this.column.id);
+ }
+ }, {
+ icon: faArrowRight,
+ text: this.$t('swap-right'),
+ action: () => {
+ this.$store.commit('deviceUser/swapRightDeckColumn', this.column.id);
+ }
+ }, this.isStacked ? {
+ icon: faArrowUp,
+ text: this.$t('swap-up'),
+ action: () => {
+ this.$store.commit('deviceUser/swapUpDeckColumn', this.column.id);
+ }
+ } : undefined, this.isStacked ? {
+ icon: faArrowDown,
+ text: this.$t('swap-down'),
+ action: () => {
+ this.$store.commit('deviceUser/swapDownDeckColumn', this.column.id);
+ }
+ } : undefined, null, {
+ icon: faWindowRestore,
+ text: this.$t('stack-left'),
+ action: () => {
+ this.$store.commit('deviceUser/stackLeftDeckColumn', this.column.id);
+ }
+ }, this.isStacked ? {
+ icon: faWindowMaximize,
+ text: this.$t('pop-right'),
+ action: () => {
+ this.$store.commit('deviceUser/popRightDeckColumn', this.column.id);
+ }
+ } : undefined, null, {
+ icon: faTrashAlt,
+ text: this.$t('remove'),
+ action: () => {
+ this.$store.commit('deviceUser/removeDeckColumn', this.column.id);
+ }
+ }];
+
+ if (this.menu) {
+ for (const i of this.menu.reverse()) {
+ items.unshift(i);
+ }
+ }
+
+ return items;
+ },
+
+ onContextmenu(e) {
+ if (this.isMainColumn) return;
+ this.showMenu();
+ },
+
+ showMenu() {
+ this.$root.menu({
+ items: this.getMenu(),
+ source: this.$refs.menu,
+ });
+ },
+
+ close() {
+ this.$router.push('/');
+ },
+
+ goTop() {
+ this.$refs.body.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ });
+ },
+
+ onDragstart(e) {
+ // メインカラムはドラッグさせない
+ if (this.isMainColumn) {
+ 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.isMainColumn) {
+ 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('deviceUser/swapDeckColumn', {
+ a: this.column.id,
+ b: id
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.dnpfarvg {
+ $header-height: 42px;
+
+ height: 100%;
+ overflow: hidden;
+ box-shadow: 0 0 0 1px var(--deckColumnBorder);
+
+ &.draghover {
+ box-shadow: 0 0 0 2px var(--focus);
+
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ z-index: 1000;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--focus);
+ }
+ }
+
+ &.dragging {
+ box-shadow: 0 0 0 2px var(--focus);
+ }
+
+ &.dropready {
+ * {
+ pointer-events: none;
+ }
+ }
+
+ &:not(.active) {
+ flex-basis: $header-height;
+ min-height: $header-height;
+
+ > header.indicated {
+ box-shadow: 4px 0px var(--accent) inset;
+ }
+ }
+
+ &.naked {
+ //background: var(--deckAcrylicColumnBg);
+ background: transparent !important;
+
+ > header {
+ background: transparent;
+ box-shadow: none;
+
+ > button {
+ color: var(--fg);
+ }
+ }
+ }
+
+ &.paged {
+ > div {
+ background: var(--bg);
+ padding: var(--margin);
+ }
+ }
+
+ > header {
+ position: relative;
+ display: flex;
+ z-index: 2;
+ line-height: $header-height;
+ padding: 0 16px;
+ font-size: 0.9em;
+ color: var(--panelHeaderFg);
+ background: var(--panelHeaderBg);
+ box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
+ cursor: pointer;
+
+ &, * {
+ user-select: none;
+ }
+
+ &.indicated {
+ box-shadow: 0 3px 0 0 var(--accent);
+ }
+
+ > .header {
+ display: inline-block;
+ align-items: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ > span:only-of-type {
+ width: 100%;
+ }
+
+ > .toggleActive,
+ > .action > *,
+ > .menu,
+ > .close {
+ z-index: 1;
+ width: $header-height;
+ line-height: $header-height;
+ font-size: 16px;
+ color: var(--faceTextButton);
+
+ &:hover {
+ color: var(--faceTextButtonHover);
+ }
+
+ &:active {
+ color: var(--faceTextButtonActive);
+ }
+ }
+
+ > .toggleActive, > .action {
+ margin-left: -16px;
+ }
+
+ > .action {
+ z-index: 1;
+ }
+
+ > .action:empty {
+ display: none;
+ }
+
+ > .menu,
+ > .close {
+ margin-left: auto;
+ margin-right: -16px;
+ }
+ }
+
+ > div {
+ height: calc(100% - #{$header-height});
+ overflow: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ box-sizing: border-box;
+ }
+}
+</style>
diff --git a/src/client/components/deck/direct-column.vue b/src/client/components/deck/direct-column.vue
new file mode 100644
index 0000000000..f340048d6a
--- /dev/null
+++ b/src/client/components/deck/direct-column.vue
@@ -0,0 +1,39 @@
+<template>
+<x-column :name="name" :column="column" :is-stacked="isStacked" :menu="menu">
+ <template #header><fa :icon="faEnvelope" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+ <x-direct/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XDirect from '../../pages/messages.vue';
+
+export default Vue.extend({
+ components: {
+ XColumn,
+ XDirect
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ menu: null,
+ faEnvelope
+ }
+ },
+});
+</script>
diff --git a/src/client/components/deck/list-column.vue b/src/client/components/deck/list-column.vue
new file mode 100644
index 0000000000..a3576e8d67
--- /dev/null
+++ b/src/client/components/deck/list-column.vue
@@ -0,0 +1,87 @@
+<template>
+<x-column :menu="menu" :column="column" :is-stacked="isStacked">
+ <template #header>
+ <fa :icon="faListUl"/><span style="margin-left: 8px;">{{ column.name }}</span>
+ </template>
+
+ <x-timeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faListUl, faCog } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XTimeline from '../timeline.vue';
+
+export default Vue.extend({
+ components: {
+ XColumn,
+ XTimeline,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ faListUl
+ };
+ },
+
+ watch: {
+ mediaOnly() {
+ (this.$refs.timeline as any).reload();
+ }
+ },
+
+ created() {
+ this.menu = [{
+ icon: faCog,
+ text: this.$t('list'),
+ action: this.setList
+ }];
+ },
+
+ mounted() {
+ if (this.column.listId == null) {
+ this.setList();
+ }
+ },
+
+ methods: {
+ async setList() {
+ const lists = await this.$root.api('users/lists/list');
+ const { canceled, result: list } = await this.$root.dialog({
+ title: this.$t('list'),
+ type: null,
+ select: {
+ items: lists.map(x => ({
+ value: x, text: x.name
+ })),
+ default: this.column.listId
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ Vue.set(this.column, 'listId', list.id);
+ this.$store.commit('deviceUser/updateDeckColumn', this.column);
+ },
+
+ focus() {
+ (this.$refs.timeline as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/src/client/components/deck/mentions-column.vue b/src/client/components/deck/mentions-column.vue
new file mode 100644
index 0000000000..19e49d2a89
--- /dev/null
+++ b/src/client/components/deck/mentions-column.vue
@@ -0,0 +1,39 @@
+<template>
+<x-column :column="column" :is-stacked="isStacked" :menu="menu">
+ <template #header><fa :icon="faAt" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+ <x-mentions/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faAt } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XMentions from '../../pages/mentions.vue';
+
+export default Vue.extend({
+ components: {
+ XColumn,
+ XMentions
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ menu: null,
+ faAt
+ }
+ },
+});
+</script>
diff --git a/src/client/components/deck/notifications-column.vue b/src/client/components/deck/notifications-column.vue
new file mode 100644
index 0000000000..58873aa130
--- /dev/null
+++ b/src/client/components/deck/notifications-column.vue
@@ -0,0 +1,69 @@
+<template>
+<x-column :column="column" :is-stacked="isStacked" :menu="menu">
+ <template #header><fa :icon="faBell" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+ <x-notifications/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCog } from '@fortawesome/free-solid-svg-icons';
+import { faBell } from '@fortawesome/free-regular-svg-icons';
+import XColumn from './column.vue';
+import XNotifications from '../notifications.vue';
+
+export default Vue.extend({
+ components: {
+ XColumn,
+ XNotifications
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ menu: null,
+ faBell
+ }
+ },
+
+ created() {
+ if (this.column.notificationType == null) {
+ this.column.notificationType = 'all';
+ this.$store.commit('deviceUser/updateDeckColumn', this.column);
+ }
+
+ this.menu = [{
+ icon: faCog,
+ text: this.$t('@.notification-type'),
+ action: () => {
+ this.$root.dialog({
+ title: this.$t('@.notification-type'),
+ type: null,
+ select: {
+ items: ['all', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'].map(x => ({
+ value: x, text: this.$t('@.notification-types.' + x)
+ }))
+ default: this.column.notificationType,
+ },
+ showCancelButton: true
+ }).then(({ canceled, result: type }) => {
+ if (canceled) return;
+ this.column.notificationType = type;
+ this.$store.commit('deviceUser/updateDeckColumn', this.column);
+ });
+ }
+ }];
+ },
+});
+</script>
diff --git a/src/client/components/deck/tl-column.vue b/src/client/components/deck/tl-column.vue
new file mode 100644
index 0000000000..c3ee67af3a
--- /dev/null
+++ b/src/client/components/deck/tl-column.vue
@@ -0,0 +1,141 @@
+<template>
+<x-column :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState">
+ <template #header>
+ <fa v-if="column.tl === 'home'" :icon="faHome"/>
+ <fa v-else-if="column.tl === 'local'" :icon="faComments"/>
+ <fa v-else-if="column.tl === 'social'" :icon="faShareAlt"/>
+ <fa v-else-if="column.tl === 'global'" :icon="faGlobe"/>
+ <span style="margin-left: 8px;">{{ column.name }}</span>
+ </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-timeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faMinusCircle, faHome, faComments, faShareAlt, faGlobe, faCog } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XTimeline from '../timeline.vue';
+
+export default Vue.extend({
+ components: {
+ XColumn,
+ XTimeline,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ menu: null,
+ disabled: false,
+ indicated: false,
+ columnActive: true,
+ faMinusCircle, faHome, faComments, faShareAlt, faGlobe,
+ };
+ },
+
+ watch: {
+ mediaOnly() {
+ (this.$refs.timeline as any).reload();
+ }
+ },
+
+ created() {
+ this.menu = [{
+ icon: faCog,
+ text: this.$t('timeline'),
+ action: this.setType
+ }];
+ },
+
+ mounted() {
+ if (this.column.tl == null) {
+ this.setType();
+ } else {
+ this.disabled = !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin && (
+ this.$store.state.instance.meta.disableLocalTimeline && ['local', 'social'].includes(this.column.tl) ||
+ this.$store.state.instance.meta.disableGlobalTimeline && ['global'].includes(this.column.tl));
+ }
+ },
+
+ methods: {
+ async setType() {
+ const { canceled, result: src } = await this.$root.dialog({
+ title: this.$t('timeline'),
+ type: null,
+ select: {
+ items: [{
+ value: 'home', text: this.$t('_timelines.home')
+ }, {
+ value: 'local', text: this.$t('_timelines.local')
+ }, {
+ value: 'social', text: this.$t('_timelines.social')
+ }, {
+ value: 'global', text: this.$t('_timelines.global')
+ }]
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ Vue.set(this.column, 'tl', src);
+ this.$store.commit('deviceUser/updateDeckColumn', this.column);
+ },
+
+ queueUpdated(q) {
+ if (this.columnActive) {
+ this.indicated = q !== 0;
+ }
+ },
+
+ onNote() {
+ if (!this.columnActive) {
+ this.indicated = true;
+ }
+ },
+
+ onChangeActiveState(state) {
+ this.columnActive = state;
+
+ if (this.columnActive) {
+ this.indicated = false;
+ }
+ },
+
+ focus() {
+ (this.$refs.timeline as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.iwaalbte {
+ text-align: center;
+
+ > p {
+ margin: 16px;
+
+ &.desc {
+ font-size: 14px;
+ }
+ }
+}
+</style>
diff --git a/src/client/components/deck/widgets-column.vue b/src/client/components/deck/widgets-column.vue
new file mode 100644
index 0000000000..37b17451ec
--- /dev/null
+++ b/src/client/components/deck/widgets-column.vue
@@ -0,0 +1,151 @@
+<template>
+<x-column :menu="menu" :naked="true" :column="column" :is-stacked="isStacked">
+ <template #header><fa :icon="faWindowMaximize" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+ <div class="wtdtxvec">
+ <template v-if="edit">
+ <header>
+ <select v-model="widgetAdderSelected" @change="addWidget">
+ <option v-for="widget in widgets" :value="widget" :key="widget">{{ widget }}</option>
+ </select>
+ </header>
+ <x-draggable
+ :list="column.widgets"
+ animation="150"
+ @sort="onWidgetSort"
+ >
+ <div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @click="widgetFunc(widget.id)">
+ <button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button>
+ <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :column="column"/>
+ </div>
+ </x-draggable>
+ </template>
+ <component v-else class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :column="column"/>
+ </div>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import { v4 as uuid } from 'uuid';
+import { faWindowMaximize, faTimes } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import { widgets } from '../../widgets';
+
+export default Vue.extend({
+ components: {
+ XColumn,
+ XDraggable,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true,
+ },
+ isStacked: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ edit: false,
+ menu: null,
+ widgetAdderSelected: null,
+ widgets,
+ faWindowMaximize, faTimes
+ };
+ },
+
+ created() {
+ this.menu = [{
+ icon: 'cog',
+ text: this.$t('edit'),
+ action: () => {
+ this.edit = !this.edit;
+ }
+ }];
+ },
+
+ methods: {
+ widgetFunc(id) {
+ this.$refs[id][0].setting();
+ },
+
+ onWidgetSort() {
+ this.saveWidgets();
+ },
+
+ addWidget() {
+ this.$store.commit('deviceUser/addDeckWidget', {
+ id: this.column.id,
+ widget: {
+ name: this.widgetAdderSelected,
+ id: uuid(),
+ data: {}
+ }
+ });
+
+ this.widgetAdderSelected = null;
+ },
+
+ removeWidget(widget) {
+ this.$store.commit('deviceUser/removeDeckWidget', {
+ id: this.column.id,
+ widget
+ });
+ },
+
+ saveWidgets() {
+ this.$store.commit('deviceUser/updateDeckColumn', this.column);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wtdtxvec {
+ padding-top: 1px; // ウィジェットのbox-shadowを利用した1px borderを隠さないようにするため
+
+ > header {
+ padding: 16px;
+
+ > * {
+ width: 100%;
+ padding: 4px;
+ }
+ }
+
+ > .widget, .customize-container {
+ margin: 8px;
+
+ &:first-of-type {
+ margin-top: 0;
+ }
+ }
+
+ .customize-container {
+ position: relative;
+ cursor: move;
+
+ > *:not(.remove) {
+ pointer-events: none;
+ }
+
+ > .remove {
+ position: absolute;
+ z-index: 2;
+ top: 8px;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ color: #fff;
+ background: rgba(#000, 0.7);
+ border-radius: 4px;
+ }
+ }
+}
+</style>