diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2df16db6a4..ab4b10549c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -519,6 +519,8 @@ fixedWidgetsPosition: "ウィジェットの位置を固定する" enablePlayer: "プレイヤーを開く" disablePlayer: "プレイヤーを閉じる" expandTweet: "ツイートを展開する" +deck: "デッキ" +undeck: "デッキ解除" _theme: explore: "テーマを探す" @@ -651,6 +653,7 @@ _widgets: rss: "RSSリーダー" activity: "アクティビティ" photos: "フォト" + digitalClock: "デジタル時計" _cw: hide: "隠す" @@ -1129,3 +1132,15 @@ _notification: yourFollowRequestAccepted: "フォローリクエストが承認されました" youWereInvitedToGroup: "グループに招待されました" +_deck: + alwaysShowMainColumn: "常にメインカラムを表示" + columnAlign: "カラムの寄せ" + + _columns: + widgets: "ウィジェット" + notifications: "通知" + tl: "タイムライン" + antenna: "アンテナ" + list: "リスト" + mentions: "あなた宛て" + direct: "ダイレクト" diff --git a/src/client/app.vue b/src/client/app.vue index f1a8340490..4f39183564 100644 --- a/src/client/app.vue +++ b/src/client/app.vue @@ -29,47 +29,7 @@ </div> </header> - <transition name="nav-back"> - <div class="nav-back" - v-if="showNav" - @click="showNav = false" - @touchstart="showNav = false" - ></div> - </transition> - - <transition name="nav"> - <nav class="nav" ref="nav" v-show="showNav"> - <div> - <button class="item _button account" @click="openAccountMenu" v-if="$store.getters.isSignedIn"> - <mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/> - </button> - <button class="item _button index active" @click="top()" v-if="$route.name === 'index'"> - <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> - </button> - <router-link class="item index" active-class="active" to="/" exact v-else> - <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> - </router-link> - <template v-for="item in menu"> - <div v-if="item === '-'" class="divider"></div> - <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to"> - <fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span> - <i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i> - </component> - </template> - <div class="divider"></div> - <button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu"> - <fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span> - </button> - <button class="item _button" @click="more"> - <fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span> - <i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i> - </button> - <router-link class="item" active-class="active" to="/preferences"> - <fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span> - </router-link> - </div> - </nav> - </transition> + <x-sidebar ref="nav"/> <div class="contents" ref="contents" :class="{ wallpaper }"> <main ref="main"> @@ -103,20 +63,20 @@ <span class="handle"><fa :icon="faBars"/></span>{{ $t('_widgets.' + widget.name) }}<button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button> </header> <div @click="widgetFunc(widget.id)"> - <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/> + <component class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/> </div> </div> </x-draggable> </div> <div class="container" v-else> - <component class="_widget" v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/> + <component v-for="widget in widgets[place]" class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/> </div> </div> </template> </div> <div class="buttons"> - <button class="button nav _button" @click="showNav = true" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button> + <button class="button nav _button" @click="showNav" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button> <button v-if="$route.name === 'index'" class="button home _button" @click="top()"><fa :icon="faHome"/></button> <button v-else class="button home _button" @click="$router.push('/')"><fa :icon="faHome"/></button> <button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="$router.push('/my/notifications')"><fa :icon="faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button> @@ -135,14 +95,17 @@ import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; import { ResizeObserver } from '@juggle/resize-observer'; import { v4 as uuid } from 'uuid'; -import { host, instanceName } from './config'; +import { host } from './config'; import { search } from './scripts/search'; import { StickySidebar } from './scripts/sticky-sidebar'; +import { widgets } from './widgets'; +import XSidebar from './components/sidebar.vue'; const DESKTOP_THRESHOLD = 1100; export default Vue.extend({ components: { + XSidebar, XClock: () => import('./components/header-clock.vue').then(m => m.default), MkButton: () => import('./components/ui/button.vue').then(m => m.default), XDraggable: () => import('vuedraggable'), @@ -152,19 +115,14 @@ export default Vue.extend({ return { host: host, pageKey: 0, - showNav: false, searching: false, - accounts: [], - lists: [], connection: null, searchQuery: '', searchWait: false, widgetsEditMode: false, - menuDef: this.$store.getters.nav({ - search: this.search - }), isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, canBack: false, + menuDef: this.$store.getters.nav({}), wallpaper: localStorage.getItem('wallpaper') != null, faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram }; @@ -210,30 +168,19 @@ export default Vue.extend({ return this.$store.state.deviceUser.menu; }, - otherNavItemIndicated(): boolean { - if (!this.$store.getters.isSignedIn) return false; - for (const def in this.menuDef) { - if (this.menu.includes(def)) continue; - if (this.menuDef[def].indicated) return true; - } - return false; - }, - navIndicated(): boolean { if (!this.$store.getters.isSignedIn) return false; for (const def in this.menuDef) { - if (def === 'timeline') continue; - if (def === 'notifications') continue; + if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから if (this.menuDef[def].indicated) return true; } return false; } }, - watch:{ + watch: { $route(to, from) { this.pageKey++; - this.showNav = false; this.canBack = (window.history.length > 0 && !['index'].includes(to.name)); }, @@ -245,6 +192,8 @@ export default Vue.extend({ }, created() { + document.documentElement.style.overflowY = 'scroll'; + if (this.$store.getters.isSignedIn) { this.connection = this.$root.stream.useSharedConnection('main'); this.connection.on('notification', this.onNotification); @@ -266,7 +215,7 @@ export default Vue.extend({ mounted() { const adjustTitlePosition = () => { - const left = this.$refs.main.getBoundingClientRect().left - this.$refs.nav.offsetWidth; + const left = this.$refs.main.getBoundingClientRect().left - this.$refs.nav.$el.offsetWidth; if (left >= 0) { this.$refs.title.style.left = left + 'px'; } @@ -293,6 +242,10 @@ export default Vue.extend({ }, methods: { + showNav() { + this.$refs.nav.show(); + }, + attachSticky() { if (!this.isDesktop) return; if (this.$store.state.device.fixedWidgetsPosition) return; @@ -351,180 +304,6 @@ export default Vue.extend({ } }, - async openAccountMenu(ev) { - const accounts = (await this.$root.api('users/show', { userIds: this.$store.state.device.accounts.map(x => x.id) })).filter(x => x.id !== this.$store.state.i.id); - - const accountItems = accounts.map(account => ({ - type: 'user', - user: account, - action: () => { this.switchAccount(account); } - })); - - this.$root.menu({ - items: [...[{ - type: 'link', - text: this.$t('profile'), - to: `/@${ this.$store.state.i.username }`, - avatar: this.$store.state.i, - }, { - type: 'link', - text: this.$t('accountSettings'), - to: '/my/settings', - icon: faCog, - }, null, ...accountItems, { - icon: faPlus, - text: this.$t('addAcount'), - action: () => { - this.$root.menu({ - items: [{ - text: this.$t('existingAcount'), - action: () => { this.addAcount(); }, - }, { - text: this.$t('createAccount'), - action: () => { this.createAccount(); }, - }], - align: 'left', - fixed: true, - width: 240, - source: ev.currentTarget || ev.target, - }); - }, - }]], - align: 'left', - fixed: true, - width: 240, - source: ev.currentTarget || ev.target, - }); - }, - - oepnInstanceMenu(ev) { - this.$root.menu({ - items: [{ - type: 'link', - text: this.$t('dashboard'), - to: '/instance', - icon: faTachometerAlt, - }, null, { - type: 'link', - text: this.$t('settings'), - to: '/instance/settings', - icon: faCog, - }, { - type: 'link', - text: this.$t('customEmojis'), - to: '/instance/emojis', - icon: faLaugh, - }, { - type: 'link', - text: this.$t('users'), - to: '/instance/users', - icon: faUsers, - }, { - type: 'link', - text: this.$t('files'), - to: '/instance/files', - icon: faCloud, - }, { - type: 'link', - text: this.$t('jobQueue'), - to: '/instance/queue', - icon: faExchangeAlt, - }, { - type: 'link', - text: this.$t('federation'), - to: '/instance/federation', - icon: faGlobe, - }, { - type: 'link', - text: this.$t('relays'), - to: '/instance/relays', - icon: faProjectDiagram, - }, { - type: 'link', - text: this.$t('announcements'), - to: '/instance/announcements', - icon: faBroadcastTower, - }], - align: 'left', - fixed: true, - width: 200, - source: ev.currentTarget || ev.target, - }); - }, - - more(ev) { - const items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ - type: def.to ? 'link' : 'button', - text: this.$t(def.title), - icon: def.icon, - to: def.to, - action: def.action, - indicate: def.indicated, - })); - this.$root.menu({ - items: [...items, null, { - type: 'link', - text: this.$t('help'), - to: '/docs', - icon: faQuestionCircle, - }, { - type: 'link', - text: this.$t('aboutX', { x: instanceName || host }), - to: '/about', - icon: faInfoCircle, - }, { - type: 'link', - text: this.$t('aboutMisskey'), - to: '/about-misskey', - icon: faInfoCircle, - }], - align: 'left', - fixed: true, - width: 200, - source: ev.currentTarget || ev.target, - }); - }, - - async addAcount() { - this.$root.new(await import('./components/signin-dialog.vue').then(m => m.default)).$once('login', res => { - this.$store.dispatch('addAcount', res); - this.$root.dialog({ - type: 'success', - iconOnly: true, autoClose: true - }); - }); - }, - - async createAccount() { - this.$root.new(await import('./components/signup-dialog.vue').then(m => m.default)).$once('signup', res => { - this.$store.dispatch('addAcount', res); - this.switchAccountWithToken(res.i); - }); - }, - - async switchAccount(account: any) { - const token = this.$store.state.device.accounts.find((x: any) => x.id === account.id).token; - this.switchAccountWithToken(token); - }, - - switchAccountWithToken(token: string) { - this.$root.dialog({ - type: 'waiting', - iconOnly: true - }); - - this.$root.api('i', {}, token).then((i: any) => { - this.$store.dispatch('switchAccount', { - ...i, - token: token - }).then(() => { - this.$nextTick(() => { - location.reload(); - }); - }); - }); - }, - async onNotification(notification) { if (document.visibilityState === 'visible') { this.$root.stream.send('readNotification', { @@ -540,8 +319,7 @@ export default Vue.extend({ }, widgetFunc(id) { - const w = this.$refs[id][0]; - if (w.func) w.func(); + this.$refs[id][0].setting(); }, onWidgetSort() { @@ -549,18 +327,6 @@ export default Vue.extend({ }, async addWidget(place) { - const widgets = [ - 'memo', - 'notifications', - 'timeline', - 'calendar', - 'rss', - 'trends', - 'clock', - 'activity', - 'photos', - ]; - const { canceled, result: widget } = await this.$root.dialog({ type: null, title: this.$t('chooseWidget'), @@ -594,36 +360,14 @@ export default Vue.extend({ </script> <style lang="scss" scoped> -.nav-enter-active, -.nav-leave-active { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.nav-enter, -.nav-leave-active { - opacity: 0; - transform: translateX(-240px); -} - -.nav-back-enter-active, -.nav-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.nav-back-enter, -.nav-back-leave-active { - opacity: 0; -} - .mk-app { $header-height: 60px; - $nav-width: 250px; - $nav-icon-only-width: 80px; + $nav-width: 250px; // TODO: どこかに集約したい + $nav-icon-only-width: 80px; // TODO: どこかに集約したい $main-width: 670px; - $ui-font-size: 1em; - $nav-icon-only-threshold: 1279px; - $nav-hide-threshold: 650px; + $ui-font-size: 1em; // TODO: どこかに集約したい + $nav-icon-only-threshold: 1279px; // TODO: どこかに集約したい + $nav-hide-threshold: 650px; // TODO: どこかに集約したい $header-sub-hide-threshold: 1090px; $left-widgets-hide-threshold: 1600px; $right-widgets-hide-threshold: 1090px; @@ -780,176 +524,6 @@ export default Vue.extend({ } } - > .nav-back { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - width: 100%; - height: 100%; - background: var(--modalBg); - } - - > .nav { - $avatar-size: 32px; - $avatar-margin: ($header-height - $avatar-size) / 2; - - flex: 0 0 $nav-width; - width: $nav-width; - box-sizing: border-box; - - @media (max-width: $nav-icon-only-threshold) { - flex: 0 0 $nav-icon-only-width; - width: $nav-icon-only-width; - } - - @media (max-width: $nav-hide-threshold) { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - } - - @media (min-width: $nav-hide-threshold + 1px) { - display: block !important; - } - - > div { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - width: $nav-width; - height: 100vh; - box-sizing: border-box; - overflow: auto; - background: var(--navBg); - border-right: solid 1px var(--divider); - - > .divider { - margin: 16px 0; - border-top: solid 1px var(--divider); - } - - @media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) { - width: $nav-icon-only-width; - - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - } - - > .item { - &:first-child { - margin-bottom: 8px; - } - - &:last-child { - margin-top: 8px; - } - } - } - - > .item { - position: relative; - display: block; - padding-left: 32px; - font-size: $ui-font-size; - line-height: 3.2rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--navFg); - - > [data-icon] { - width: ($header-height - ($avatar-margin * 2)); - } - - > [data-icon], - > .avatar { - margin-right: $avatar-margin; - } - - > .avatar { - width: $avatar-size; - height: $avatar-size; - vertical-align: middle; - } - - > i { - position: absolute; - top: 0; - left: 20px; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } - - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } - - &.active { - color: var(--navActive); - } - - &:first-child, &:last-child { - position: sticky; - z-index: 1; - padding-top: 8px; - padding-bottom: 8px; - background: var(--X14); - -webkit-backdrop-filter: blur(8px); - backdrop-filter: blur(8px); - } - - &:first-child { - top: 0; - margin-bottom: 16px; - border-bottom: solid 1px var(--divider); - } - - &:last-child { - bottom: 0; - margin-top: 16px; - border-top: solid 1px var(--divider); - } - - @media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) { - padding-left: 0; - width: 100%; - text-align: center; - font-size: $ui-font-size * 1.2; - line-height: 3.7rem; - - > [data-icon], - > .avatar { - margin-right: 0; - } - - > i { - left: 10px; - } - - > .text { - display: none; - } - } - } - - @media (max-width: $nav-hide-threshold) { - > .index, - > .notifications { - display: none; - } - } - } - } - > .contents { display: flex; margin: 0 auto; 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> diff --git a/src/client/components/error.vue b/src/client/components/error.vue index b1d91fb3ef..90efa700b2 100644 --- a/src/client/components/error.vue +++ b/src/client/components/error.vue @@ -40,7 +40,7 @@ export default Vue.extend({ > img { vertical-align: bottom; - height: 150px; + height: 128px; margin-bottom: 16px; border-radius: 16px; } diff --git a/src/client/components/form-window.vue b/src/client/components/form-window.vue new file mode 100644 index 0000000000..25eee91647 --- /dev/null +++ b/src/client/components/form-window.vue @@ -0,0 +1,71 @@ +<template> +<x-window ref="window" :width="400" :height="450" :no-padding="true" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="false" @ok="ok()" :can-close="false"> + <template #header> + {{ title }} + </template> + <div class="xkpnjxcv"> + <label v-for="item in Object.keys(form).filter(item => !form[item].hidden)" :key="item"> + <mk-input v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"><span v-text="form[item].label || item"></span></mk-input> + <mk-input v-else-if="form[item].type === 'string' && !item.multiline" v-model="values[item]" type="text"><span v-text="form[item].label || item"></span></mk-input> + <mk-textarea v-else-if="form[item].type === 'string' && item.multiline" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-textarea> + <mk-switch v-else-if="form[item].type === 'boolean'" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-switch> + </label> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XWindow from './window.vue'; +import MkInput from './ui/input.vue'; +import MkTextarea from './ui/textarea.vue'; +import MkSwitch from './ui/switch.vue'; + +export default Vue.extend({ + components: { + XWindow, + MkInput, + MkTextarea, + MkSwitch, + }, + + props: { + title: { + type: String, + required: true, + }, + form: { + type: Object, + required: true, + }, + }, + + data() { + return { + values: {} + }; + }, + + created() { + for (const item in this.form) { + Vue.set(this.values, item, this.form[item].default || null); + } + }, + + methods: { + ok() { + this.$emit('ok', this.values); + this.$refs.window.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.xkpnjxcv { + > label { + display: block; + padding: 16px 24px; + } +} +</style> diff --git a/src/client/components/modal.vue b/src/client/components/modal.vue index 1a9d98a8cc..f941d4d503 100644 --- a/src/client/components/modal.vue +++ b/src/client/components/modal.vue @@ -1,10 +1,10 @@ <template> <div class="mk-modal" v-hotkey.global="keymap"> <transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear> - <div class="bg" ref="bg" v-if="show" @click="close()"></div> + <div class="bg" ref="bg" v-if="show" @click="canClose ? close() : () => {}"></div> </transition> <transition :name="$store.state.device.animation ? 'modal' : ''" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> - <div class="content" ref="content" v-if="show" @click.self="close()"><slot></slot></div> + <div class="content" ref="content" v-if="show" @click.self="canClose ? close() : () => {}"><slot></slot></div> </transition> </div> </template> @@ -14,6 +14,11 @@ import Vue from 'vue'; export default Vue.extend({ props: { + canClose: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { diff --git a/src/client/components/note-header.vue b/src/client/components/note-header.vue index 93cf2cdf39..039287818f 100644 --- a/src/client/components/note-header.vue +++ b/src/client/components/note-header.vue @@ -54,7 +54,6 @@ export default Vue.extend({ margin: 0 .5em 0 0; padding: 0; overflow: hidden; - color: var(--noteHeaderName); font-size: 1em; font-weight: bold; text-decoration: none; diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 118fef661c..badb9f12f3 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -724,61 +724,6 @@ export default Vue.extend({ transition: box-shadow 0.1s ease; overflow: hidden; - &.max-width_500px { - font-size: 0.9em; - } - - &.max-width_450px { - > .renote { - padding: 8px 16px 0 16px; - } - - > .article { - padding: 14px 16px 9px; - - > .avatar { - margin: 0 10px 8px 0; - width: 50px; - height: 50px; - } - } - } - - &.max-width_350px { - > .article { - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 18px; - } - } - } - } - } - } - - &.max-width_300px { - font-size: 0.825em; - - > .article { - > .avatar { - width: 44px; - height: 44px; - } - - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 12px; - } - } - } - } - } - } - &:focus { outline: none; box-shadow: 0 0 0 3px var(--focus); @@ -797,10 +742,6 @@ export default Vue.extend({ white-space: pre; color: #d28a3f; - @media (max-width: 450px) { - padding: 8px 16px 0 16px; - } - > [data-icon] { margin-right: 4px; } @@ -985,5 +926,64 @@ export default Vue.extend({ > .reply { border-top: solid 1px var(--divider); } + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .info { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 14px 16px 9px; + + > .avatar { + margin: 0 10px 8px 0; + width: 50px; + height: 50px; + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .avatar { + width: 44px; + height: 44px; + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } } </style> diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue new file mode 100644 index 0000000000..3ddef7d127 --- /dev/null +++ b/src/client/components/sidebar.vue @@ -0,0 +1,488 @@ +<template> +<div class="mvcprjjd"> + <transition name="nav-back"> + <div class="nav-back" + v-if="showing" + @click="showing = false" + @touchstart="showing = false" + ></div> + </transition> + + <transition name="nav"> + <nav class="nav" v-show="showing"> + <div> + <button class="item _button account" @click="openAccountMenu" v-if="$store.getters.isSignedIn"> + <mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/> + </button> + <button class="item _button index active" @click="top()" v-if="$route.name === 'index'"> + <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> + </button> + <router-link class="item index" active-class="active" to="/" exact v-else> + <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span> + </router-link> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to"> + <fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span> + <i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i> + </component> + </template> + <div class="divider"></div> + <button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu"> + <fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span> + </button> + <button class="item _button" @click="more"> + <fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span> + <i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i> + </button> + <router-link class="item" active-class="active" to="/preferences"> + <fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span> + </router-link> + </div> + </nav> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; +import { host, instanceName } from '../config'; +import { search } from '../scripts/search'; + +export default Vue.extend({ + data() { + return { + host: host, + showing: false, + searching: false, + accounts: [], + connection: null, + menuDef: this.$store.getters.nav({ + search: this.search + }), + faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.deviceUser.menu; + }, + + otherNavItemIndicated(): boolean { + if (!this.$store.getters.isSignedIn) return false; + for (const def in this.menuDef) { + if (this.menu.includes(def)) continue; + if (this.menuDef[def].indicated) return true; + } + return false; + }, + }, + + watch: { + $route(to, from) { + this.showing = false; + }, + }, + + methods: { + show() { + this.showing = true; + }, + + search() { + if (this.searching) return; + + this.$root.dialog({ + title: this.$t('search'), + input: true + }).then(async ({ canceled, result: query }) => { + if (canceled || query == null || query === '') return; + + this.searching = true; + search(this, query).finally(() => { + this.searching = false; + }); + }); + }, + + async openAccountMenu(ev) { + const accounts = (await this.$root.api('users/show', { userIds: this.$store.state.device.accounts.map(x => x.id) })).filter(x => x.id !== this.$store.state.i.id); + + const accountItems = accounts.map(account => ({ + type: 'user', + user: account, + action: () => { this.switchAccount(account); } + })); + + this.$root.menu({ + items: [...[{ + type: 'link', + text: this.$t('profile'), + to: `/@${ this.$store.state.i.username }`, + avatar: this.$store.state.i, + }, { + type: 'link', + text: this.$t('accountSettings'), + to: '/my/settings', + icon: faCog, + }, null, ...accountItems, { + icon: faPlus, + text: this.$t('addAcount'), + action: () => { + this.$root.menu({ + items: [{ + text: this.$t('existingAcount'), + action: () => { this.addAcount(); }, + }, { + text: this.$t('createAccount'), + action: () => { this.createAccount(); }, + }], + align: 'left', + fixed: true, + width: 240, + source: ev.currentTarget || ev.target, + }); + }, + }]], + align: 'left', + fixed: true, + width: 240, + source: ev.currentTarget || ev.target, + }); + }, + + oepnInstanceMenu(ev) { + this.$root.menu({ + items: [{ + type: 'link', + text: this.$t('dashboard'), + to: '/instance', + icon: faTachometerAlt, + }, null, { + type: 'link', + text: this.$t('settings'), + to: '/instance/settings', + icon: faCog, + }, { + type: 'link', + text: this.$t('customEmojis'), + to: '/instance/emojis', + icon: faLaugh, + }, { + type: 'link', + text: this.$t('users'), + to: '/instance/users', + icon: faUsers, + }, { + type: 'link', + text: this.$t('files'), + to: '/instance/files', + icon: faCloud, + }, { + type: 'link', + text: this.$t('jobQueue'), + to: '/instance/queue', + icon: faExchangeAlt, + }, { + type: 'link', + text: this.$t('federation'), + to: '/instance/federation', + icon: faGlobe, + }, { + type: 'link', + text: this.$t('relays'), + to: '/instance/relays', + icon: faProjectDiagram, + }, { + type: 'link', + text: this.$t('announcements'), + to: '/instance/announcements', + icon: faBroadcastTower, + }], + align: 'left', + fixed: true, + width: 200, + source: ev.currentTarget || ev.target, + }); + }, + + more(ev) { + const items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ + type: def.to ? 'link' : 'button', + text: this.$t(def.title), + icon: def.icon, + to: def.to, + action: def.action, + indicate: def.indicated, + })); + this.$root.menu({ + items: [...items, null, { + type: 'link', + text: this.$t('help'), + to: '/docs', + icon: faQuestionCircle, + }, { + type: 'link', + text: this.$t('aboutX', { x: instanceName || host }), + to: '/about', + icon: faInfoCircle, + }, { + type: 'link', + text: this.$t('aboutMisskey'), + to: '/about-misskey', + icon: faInfoCircle, + }], + align: 'left', + fixed: true, + width: 200, + source: ev.currentTarget || ev.target, + }); + }, + + async addAcount() { + this.$root.new(await import('./signin-dialog.vue').then(m => m.default)).$once('login', res => { + this.$store.dispatch('addAcount', res); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }, + + async createAccount() { + this.$root.new(await import('./signup-dialog.vue').then(m => m.default)).$once('signup', res => { + this.$store.dispatch('addAcount', res); + this.switchAccountWithToken(res.i); + }); + }, + + async switchAccount(account: any) { + const token = this.$store.state.device.accounts.find((x: any) => x.id === account.id).token; + this.switchAccountWithToken(token); + }, + + switchAccountWithToken(token: string) { + this.$root.dialog({ + type: 'waiting', + iconOnly: true + }); + + this.$root.api('i', {}, token).then((i: any) => { + this.$store.dispatch('switchAccount', { + ...i, + token: token + }).then(() => { + this.$nextTick(() => { + location.reload(); + }); + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.nav-enter-active, +.nav-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.nav-enter, +.nav-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.nav-back-enter-active, +.nav-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.nav-back-enter, +.nav-back-leave-active { + opacity: 0; +} + +.mvcprjjd { + $ui-font-size: 1em; // TODO: どこかに集約したい + $nav-width: 250px; // TODO: どこかに集約したい + $nav-icon-only-width: 80px; // TODO: どこかに集約したい + $nav-icon-only-threshold: 1279px; // TODO: どこかに集約したい + $nav-hide-threshold: 650px; // TODO: どこかに集約したい + + > .nav-back { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: 100%; + height: 100%; + background: var(--modalBg); + } + + > .nav { + $avatar-size: 32px; + $avatar-margin: 8px; + + flex: 0 0 $nav-width; + width: $nav-width; + box-sizing: border-box; + + @media (max-width: $nav-icon-only-threshold) { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width; + } + + @media (max-width: $nav-hide-threshold) { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + } + + @media (min-width: $nav-hide-threshold + 1px) { + display: block !important; + } + + > div { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: $nav-width; + height: 100vh; + box-sizing: border-box; + overflow: auto; + background: var(--navBg); + border-right: solid 1px var(--divider); + + > .divider { + margin: 16px 0; + border-top: solid 1px var(--divider); + } + + @media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) { + width: $nav-icon-only-width; + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + } + + > .item { + &:first-child { + margin-bottom: 8px; + } + + &:last-child { + margin-top: 8px; + } + } + } + + > .item { + position: relative; + display: block; + padding-left: 32px; + font-size: $ui-font-size; + line-height: 3.2rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > [data-icon] { + width: 32px; + } + + > [data-icon], + > .avatar { + margin-right: $avatar-margin; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > i { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + + &:first-child, &:last-child { + position: sticky; + z-index: 1; + padding-top: 8px; + padding-bottom: 8px; + background: var(--X14); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + } + + &:first-child { + top: 0; + margin-bottom: 16px; + border-bottom: solid 1px var(--divider); + } + + &:last-child { + bottom: 0; + margin-top: 16px; + border-top: solid 1px var(--divider); + } + + @media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) { + padding-left: 0; + width: 100%; + text-align: center; + font-size: $ui-font-size * 1.2; + line-height: 3.7rem; + + > [data-icon], + > .avatar { + margin-right: 0; + } + + > i { + left: 10px; + } + + > .text { + display: none; + } + } + } + + @media (max-width: $nav-hide-threshold) { + > .index, + > .notifications { + display: none; + } + } + } + } +} +</style> diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue index bd1901a624..ce0fd95caf 100644 --- a/src/client/components/timeline.vue +++ b/src/client/components/timeline.vue @@ -17,9 +17,11 @@ export default Vue.extend({ required: true }, list: { + type: String, required: false }, antenna: { + type: String, required: false }, sound: { @@ -53,6 +55,8 @@ export default Vue.extend({ const _note = JSON.parse(JSON.stringify(note)); // deepcopy (this.$refs.tl as any).prepend(_note); + this.$emit('note'); + if (this.sound) { this.$root.sound(note.userId === this.$store.state.i.id ? 'noteMy' : 'note'); } @@ -77,10 +81,10 @@ export default Vue.extend({ if (this.src == 'antenna') { endpoint = 'antennas/notes'; this.query = { - antennaId: this.antenna.id + antennaId: this.antenna }; this.connection = this.$root.stream.connectToChannel('antenna', { - antennaId: this.antenna.id + antennaId: this.antenna }); this.connection.on('note', prepend); } else if (this.src == 'home') { @@ -106,10 +110,10 @@ export default Vue.extend({ } else if (this.src == 'list') { endpoint = 'notes/user-list-timeline'; this.query = { - listId: this.list.id + listId: this.list }; this.connection = this.$root.stream.connectToChannel('userList', { - listId: this.list.id + listId: this.list }); this.connection.on('note', prepend); this.connection.on('userAdded', onUserAdded); diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue index 3fed1f65c7..6a718439aa 100644 --- a/src/client/components/ui/container.vue +++ b/src/client/components/ui/container.vue @@ -1,5 +1,5 @@ <template> -<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader }"> +<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable }" v-size="[{ max: 500 }]"> <header v-if="showHeader"> <div class="title"><slot name="header"></slot></div> <slot name="func"></slot> @@ -47,6 +47,11 @@ export default Vue.extend({ required: false, default: true }, + scrollable: { + type: Boolean, + required: false, + default: false + }, }, data() { return { @@ -107,10 +112,19 @@ export default Vue.extend({ box-shadow: none !important; } + &.scrollable { + display: flex; + flex-direction: column; + + > div { + overflow: auto; + } + } + > header { position: relative; box-shadow: 0 1px 0 0 var(--panelHeaderDivider); - z-index: 1; + z-index: 2; background: var(--panelHeaderBg); color: var(--panelHeaderFg); @@ -118,10 +132,6 @@ export default Vue.extend({ margin: 0; padding: 12px 16px; - @media (max-width: 500px) { - padding: 8px 10px; - } - > [data-icon] { margin-right: 6px; } @@ -141,5 +151,21 @@ export default Vue.extend({ height: 100%; } } + + &.max-width_500px { + > header { + > .title { + padding: 8px 10px; + } + } + } +} + +._forceContainerFull_ .ukygtjoj { + > header { + > .title { + padding: 12px 16px !important; + } + } } </style> diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue index c9f62e3cc0..d5317db7f9 100644 --- a/src/client/components/ui/input.vue +++ b/src/client/components/ui/input.vue @@ -20,6 +20,7 @@ :pattern="pattern" :autocomplete="autocomplete" :spellcheck="spellcheck" + :step="step" @focus="focused = true" @blur="focused = false" @keydown="$emit('keydown', $event)" @@ -36,6 +37,7 @@ :pattern="pattern" :autocomplete="autocomplete" :spellcheck="spellcheck" + :step="step" @focus="focused = true" @blur="focused = false" @keydown="$emit('keydown', $event)" @@ -114,6 +116,9 @@ export default Vue.extend({ spellcheck: { required: false }, + step: { + required: false + }, debounce: { required: false }, @@ -164,7 +169,7 @@ export default Vue.extend({ }, v(v) { if (this.type === 'number') { - this.$emit('input', parseInt(v, 10)); + this.$emit('input', parseFloat(v)); } else { this.$emit('input', v); } @@ -297,7 +302,7 @@ export default Vue.extend({ pointer-events: none; transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); transition-duration: 0.3s; - font-size: 16px; + font-size: 1em; line-height: 32px; color: var(--inputLabel); pointer-events: none; @@ -312,7 +317,7 @@ export default Vue.extend({ top: -17px; left: 0 !important; pointer-events: none; - font-size: 16px; + font-size: 1em; line-height: 32px; color: var(--inputLabel); pointer-events: none; @@ -343,7 +348,7 @@ export default Vue.extend({ padding: 0; font: inherit; font-weight: normal; - font-size: 16px; + font-size: 1em; line-height: $height; color: var(--inputText); background: transparent; @@ -364,7 +369,7 @@ export default Vue.extend({ position: absolute; z-index: 1; top: 0; - font-size: 16px; + font-size: 1em; line-height: 32px; color: var(--inputLabel); pointer-events: none; diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue index ce21949713..55f76553a7 100644 --- a/src/client/components/ui/select.vue +++ b/src/client/components/ui/select.vue @@ -135,7 +135,7 @@ export default Vue.extend({ pointer-events: none; transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); transition-duration: 0.3s; - font-size: 16px; + font-size: 1em; line-height: 32px; pointer-events: none; //will-change transform @@ -150,7 +150,7 @@ export default Vue.extend({ padding: 0; font: inherit; font-weight: normal; - font-size: 16px; + font-size: 1em; height: 32px; background: none; border: none; @@ -170,7 +170,7 @@ export default Vue.extend({ display: block; align-self: center; justify-self: center; - font-size: 16px; + font-size: 1em; line-height: 32px; color: rgba(#000, 0.54); pointer-events: none; diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue index 18a2ec33f1..9652a01024 100644 --- a/src/client/components/ui/switch.vue +++ b/src/client/components/ui/switch.vue @@ -5,7 +5,7 @@ role="switch" :aria-checked="checked" :aria-disabled="disabled" - @click="toggle" + @click.prevent="toggle" > <input type="checkbox" diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue index fab307a202..a42813ee64 100644 --- a/src/client/components/ui/textarea.vue +++ b/src/client/components/ui/textarea.vue @@ -133,7 +133,7 @@ export default Vue.extend({ pointer-events: none; transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); transition-duration: 0.3s; - font-size: 16px; + font-size: 1em; line-height: 32px; pointer-events: none; //will-change transform @@ -151,7 +151,7 @@ export default Vue.extend({ box-sizing: border-box; font: inherit; font-weight: normal; - font-size: 16px; + font-size: 1em; background: transparent; border: none; border-radius: 0; diff --git a/src/client/components/window.vue b/src/client/components/window.vue index db13985181..a0bff869b9 100644 --- a/src/client/components/window.vue +++ b/src/client/components/window.vue @@ -1,5 +1,5 @@ <template> -<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }"> +<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }" :can-close="canClose"> <div class="ebkgoccj" :class="{ noPadding }" @keydown="onKeydown" :style="{ width: `${width}px`, height: `${height}px` }"> <div class="header"> <button class="_button" v-if="withOkButton" @click="close()"><fa :icon="faTimes"/></button> @@ -57,6 +57,11 @@ export default Vue.extend({ required: false, default: 400 }, + canClose: { + type: Boolean, + required: false, + default: true, + }, }, data() { diff --git a/src/client/config.ts b/src/client/config.ts index b9a4766188..badb695245 100644 --- a/src/client/config.ts +++ b/src/client/config.ts @@ -18,3 +18,4 @@ export const getLocale = async () => Object.fromEntries((await entries(clientDb. export const version = _VERSION_; export const env = _ENV_; export const instanceName = siteName === 'Misskey' ? null : siteName; +export const deckmode = localStorage.getItem('deckmode') === 'true'; diff --git a/src/client/deck.vue b/src/client/deck.vue new file mode 100644 index 0000000000..669719ba8e --- /dev/null +++ b/src/client/deck.vue @@ -0,0 +1,312 @@ +<template> +<div class="mk-deck" :class="`${$store.state.device.deckColumnAlign}`" v-hotkey.global="keymap"> + <x-sidebar ref="nav"/> + + <!-- TODO: deckMainColumnPlace を見て位置変える --> + <deck-column class="column" v-if="$store.state.device.deckAlwaysShowMainColumn || $route.name !== 'index'"> + <template #action> + <button class="_button back" v-if="canBack" @click="back()"><fa :icon="faChevronLeft"/></button> + </template> + + <template #header> + <div class="iwnjqeul"> + <div class="default"> + <portal-target name="avatar" slim/> + <span class="title"><portal-target name="icon" slim/><portal-target name="title" slim/></span> + </div> + <div class="custom"> + <portal-target name="header" slim/> + </div> + </div> + </template> + + <router-view></router-view> + </deck-column> + + <template v-for="ids in layout"> + <div v-if="ids.length > 1" class="folder column"> + <deck-column-core v-for="id, i in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> + </div> + <deck-column-core v-else class="column" :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id === ids[0])" @parent-focus="moveFocus(ids[0], $event)"/> + </template> + + <button @click="addColumn" class="_button add"><fa :icon="faPlus"/></button> + + <button v-if="$store.getters.isSignedIn" class="nav _button" @click="showNav()"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button> + <button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button> + + <stream-indicator v-if="$store.getters.isSignedIn"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faPencilAlt, faChevronLeft, faBars, faCircle } from '@fortawesome/free-solid-svg-icons'; +import { } from '@fortawesome/free-regular-svg-icons'; +import { v4 as uuid } from 'uuid'; +import { host } from './config'; +import { search } from './scripts/search'; +import DeckColumnCore from './components/deck/column-core.vue'; +import DeckColumn from './components/deck/column.vue'; +import XSidebar from './components/sidebar.vue'; + +export default Vue.extend({ + components: { + XSidebar, + DeckColumn, + DeckColumnCore, + }, + + data() { + return { + host: host, + pageKey: 0, + searching: false, + connection: null, + searchQuery: '', + searchWait: false, + canBack: false, + menuDef: this.$store.getters.nav({}), + wallpaper: localStorage.getItem('wallpaper') != null, + faPlus, faPencilAlt, faChevronLeft, faBars, faCircle + }; + }, + + computed: { + deck() { + return this.$store.state.deviceUser.deck; + }, + columns(): any[] { + return this.deck.columns; + }, + layout(): any[] { + return this.deck.layout; + }, + navIndicated(): boolean { + if (!this.$store.getters.isSignedIn) return false; + for (const def in this.menuDef) { + if (this.menuDef[def].indicated) return true; + } + return false; + }, + keymap(): any { + return { + 'p': this.post, + 'n': this.post, + 's': this.search, + 'h|/': this.help + }; + }, + }, + + watch: { + $route(to, from) { + this.pageKey++; + this.canBack = (window.history.length > 0 && !['index'].includes(to.name)); + }, + }, + + created() { + document.documentElement.style.overflowY = 'hidden'; + + if (this.$store.getters.isSignedIn) { + this.connection = this.$root.stream.useSharedConnection('main'); + this.connection.on('notification', this.onNotification); + } + }, + + mounted() { + }, + + methods: { + showNav() { + this.$refs.nav.show(); + }, + + help() { + this.$router.push('/docs/keyboard-shortcut'); + }, + + back() { + if (this.canBack) window.history.back(); + }, + + post() { + this.$root.post(); + }, + + search() { + if (this.searching) return; + + this.$root.dialog({ + title: this.$t('search'), + input: true + }).then(async ({ canceled, result: query }) => { + if (canceled || query == null || query === '') return; + + this.searching = true; + search(this, query).finally(() => { + this.searching = false; + }); + }); + }, + + async onNotification(notification) { + if (document.visibilityState === 'visible') { + this.$root.stream.send('readNotification', { + id: notification.id + }); + + this.$root.new(await import('./components/toast.vue').then(m => m.default), { + notification + }); + } + + this.$root.sound('notification'); + }, + + async addColumn(ev) { + const columns = [ + 'widgets', + 'notifications', + 'tl', + 'antenna', + 'list', + 'mentions', + 'direct', + ]; + + const { canceled, result: column } = await this.$root.dialog({ + title: this.$t('_deck.addColumn'), + type: null, + select: { + items: columns.map(column => ({ + value: column, text: this.$t('_deck._columns.' + column) + })) + }, + showCancelButton: true + }); + if (canceled) return; + + this.$store.commit('deviceUser/addDeckColumn', { + type: column, + id: uuid(), + name: this.$t('_deck._columns.' + column), + width: 330, + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-deck { + $nav-hide-threshold: 650px; // TODO: どこかに集約したい + + // TODO: この値を設定で変えられるようにする? + $columnMargin: 12px; + + $deckMargin: 12px; + + --margin: var(--marginHalf); + + display: flex; + height: 100vh; + box-sizing: border-box; + flex: 1; + padding: $deckMargin 0 $deckMargin $deckMargin; + + &.center { + > .column:first-of-type { + margin-left: auto; + } + + > .add { + margin-right: auto; + } + } + + > .column { + flex-shrink: 0; + margin-right: $columnMargin; + + &.folder { + display: flex; + flex-direction: column; + + > *:not(:last-child) { + margin-bottom: $columnMargin; + } + } + } + + > .post, + > .nav { + position: fixed; + z-index: 1000; + bottom: 32px; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + font-size: 22px; + } + + > .post { + right: 32px; + } + + > .nav { + left: 32px; + background: var(--panel); + color: var(--fg); + + @media (min-width: ($nav-hide-threshold + 1px)) { + display: none; + } + + &:hover { + background: var(--X2); + } + + > i { + position: absolute; + top: 0; + left: 0; + color: var(--indicator); + font-size: 16px; + animation: blink 1s infinite; + } + } +} + +.iwnjqeul { + $header-height: 42px; // TODO: column.vueのそれを参照するようにしたい(出来るのか?) + + > .default { + > .avatar { + $size: 28px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: (($header-height - $size) / 2) 8px (($header-height - $size) / 2) 0; + } + + > .title { + display: inline-block; + margin: 0; + line-height: $header-height; + + > [data-icon] { + margin-right: 8px; + } + } + } + + > .custom { + position: absolute; + top: 0; + } +} +</style> diff --git a/src/client/init.ts b/src/client/init.ts index 21f233cc91..d00b4f5cca 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -1,5 +1,5 @@ /** - * App entry point + * Client entry point */ import Vue from 'vue'; @@ -12,11 +12,13 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import VueHotkey from './scripts/hotkey'; import App from './app.vue'; +import Deck from './deck.vue'; import MiOS from './mios'; -import { version, langs, instanceName, getLocale } from './config'; +import { version, langs, instanceName, getLocale, deckmode } from './config'; import PostFormDialog from './components/post-form-dialog.vue'; import Dialog from './components/dialog.vue'; import Menu from './components/menu.vue'; +import Form from './components/form-window.vue'; import { router } from './router'; import { applyTheme, lightTheme } from './scripts/theme'; import { isDeviceDarkmode } from './scripts/is-device-darkmode'; @@ -165,6 +167,7 @@ os.init(async () => { i18n // TODO: 消せないか考える SEE: https://github.com/syuilo/misskey/pull/6396#discussion_r429511030 }; }, + // TODO: ここらへんのメソッド全部Vuexに移したい methods: { api: (endpoint: string, data: { [x: string]: any } = {}, token?) => store.dispatch('api', { endpoint, data, token }), signout: os.signout, @@ -194,6 +197,13 @@ os.init(async () => { }); return p; }, + form(title, form) { + const vm = this.new(Form, { title, form }); + return new Promise((res) => { + vm.$once('ok', result => res({ canceled: false, result })); + vm.$once('cancel', () => res({ canceled: true })); + }); + }, post(opts, cb) { if (!this.$store.getters.isSignedIn) return; const vm = this.new(PostFormDialog, opts); @@ -210,11 +220,9 @@ os.init(async () => { } }, router: router, - render: createEl => createEl(App) + render: createEl => createEl(deckmode ? Deck : App) }); - os.app = app; - // マウント app.$mount('#app'); diff --git a/src/client/mios.ts b/src/client/mios.ts index c54b6fff87..efeb630d7e 100644 --- a/src/client/mios.ts +++ b/src/client/mios.ts @@ -1,7 +1,6 @@ // TODO: このファイル消したい import autobind from 'autobind-decorator'; -import Vue from 'vue'; import { EventEmitter } from 'eventemitter3'; import { apiUrl, version } from './config'; @@ -14,8 +13,6 @@ import store from './store'; * Misskey Operating System */ export default class MiOS extends EventEmitter { - public app: Vue; - public store: ReturnType<typeof store>; /** diff --git a/src/client/pages/index.home.vue b/src/client/pages/index.home.vue index 17d07e6084..2059b34ac3 100644 --- a/src/client/pages/index.home.vue +++ b/src/client/pages/index.home.vue @@ -19,7 +19,7 @@ <x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/> <x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/> - <x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list" :antenna="antenna" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/> + <x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/> </div> </template> diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue index 48629a4ebe..5464875dfb 100644 --- a/src/client/pages/note.vue +++ b/src/client/pages/note.vue @@ -15,14 +15,15 @@ <mk-remote-caution v-if="note.user.host != null" :href="note.url || note.uri" style="margin-bottom: var(--margin)"/> <x-note :note="note" :key="note.id" :detail="true"/> - <div v-if="error"> - <mk-error @retry="fetch()"/> - </div> <button class="_panel _button" v-if="hasPrev && !showPrev" @click="showPrev = true" style="margin: var(--margin) auto 0 auto;"><fa :icon="faChevronDown"/></button> <hr v-if="showPrev"/> <x-notes v-if="showPrev" ref="prev" :pagination="prev" style="margin-top: var(--margin);"/> </div> + + <div v-if="error"> + <mk-error @retry="fetch()"/> + </div> </div> </template> diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue index 92d745a846..ffc8858764 100644 --- a/src/client/pages/preferences/index.vue +++ b/src/client/pages/preferences/index.vue @@ -51,6 +51,20 @@ </div> </section> + <section class="_card"> + <div class="_title"><fa :icon="faColumns"/> {{ $t('deck') }}</div> + <div class="_content"> + <mk-switch v-model="deckAlwaysShowMainColumn"> + {{ $t('_deck.alwaysShowMainColumn') }} + </mk-switch> + </div> + <div class="_content"> + <div>{{ $t('_deck.columnAlign') }}</div> + <mk-radio v-model="deckColumnAlign" value="left">{{ $t('left') }}</mk-radio> + <mk-radio v-model="deckColumnAlign" value="center">{{ $t('center') }}</mk-radio> + </div> + </section> + <section class="_card"> <div class="_title"><fa :icon="faCog"/> {{ $t('accessibility') }}</div> <div class="_content"> @@ -93,7 +107,7 @@ <script lang="ts"> import Vue from 'vue'; -import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons'; +import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, faColumns } from '@fortawesome/free-solid-svg-icons'; import MkButton from '../../components/ui/button.vue'; import MkSwitch from '../../components/ui/switch.vue'; import MkSelect from '../../components/ui/select.vue'; @@ -145,7 +159,7 @@ export default Vue.extend({ lang: localStorage.getItem('lang'), fontSize: localStorage.getItem('fontSize'), sounds, - faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute + faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, faColumns } }, @@ -195,6 +209,16 @@ export default Vue.extend({ set(value) { this.$store.commit('device/set', { key: 'fixedWidgetsPosition', value }); } }, + deckAlwaysShowMainColumn: { + get() { return this.$store.state.device.deckAlwaysShowMainColumn; }, + set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); } + }, + + deckColumnAlign: { + get() { return this.$store.state.device.deckColumnAlign; }, + set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } + }, + sfxVolume: { get() { return this.$store.state.device.sfxVolume; }, set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); } diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue index 1878a9b1f3..f03c4adf8d 100644 --- a/src/client/pages/user/index.timeline.vue +++ b/src/client/pages/user/index.timeline.vue @@ -1,5 +1,5 @@ <template> -<div class="kjeftjfm"> +<div class="kjeftjfm" v-size="[{ max: 500 }]"> <div class="with"> <button class="_button" @click="with_ = null" :class="{ active: with_ === null }">{{ $t('notes') }}</button> <button class="_button" @click="with_ = 'replies'" :class="{ active: with_ === 'replies' }">{{ $t('notesAndReplies') }}</button> @@ -60,10 +60,6 @@ export default Vue.extend({ display: flex; margin-bottom: var(--margin); - @media (max-width: 500px) { - font-size: 80%; - } - > button { flex: 1; padding: 11px 8px 8px 8px; @@ -75,5 +71,11 @@ export default Vue.extend({ } } } + + &.max-width_500px { + > .with { + font-size: 80%; + } + } } </style> diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index 75f61a0c0c..20eaca3687 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -1,5 +1,5 @@ <template> -<div class="mk-user-page" v-if="user"> +<div class="mk-user-page" v-if="user" v-size="[{ max: 500 }]"> <portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal> <portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal> @@ -118,6 +118,7 @@ import MkContainer from '../../components/ui/container.vue'; import MkRemoteCaution from '../../components/remote-caution.vue'; import Progress from '../../scripts/loading'; import parseAcct from '../../../misc/acct/parse'; +import { getScrollPosition } from '../../scripts/scroll'; export default Vue.extend({ components: { @@ -168,12 +169,8 @@ export default Vue.extend({ mounted() { window.requestAnimationFrame(this.parallaxLoop); - window.addEventListener('scroll', this.parallax, { passive: true }); - document.addEventListener('touchmove', this.parallax, { passive: true }); this.$once('hook:beforeDestroy', () => { window.cancelAnimationFrame(this.parallaxAnimationId); - window.removeEventListener('scroll', this.parallax); - document.removeEventListener('touchmove', this.parallax); }); }, @@ -205,7 +202,7 @@ export default Vue.extend({ const banner = this.$refs.banner as any; if (banner == null) return; - const top = window.scrollY; + const top = getScrollPosition(this.$el); if (top < 0) return; @@ -219,7 +216,6 @@ export default Vue.extend({ <style lang="scss" scoped> .mk-user-page { - > .punished { font-size: 0.8em; padding: 16px; @@ -237,10 +233,6 @@ export default Vue.extend({ background-size: cover; background-position: center; - @media (max-width: 500px) { - height: 140px; - } - > .banner { height: 100%; background-color: #4c5e6d; @@ -257,10 +249,6 @@ export default Vue.extend({ width: 100%; height: 78px; background: linear-gradient(transparent, rgba(#000, 0.7)); - - @media (max-width: 500px) { - display: none; - } } > .followed { @@ -308,10 +296,6 @@ export default Vue.extend({ box-sizing: border-box; color: #fff; - @media (max-width: 500px) { - display: none; - } - > .name { display: block; margin: 0; @@ -343,10 +327,6 @@ export default Vue.extend({ font-weight: bold; border-bottom: solid 1px var(--divider); - @media (max-width: 500px) { - display: block; - } - > .bottom { > * { display: inline-block; @@ -365,26 +345,12 @@ export default Vue.extend({ width: 120px; height: 120px; box-shadow: 1px 1px 3px rgba(#000, 0.2); - - @media (max-width: 500px) { - top: 90px; - left: 0; - right: 0; - width: 92px; - height: 92px; - margin: auto; - } } > .description { padding: 24px 24px 24px 154px; font-size: 0.95em; - @media (max-width: 500px) { - padding: 16px; - text-align: center; - } - > .empty { margin: 0; opacity: 0.5; @@ -396,10 +362,6 @@ export default Vue.extend({ font-size: 0.9em; border-top: solid 1px var(--divider); - @media (max-width: 500px) { - padding: 16px; - } - > .field { display: flex; padding: 0; @@ -436,10 +398,6 @@ export default Vue.extend({ padding: 24px; border-top: solid 1px var(--divider); - @media (max-width: 500px) { - padding: 16px; - } - > a { flex: 1; text-align: center; @@ -473,5 +431,47 @@ export default Vue.extend({ > .content { margin-bottom: var(--margin); } + + &.max-width_500px { + > .profile { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + } } </style> diff --git a/src/client/scripts/form.ts b/src/client/scripts/form.ts new file mode 100644 index 0000000000..3cf062be2a --- /dev/null +++ b/src/client/scripts/form.ts @@ -0,0 +1,26 @@ +export type FormItem = { + label?: string; + type: 'string'; + default: string | null; + hidden?: boolean; + multiline?: boolean; +} | { + label?: string; + type: 'number'; + default: number | null; + hidden?: boolean; + step?: number; +} | { + label?: string; + type: 'boolean'; + default: boolean | null; + hidden?: boolean; +} | { + label?: string; + type: 'enum'; + default: string | null; + hidden?: boolean; + enum: string[]; +}; + +export type Form = Record<string, FormItem>; diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts index 1f302753e1..8efff7aa41 100644 --- a/src/client/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -13,7 +13,7 @@ export default (opts) => ({ moreFetching: false, inited: false, more: false, - backed: false, + backed: false, // 遡り中か否か isBackTop: false, ilObserver: new IntersectionObserver( (entries) => entries.some((entry) => entry.isIntersecting) diff --git a/src/client/scripts/scroll.ts b/src/client/scripts/scroll.ts index 76881bbde1..f32e50cdc7 100644 --- a/src/client/scripts/scroll.ts +++ b/src/client/scripts/scroll.ts @@ -1,7 +1,7 @@ export function getScrollContainer(el: Element | null): Element | null { if (el == null || el.tagName === 'BODY') return null; - const style = window.getComputedStyle(el); - if (style.getPropertyValue('overflow') === 'auto') { + const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); + if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる return el; } else { return getScrollContainer(el.parentElement); diff --git a/src/client/store.ts b/src/client/store.ts index eee3f59618..5eff0567a8 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -1,9 +1,10 @@ import Vuex from 'vuex'; import createPersistedState from 'vuex-persistedstate'; import * as nestedProperty from 'nested-property'; -import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed } from '@fortawesome/free-solid-svg-icons'; +import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons'; import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons'; -import { apiUrl } from './config'; +import { apiUrl, deckmode } from './config'; +import { erase } from '../prelude/array'; export const defaultSettings = { tutorial: 0, @@ -35,7 +36,13 @@ export const defaultDeviceUserSettings = { 'explore', 'announcements', 'search', + '-', + 'deck', ], + deck: { + columns: [], + layout: [], + }, }; export const defaultDeviceSettings = { @@ -50,6 +57,7 @@ export const defaultDeviceSettings = { darkTheme: '8c539dc1-0fab-4d47-9194-39c508e9bfe1', lightTheme: '4eea646f-7afa-4645-83e9-83af0333cd37', darkMode: false, + deckMode: false, syncDeviceDarkMode: true, animation: true, animatedMfm: true, @@ -60,6 +68,9 @@ export const defaultDeviceSettings = { fixedWidgetsPosition: false, roomGraphicsQuality: 'medium', roomUseOrthographicCamera: true, + deckColumnAlign: 'left', + deckAlwaysShowMainColumn: true, + deckMainColumnPlace: 'left', sfxVolume: 0.3, sfxNote: 'syuilo/down', sfxNoteMy: 'syuilo/up', @@ -197,6 +208,14 @@ export default () => new Vuex.Store({ get show() { return getters.isSignedIn; }, get to() { return `/@${state.i.username}/room`; }, }, + deck: { + title: deckmode ? 'undeck' : 'deck', + icon: faColumns, + action: () => { + localStorage.setItem('deckmode', (!deckmode).toString()); + location.reload(); + }, + }, }), }, @@ -399,6 +418,137 @@ export default () => new Vuex.Store({ w.data = x.data; } }, + + //#region Deck + addDeckColumn(state, column) { + if (column.name == undefined) column.name = null; + state.deck.columns.push(column); + state.deck.layout.push([column.id]); + }, + + removeDeckColumn(state, id) { + state.deck.columns = state.deck.columns.filter(c => c.id != id); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); + state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); + }, + + swapDeckColumn(state, x) { + const a = x.a; + const b = x.b; + const aX = state.deck.layout.findIndex(ids => ids.indexOf(a) != -1); + const aY = state.deck.layout[aX].findIndex(id => id == a); + const bX = state.deck.layout.findIndex(ids => ids.indexOf(b) != -1); + const bY = state.deck.layout[bX].findIndex(id => id == b); + state.deck.layout[aX][aY] = b; + state.deck.layout[bX][bY] = a; + }, + + swapLeftDeckColumn(state, id) { + state.deck.layout.some((ids, i) => { + if (ids.indexOf(id) != -1) { + const left = state.deck.layout[i - 1]; + if (left) { + // https://vuejs.org/v2/guide/list.html#Caveats + //state.deck.layout[i - 1] = state.deck.layout[i]; + //state.deck.layout[i] = left; + state.deck.layout.splice(i - 1, 1, state.deck.layout[i]); + state.deck.layout.splice(i, 1, left); + } + return true; + } + }); + }, + + swapRightDeckColumn(state, id) { + state.deck.layout.some((ids, i) => { + if (ids.indexOf(id) != -1) { + const right = state.deck.layout[i + 1]; + if (right) { + // https://vuejs.org/v2/guide/list.html#Caveats + //state.deck.layout[i + 1] = state.deck.layout[i]; + //state.deck.layout[i] = right; + state.deck.layout.splice(i + 1, 1, state.deck.layout[i]); + state.deck.layout.splice(i, 1, right); + } + return true; + } + }); + }, + + swapUpDeckColumn(state, id) { + const ids = state.deck.layout.find(ids => ids.indexOf(id) != -1); + ids.some((x, i) => { + if (x == id) { + const up = ids[i - 1]; + if (up) { + // https://vuejs.org/v2/guide/list.html#Caveats + //ids[i - 1] = id; + //ids[i] = up; + ids.splice(i - 1, 1, id); + ids.splice(i, 1, up); + } + return true; + } + }); + }, + + swapDownDeckColumn(state, id) { + const ids = state.deck.layout.find(ids => ids.indexOf(id) != -1); + ids.some((x, i) => { + if (x == id) { + const down = ids[i + 1]; + if (down) { + // https://vuejs.org/v2/guide/list.html#Caveats + //ids[i + 1] = id; + //ids[i] = down; + ids.splice(i + 1, 1, id); + ids.splice(i, 1, down); + } + return true; + } + }); + }, + + stackLeftDeckColumn(state, id) { + const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); + const left = state.deck.layout[i - 1]; + if (left) state.deck.layout[i - 1].push(id); + state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); + }, + + popRightDeckColumn(state, id) { + const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1); + state.deck.layout = state.deck.layout.map(ids => erase(id, ids)); + state.deck.layout.splice(i + 1, 0, [id]); + state.deck.layout = state.deck.layout.filter(ids => ids.length > 0); + }, + + addDeckWidget(state, x) { + const column = state.deck.columns.find(c => c.id == x.id); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets.unshift(x.widget); + }, + + removeDeckWidget(state, x) { + const column = state.deck.columns.find(c => c.id == x.id); + if (column == null) return; + column.widgets = column.widgets.filter(w => w.id != x.widget.id); + }, + + renameDeckColumn(state, x) { + const column = state.deck.columns.find(c => c.id == x.id); + if (column == null) return; + column.name = x.name; + }, + + updateDeckColumn(state, x) { + let column = state.deck.columns.find(c => c.id == x.id); + if (column == null) return; + column = x; + }, + //#endregion } }, diff --git a/src/client/style.scss b/src/client/style.scss index 3faecee430..cc650ab123 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -3,7 +3,7 @@ :root { --radius: 8px; --marginFull: 16px; - --marginHalf: 8px; + --marginHalf: 10px; --margin: var(--marginFull); @@ -25,7 +25,6 @@ html { background-position: center; color: var(--fg); overflow: auto; - overflow-y: scroll; &, * { scrollbar-color: var(--scrollbarHandle) var(--panel); @@ -278,13 +277,14 @@ hr { ._panel { position: relative; + z-index: 1; background: var(--panel); border-radius: var(--radius); box-shadow: 0 0 0 1px var(--panelBorder); overflow: hidden; } -._widget ._list_ ._panel { +._close_ ._list_ > * { box-shadow: 0 1px 0 0 var(--divider), 0 -1px 0 0 var(--divider); border-radius: 0; margin: 0 !important; @@ -348,31 +348,6 @@ hr { & + ._content { border-top: solid 1px var(--divider); } - - &._list { - padding: 16px; - - @media (max-width: 500px) { - padding: 8px; - } - - ._listItem { - padding: 8px 16px; - border-radius: var(--radius); - - @media (max-width: 500px) { - padding: 8px; - } - - &:hover { - background: var(--listItemHoverBg); - } - - > * { - pointer-events: none; - } - } - } } > ._footer { @@ -385,6 +360,21 @@ hr { } } +._narrow_ ._card { + > ._title { + padding: 16px; + font-size: 1em; + } + + > ._content { + padding: 16px; + } + + > ._footer { + padding: 16px; + } +} + ._fullinfo { padding: 64px 32px; text-align: center; diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5 index 9b80128600..4e5225db36 100644 --- a/src/client/themes/_dark.json5 +++ b/src/client/themes/_dark.json5 @@ -26,8 +26,8 @@ panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: 'rgba(0, 0, 0, 0)', shadow: 'rgba(0, 0, 0, 0.1)', - header: 'rgba(20, 20, 20, 0.75)', - navBg: '@panel', + header: ':alpha<0.7<@bg', + navBg: '@bg', navFg: '@fg', navHoverFg: ':lighten<17<@fg', navActive: '@accent', @@ -58,6 +58,7 @@ wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', badge: '#31b1ce', messageBg: ':lighten<5<@bg', + deckColumnBorder: ':lighten<10<@panel', X1: ':alpha<0<@bg', X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5 index e0b6d3cd6f..2317ddef65 100644 --- a/src/client/themes/_light.json5 +++ b/src/client/themes/_light.json5 @@ -26,8 +26,8 @@ panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: 'rgba(0, 0, 0, 0)', shadow: 'rgba(0, 0, 0, 0.1)', - header: 'rgba(255, 255, 255, 0.75)', - navBg: '@panel', + header: ':alpha<0.7<@bg', + navBg: '@bg', navFg: '@fg', navHoverFg: ':darken<17<@fg', navActive: '@accent', @@ -58,6 +58,7 @@ wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', badge: '#31b1ce', messageBg: '@panel', + deckColumnBorder: ':darken<20<@panel', X1: ':alpha<0<@bg', X2: ':darken<2<@panel', X3: 'rgba(0, 0, 0, 0.05)', diff --git a/src/client/themes/black.json5 b/src/client/themes/black.json5 index 33a9050f66..3504f15932 100644 --- a/src/client/themes/black.json5 +++ b/src/client/themes/black.json5 @@ -13,5 +13,6 @@ panelHeaderDivider: '@divider', panelBorder: '@divider', messageBg: '#1d1d1d', + deckColumnBorder: '@divider', }, } diff --git a/src/client/themes/lilac.json5 b/src/client/themes/lilac.json5 index 44e2591512..084f3fc406 100644 --- a/src/client/themes/lilac.json5 +++ b/src/client/themes/lilac.json5 @@ -10,9 +10,11 @@ accent: 'rgb(206, 147, 191)', bg: 'rgb(253, 242, 243)', fg: 'rgb(161, 139, 146)', + divider: '#ece7e7', renote: '@accent', link: '@accent', mention: '@accent', hashtag: '@accent', + panelHeaderDivider: '@divider', }, } diff --git a/src/client/themes/rainy.json5 b/src/client/themes/rainy.json5 index 0ad6338295..a7dc181643 100644 --- a/src/client/themes/rainy.json5 +++ b/src/client/themes/rainy.json5 @@ -11,5 +11,6 @@ bg: 'rgb(220, 229, 232)', fg: 'rgb(139, 153, 161)', renote: '@accent', + panelHeaderDivider: '@divider', }, } diff --git a/src/client/themes/white.json5 b/src/client/themes/white.json5 index 5e2e1d7300..4c3db53acd 100644 --- a/src/client/themes/white.json5 +++ b/src/client/themes/white.json5 @@ -8,7 +8,11 @@ base: 'light', props: { + bg: '#f2f2f2', + header: ':alpha<0.7<@bg', + navBg: '@bg', panelHeaderDivider: '@divider', messageBg: '#dedede', + deckColumnBorder: '#cccccc', }, } diff --git a/src/client/widgets/activity.vue b/src/client/widgets/activity.vue index 4fdd81ae52..58b1631367 100644 --- a/src/client/widgets/activity.vue +++ b/src/client/widgets/activity.vue @@ -1,18 +1,16 @@ <template> -<div> - <mk-container :show-header="props.design === 0" :naked="props.design === 2"> - <template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template> - <template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template> +<mk-container :show-header="props.showHeader" :naked="props.transparent"> + <template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template> + <template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template> - <div> - <mk-loading v-if="fetching"/> - <template v-else> - <x-calendar v-show="props.view === 0" :data="[].concat(activity)"/> - <x-chart v-show="props.view === 1" :data="[].concat(activity)"/> - </template> - </div> - </mk-container> -</div> + <div> + <mk-loading v-if="fetching"/> + <template v-else> + <x-calendar v-show="props.view === 0" :data="[].concat(activity)"/> + <x-chart v-show="props.view === 1" :data="[].concat(activity)"/> + </template> + </div> +</mk-container> </template> <script lang="ts"> @@ -25,8 +23,19 @@ import XChart from './activity.chart.vue'; export default define({ name: 'activity', props: () => ({ - design: 0, - view: 0 + showHeader: { + type: 'boolean', + default: true, + }, + transparent: { + type: 'boolean', + default: false, + }, + view: { + type: 'number', + default: 0, + hidden: true, + }, }) }).extend({ components: { @@ -57,14 +66,6 @@ export default define({ }); }, methods: { - func() { - if (this.props.design === 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, toggleView() { if (this.props.view === 1) { this.props.view = 0; diff --git a/src/client/widgets/calendar.vue b/src/client/widgets/calendar.vue index a29f73d3c6..8ef74ff744 100644 --- a/src/client/widgets/calendar.vue +++ b/src/client/widgets/calendar.vue @@ -1,5 +1,5 @@ <template> -<div class="mkw-calendar" :class="{ _panel: props.design === 0 }"> +<div class="mkw-calendar" :class="{ _panel: !props.transparent }"> <div class="calendar" :data-is-holiday="isHoliday"> <p class="month-and-year"> <span class="year">{{ $t('yearX', { year }) }}</span> @@ -37,7 +37,10 @@ import define from './define'; export default define({ name: 'calendar', props: () => ({ - design: 0 + transparent: { + type: 'boolean', + default: false, + }, }) }).extend({ data() { @@ -62,14 +65,6 @@ export default define({ clearInterval(this.clock); }, methods: { - func() { - if (this.props.design === 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, tick() { const now = new Date(); const nd = now.getDate(); diff --git a/src/client/widgets/clock.vue b/src/client/widgets/clock.vue index 8e61898033..6388324125 100644 --- a/src/client/widgets/clock.vue +++ b/src/client/widgets/clock.vue @@ -1,11 +1,9 @@ <template> -<div> - <mk-container :naked="props.style % 2 === 0" :show-header="false"> - <div class="vubelbmv"> - <mk-analog-clock class="clock" :smooth="props.style < 2"/> - </div> - </mk-container> -</div> +<mk-container :naked="props.transparent" :show-header="false"> + <div class="vubelbmv"> + <mk-analog-clock class="clock"/> + </div> +</mk-container> </template> <script lang="ts"> @@ -16,19 +14,16 @@ import MkAnalogClock from '../components/analog-clock.vue'; export default define({ name: 'clock', props: () => ({ - style: 0 + transparent: { + type: 'boolean', + default: false, + }, }) }).extend({ components: { MkContainer, MkAnalogClock }, - methods: { - func() { - this.props.style = (this.props.style + 1) % 4; - this.save(); - } - } }); </script> diff --git a/src/client/widgets/define.ts b/src/client/widgets/define.ts index 96b1b4ab56..107045bf4b 100644 --- a/src/client/widgets/define.ts +++ b/src/client/widgets/define.ts @@ -1,6 +1,7 @@ import Vue from 'vue'; +import { Form } from '../scripts/form'; -export default function <T extends object>(data: { +export default function <T extends Form>(data: { name: string; props?: () => T; }) { @@ -15,22 +16,22 @@ export default function <T extends object>(data: { } }, + data() { + return { + bakedOldProps: null + }; + }, + computed: { id(): string { return this.widget.id; }, - props(): T { + props(): Record<string, any> { return this.widget.data; } }, - data() { - return { - bakedOldProps: null - }; - }, - created() { this.mergeProps(); @@ -45,11 +46,26 @@ export default function <T extends object>(data: { const defaultProps = data.props(); for (const prop of Object.keys(defaultProps)) { if (this.props.hasOwnProperty(prop)) continue; - Vue.set(this.props, prop, defaultProps[prop]); + Vue.set(this.props, prop, defaultProps[prop].default); } } }, + async setting() { + const form = data.props(); + for (const item of Object.keys(form)) { + form[item].default = this.props[item]; + } + const { canceled, result } = await this.$root.form(data.name, form); + if (canceled) return; + + for (const key of Object.keys(result)) { + Vue.set(this.props, key, result[key]); + } + + this.save(); + }, + save() { this.$store.commit('deviceUser/updateWidget', this.widget); } diff --git a/src/client/widgets/digital-clock.vue b/src/client/widgets/digital-clock.vue new file mode 100644 index 0000000000..0e68fe0ff4 --- /dev/null +++ b/src/client/widgets/digital-clock.vue @@ -0,0 +1,75 @@ +<template> +<div class="mkw-digitalClock" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }"> + <span> + <span v-text="hh"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="mm"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="ss"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }" v-if="props.showMs">:</span> + <span v-text="ms" v-if="props.showMs"></span> + </span> +</div> +</template> + +<script lang="ts"> +import define from './define'; + +export default define({ + name: 'digitalClock', + props: () => ({ + transparent: { + type: 'boolean', + default: false, + }, + fontSize: { + type: 'number', + default: 1.5, + step: 0.1, + }, + showMs: { + type: 'boolean', + default: true, + }, + }) +}).extend({ + data() { + return { + clock: null, + hh: null, + mm: null, + ss: null, + ms: null, + showColon: true, + }; + }, + created() { + this.tick(); + this.$watch('props.showMs', () => { + if (this.clock) clearInterval(this.clock); + this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000); + }, { immediate: true }); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + tick() { + const now = new Date(); + this.hh = now.getHours().toString().padStart(2, '0'); + this.mm = now.getMinutes().toString().padStart(2, '0'); + this.ss = now.getSeconds().toString().padStart(2, '0'); + this.ms = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0'); + this.showColon = now.getSeconds() % 2 === 0; + } + } +}); +</script> + +<style lang="scss" scoped> +.mkw-digitalClock { + padding: 16px 0; + font-family: Lucida Console, Courier, monospace; + text-align: center; +} +</style> diff --git a/src/client/widgets/index.ts b/src/client/widgets/index.ts index 878d42c0c3..2d27d27e58 100644 --- a/src/client/widgets/index.ts +++ b/src/client/widgets/index.ts @@ -10,3 +10,17 @@ Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default)); Vue.component('mkw-clock', () => import('./clock.vue').then(m => m.default)); Vue.component('mkw-activity', () => import('./activity.vue').then(m => m.default)); Vue.component('mkw-photos', () => import('./photos.vue').then(m => m.default)); +Vue.component('mkw-digitalClock', () => import('./digital-clock.vue').then(m => m.default)); + +export const widgets = [ + 'memo', + 'notifications', + 'timeline', + 'calendar', + 'rss', + 'trends', + 'clock', + 'activity', + 'photos', + 'digitalClock', +]; diff --git a/src/client/widgets/memo.vue b/src/client/widgets/memo.vue index cdc716b9fa..0d319b225e 100644 --- a/src/client/widgets/memo.vue +++ b/src/client/widgets/memo.vue @@ -1,14 +1,12 @@ <template> -<div> - <mk-container :show-header="!props.compact"> - <template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template> +<mk-container :show-header="props.showHeader"> + <template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template> - <div class="otgbylcu"> - <textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea> - <button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $t('save') }}</button> - </div> - </mk-container> -</div> + <div class="otgbylcu"> + <textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea> + <button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $t('save') }}</button> + </div> +</mk-container> </template> <script lang="ts"> @@ -19,10 +17,12 @@ import define from './define'; export default define({ name: 'memo', props: () => ({ - compact: false + showHeader: { + type: 'boolean', + default: true, + }, }) }).extend({ - components: { MkContainer }, @@ -45,11 +45,6 @@ export default define({ }, methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, - onChange() { this.changed = true; clearTimeout(this.timeoutId); diff --git a/src/client/widgets/notifications.vue b/src/client/widgets/notifications.vue index 39fc8a9361..24d7fe4200 100644 --- a/src/client/widgets/notifications.vue +++ b/src/client/widgets/notifications.vue @@ -1,13 +1,11 @@ <template> -<div class="mkw-notifications" :style="`flex-basis: calc(${basis}% - var(--margin)); height: ${previewHeight}px;`"> - <mk-container :show-header="!props.compact" class="container"> - <template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template> +<mk-container :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true"> + <template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template> - <div> - <x-notifications/> - </div> - </mk-container> -</div> + <div> + <x-notifications/> + </div> +</mk-container> </template> <script lang="ts"> @@ -16,17 +14,19 @@ import MkContainer from '../components/ui/container.vue'; import XNotifications from '../components/notifications.vue'; import define from './define'; -const basisSteps = [25, 50, 75, 100] -const previewHeights = [200, 300, 400, 500] - export default define({ name: 'notifications', props: () => ({ - compact: false, - basisStep: 0 + showHeader: { + type: 'boolean', + default: true, + }, + height: { + type: 'number', + default: 300, + }, }) }).extend({ - components: { MkContainer, XNotifications, @@ -37,47 +37,5 @@ export default define({ faBell }; }, - - computed: { - basis(): number { - return basisSteps[this.props.basisStep] || 25 - }, - - previewHeight(): number { - return previewHeights[this.props.basisStep] || 200 - } - }, - - methods: { - func() { - if (this.props.basisStep === basisSteps.length - 1) { - this.props.basisStep = 0 - this.props.compact = !this.props.compact; - } else { - this.props.basisStep += 1 - } - - this.save(); - } - } }); </script> - -<style lang="scss"> -.mkw-notifications { - flex-grow: 1; - flex-shrink: 0; - min-height: 0; // https://www.gwtcenter.com/min-height-required-on-firefox-flexbox - - .container { - display: flex; - flex-direction: column; - height: 100%; - - > div { - overflow: auto; - flex-grow: 1; - } - } -} -</style> diff --git a/src/client/widgets/photos.vue b/src/client/widgets/photos.vue index 6e4e43a565..2b8399df9b 100644 --- a/src/client/widgets/photos.vue +++ b/src/client/widgets/photos.vue @@ -1,19 +1,17 @@ <template> -<div> - <mk-container :show-header="props.design === 0" :naked="props.design === 2" :class="$style.root" :data-melt="props.design === 2"> - <template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template> +<mk-container :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent"> + <template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template> - <div class=""> - <mk-loading v-if="fetching"/> - <div v-else :class="$style.stream"> - <div v-for="(image, i) in images" :key="i" - :class="$style.img" - :style="`background-image: url(${thumbnail(image)})`" - ></div> - </div> + <div class=""> + <mk-loading v-if="fetching"/> + <div v-else :class="$style.stream"> + <div v-for="(image, i) in images" :key="i" + :class="$style.img" + :style="`background-image: url(${thumbnail(image)})`" + ></div> </div> - </mk-container> -</div> + </div> +</mk-container> </template> <script lang="ts"> @@ -25,7 +23,14 @@ import { getStaticImageUrl } from '../scripts/get-static-image-url'; export default define({ name: 'photos', props: () => ({ - design: 0, + showHeader: { + type: 'boolean', + default: true, + }, + transparent: { + type: 'boolean', + default: false, + }, }) }).extend({ components: { @@ -63,15 +68,6 @@ export default define({ } }, - func() { - if (this.props.design === 2) { - this.props.design = 0; - } else { - this.props.design++; - } - this.save(); - }, - thumbnail(image: any): string { return this.$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(image.thumbnailUrl) @@ -82,7 +78,7 @@ export default define({ </script> <style lang="scss" module> -.root[data-melt] { +.root[data-transparent] { .stream { padding: 0; } diff --git a/src/client/widgets/rss.vue b/src/client/widgets/rss.vue index 4e57281e9f..3a76c8fb4f 100644 --- a/src/client/widgets/rss.vue +++ b/src/client/widgets/rss.vue @@ -1,17 +1,15 @@ <template> -<div> - <mk-container :show-header="!props.compact"> - <template #header><fa :icon="faRssSquare"/>RSS</template> - <template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template> +<mk-container :show-header="props.showHeader"> + <template #header><fa :icon="faRssSquare"/>RSS</template> + <template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template> - <div class="ekmkgxbj"> - <mk-loading v-if="fetching"/> - <div class="feed" v-else> - <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> - </div> + <div class="ekmkgxbj"> + <mk-loading v-if="fetching"/> + <div class="feed" v-else> + <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> </div> - </mk-container> -</div> + </div> +</mk-container> </template> <script lang="ts"> @@ -22,8 +20,14 @@ import define from './define'; export default define({ name: 'rss', props: () => ({ - compact: false, - url: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews' + showHeader: { + type: 'boolean', + default: true, + }, + url: { + type: 'string', + default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', + }, }) }).extend({ components: { @@ -40,15 +44,12 @@ export default define({ mounted() { this.fetch(); this.clock = setInterval(this.fetch, 60000); + this.$watch('props.url', this.fetch); }, beforeDestroy() { clearInterval(this.clock); }, methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, fetch() { fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, { }).then(res => { @@ -58,20 +59,6 @@ export default define({ }); }); }, - setting() { - this.$root.dialog({ - title: 'URL', - input: { - type: 'url', - default: this.props.url - } - }).then(({ canceled, result: url }) => { - if (canceled) return; - this.props.url = url; - this.save(); - this.fetch(); - }); - } } }); </script> diff --git a/src/client/widgets/timeline.vue b/src/client/widgets/timeline.vue index 6331311828..fb7486cb70 100644 --- a/src/client/widgets/timeline.vue +++ b/src/client/widgets/timeline.vue @@ -1,24 +1,22 @@ <template> -<div class="mkw-timeline" :style="`flex-basis: calc(${basis}% - var(--margin)); height: ${previewHeight}px;`"> - <mk-container :show-header="!props.compact" class="container"> - <template #header> - <button @click="choose" class="_button"> - <fa v-if="props.src === 'home'" :icon="faHome"/> - <fa v-if="props.src === 'local'" :icon="faComments"/> - <fa v-if="props.src === 'social'" :icon="faShareAlt"/> - <fa v-if="props.src === 'global'" :icon="faGlobe"/> - <fa v-if="props.src === 'list'" :icon="faListUl"/> - <fa v-if="props.src === 'antenna'" :icon="faSatellite"/> - <span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span> - <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> - </button> - </template> +<mk-container :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true"> + <template #header> + <button @click="choose" class="_button"> + <fa v-if="props.src === 'home'" :icon="faHome"/> + <fa v-if="props.src === 'local'" :icon="faComments"/> + <fa v-if="props.src === 'social'" :icon="faShareAlt"/> + <fa v-if="props.src === 'global'" :icon="faGlobe"/> + <fa v-if="props.src === 'list'" :icon="faListUl"/> + <fa v-if="props.src === 'antenna'" :icon="faSatellite"/> + <span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span> + <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/> + </button> + </template> - <div> - <x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list" :antenna="props.antenna"/> - </div> - </mk-container> -</div> + <div> + <x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/> + </div> +</mk-container> </template> <script lang="ts"> @@ -28,19 +26,25 @@ import MkContainer from '../components/ui/container.vue'; import XTimeline from '../components/timeline.vue'; import define from './define'; -const basisSteps = [25, 50, 75, 100] -const previewHeights = [200, 300, 400, 500] - export default define({ name: 'timeline', props: () => ({ - src: 'home', - list: null, - compact: false, - basisStep: 0 + showHeader: { + type: 'boolean', + default: true, + }, + src: { + type: 'string', + default: 'home', + hidden: true, + }, + list: { + type: 'object', + default: null, + hidden: true, + }, }) }).extend({ - components: { MkContainer, XTimeline, @@ -53,28 +57,7 @@ export default define({ }; }, - computed: { - basis(): number { - return basisSteps[this.props.basisStep] || 25 - }, - - previewHeight(): number { - return previewHeights[this.props.basisStep] || 200 - } - }, - methods: { - func() { - if (this.props.basisStep === basisSteps.length - 1) { - this.props.basisStep = 0 - this.props.compact = !this.props.compact; - } else { - this.props.basisStep += 1 - } - - this.save(); - }, - async choose(ev) { this.menuOpened = true; const [antennas, lists] = await Promise.all([ @@ -129,22 +112,3 @@ export default define({ } }); </script> - -<style lang="scss"> -.mkw-timeline { - flex-grow: 1; - flex-shrink: 0; - min-height: 0; // https://www.gwtcenter.com/min-height-required-on-firefox-flexbox - - .container { - display: flex; - flex-direction: column; - height: 100%; - - > div { - overflow: auto; - flex-grow: 1; - } - } -} -</style> diff --git a/src/client/widgets/trends.vue b/src/client/widgets/trends.vue index 61f5bfbd32..d4a4b2d289 100644 --- a/src/client/widgets/trends.vue +++ b/src/client/widgets/trends.vue @@ -1,22 +1,20 @@ <template> -<div> - <mk-container :show-header="!props.compact"> - <template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template> +<mk-container :show-header="props.showHeader"> + <template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template> - <div class="wbrkwala"> - <mk-loading v-if="fetching"/> - <transition-group tag="div" name="chart" class="tags" v-else> - <div v-for="stat in stats" :key="stat.tag"> - <div class="tag"> - <router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> - <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p> - </div> - <x-chart class="chart" :src="stat.chart"/> + <div class="wbrkwala"> + <mk-loading v-if="fetching"/> + <transition-group tag="div" name="chart" class="tags" v-else> + <div v-for="stat in stats" :key="stat.tag"> + <div class="tag"> + <router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link> + <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p> </div> - </transition-group> - </div> - </mk-container> -</div> + <x-chart class="chart" :src="stat.chart"/> + </div> + </transition-group> + </div> +</mk-container> </template> <script lang="ts"> @@ -28,7 +26,10 @@ import XChart from './trends.chart.vue'; export default define({ name: 'hashtags', props: () => ({ - compact: false + showHeader: { + type: 'boolean', + default: true, + }, }) }).extend({ components: { @@ -49,10 +50,6 @@ export default define({ clearInterval(this.clock); }, methods: { - func() { - this.props.compact = !this.props.compact; - this.save(); - }, fetch() { this.$root.api('hashtags/trend').then(stats => { this.stats = stats;