diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /src/client/ui | |
| parent | update deps (diff) | |
| download | misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'src/client/ui')
45 files changed, 0 insertions, 9017 deletions
diff --git a/src/client/ui/_common_/common.vue b/src/client/ui/_common_/common.vue deleted file mode 100644 index 8da19a0984..0000000000 --- a/src/client/ui/_common_/common.vue +++ /dev/null @@ -1,89 +0,0 @@ -<template> -<component v-for="popup in popups" - :key="popup.id" - :is="popup.component" - v-bind="popup.props" - v-on="popup.events" -/> - -<XUpload v-if="uploads.length > 0"/> - -<XStreamIndicator/> - -<div id="wait" v-if="pendingApiRequestsCount > 0"></div> -</template> - -<script lang="ts"> -import { defineAsyncComponent, defineComponent } from 'vue'; -import { stream, popup, popups, uploads, pendingApiRequestsCount } from '@client/os'; -import * as sound from '@client/scripts/sound'; -import { $i } from '@client/account'; - -export default defineComponent({ - components: { - XStreamIndicator: defineAsyncComponent(() => import('./stream-indicator.vue')), - XUpload: defineAsyncComponent(() => import('./upload.vue')), - }, - - setup() { - const onNotification = notification => { - if ($i.mutingNotificationTypes.includes(notification.type)) return; - - if (document.visibilityState === 'visible') { - stream.send('readNotification', { - id: notification.id - }); - - popup(import('@client/components/toast.vue'), { - notification - }, {}, 'closed'); - } - - sound.play('notification'); - }; - - if ($i) { - const connection = stream.useChannel('main', null, 'UI'); - connection.on('notification', onNotification); - } - - return { - uploads, - popups, - pendingApiRequestsCount, - }; - }, -}); -</script> - -<style lang="scss"> -#wait { - display: block; - position: fixed; - z-index: 10000; - top: 15px; - right: 15px; - - &:before { - content: ""; - display: block; - width: 18px; - height: 18px; - box-sizing: border-box; - border: solid 2px transparent; - border-top-color: var(--accent); - border-left-color: var(--accent); - border-radius: 50%; - animation: progress-spinner 400ms linear infinite; - } -} - -@keyframes progress-spinner { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} -</style> diff --git a/src/client/ui/_common_/sidebar.vue b/src/client/ui/_common_/sidebar.vue deleted file mode 100644 index cd78b6ae46..0000000000 --- a/src/client/ui/_common_/sidebar.vue +++ /dev/null @@ -1,388 +0,0 @@ -<template> -<div class="mvcprjjd"> - <transition name="nav-back"> - <div class="nav-back _modalBg" - v-if="showing" - @click="showing = false" - @touchstart.passive="showing = false" - ></div> - </transition> - - <transition name="nav"> - <nav class="nav" :class="{ iconOnly, hidden }" v-show="showing"> - <div> - <button class="item _button account" @click="openAccountMenu" v-click-anime> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> - </button> - <MkA class="item index" active-class="active" to="/" exact v-click-anime> - <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> - </MkA> - <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 ? 'MkA' : 'button'" class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime> - <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> - <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> - </component> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" v-click-anime> - <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> - </MkA> - <button class="item _button" @click="more" v-click-anime> - <i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> - </button> - <MkA class="item" active-class="active" to="/settings" v-click-anime> - <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> - </MkA> - <button class="item _button post" @click="post" data-cy-open-post-form> - <i class="fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> - </button> - </div> - </nav> - </transition> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { host } from '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import { openAccountMenu } from '@client/account'; - -export default defineComponent({ - props: { - defaultHidden: { - type: Boolean, - required: false, - default: false, - } - }, - - data() { - return { - host: host, - showing: false, - accounts: [], - connection: null, - menuDef: menuDef, - iconOnly: false, - hidden: this.defaultHidden, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - - otherNavItemIndicated(): boolean { - 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; - }, - - '$store.reactiveState.menuDisplay.value'() { - this.calcViewState(); - }, - - iconOnly() { - this.$nextTick(() => { - this.$emit('change-view-mode'); - }); - }, - - hidden() { - this.$nextTick(() => { - this.$emit('change-view-mode'); - }); - } - }, - - created() { - window.addEventListener('resize', this.calcViewState); - this.calcViewState(); - }, - - methods: { - calcViewState() { - this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.menuDisplay === 'sideIcon'); - if (!this.defaultHidden) { - this.hidden = (window.innerWidth <= 650); - } - }, - - show() { - this.showing = true; - }, - - post() { - os.post(); - }, - - search() { - search(); - }, - - more(ev) { - os.popup(import('@client/components/launch-pad.vue'), {}, { - }, 'closed'); - }, - - openAccountMenu, - } -}); -</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-from, -.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-from, -.nav-back-leave-active { - opacity: 0; -} - -.mvcprjjd { - $ui-font-size: 1em; // TODO: どこかに集約したい - $nav-width: 250px; - $nav-icon-only-width: 86px; - - > .nav-back { - z-index: 1001; - } - - > .nav { - $avatar-size: 32px; - $avatar-margin: 8px; - - flex: 0 0 $nav-width; - width: $nav-width; - box-sizing: border-box; - - &.iconOnly { - flex: 0 0 $nav-icon-only-width; - width: $nav-icon-only-width; - - &:not(.hidden) { - > div { - width: $nav-icon-only-width; - - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - } - - > .item { - padding-left: 0; - padding: 18px 0; - width: 100%; - text-align: center; - font-size: $ui-font-size * 1.1; - line-height: initial; - - > i, - > .avatar { - display: block; - margin: 0 auto; - } - - > i { - opacity: 0.7; - } - - > .text { - display: none; - } - - &:hover, &.active { - > i, > .text { - opacity: 1; - } - } - - &:first-child { - margin-bottom: 8px; - } - - &:last-child { - margin-top: 8px; - } - } - } - } - } - - &.hidden { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - } - - &:not(.hidden) { - display: block !important; - } - - > div { - position: fixed; - top: 0; - left: 0; - z-index: 1001; - width: $nav-width; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - overflow: auto; - overflow-x: clip; - background: var(--navBg); - - > .divider { - margin: 16px 16px; - border-top: solid 0.5px var(--divider); - } - - > .item { - position: relative; - display: block; - padding-left: 24px; - font-size: $ui-font-size; - line-height: 2.85rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - color: var(--navFg); - - > i { - position: relative; - width: 32px; - } - - > i, - > .avatar { - margin-right: $avatar-margin; - } - - > .avatar { - width: $avatar-size; - height: $avatar-size; - vertical-align: middle; - } - - > .indicator { - position: absolute; - top: 0; - left: 20px; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } - - > .text { - position: relative; - font-size: 0.9em; - } - - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } - - &.active { - color: var(--navActive); - } - - &:hover, &.active { - &:before { - content: ""; - display: block; - width: calc(100% - 24px); - height: 100%; - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: var(--accentedBg); - } - } - - &:first-child, &:last-child { - position: sticky; - z-index: 1; - padding-top: 8px; - padding-bottom: 8px; - background: var(--X14); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - } - - &:first-child { - top: 0; - - &:hover, &.active { - &:before { - content: none; - } - } - } - - &:last-child { - bottom: 0; - color: var(--fgOnAccent); - - &:before { - content: ""; - display: block; - width: calc(100% - 20px); - height: calc(100% - 20px); - margin: auto; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border-radius: 999px; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); - } - - &:hover, &.active { - &:before { - background: var(--accentLighten); - } - } - } - } - } - } -} -</style> diff --git a/src/client/ui/_common_/stream-indicator.vue b/src/client/ui/_common_/stream-indicator.vue deleted file mode 100644 index 23f2357d85..0000000000 --- a/src/client/ui/_common_/stream-indicator.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<div class="nsbbhtug" v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" @click="resetDisconnected"> - <div>{{ $ts.disconnectedFromServer }}</div> - <div class="command"> - <button class="_textButton" @click="reload">{{ $ts.reload }}</button> - <button class="_textButton">{{ $ts.doNothing }}</button> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - data() { - return { - hasDisconnected: false, - } - }, - computed: { - stream() { - return os.stream; - }, - }, - created() { - os.stream.on('_disconnected_', this.onDisconnected); - }, - beforeUnmount() { - os.stream.off('_disconnected_', this.onDisconnected); - }, - methods: { - onDisconnected() { - this.hasDisconnected = true; - }, - resetDisconnected() { - this.hasDisconnected = false; - }, - reload() { - location.reload(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.nsbbhtug { - position: fixed; - z-index: 16385; - bottom: 8px; - right: 8px; - margin: 0; - padding: 6px 12px; - font-size: 0.9em; - color: #fff; - background: #000; - opacity: 0.8; - border-radius: 4px; - max-width: 320px; - - > .command { - display: flex; - justify-content: space-around; - - > button { - padding: 0.7em; - } - } -} -</style> diff --git a/src/client/ui/_common_/upload.vue b/src/client/ui/_common_/upload.vue deleted file mode 100644 index 25a807cd36..0000000000 --- a/src/client/ui/_common_/upload.vue +++ /dev/null @@ -1,134 +0,0 @@ -<template> -<div class="mk-uploader _acrylic"> - <ol v-if="uploads.length > 0"> - <li v-for="ctx in uploads" :key="ctx.id"> - <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> - <div class="top"> - <p class="name"><i class="fas fa-spinner fa-pulse"></i>{{ ctx.name }}</p> - <p class="status"> - <span class="initing" v-if="ctx.progressValue === undefined">{{ $ts.waiting }}<MkEllipsis/></span> - <span class="kb" v-if="ctx.progressValue !== undefined">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> - <span class="percentage" v-if="ctx.progressValue !== undefined">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> - </p> - </div> - <progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress> - </li> - </ol> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - data() { - return { - uploads: os.uploads, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.mk-uploader { - position: fixed; - z-index: 10000; - right: 16px; - width: 260px; - top: 32px; - padding: 16px 20px; - pointer-events: none; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); - border-radius: 8px; -} -.mk-uploader:empty { - display: none; -} -.mk-uploader > ol { - display: block; - margin: 0; - padding: 0; - list-style: none; -} -.mk-uploader > ol > li { - display: grid; - margin: 8px 0 0 0; - padding: 0; - height: 36px; - width: 100%; - border-top: solid 8px transparent; - grid-template-columns: 36px calc(100% - 44px); - grid-template-rows: 1fr 8px; - column-gap: 8px; - box-sizing: content-box; -} -.mk-uploader > ol > li:first-child { - margin: 0; - box-shadow: none; - border-top: none; -} -.mk-uploader > ol > li > .img { - display: block; - background-size: cover; - background-position: center center; - grid-column: 1/2; - grid-row: 1/3; -} -.mk-uploader > ol > li > .top { - display: flex; - grid-column: 2/3; - grid-row: 1/2; -} -.mk-uploader > ol > li > .top > .name { - display: block; - padding: 0 8px 0 0; - margin: 0; - font-size: 0.8em; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - flex-shrink: 1; -} -.mk-uploader > ol > li > .top > .name > i { - margin-right: 4px; -} -.mk-uploader > ol > li > .top > .status { - display: block; - margin: 0 0 0 auto; - padding: 0; - font-size: 0.8em; - flex-shrink: 0; -} -.mk-uploader > ol > li > .top > .status > .initing { -} -.mk-uploader > ol > li > .top > .status > .kb { -} -.mk-uploader > ol > li > .top > .status > .percentage { - display: inline-block; - width: 48px; - text-align: right; -} -.mk-uploader > ol > li > .top > .status > .percentage:after { - content: '%'; -} -.mk-uploader > ol > li > progress { - display: block; - background: transparent; - border: none; - border-radius: 4px; - overflow: hidden; - grid-column: 2/3; - grid-row: 2/3; - z-index: 2; - width: 100%; - height: 8px; -} -.mk-uploader > ol > li > progress::-webkit-progress-value { - background: var(--accent); -} -.mk-uploader > ol > li > progress::-webkit-progress-bar { - //background: var(--accentAlpha01); - background: transparent; -} -</style> diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue deleted file mode 100644 index 12638cd230..0000000000 --- a/src/client/ui/chat/date-separated-list.vue +++ /dev/null @@ -1,163 +0,0 @@ -<script lang="ts"> -import { defineComponent, h, PropType, TransitionGroup } from 'vue'; -import MkAd from '@client/components/global/ad.vue'; - -export default defineComponent({ - props: { - items: { - type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, - required: true, - }, - reversed: { - type: Boolean, - required: false, - default: false - }, - ad: { - type: Boolean, - required: false, - default: false - }, - }, - - methods: { - focus() { - this.$slots.default[0].elm.focus(); - } - }, - - render() { - const getDateText = (time: string) => { - const date = new Date(time).getDate(); - const month = new Date(time).getMonth() + 1; - return this.$t('monthAndDay', { - month: month.toString(), - day: date.toString() - }); - } - - return h(this.reversed ? 'div' : TransitionGroup, { - class: 'hmjzthxl', - name: this.reversed ? 'list-reversed' : 'list', - tag: 'div', - }, this.items.map((item, i) => { - const el = this.$slots.default({ - item: item - })[0]; - if (el.key == null && item.id) el.key = item.id; - - if ( - i != this.items.length - 1 && - new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() - ) { - const separator = h('div', { - class: 'separator', - key: item.id + ':separator', - }, h('p', { - class: 'date' - }, [ - h('span', [ - h('i', { - class: 'fas fa-angle-up icon', - }), - getDateText(item.createdAt) - ]), - h('span', [ - getDateText(this.items[i + 1].createdAt), - h('i', { - class: 'fas fa-angle-down icon', - }) - ]) - ])); - - return [el, separator]; - } else { - if (this.ad && item._shouldInsertAd_) { - return [h(MkAd, { - class: 'a', // advertiseの意(ブロッカー対策) - key: item.id + ':ad', - prefer: ['horizontal', 'horizontal-big'], - }), el]; - } else { - return el; - } - } - })); - }, -}); -</script> - -<style lang="scss"> -.hmjzthxl { - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - > .list-enter-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - > .list-enter-from { - opacity: 0; - transform: translateY(-64px); - } - - > .list-reversed-enter-active, > .list-reversed-leave-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - > .list-reversed-enter-from { - opacity: 0; - transform: translateY(64px); - } -} -</style> - -<style lang="scss"> -.hmjzthxl { - > .separator { - text-align: center; - position: relative; - - &:before { - content: ""; - display: block; - position: absolute; - top: 50%; - left: 0; - right: 0; - margin: auto; - width: calc(100% - 32px); - height: 1px; - background: var(--divider); - } - - > .date { - display: inline-block; - position: relative; - margin: 0; - padding: 0 16px; - line-height: 32px; - text-align: center; - font-size: 12px; - color: var(--dateLabelFg); - background: var(--panel); - - > span { - &:first-child { - margin-right: 8px; - - > .icon { - margin-right: 8px; - } - } - - &:last-child { - margin-left: 8px; - - > .icon { - margin-left: 8px; - } - } - } - } - } -} -</style> diff --git a/src/client/ui/chat/header-clock.vue b/src/client/ui/chat/header-clock.vue deleted file mode 100644 index 69ec3cb64b..0000000000 --- a/src/client/ui/chat/header-clock.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<div class="acemodlh _monospace"> - <div> - <span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span> - </div> - <div> - <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> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - data() { - return { - clock: null, - y: null, - m: null, - d: null, - hh: null, - mm: null, - ss: null, - showColon: true, - }; - }, - created() { - this.tick(); - this.clock = setInterval(this.tick, 1000); - }, - beforeUnmount() { - clearInterval(this.clock); - }, - methods: { - tick() { - const now = new Date(); - this.y = now.getFullYear().toString(); - this.m = (now.getMonth() + 1).toString().padStart(2, '0'); - this.d = now.getDate().toString().padStart(2, '0'); - 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.showColon = now.getSeconds() % 2 === 0; - } - } -}); -</script> - -<style lang="scss" scoped> -.acemodlh { - opacity: 0.7; - font-size: 0.85em; - line-height: 1em; - text-align: center; -} -</style> diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue deleted file mode 100644 index 4c068b0d94..0000000000 --- a/src/client/ui/chat/index.vue +++ /dev/null @@ -1,467 +0,0 @@ -<template> -<div class="mk-app" @contextmenu.self.prevent="onContextmenu"> - <XSidebar ref="menu" class="menu" :default-hidden="true"/> - - <div class="nav"> - <header class="header"> - <div class="left"> - <button class="_button account" @click="openAccountMenu"> - <MkAvatar :user="$i" class="avatar"/><!--<MkAcct class="text" :user="$i"/>--> - </button> - </div> - <div class="right"> - <MkA class="item" to="/my/messaging" v-tooltip="$ts.messaging"><i class="fas fa-comments icon"></i><span v-if="$i.hasUnreadMessagingMessage" class="indicator"><i class="fas fa-circle"></i></span></MkA> - <MkA class="item" to="/my/messages" v-tooltip="$ts.directNotes"><i class="fas fa-envelope icon"></i><span v-if="$i.hasUnreadSpecifiedNotes" class="indicator"><i class="fas fa-circle"></i></span></MkA> - <MkA class="item" to="/my/mentions" v-tooltip="$ts.mentions"><i class="fas fa-at icon"></i><span v-if="$i.hasUnreadMentions" class="indicator"><i class="fas fa-circle"></i></span></MkA> - <MkA class="item" to="/my/notifications" v-tooltip="$ts.notifications"><i class="fas fa-bell icon"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></MkA> - </div> - </header> - <div class="body"> - <div class="container"> - <div class="header">{{ $ts.timeline }}</div> - <div class="body"> - <MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><i class="fas fa-home icon"></i>{{ $ts._timelines.home }}</MkA> - <MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><i class="fas fa-comments icon"></i>{{ $ts._timelines.local }}</MkA> - <MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><i class="fas fa-share-alt icon"></i>{{ $ts._timelines.social }}</MkA> - <MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><i class="fas fa-globe icon"></i>{{ $ts._timelines.global }}</MkA> - </div> - </div> - <div class="container" v-if="followedChannels"> - <div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div> - <div class="body"> - <MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA> - </div> - </div> - <div class="container" v-if="featuredChannels"> - <div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div> - <div class="body"> - <MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA> - </div> - </div> - <div class="container" v-if="lists"> - <div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><i class="fas fa-plus"></i></button></div> - <div class="body"> - <MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><i class="fas fa-list-ul icon"></i>{{ list.name }}</MkA> - </div> - </div> - <div class="container" v-if="antennas"> - <div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><i class="fas fa-plus"></i></button></div> - <div class="body"> - <MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><i class="fas fa-satellite icon"></i>{{ antenna.name }}</MkA> - </div> - </div> - <div class="container"> - <div class="body"> - <MkA to="/my/favorites" class="item"><i class="fas fa-star icon"></i>{{ $ts.favorites }}</MkA> - </div> - </div> - <MkAd class="a" :prefer="['square']"/> - </div> - <footer class="footer"> - <div class="left"> - <button class="_button menu" @click="showMenu"> - <i class="fas fa-bars icon"></i> - </button> - </div> - <div class="right"> - <button class="_button item search" @click="search" v-tooltip="$ts.search"> - <i class="fas fa-search icon"></i> - </button> - <MkA class="item" to="/settings" v-tooltip="$ts.settings"><i class="fas fa-cog icon"></i></MkA> - </div> - </footer> - </div> - - <main class="main" @contextmenu.stop="onContextmenu"> - <header class="header"> - <MkHeader class="header" :info="pageInfo" :menu="menu" :center="false" @click="onHeaderClick"/> - </header> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['timeline']"> - <component :is="Component" :ref="changePage" class="body"/> - </keep-alive> - </transition> - </router-view> - </main> - - <XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/> - <div class="side widgets" :class="{ sideViewOpening }"> - <XWidgets/> - </div> - - <XCommon/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { instanceName, url } from '@client/config'; -import XSidebar from '@client/ui/_common_/sidebar.vue'; -import XWidgets from './widgets.vue'; -import XCommon from '../_common_/common.vue'; -import XSide from './side.vue'; -import XHeaderClock from './header-clock.vue'; -import * as os from '@client/os'; -import { router } from '@client/router'; -import { menuDef } from '@client/menu'; -import { search } from '@client/scripts/search'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { store } from './store'; -import * as symbols from '@client/symbols'; -import { openAccountMenu } from '@client/account'; - -export default defineComponent({ - components: { - XCommon, - XSidebar, - XWidgets, - XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる - XHeaderClock, - }, - - provide() { - return { - sideViewHook: (path) => { - this.$refs.side.navigate(path); - } - }; - }, - - data() { - return { - pageInfo: null, - lists: null, - antennas: null, - followedChannels: null, - featuredChannels: null, - currentChannel: null, - menuDef: menuDef, - sideViewOpening: false, - instanceName, - }; - }, - - computed: { - menu() { - return [{ - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.$refs.side.navigate(this.$route.path); - } - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(this.$route.path); - } - }]; - } - }, - - created() { - if (window.innerWidth < 1024) { - localStorage.setItem('ui', 'default'); - location.reload(); - } - - os.api('users/lists/list').then(lists => { - this.lists = lists; - }); - - os.api('antennas/list').then(antennas => { - this.antennas = antennas; - }); - - os.api('channels/followed', { limit: 20 }).then(channels => { - this.followedChannels = channels; - }); - - // TODO: pagination - os.api('channels/featured', { limit: 20 }).then(channels => { - this.featuredChannels = channels; - }); - }, - - methods: { - changePage(page) { - console.log(page); - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - document.title = `${this.pageInfo.title} | ${instanceName}`; - } - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - - showMenu() { - this.$refs.menu.show(); - }, - - post() { - os.post(); - }, - - search() { - search(); - }, - - back() { - history.back(); - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - - onHeaderClick() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; - if (window.getSelection().toString() !== '') return; - const path = this.$route.path; - os.contextMenu([{ - type: 'label', - text: path, - }, { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.$refs.side.navigate(path); - } - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(path); - } - }], e); - }, - - openAccountMenu, - } -}); -</script> - -<style lang="scss" scoped> -.mk-app { - $header-height: 54px; // TODO: どこかに集約したい - $ui-font-size: 1em; // TODO: どこかに集約したい - - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); - display: flex; - - > .nav { - display: flex; - flex-direction: column; - width: 250px; - height: 100vh; - border-right: solid 4px var(--divider); - - > .header, > .footer { - $padding: 8px; - display: flex; - align-items: center; - z-index: 1000; - height: $header-height; - padding: $padding; - box-sizing: border-box; - user-select: none; - - &.header { - border-bottom: solid 0.5px var(--divider); - } - - &.footer { - border-top: solid 0.5px var(--divider); - } - - > .left, > .right { - > .item, > .menu { - display: inline-flex; - vertical-align: middle; - height: ($header-height - ($padding * 2)); - width: ($header-height - ($padding * 2)); - box-sizing: border-box; - //opacity: 0.6; - position: relative; - border-radius: 5px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - > .icon { - margin: auto; - } - - > .indicator { - position: absolute; - top: 8px; - right: 8px; - color: var(--indicator); - font-size: 8px; - line-height: 8px; - animation: blink 1s infinite; - } - } - } - - > .left { - flex: 1; - min-width: 0; - - > .account { - display: flex; - align-items: center; - padding: 0 8px; - - > .avatar { - width: 26px; - height: 26px; - margin-right: 8px; - } - - > .text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 0.9em; - } - } - } - - > .right { - margin-left: auto; - } - } - - > .body { - flex: 1; - min-width: 0; - overflow: auto; - - > .container { - margin-top: 8px; - margin-bottom: 8px; - - & + .container { - margin-top: 16px; - } - - > .header { - display: flex; - font-size: 0.9em; - padding: 8px 16px; - position: sticky; - top: 0; - background: var(--X17); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - z-index: 1; - color: var(--fgTransparentWeak); - - > .add { - margin-left: auto; - color: var(--fgTransparentWeak); - - &:hover { - color: var(--fg); - } - } - } - - > .body { - padding: 0 8px; - - > .item { - display: block; - padding: 6px 8px; - border-radius: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &:hover { - text-decoration: none; - background: rgba(0, 0, 0, 0.05); - } - - &.active, &.active:hover { - background: var(--accent); - color: #fff !important; - } - - &.read { - color: var(--fgTransparent); - } - - > .icon { - margin-right: 8px; - opacity: 0.6; - } - } - } - } - - > .a { - margin: 12px; - } - } - } - - > .main { - display: flex; - flex: 1; - flex-direction: column; - min-width: 0; - height: 100vh; - position: relative; - background: var(--panel); - - > .header { - z-index: 1000; - height: $header-height; - background-color: var(--panel); - border-bottom: solid 0.5px var(--divider); - user-select: none; - } - - > .body { - width: 100%; - box-sizing: border-box; - overflow: auto; - } - } - - > .side { - width: 350px; - border-left: solid 4px var(--divider); - background: var(--panel); - - &.widgets.sideViewOpening { - @media (max-width: 1400px) { - display: none; - } - } - } -} -</style> diff --git a/src/client/ui/chat/note-header.vue b/src/client/ui/chat/note-header.vue deleted file mode 100644 index e40f22f588..0000000000 --- a/src/client/ui/chat/note-header.vue +++ /dev/null @@ -1,112 +0,0 @@ -<template> -<header class="dehvdgxo"> - <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> - <MkUserName :user="note.user"/> - </MkA> - <span class="is-bot" v-if="note.user.isBot">bot</span> - <span class="username"><MkAcct :user="note.user"/></span> - <span class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></span> - <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></span> - <div class="info"> - <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span> - <MkA class="created-at" :to="notePage(note)"> - <MkTime :time="note.createdAt"/> - </MkA> - <span class="visibility" v-if="note.visibility !== 'public'"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> - </div> -</header> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import notePage from '@client/filters/note'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - note: { - type: Object, - required: true - }, - }, - - data() { - return { - }; - }, - - methods: { - notePage, - userPage - } -}); -</script> - -<style lang="scss" scoped> -.dehvdgxo { - display: flex; - align-items: baseline; - white-space: nowrap; - font-size: 0.9em; - - > .name { - display: block; - margin: 0 .5em 0 0; - padding: 0; - overflow: hidden; - font-size: 1em; - font-weight: bold; - text-decoration: none; - text-overflow: ellipsis; - - &:hover { - text-decoration: underline; - } - } - - > .is-bot { - flex-shrink: 0; - align-self: center; - margin: 0 .5em 0 0; - padding: 1px 6px; - font-size: 80%; - border: solid 0.5px var(--divider); - border-radius: 3px; - } - - > .admin, - > .moderator { - margin-right: 0.5em; - color: var(--badge); - } - - > .username { - margin: 0 .5em 0 0; - overflow: hidden; - text-overflow: ellipsis; - } - - > .info { - font-size: 0.9em; - opacity: 0.7; - - > .mobile { - margin-right: 8px; - } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } - } -} -</style> diff --git a/src/client/ui/chat/note-preview.vue b/src/client/ui/chat/note-preview.vue deleted file mode 100644 index beb38de644..0000000000 --- a/src/client/ui/chat/note-preview.vue +++ /dev/null @@ -1,112 +0,0 @@ -<template> -<div class="hduudsxk"> - <MkAvatar class="avatar" :user="note.user"/> - <div class="main"> - <XNoteHeader class="header" :note="note" :mini="true"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <XCwButton v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <XSubNote-content class="text" :note="note"/> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XNoteHeader from './note-header.vue'; -import XSubNoteContent from './sub-note-content.vue'; -import XCwButton from '@client/components/cw-button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XNoteHeader, - XSubNoteContent, - XCwButton, - }, - - props: { - note: { - type: Object, - required: true - } - }, - - data() { - return { - showContent: false - }; - } -}); -</script> - -<style lang="scss" scoped> -.hduudsxk { - display: flex; - margin: 0; - padding: 0; - overflow: hidden; - font-size: 0.95em; - - > .avatar { - - @media (min-width: 350px) { - margin: 0 10px 0 0; - width: 44px; - height: 44px; - } - - @media (min-width: 500px) { - margin: 0 12px 0 0; - width: 48px; - height: 48px; - } - } - - > .avatar { - flex-shrink: 0; - display: block; - margin: 0 10px 0 0; - width: 40px; - height: 40px; - border-radius: 8px; - } - - > .main { - flex: 1; - min-width: 0; - - > .header { - margin-bottom: 2px; - } - - > .body { - - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - > .text { - cursor: default; - margin: 0; - padding: 0; - } - } - } - } -} -</style> diff --git a/src/client/ui/chat/note.sub.vue b/src/client/ui/chat/note.sub.vue deleted file mode 100644 index a284ba2bf4..0000000000 --- a/src/client/ui/chat/note.sub.vue +++ /dev/null @@ -1,137 +0,0 @@ -<template> -<div class="wrpstxzv" :class="{ children }"> - <div class="main"> - <MkAvatar class="avatar" :user="note.user"/> - <div class="body"> - <XNoteHeader class="header" :note="note" :mini="true"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> - <XCwButton v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <XSubNote-content class="text" :note="note"/> - </div> - </div> - </div> - </div> - <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XNoteHeader from './note-header.vue'; -import XSubNoteContent from './sub-note-content.vue'; -import XCwButton from '@client/components/cw-button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - name: 'XSub', - - components: { - XNoteHeader, - XSubNoteContent, - XCwButton, - }, - - props: { - note: { - type: Object, - required: true - }, - detail: { - type: Boolean, - required: false, - default: false - }, - children: { - type: Boolean, - required: false, - default: false - }, - // TODO - truncate: { - type: Boolean, - default: true - } - }, - - data() { - return { - showContent: false, - replies: [], - }; - }, - - created() { - if (this.detail) { - os.api('notes/children', { - noteId: this.note.id, - limit: 5 - }).then(replies => { - this.replies = replies; - }); - } - }, -}); -</script> - -<style lang="scss" scoped> -.wrpstxzv { - padding: 16px 16px; - font-size: 0.8em; - - &.children { - padding: 10px 0 0 16px; - font-size: 1em; - } - - > .main { - display: flex; - - > .avatar { - flex-shrink: 0; - display: block; - margin: 0 8px 0 0; - width: 36px; - height: 36px; - } - - > .body { - flex: 1; - min-width: 0; - - > .header { - margin-bottom: 2px; - } - - > .body { - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - > .text { - margin: 0; - padding: 0; - } - } - } - } - } - - > .reply { - border-left: solid 0.5px var(--divider); - margin-top: 10px; - } -} -</style> diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue deleted file mode 100644 index 0a054d1057..0000000000 --- a/src/client/ui/chat/note.vue +++ /dev/null @@ -1,1144 +0,0 @@ -<template> -<div - class="vfzoeqcg" - v-if="!muted" - v-show="!isDeleted" - :tabindex="!isDeleted ? '-1' : null" - :class="{ renote: isRenote, highlighted: appearNote._prId_ || appearNote._featuredId_, operating }" - v-hotkey="keymap" -> - <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> - <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> - <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> - <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> - <div class="renote" v-if="isRenote"> - <MkAvatar class="avatar" :user="note.user"/> - <i class="fas fa-retweet"></i> - <I18n :src="$ts.renotedBy" tag="span"> - <template #user> - <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> - <MkUserName :user="note.user"/> - </MkA> - </template> - </I18n> - <div class="info"> - <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> - <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> - <MkTime :time="note.createdAt"/> - </button> - <span class="visibility" v-if="note.visibility !== 'public'"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> - </div> - </div> - <article class="article" @contextmenu.stop="onContextmenu"> - <MkAvatar class="avatar" :user="appearNote.user"/> - <div class="main"> - <XNoteHeader class="header" :note="appearNote" :mini="true"/> - <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> - <div class="body"> - <p v-if="appearNote.cw != null" class="cw"> - <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <XCwButton v-model="showContent" :note="appearNote"/> - </p> - <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> - <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <a class="rp" v-if="appearNote.renote != null">RN:</a> - </div> - <div class="files" v-if="appearNote.files.length > 0"> - <XMediaList :media-list="appearNote.files"/> - </div> - <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> - <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> - <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> - <button v-if="collapsed" class="fade _button" @click="collapsed = false"> - <span>{{ $ts.showMore }}</span> - </button> - </div> - <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> - </div> - <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> - <footer class="footer _panel"> - <button @click="reply()" class="button _button" v-tooltip="$ts.reply"> - <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> - <template v-else><i class="fas fa-reply"></i></template> - <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> - </button> - <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton" v-tooltip="$ts.renote"> - <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> - </button> - <button v-else class="button _button"> - <i class="fas fa-ban"></i> - </button> - <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton" v-tooltip="$ts.reaction"> - <i class="fas fa-plus"></i> - </button> - <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton" v-tooltip="$ts.reaction"> - <i class="fas fa-minus"></i> - </button> - <button class="button _button" @click="menu()" ref="menuButton"> - <i class="fas fa-ellipsis-h"></i> - </button> - </footer> - </div> - </article> -</div> -<div v-else class="muted" @click="muted = false"> - <I18n :src="$ts.userSaysSomething" tag="small"> - <template #name> - <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> - <MkUserName :user="appearNote.user"/> - </MkA> - </template> - </I18n> -</div> -</template> - -<script lang="ts"> -import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; -import * as mfm from 'mfm-js'; -import { sum } from '../../../prelude/array'; -import XSub from './note.sub.vue'; -import XNoteHeader from './note-header.vue'; -import XNoteSimple from './note-preview.vue'; -import XReactionsViewer from '@client/components/reactions-viewer.vue'; -import XMediaList from '@client/components/media-list.vue'; -import XCwButton from '@client/components/cw-button.vue'; -import XPoll from '@client/components/poll.vue'; -import { pleaseLogin } from '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -export default defineComponent({ - components: { - XSub, - XNoteHeader, - XNoteSimple, - XReactionsViewer, - XMediaList, - XCwButton, - XPoll, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), - }, - - inject: { - inChannel: { - default: null - }, - }, - - props: { - note: { - type: Object, - required: true - }, - pinned: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['update:note'], - - data() { - return { - connection: null, - replies: [], - showContent: false, - collapsed: false, - isDeleted: false, - muted: false, - operating: false, - }; - }, - - computed: { - rs() { - return this.$store.state.reactions; - }, - keymap(): any { - return { - 'r': () => this.reply(true), - 'e|a|plus': () => this.react(true), - 'q': () => this.renote(true), - 'f|b': this.favorite, - 'delete|ctrl+d': this.del, - 'ctrl+q': this.renoteDirectly, - 'up|k|shift+tab': this.focusBefore, - 'down|j|tab': this.focusAfter, - 'esc': this.blur, - 'm|o': () => this.menu(true), - 's': this.toggleShowContent, - '1': () => this.reactDirectly(this.rs[0]), - '2': () => this.reactDirectly(this.rs[1]), - '3': () => this.reactDirectly(this.rs[2]), - '4': () => this.reactDirectly(this.rs[3]), - '5': () => this.reactDirectly(this.rs[4]), - '6': () => this.reactDirectly(this.rs[5]), - '7': () => this.reactDirectly(this.rs[6]), - '8': () => this.reactDirectly(this.rs[7]), - '9': () => this.reactDirectly(this.rs[8]), - '0': () => this.reactDirectly(this.rs[9]), - }; - }, - - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - this.note.fileIds.length == 0 && - this.note.poll == null); - }, - - appearNote(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - isMyNote(): boolean { - return this.$i && (this.$i.id === this.appearNote.userId); - }, - - isMyRenote(): boolean { - return this.$i && (this.$i.id === this.note.userId); - }, - - canRenote(): boolean { - return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; - }, - - reactionsCount(): number { - return this.appearNote.reactions - ? sum(Object.values(this.appearNote.reactions)) - : 0; - }, - - urls(): string[] { - if (this.appearNote.text) { - return extractUrlFromMfm(mfm.parse(this.appearNote.text)); - } else { - return null; - } - }, - - showTicker() { - if (this.$store.state.instanceTicker === 'always') return true; - if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; - return false; - } - }, - - async created() { - if (this.$i) { - this.connection = os.stream; - } - - this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( - (this.appearNote.text.split('\n').length > 9) || - (this.appearNote.text.length > 500) - ); - this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); - - // plugin - if (noteViewInterruptors.length > 0) { - let result = this.note; - for (const interruptor of noteViewInterruptors) { - result = await interruptor.handler(JSON.parse(JSON.stringify(result))); - } - this.$emit('update:note', Object.freeze(result)); - } - }, - - mounted() { - this.capture(true); - - if (this.$i) { - this.connection.on('_connected_', this.onStreamConnected); - } - }, - - beforeUnmount() { - this.decapture(true); - - if (this.$i) { - this.connection.off('_connected_', this.onStreamConnected); - } - }, - - methods: { - updateAppearNote(v) { - this.$emit('update:note', Object.freeze(this.isRenote ? { - ...this.note, - renote: { - ...this.note.renote, - ...v - } - } : { - ...this.note, - ...v - })); - }, - - readPromo() { - os.api('promo/read', { - noteId: this.appearNote.id - }); - this.isDeleted = true; - }, - - capture(withHandler = false) { - if (this.$i) { - // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); - if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); - } - }, - - decapture(withHandler = false) { - if (this.$i) { - this.connection.send('un', { - id: this.appearNote.id - }); - if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); - } - }, - - onStreamConnected() { - this.capture(); - }, - - onStreamNoteUpdated(data) { - const { type, id, body } = data; - - if (id !== this.appearNote.id) return; - - switch (type) { - case 'reacted': { - const reaction = body.reaction; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - if (body.emoji) { - const emojis = this.appearNote.emojis || []; - if (!emojis.includes(body.emoji)) { - n.emojis = [...emojis, body.emoji]; - } - } - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (this.appearNote.reactions || {})[reaction] || 0; - - // Increment the count - n.reactions = { - ...this.appearNote.reactions, - [reaction]: currentCount + 1 - }; - - if (body.userId === this.$i.id) { - n.myReaction = reaction; - } - - this.updateAppearNote(n); - break; - } - - case 'unreacted': { - const reaction = body.reaction; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (this.appearNote.reactions || {})[reaction] || 0; - - // Decrement the count - n.reactions = { - ...this.appearNote.reactions, - [reaction]: Math.max(0, currentCount - 1) - }; - - if (body.userId === this.$i.id) { - n.myReaction = null; - } - - this.updateAppearNote(n); - break; - } - - case 'pollVoted': { - const choice = body.choice; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - const choices = [...this.appearNote.poll.choices]; - choices[choice] = { - ...choices[choice], - votes: choices[choice].votes + 1, - ...(body.userId === this.$i.id ? { - isVoted: true - } : {}) - }; - - n.poll = { - ...this.appearNote.poll, - choices: choices - }; - - this.updateAppearNote(n); - break; - } - - case 'deleted': { - this.isDeleted = true; - break; - } - } - }, - - reply(viaKeyboard = false) { - pleaseLogin(); - this.operating = true; - os.post({ - reply: this.appearNote, - animation: !viaKeyboard, - }, () => { - this.operating = false; - this.focus(); - }); - }, - - renote(viaKeyboard = false) { - pleaseLogin(); - this.operating = true; - this.blur(); - os.popupMenu([{ - text: this.$ts.renote, - icon: 'fas fa-retweet', - action: () => { - os.api('notes/create', { - renoteId: this.appearNote.id - }); - } - }, { - text: this.$ts.quote, - icon: 'fas fa-quote-right', - action: () => { - os.post({ - renote: this.appearNote, - }); - } - }], this.$refs.renoteButton, { - viaKeyboard - }).then(() => { - this.operating = false; - }); - }, - - renoteDirectly() { - os.apiWithDialog('notes/create', { - renoteId: this.appearNote.id - }, undefined, (res: any) => { - os.dialog({ - type: 'success', - text: this.$ts.renoted, - }); - }, (e: Error) => { - if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { - os.dialog({ - type: 'error', - text: this.$ts.cantRenote, - }); - } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { - os.dialog({ - type: 'error', - text: this.$ts.cantReRenote, - }); - } - }); - }, - - async react(viaKeyboard = false) { - pleaseLogin(); - this.operating = true; - this.blur(); - reactionPicker.show(this.$refs.reactButton, reaction => { - os.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - }, () => { - this.operating = false; - this.focus(); - }); - }, - - reactDirectly(reaction) { - os.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - }, - - undoReact(note) { - const oldReaction = note.myReaction; - if (!oldReaction) return; - os.api('notes/reactions/delete', { - noteId: note.id - }); - }, - - favorite() { - pleaseLogin(); - os.apiWithDialog('notes/favorites/create', { - noteId: this.appearNote.id - }, undefined, (res: any) => { - os.dialog({ - type: 'success', - text: this.$ts.favorited, - }); - }, (e: Error) => { - if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { - os.dialog({ - type: 'error', - text: this.$ts.alreadyFavorited, - }); - } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { - os.dialog({ - type: 'error', - text: this.$ts.cantFavorite, - }); - } - }); - }, - - del() { - os.dialog({ - type: 'warning', - text: this.$ts.noteDeleteConfirm, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - os.api('notes/delete', { - noteId: this.appearNote.id - }); - }); - }, - - delEdit() { - os.dialog({ - type: 'warning', - text: this.$ts.deleteAndEditConfirm, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - os.api('notes/delete', { - noteId: this.appearNote.id - }); - - os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); - }); - }, - - toggleFavorite(favorite: boolean) { - os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { - noteId: this.appearNote.id - }); - }, - - toggleWatch(watch: boolean) { - os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { - noteId: this.appearNote.id - }); - }, - - getMenu() { - let menu; - if (this.$i) { - const statePromise = os.api('notes/state', { - noteId: this.appearNote.id - }); - - menu = [{ - icon: 'fas fa-copy', - text: this.$ts.copyContent, - action: this.copyContent - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: this.copyLink - }, (this.appearNote.url || this.appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: this.$ts.showOnRemote, - action: () => { - window.open(this.appearNote.url || this.appearNote.uri, '_blank'); - } - } : undefined, - { - icon: 'fas fa-share-alt', - text: this.$ts.share, - action: this.share - }, - null, - statePromise.then(state => state.isFavorited ? { - icon: 'fas fa-star', - text: this.$ts.unfavorite, - action: () => this.toggleFavorite(false) - } : { - icon: 'fas fa-star', - text: this.$ts.favorite, - action: () => this.toggleFavorite(true) - }), - { - icon: 'fas fa-paperclip', - text: this.$ts.clip, - action: () => this.clip() - }, - (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { - icon: 'fas fa-eye-slash', - text: this.$ts.unwatch, - action: () => this.toggleWatch(false) - } : { - icon: 'fas fa-eye', - text: this.$ts.watch, - action: () => this.toggleWatch(true) - }) : undefined, - this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { - icon: 'fas fa-thumbtack', - text: this.$ts.unpin, - action: () => this.togglePin(false) - } : { - icon: 'fas fa-thumbtack', - text: this.$ts.pin, - action: () => this.togglePin(true) - } : undefined, - ...(this.$i.isModerator || this.$i.isAdmin ? [ - null, - { - icon: 'fas fa-bullhorn', - text: this.$ts.promote, - action: this.promote - }] - : [] - ), - ...(this.appearNote.userId != this.$i.id ? [ - null, - { - icon: 'fas fa-exclamation-circle', - text: this.$ts.reportAbuse, - action: () => { - const u = `${url}/notes/${this.appearNote.id}`; - os.popup(import('@client/components/abuse-report-window.vue'), { - user: this.appearNote.user, - initialComment: `Note: ${u}\n-----\n` - }, {}, 'closed'); - } - }] - : [] - ), - ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ - null, - this.appearNote.userId == this.$i.id ? { - icon: 'fas fa-edit', - text: this.$ts.deleteAndEdit, - action: this.delEdit - } : undefined, - { - icon: 'fas fa-trash-alt', - text: this.$ts.delete, - danger: true, - action: this.del - }] - : [] - )] - .filter(x => x !== undefined); - } else { - menu = [{ - icon: 'fas fa-copy', - text: this.$ts.copyContent, - action: this.copyContent - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: this.copyLink - }, (this.appearNote.url || this.appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: this.$ts.showOnRemote, - action: () => { - window.open(this.appearNote.url || this.appearNote.uri, '_blank'); - } - } : undefined] - .filter(x => x !== undefined); - } - - if (noteActions.length > 0) { - menu = menu.concat([null, ...noteActions.map(action => ({ - icon: 'fas fa-plug', - text: action.title, - action: () => { - action.handler(this.appearNote); - } - }))]); - } - - return menu; - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (window.getSelection().toString() !== '') return; - - if (this.$store.state.useReactionPickerForContextMenu) { - e.preventDefault(); - this.react(); - } else { - os.contextMenu(this.getMenu(), e).then(this.focus); - } - }, - - menu(viaKeyboard = false) { - this.operating = true; - os.popupMenu(this.getMenu(), this.$refs.menuButton, { - viaKeyboard - }).then(() => { - this.operating = false; - this.focus(); - }); - }, - - showRenoteMenu(viaKeyboard = false) { - if (!this.isMyRenote) return; - os.popupMenu([{ - text: this.$ts.unrenote, - icon: 'fas fa-trash-alt', - danger: true, - action: () => { - os.api('notes/delete', { - noteId: this.note.id - }); - this.isDeleted = true; - } - }], this.$refs.renoteTime, { - viaKeyboard: viaKeyboard - }); - }, - - toggleShowContent() { - this.showContent = !this.showContent; - }, - - copyContent() { - copyToClipboard(this.appearNote.text); - os.success(); - }, - - copyLink() { - copyToClipboard(`${url}/notes/${this.appearNote.id}`); - os.success(); - }, - - togglePin(pin: boolean) { - os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { - noteId: this.appearNote.id - }, undefined, null, e => { - if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { - os.dialog({ - type: 'error', - text: this.$ts.pinLimitExceeded - }); - } - }); - }, - - async clip() { - const clips = await os.api('clips/list'); - os.popupMenu([{ - icon: 'fas fa-plus', - text: this.$ts.createNew, - action: async () => { - const { canceled, result } = await os.form(this.$ts.createNewClip, { - name: { - type: 'string', - label: this.$ts.name - }, - description: { - type: 'string', - required: false, - multiline: true, - label: this.$ts.description - }, - isPublic: { - type: 'boolean', - label: this.$ts.public, - default: false - } - }); - if (canceled) return; - - const clip = await os.apiWithDialog('clips/create', result); - - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); - } - }, null, ...clips.map(clip => ({ - text: clip.name, - action: () => { - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); - } - }))], this.$refs.menuButton, { - }).then(this.focus); - }, - - async promote() { - const { canceled, result: days } = await os.dialog({ - title: this.$ts.numberOfDays, - input: { type: 'number' } - }); - - if (canceled) return; - - os.apiWithDialog('admin/promo/create', { - noteId: this.appearNote.id, - expiresAt: Date.now() + (86400000 * days) - }); - }, - - share() { - navigator.share({ - title: this.$t('noteOf', { user: this.appearNote.user.name }), - text: this.appearNote.text, - url: `${url}/notes/${this.appearNote.id}` - }); - }, - - focus() { - this.$el.focus(); - }, - - blur() { - this.$el.blur(); - }, - - focusBefore() { - focusPrev(this.$el); - }, - - focusAfter() { - focusNext(this.$el); - }, - - userPage - } -}); -</script> - -<style lang="scss" scoped> -.vfzoeqcg { - position: relative; - contain: content; - - // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 - // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう - // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 - // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる - // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) - //content-visibility: auto; - //contain-intrinsic-size: 0 128px; - - &:focus-visible { - outline: none; - } - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &:hover, &.operating { - > .article > .main > .footer { - display: block; - } - } - - &.renote { - background: rgba(128, 255, 0, 0.05); - } - - &.highlighted { - background: rgba(255, 128, 0, 0.05); - } - - > .info { - display: flex; - align-items: center; - padding: 12px 16px 4px 16px; - line-height: 24px; - font-size: 85%; - white-space: pre; - color: #d28a3f; - - > i { - margin-right: 4px; - } - - > .hide { - margin-left: 16px; - color: inherit; - opacity: 0.7; - } - } - - > .info + .article { - padding-top: 8px; - } - - > .reply-to { - opacity: 0.7; - padding-bottom: 0; - } - - > .renote { - display: flex; - align-items: center; - padding: 12px 16px 4px 16px; - line-height: 28px; - white-space: pre; - color: var(--renote); - font-size: 0.9em; - - > .avatar { - flex-shrink: 0; - display: inline-block; - width: 28px; - height: 28px; - margin: 0 8px 0 0; - border-radius: 6px; - } - - > i { - margin-right: 4px; - } - - > span { - overflow: hidden; - flex-shrink: 1; - text-overflow: ellipsis; - white-space: nowrap; - - > .name { - font-weight: bold; - } - } - - > .info { - margin-left: 8px; - font-size: 0.9em; - opacity: 0.7; - - > .time { - flex-shrink: 0; - color: inherit; - - > .dropdownIcon { - margin-right: 4px; - } - } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } - } - } - - > .renote + .article { - padding-top: 8px; - } - - > .article { - display: flex; - padding: 12px 16px; - - > .avatar { - flex-shrink: 0; - display: block; - position: sticky; - top: 0; - margin: 0 14px 0 0; - width: 46px; - height: 46px; - } - - > .main { - flex: 1; - min-width: 0; - - > .body { - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - &.collapsed { - position: relative; - max-height: 9em; - overflow: hidden; - - > .fade { - display: block; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 64px; - background: linear-gradient(0deg, var(--panel), var(--X15)); - - > span { - display: inline-block; - background: var(--panel); - padding: 6px 10px; - font-size: 0.8em; - border-radius: 999px; - box-shadow: 0 2px 6px rgb(0 0 0 / 20%); - } - - &:hover { - > span { - background: var(--panelHighlight); - } - } - } - } - - > .text { - overflow-wrap: break-word; - - > .reply { - color: var(--accent); - margin-right: 0.5em; - } - - > .rp { - margin-left: 4px; - font-style: oblique; - color: var(--renote); - } - } - - > .files { - max-width: 500px; - } - - > .url-preview { - margin-top: 8px; - max-width: 500px; - } - - > .poll { - font-size: 80%; - max-width: 500px; - } - - > .renote { - padding: 8px 0; - - > * { - padding: 16px; - border: dashed 1px var(--renote); - border-radius: 8px; - } - } - } - - > .channel { - opacity: 0.7; - font-size: 80%; - } - } - - > .footer { - display: none; - position: absolute; - top: 8px; - right: 8px; - padding: 0 6px; - opacity: 0.7; - - &:hover { - opacity: 1; - } - - > .button { - margin: 0; - padding: 8px; - opacity: 0.7; - - &:hover { - color: var(--accent); - } - - > .count { - display: inline; - margin: 0 0 0 8px; - opacity: 0.7; - } - - &.reacted { - color: var(--accent); - } - } - } - } - } - - > .reply { - border-top: solid 0.5px var(--divider); - } -} - -.muted { - padding: 8px 16px; - opacity: 0.7; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } -} -</style> diff --git a/src/client/ui/chat/notes.vue b/src/client/ui/chat/notes.vue deleted file mode 100644 index 6690baf584..0000000000 --- a/src/client/ui/chat/notes.vue +++ /dev/null @@ -1,94 +0,0 @@ -<template> -<div class=""> - <div class="_fullinfo" v-if="empty"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noNotes }}</div> - </div> - - <MkLoading v-if="fetching"/> - - <MkError v-if="error" @retry="init()"/> - - <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <MkButton style="margin: 0 auto;" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> - - <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :ad="true"> - <XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> - </XList> - - <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import paging from '@client/scripts/paging'; -import XNote from './note.vue'; -import XList from './date-separated-list.vue'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - XNote, XList, MkButton, - }, - - mixins: [ - paging({ - before: (self) => { - self.$emit('before'); - }, - - after: (self, e) => { - self.$emit('after', e); - } - }), - ], - - props: { - pagination: { - required: true - }, - - prop: { - type: String, - required: false - } - }, - - emits: ['before', 'after'], - - computed: { - notes(): any[] { - return this.prop ? this.items.map(item => item[this.prop]) : this.items; - }, - - reversed(): boolean { - return this.pagination.reversed; - } - }, - - methods: { - updated(oldValue, newValue) { - const i = this.notes.findIndex(n => n === oldValue); - if (this.prop) { - this.items[i][this.prop] = newValue; - } else { - this.items[i] = newValue; - } - }, - - focus() { - this.$refs.notes.focus(); - } - } -}); -</script> diff --git a/src/client/ui/chat/pages/channel.vue b/src/client/ui/chat/pages/channel.vue deleted file mode 100644 index d11d40b210..0000000000 --- a/src/client/ui/chat/pages/channel.vue +++ /dev/null @@ -1,259 +0,0 @@ -<template> -<div v-if="channel" class="hhizbblb"> - <div class="info" v-if="date"> - <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> - </div> - <div class="tl" ref="body"> - <div class="new" v-if="queue > 0" :style="{ width: width + 'px', bottom: bottom + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> - <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="true"/> - </div> - <div class="bottom"> - <div class="typers" v-if="typers.length > 0"> - <I18n :src="$ts.typingUsers" text-tag="span" class="users"> - <template #users> - <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> - </template> - </I18n> - <MkEllipsis/> - </div> - <XPostForm :channel="channel"/> - </div> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, markRaw } from 'vue'; -import * as Misskey from 'misskey-js'; -import XNotes from '../notes.vue'; -import * as os from '@client/os'; -import * as sound from '@client/scripts/sound'; -import { scrollToBottom, getScrollPosition, getScrollContainer } from '@client/scripts/scroll'; -import follow from '@client/directives/follow-append'; -import XPostForm from '../post-form.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - XNotes, - XPostForm, - MkInfo, - }, - - directives: { - follow - }, - - provide() { - return { - inChannel: true - }; - }, - - props: { - channelId: { - type: String, - required: true - }, - }, - - data() { - return { - channel: null as Misskey.entities.Channel | null, - connection: null, - pagination: null, - baseQuery: { - includeMyRenotes: this.$store.state.showMyRenotes, - includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.showLocalRenotes - }, - queue: 0, - width: 0, - top: 0, - bottom: 0, - typers: [], - date: null, - [symbols.PAGE_INFO]: computed(() => ({ - title: this.channel ? this.channel.name : '-', - subtitle: this.channel ? this.channel.description : '-', - icon: 'fas fa-satellite-dish', - actions: [{ - icon: this.channel?.isFollowing ? 'fas fa-star' : 'far fa-star', - text: this.channel?.isFollowing ? this.$ts.unfollow : this.$ts.follow, - highlighted: this.channel?.isFollowing, - handler: this.toggleChannelFollow - }, { - icon: 'fas fa-search', - text: this.$ts.inChannelSearch, - handler: this.inChannelSearch - }, { - icon: 'fas fa-calendar-alt', - text: this.$ts.jumpToSpecifiedDate, - handler: this.timetravel - }] - })), - }; - }, - - async created() { - this.channel = await os.api('channels/show', { channelId: this.channelId }); - - const prepend = note => { - (this.$refs.tl as any).prepend(note); - - this.$emit('note'); - - sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); - }; - - this.connection = markRaw(os.stream.useChannel('channel', { - channelId: this.channelId - })); - this.connection.on('note', prepend); - this.connection.on('typers', typers => { - this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers; - }); - - this.pagination = { - endpoint: 'channels/timeline', - reversed: true, - limit: 10, - params: init => ({ - channelId: this.channelId, - untilDate: this.date?.getTime(), - ...this.baseQuery - }) - }; - }, - - mounted() { - - }, - - beforeUnmount() { - this.connection.dispose(); - }, - - methods: { - focus() { - this.$refs.body.focus(); - }, - - goTop() { - const container = getScrollContainer(this.$refs.body); - container.scrollTop = 0; - }, - - queueUpdated(q) { - if (this.$refs.body.offsetWidth !== 0) { - const rect = this.$refs.body.getBoundingClientRect(); - this.width = this.$refs.body.offsetWidth; - this.top = rect.top; - this.bottom = this.$refs.body.offsetHeight; - } - this.queue = q; - }, - - async inChannelSearch() { - const { canceled, result: query } = await os.dialog({ - title: this.$ts.inChannelSearch, - input: true - }); - if (canceled || query == null || query === '') return; - router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.channelId}`); - }, - - async toggleChannelFollow() { - if (this.channel.isFollowing) { - await os.apiWithDialog('channels/unfollow', { - channelId: this.channel.id - }); - this.channel.isFollowing = false; - } else { - await os.apiWithDialog('channels/follow', { - channelId: this.channel.id - }); - this.channel.isFollowing = true; - } - }, - - openChannelMenu(ev) { - os.popupMenu([{ - text: this.$ts.copyUrl, - icon: 'fas fa-link', - action: () => { - copyToClipboard(`${url}/channels/${this.currentChannel.id}`); - } - }], ev.currentTarget || ev.target); - }, - - timetravel(date?: Date) { - this.date = date; - this.$refs.tl.reload(); - } - } -}); -</script> - -<style lang="scss" scoped> -.hhizbblb { - display: flex; - flex-direction: column; - flex: 1; - overflow: auto; - - > .info { - padding: 16px 16px 0 16px; - } - - > .top { - padding: 16px 16px 0 16px; - } - - > .bottom { - padding: 0 16px 16px 16px; - position: relative; - - > .typers { - position: absolute; - bottom: 100%; - padding: 0 8px 0 8px; - font-size: 0.9em; - background: var(--panel); - border-radius: 0 8px 0 0; - color: var(--fgTransparentWeak); - - > .users { - > .user + .user:before { - content: ", "; - font-weight: normal; - } - - > .user:last-of-type:after { - content: " "; - } - } - } - } - - > .tl { - position: relative; - padding: 16px 0; - flex: 1; - min-width: 0; - overflow: auto; - - > .new { - position: fixed; - z-index: 1000; - - > button { - display: block; - margin: 16px auto; - padding: 8px 16px; - border-radius: 32px; - } - } - } -} -</style> diff --git a/src/client/ui/chat/pages/timeline.vue b/src/client/ui/chat/pages/timeline.vue deleted file mode 100644 index 0f9cd7f11e..0000000000 --- a/src/client/ui/chat/pages/timeline.vue +++ /dev/null @@ -1,221 +0,0 @@ -<template> -<div class="dbiokgaf"> - <div class="info" v-if="date"> - <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> - </div> - <div class="top"> - <XPostForm/> - </div> - <div class="tl" ref="body"> - <div class="new" v-if="queue > 0" :style="{ width: width + 'px', top: top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> - <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated"/> - </div> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, markRaw } from 'vue'; -import XNotes from '../notes.vue'; -import * as os from '@client/os'; -import * as sound from '@client/scripts/sound'; -import { scrollToBottom, getScrollPosition, getScrollContainer } from '@client/scripts/scroll'; -import follow from '@client/directives/follow-append'; -import XPostForm from '../post-form.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - XNotes, - XPostForm, - MkInfo, - }, - - directives: { - follow - }, - - props: { - src: { - type: String, - required: true - }, - }, - - data() { - return { - connection: null, - connection2: null, - pagination: null, - baseQuery: { - includeMyRenotes: this.$store.state.showMyRenotes, - includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.showLocalRenotes - }, - query: {}, - queue: 0, - width: 0, - top: 0, - bottom: 0, - typers: [], - date: null, - [symbols.PAGE_INFO]: computed(() => ({ - title: this.$ts.timeline, - icon: 'fas fa-home', - actions: [{ - icon: 'fas fa-calendar-alt', - text: this.$ts.jumpToSpecifiedDate, - handler: this.timetravel - }] - })), - }; - }, - - created() { - const prepend = note => { - (this.$refs.tl as any).prepend(note); - - this.$emit('note'); - - sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); - }; - - const onChangeFollowing = () => { - if (!this.$refs.tl.backed) { - this.$refs.tl.reload(); - } - }; - - let endpoint; - - if (this.src == 'home') { - endpoint = 'notes/timeline'; - this.connection = markRaw(os.stream.useChannel('homeTimeline')); - this.connection.on('note', prepend); - - this.connection2 = markRaw(os.stream.useChannel('main')); - this.connection2.on('follow', onChangeFollowing); - this.connection2.on('unfollow', onChangeFollowing); - } else if (this.src == 'local') { - endpoint = 'notes/local-timeline'; - this.connection = markRaw(os.stream.useChannel('localTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'social') { - endpoint = 'notes/hybrid-timeline'; - this.connection = markRaw(os.stream.useChannel('hybridTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'global') { - endpoint = 'notes/global-timeline'; - this.connection = markRaw(os.stream.useChannel('globalTimeline')); - this.connection.on('note', prepend); - } - - this.pagination = { - endpoint: endpoint, - limit: 10, - params: init => ({ - untilDate: this.date?.getTime(), - ...this.baseQuery, ...this.query - }) - }; - }, - - mounted() { - - }, - - beforeUnmount() { - this.connection.dispose(); - if (this.connection2) this.connection2.dispose(); - }, - - methods: { - focus() { - this.$refs.body.focus(); - }, - - goTop() { - const container = getScrollContainer(this.$refs.body); - container.scrollTop = 0; - }, - - queueUpdated(q) { - if (this.$refs.body.offsetWidth !== 0) { - const rect = this.$refs.body.getBoundingClientRect(); - this.width = this.$refs.body.offsetWidth; - this.top = rect.top; - this.bottom = this.$refs.body.offsetHeight; - } - this.queue = q; - }, - - timetravel(date?: Date) { - this.date = date; - this.$refs.tl.reload(); - } - } -}); -</script> - -<style lang="scss" scoped> -.dbiokgaf { - display: flex; - flex-direction: column; - flex: 1; - overflow: auto; - - > .info { - padding: 16px 16px 0 16px; - } - - > .top { - padding: 16px 16px 0 16px; - } - - > .bottom { - padding: 0 16px 16px 16px; - position: relative; - - > .typers { - position: absolute; - bottom: 100%; - padding: 0 8px 0 8px; - font-size: 0.9em; - background: var(--panel); - border-radius: 0 8px 0 0; - color: var(--fgTransparentWeak); - - > .users { - > .user + .user:before { - content: ", "; - font-weight: normal; - } - - > .user:last-of-type:after { - content: " "; - } - } - } - } - - > .tl { - position: relative; - padding: 16px 0; - flex: 1; - min-width: 0; - overflow: auto; - - > .new { - position: fixed; - z-index: 1000; - - > button { - display: block; - margin: 16px auto; - padding: 8px 16px; - border-radius: 32px; - } - } - } -} -</style> diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue deleted file mode 100644 index e1e56dee35..0000000000 --- a/src/client/ui/chat/post-form.vue +++ /dev/null @@ -1,773 +0,0 @@ -<template> -<div class="pxiwixjf" - @dragover.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.stop="onDrop" -> - <div class="form"> - <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> - <div v-if="visibility === 'specified'" class="to-specified"> - <span style="margin-right: 8px;">{{ $ts.recipient }}</span> - <div class="visibleUsers"> - <span v-for="u in visibleUsers" :key="u.id"> - <MkAcct :user="u"/> - <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button> - </span> - <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button> - </div> - </div> - <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> - <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" /> - <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> - <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> - <footer> - <div class="left"> - <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button> - <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button> - <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button> - <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button> - <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button> - <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button> - </div> - <div class="right"> - <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> - <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span> - <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> - <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span> - <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span> - <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> - <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> - </button> - <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> - </div> - </footer> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import insertTextAtCursor from 'insert-text-at-cursor'; -import { length } from 'stringz'; -import { toASCII } from 'punycode/'; -import * as mfm from 'mfm-js'; -import { host, url } from '@client/config'; -import { erase, unique } from '../../../prelude/array'; -import { extractMentions } from '@/misc/extract-mentions'; -import { getAcct } from '@/misc/acct'; -import { formatTimeString } from '@/misc/format-time-string'; -import { Autocomplete } from '@client/scripts/autocomplete'; -import { noteVisibilities } from '../../../types'; -import * as os from '@client/os'; -import { selectFile } from '@client/scripts/select-file'; -import { notePostInterruptors, postFormActions } from '@client/store'; -import { isMobile } from '@client/scripts/is-mobile'; -import { throttle } from 'throttle-debounce'; - -export default defineComponent({ - components: { - XPostFormAttaches: defineAsyncComponent(() => import('@client/components/post-form-attaches.vue')), - XPollEditor: defineAsyncComponent(() => import('@client/components/poll-editor.vue')) - }, - - props: { - reply: { - type: Object, - required: false - }, - renote: { - type: Object, - required: false - }, - channel: { - type: String, - required: false - }, - mention: { - type: Object, - required: false - }, - specified: { - type: Object, - required: false - }, - initialText: { - type: String, - required: false - }, - initialNote: { - type: Object, - required: false - }, - share: { - type: Boolean, - required: false, - default: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['posted', 'cancel', 'esc'], - - data() { - return { - posting: false, - text: '', - files: [], - poll: null, - useCw: false, - cw: null, - localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, - visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility, - visibleUsers: [], - autocomplete: null, - draghover: false, - quoteId: null, - recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), - imeText: '', - typing: throttle(3000, () => { - if (this.channel) { - os.stream.send('typingOnChannel', { channel: this.channel }); - } - }), - postFormActions, - }; - }, - - computed: { - draftKey(): string { - let key = this.channel ? `channel:${this.channel}` : ''; - - if (this.renote) { - key += `renote:${this.renote.id}`; - } else if (this.reply) { - key += `reply:${this.reply.id}`; - } else { - key += 'note'; - } - - return key; - }, - - placeholder(): string { - if (this.renote) { - return this.$ts._postForm.quotePlaceholder; - } else if (this.reply) { - return this.$ts._postForm.replyPlaceholder; - } else if (this.channel) { - return this.$ts._postForm.channelPlaceholder; - } else { - const xs = [ - this.$ts._postForm._placeholders.a, - this.$ts._postForm._placeholders.b, - this.$ts._postForm._placeholders.c, - this.$ts._postForm._placeholders.d, - this.$ts._postForm._placeholders.e, - this.$ts._postForm._placeholders.f - ]; - return xs[Math.floor(Math.random() * xs.length)]; - } - }, - - submitText(): string { - return this.renote - ? this.$ts.quote - : this.reply - ? this.$ts.reply - : this.$ts.note; - }, - - textLength(): number { - return length((this.text + this.imeText).trim()); - }, - - canPost(): boolean { - return !this.posting && - (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) && - (this.textLength <= this.max) && - (!this.poll || this.poll.choices.length >= 2); - }, - - max(): number { - return this.$instance ? this.$instance.maxNoteTextLength : 1000; - } - }, - - mounted() { - if (this.initialText) { - this.text = this.initialText; - } - - if (this.mention) { - this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; - this.text += ' '; - } - - if (this.reply && this.reply.user.host != null) { - this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; - } - - if (this.reply && this.reply.text != null) { - const ast = mfm.parse(this.reply.text); - - for (const x of extractMentions(ast)) { - const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; - - // 自分は除外 - if (this.$i.username == x.username && x.host == null) continue; - if (this.$i.username == x.username && x.host == host) continue; - - // 重複は除外 - if (this.text.indexOf(`${mention} `) != -1) continue; - - this.text += `${mention} `; - } - } - - if (this.channel) { - this.visibility = 'public'; - this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す - } - - // 公開以外へのリプライ時は元の公開範囲を引き継ぐ - if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { - this.visibility = this.reply.visibility; - if (this.reply.visibility === 'specified') { - os.api('users/show', { - userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId) - }).then(users => { - this.visibleUsers.push(...users); - }); - - if (this.reply.userId !== this.$i.id) { - os.api('users/show', { userId: this.reply.userId }).then(user => { - this.visibleUsers.push(user); - }); - } - } - } - - if (this.specified) { - this.visibility = 'specified'; - this.visibleUsers.push(this.specified); - } - - // keep cw when reply - if (this.$store.state.keepCw && this.reply && this.reply.cw) { - this.useCw = true; - this.cw = this.reply.cw; - } - - if (this.autofocus) { - this.focus(); - - this.$nextTick(() => { - this.focus(); - }); - } - - // TODO: detach when unmount - new Autocomplete(this.$refs.text, this, { model: 'text' }); - new Autocomplete(this.$refs.cw, this, { model: 'cw' }); - - this.$nextTick(() => { - // 書きかけの投稿を復元 - if (!this.share && !this.mention && !this.specified) { - const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; - if (draft) { - this.text = draft.data.text; - this.useCw = draft.data.useCw; - this.cw = draft.data.cw; - this.visibility = draft.data.visibility; - this.localOnly = draft.data.localOnly; - this.files = (draft.data.files || []).filter(e => e); - if (draft.data.poll) { - this.poll = draft.data.poll; - } - } - } - - // 削除して編集 - if (this.initialNote) { - const init = this.initialNote; - this.text = init.text ? init.text : ''; - this.files = init.files; - this.cw = init.cw; - this.useCw = init.cw != null; - if (init.poll) { - this.poll = init.poll; - } - this.visibility = init.visibility; - this.localOnly = init.localOnly; - this.quoteId = init.renote ? init.renote.id : null; - } - - this.$nextTick(() => this.watch()); - }); - }, - - methods: { - watch() { - this.$watch('text', () => this.saveDraft()); - this.$watch('useCw', () => this.saveDraft()); - this.$watch('cw', () => this.saveDraft()); - this.$watch('poll', () => this.saveDraft()); - this.$watch('files', () => this.saveDraft(), { deep: true }); - this.$watch('visibility', () => this.saveDraft()); - this.$watch('localOnly', () => this.saveDraft()); - }, - - togglePoll() { - if (this.poll) { - this.poll = null; - } else { - this.poll = { - choices: ['', ''], - multiple: false, - expiresAt: null, - expiredAfter: null, - }; - } - }, - - addTag(tag: string) { - insertTextAtCursor(this.$refs.text, ` #${tag} `); - }, - - focus() { - (this.$refs.text as any).focus(); - }, - - chooseFileFrom(ev) { - selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { - for (const file of files) { - this.files.push(file); - } - }); - }, - - detachFile(id) { - this.files = this.files.filter(x => x.id != id); - }, - - updateFiles(files) { - this.files = files; - }, - - updateFileSensitive(file, sensitive) { - this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive; - }, - - updateFileName(file, name) { - this.files[this.files.findIndex(x => x.id === file.id)].name = name; - }, - - upload(file: File, name?: string) { - os.upload(file, this.$store.state.uploadFolder, name).then(res => { - this.files.push(res); - }); - }, - - onPollUpdate(poll) { - this.poll = poll; - this.saveDraft(); - }, - - setVisibility() { - if (this.channel) { - // TODO: information dialog - return; - } - - os.popup(import('@client/components/visibility-picker.vue'), { - currentVisibility: this.visibility, - currentLocalOnly: this.localOnly, - src: this.$refs.visibilityButton - }, { - changeVisibility: visibility => { - this.visibility = visibility; - if (this.$store.state.rememberNoteVisibility) { - this.$store.set('visibility', visibility); - } - }, - changeLocalOnly: localOnly => { - this.localOnly = localOnly; - if (this.$store.state.rememberNoteVisibility) { - this.$store.set('localOnly', localOnly); - } - } - }, 'closed'); - }, - - addVisibleUser() { - os.selectUser().then(user => { - this.visibleUsers.push(user); - }); - }, - - removeVisibleUser(user) { - this.visibleUsers = erase(user, this.visibleUsers); - }, - - clear() { - this.text = ''; - this.files = []; - this.poll = null; - this.quoteId = null; - }, - - onKeydown(e: KeyboardEvent) { - if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); - if (e.which === 27) this.$emit('esc'); - this.typing(); - }, - - onCompositionUpdate(e: CompositionEvent) { - this.imeText = e.data; - this.typing(); - }, - - onCompositionEnd(e: CompositionEvent) { - this.imeText = ''; - }, - - async onPaste(e: ClipboardEvent) { - for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { - if (item.kind == 'file') { - const file = item.getAsFile(); - const lio = file.name.lastIndexOf('.'); - const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; - this.upload(file, formatted); - } - } - - const paste = e.clipboardData.getData('text'); - - if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { - e.preventDefault(); - - os.dialog({ - type: 'info', - text: this.$ts.quoteQuestion, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) { - insertTextAtCursor(this.$refs.text, paste); - return; - } - - this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; - }); - } - }, - - onDragover(e) { - if (!e.dataTransfer.items[0]) return; - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { - e.preventDefault(); - this.draghover = true; - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } - }, - - onDragenter(e) { - this.draghover = true; - }, - - onDragleave(e) { - this.draghover = false; - }, - - onDrop(e): void { - this.draghover = false; - - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - e.preventDefault(); - for (const x of Array.from(e.dataTransfer.files)) this.upload(x); - return; - } - - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - this.files.push(file); - e.preventDefault(); - } - //#endregion - }, - - saveDraft() { - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); - - data[this.draftKey] = { - updatedAt: new Date(), - data: { - text: this.text, - useCw: this.useCw, - cw: this.cw, - visibility: this.visibility, - localOnly: this.localOnly, - files: this.files, - poll: this.poll - } - }; - - localStorage.setItem('drafts', JSON.stringify(data)); - }, - - deleteDraft() { - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); - - delete data[this.draftKey]; - - localStorage.setItem('drafts', JSON.stringify(data)); - }, - - async post() { - let data = { - text: this.text == '' ? undefined : this.text, - fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, - replyId: this.reply ? this.reply.id : undefined, - renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, - channelId: this.channel ? this.channel : undefined, - poll: this.poll, - cw: this.useCw ? this.cw || '' : undefined, - localOnly: this.localOnly, - visibility: this.visibility, - visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, - viaMobile: isMobile - }; - - // plugin - if (notePostInterruptors.length > 0) { - for (const interruptor of notePostInterruptors) { - data = await interruptor.handler(JSON.parse(JSON.stringify(data))); - } - } - - this.posting = true; - os.api('notes/create', data).then(() => { - this.clear(); - this.$nextTick(() => { - this.deleteDraft(); - this.$emit('posted'); - if (this.text && this.text != '') { - const hashtags = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); - const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; - localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); - } - this.posting = false; - }); - }).catch(err => { - this.posting = false; - os.dialog({ - type: 'error', - text: err.message + '\n' + (err as any).id, - }); - }); - }, - - cancel() { - this.$emit('cancel'); - }, - - insertMention() { - os.selectUser().then(user => { - insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' '); - }); - }, - - async insertEmoji(ev) { - os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); - }, - - showActions(ev) { - os.popupMenu(postFormActions.map(action => ({ - text: action.title, - action: () => { - action.handler({ - text: this.text - }, (key, value) => { - if (key === 'text') { this.text = value; } - }); - } - })), ev.currentTarget || ev.target); - } - } -}); -</script> - -<style lang="scss" scoped> -.pxiwixjf { - position: relative; - border: solid 0.5px var(--divider); - border-radius: 8px; - - > .form { - > .preview { - padding: 16px; - } - - > .with-quote { - margin: 0 0 8px 0; - color: var(--accent); - - > button { - padding: 4px 8px; - color: var(--accentAlpha04); - - &:hover { - color: var(--accentAlpha06); - } - - &:active { - color: var(--accentDarken30); - } - } - } - - > .to-specified { - padding: 6px 24px; - margin-bottom: 8px; - overflow: auto; - white-space: nowrap; - - > .visibleUsers { - display: inline; - top: -1px; - font-size: 14px; - - > button { - padding: 4px; - border-radius: 8px; - } - - > span { - margin-right: 14px; - padding: 8px 0 8px 8px; - border-radius: 8px; - background: var(--X4); - - > button { - padding: 4px 8px; - } - } - } - } - - > .cw, - > .text { - display: block; - box-sizing: border-box; - padding: 16px; - margin: 0; - width: 100%; - font-size: 16px; - border: none; - border-radius: 0; - background: transparent; - color: var(--fg); - font-family: inherit; - - &:focus { - outline: none; - } - - &:disabled { - opacity: 0.5; - } - } - - > .cw { - z-index: 1; - padding-bottom: 8px; - border-bottom: solid 0.5px var(--divider); - } - - > .text { - max-width: 100%; - min-width: 100%; - min-height: 60px; - - &.withCw { - padding-top: 8px; - } - } - - > footer { - $height: 44px; - display: flex; - padding: 0 8px 8px 8px; - line-height: $height; - - > .left { - > button { - display: inline-block; - padding: 0; - margin: 0; - font-size: 16px; - width: $height; - height: $height; - border-radius: 6px; - - &:hover { - background: var(--X5); - } - - &.active { - color: var(--accent); - } - } - } - - > .right { - margin-left: auto; - - > .text-count { - opacity: 0.7; - } - - > .visibility { - width: $height; - margin: 0 8px; - - & + .localOnly { - margin-left: 0 !important; - } - } - - > .local-only { - margin: 0 0 0 12px; - opacity: 0.7; - } - - > .submit { - margin: 0; - padding: 0 12px; - line-height: 34px; - font-weight: bold; - border-radius: 4px; - - &:disabled { - opacity: 0.7; - } - - > i { - margin-left: 6px; - } - } - } - } - } -} -</style> diff --git a/src/client/ui/chat/side.vue b/src/client/ui/chat/side.vue deleted file mode 100644 index a8a538c75b..0000000000 --- a/src/client/ui/chat/side.vue +++ /dev/null @@ -1,157 +0,0 @@ -<template> -<div class="mrajymqm _narrow_" v-if="component"> - <header class="header" @contextmenu.prevent.stop="onContextmenu"> - <MkHeader class="title" :info="pageInfo" :center="false"/> - </header> - <component :is="component" v-bind="props" :ref="changePage" class="body"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - }, - - provide() { - return { - navHook: (path) => { - this.navigate(path); - } - }; - }, - - data() { - return { - path: null, - component: null, - props: {}, - pageInfo: null, - history: [], - }; - }, - - computed: { - url(): string { - return url + this.path; - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - navigate(path, record = true) { - if (record && this.path) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - this.$emit('open'); - }, - - back() { - this.navigate(this.history.pop(), false); - }, - - close() { - this.path = null; - this.component = null; - this.props = {}; - this.$emit('close'); - }, - - onContextmenu(e) { - os.contextMenu([{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: () => { - this.$router.push(this.path); - this.close(); - } - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(this.path); - this.close(); - } - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }], e); - } - } -}); -</script> - -<style lang="scss" scoped> -.mrajymqm { - $header-height: 54px; // TODO: どこかに集約したい - - --root-margin: 16px; - --margin: var(--marginHalf); - - height: 100%; - overflow: auto; - box-sizing: border-box; - - > .header { - display: flex; - position: sticky; - z-index: 1000; - top: 0; - height: $header-height; - width: 100%; - font-weight: bold; - //background-color: var(--panel); - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--header); - border-bottom: solid 0.5px var(--divider); - box-sizing: border-box; - - > ._button { - height: $header-height; - width: $header-height; - - &:hover { - color: var(--fgHighlighted); - } - } - - > .title { - flex: 1; - position: relative; - } - } - - > .body { - - } -} -</style> - diff --git a/src/client/ui/chat/store.ts b/src/client/ui/chat/store.ts deleted file mode 100644 index 389d56afb6..0000000000 --- a/src/client/ui/chat/store.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { markRaw } from 'vue'; -import { Storage } from '../../pizzax'; - -export const store = markRaw(new Storage('chatUi', { - widgets: { - where: 'account', - default: [] as { - name: string; - id: string; - data: Record<string, any>; - }[] - }, - tl: { - where: 'deviceAccount', - default: 'home' - }, -})); diff --git a/src/client/ui/chat/sub-note-content.vue b/src/client/ui/chat/sub-note-content.vue deleted file mode 100644 index 8a3cf1160f..0000000000 --- a/src/client/ui/chat/sub-note-content.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<div class="wrmlmaau"> - <div class="body"> - <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> - <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> - <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> - </div> - <details v-if="note.files.length > 0"> - <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> - <XMediaList :media-list="note.files"/> - </details> - <details v-if="note.poll"> - <summary>{{ $ts.poll }}</summary> - <XPoll :note="note"/> - </details> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XPoll from '@client/components/poll.vue'; -import XMediaList from '@client/components/media-list.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XPoll, - XMediaList, - }, - props: { - note: { - type: Object, - required: true - } - }, - data() { - return { - }; - } -}); -</script> - -<style lang="scss" scoped> -.wrmlmaau { - overflow-wrap: break-word; - - > .body { - > .reply { - margin-right: 6px; - color: var(--accent); - } - - > .rp { - margin-left: 4px; - font-style: oblique; - color: var(--renote); - } - } -} -</style> diff --git a/src/client/ui/chat/widgets.vue b/src/client/ui/chat/widgets.vue deleted file mode 100644 index 4d1865f616..0000000000 --- a/src/client/ui/chat/widgets.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<div class="qydbhufi"> - <XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> - - <button v-if="edit" @click="edit = false" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgetsExit }}</button> - <button v-else @click="edit = true" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgets }}</button> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import XWidgets from '@client/components/widgets.vue'; -import { store } from './store'; - -export default defineComponent({ - components: { - XWidgets, - }, - - data() { - return { - edit: false, - widgets: store.reactiveState.widgets - }; - }, - - methods: { - addWidget(widget) { - store.set('widgets', [widget, ...store.state.widgets]); - }, - - removeWidget(widget) { - store.set('widgets', store.state.widgets.filter(w => w.id != widget.id)); - }, - - updateWidget({ id, data }) { - // TODO: throttleしたい - store.set('widgets', store.state.widgets.map(w => w.id === id ? { - ...w, - data: data - } : w)); - }, - - updateWidgets(widgets) { - store.set('widgets', widgets); - } - } -}); -</script> - -<style lang="scss" scoped> -.qydbhufi { - height: 100%; - box-sizing: border-box; - overflow: auto; - padding: var(--margin); - - ::v-deep(._panel) { - box-shadow: none; - } -} -</style> diff --git a/src/client/ui/classic.header.vue b/src/client/ui/classic.header.vue deleted file mode 100644 index ad5080e3f6..0000000000 --- a/src/client/ui/classic.header.vue +++ /dev/null @@ -1,210 +0,0 @@ -<template> -<div class="azykntjl"> - <div class="body"> - <div class="left"> - <MkA class="item index" active-class="active" to="/" exact v-click-anime v-tooltip="$ts.timeline"> - <i class="fas fa-home fa-fw"></i> - </MkA> - <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 ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime v-tooltip="$ts[menuDef[item].title]"> - <i class="fa-fw" :class="menuDef[item].icon"></i> - <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> - </component> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.controlPanel"> - <i class="fas fa-door-open fa-fw"></i> - </MkA> - <button class="item _button" @click="more" v-click-anime> - <i class="fas fa-ellipsis-h fa-fw"></i> - <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> - </button> - </div> - <div class="right"> - <MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.settings"> - <i class="fas fa-cog fa-fw"></i> - </MkA> - <button class="item _button account" @click="openAccountMenu" v-click-anime> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/> - </button> - <div class="post" @click="post"> - <MkButton class="button" gradate full rounded> - <i class="fas fa-pencil-alt fa-fw"></i> - </MkButton> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { host } from '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import { openAccountMenu } from '@client/account'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - MkButton, - }, - - data() { - return { - host: host, - accounts: [], - connection: null, - menuDef: menuDef, - settingsWindowed: false, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - - otherNavItemIndicated(): boolean { - for (const def in this.menuDef) { - if (this.menu.includes(def)) continue; - if (this.menuDef[def].indicated) return true; - } - return false; - }, - }, - - watch: { - '$store.reactiveState.menuDisplay.value'() { - this.calcViewState(); - }, - }, - - created() { - window.addEventListener('resize', this.calcViewState); - this.calcViewState(); - }, - - methods: { - calcViewState() { - this.settingsWindowed = (window.innerWidth > 1400); - }, - - post() { - os.post(); - }, - - search() { - search(); - }, - - more(ev) { - os.popup(import('@client/components/launch-pad.vue'), {}, { - }, 'closed'); - }, - - openAccountMenu, - } -}); -</script> - -<style lang="scss" scoped> -.azykntjl { - $height: 60px; - $avatar-size: 32px; - $avatar-margin: 8px; - - position: sticky; - top: 0; - z-index: 1000; - width: 100%; - height: $height; - background-color: var(--bg); - - > .body { - max-width: 1380px; - margin: 0 auto; - display: flex; - - > .right, - > .left { - - > .item { - position: relative; - font-size: 0.9em; - display: inline-block; - padding: 0 12px; - line-height: $height; - - > i, - > .avatar { - margin-right: 0; - } - - > i { - left: 10px; - } - - > .avatar { - width: $avatar-size; - height: $avatar-size; - vertical-align: middle; - } - - > .indicator { - position: absolute; - top: 0; - left: 0; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } - - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } - - &.active { - color: var(--navActive); - } - } - - > .divider { - display: inline-block; - height: 16px; - margin: 0 10px; - border-right: solid 0.5px var(--divider); - } - - > .post { - display: inline-block; - - > .button { - width: 40px; - height: 40px; - padding: 0; - min-width: 0; - } - } - - > .account { - display: inline-flex; - align-items: center; - vertical-align: top; - margin-right: 8px; - - > .acct { - margin-left: 8px; - } - } - } - - > .right { - margin-left: auto; - } - } -} -</style> diff --git a/src/client/ui/classic.side.vue b/src/client/ui/classic.side.vue deleted file mode 100644 index c7d2abff26..0000000000 --- a/src/client/ui/classic.side.vue +++ /dev/null @@ -1,158 +0,0 @@ -<template> -<div class="qvzfzxam _narrow_" v-if="component"> - <div class="container"> - <header class="header" @contextmenu.prevent.stop="onContextmenu"> - <button class="_button" @click="back()" v-if="history.length > 0"><i class="fas fa-chevron-left"></i></button> - <button class="_button" style="pointer-events: none;" v-else><!-- マージンのバランスを取るためのダミー --></button> - <span class="title">{{ pageInfo.title }}</span> - <button class="_button" @click="close()"><i class="fas fa-times"></i></button> - </header> - <MkHeader class="pageHeader" :info="pageInfo"/> - <component :is="component" v-bind="props" :ref="changePage"/> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - provide() { - return { - navHook: (path) => { - this.navigate(path); - } - }; - }, - - data() { - return { - path: null, - component: null, - props: {}, - pageInfo: null, - history: [], - }; - }, - - computed: { - url(): string { - return url + this.path; - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - navigate(path, record = true) { - if (record && this.path) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - }, - - back() { - this.navigate(this.history.pop(), false); - }, - - close() { - this.path = null; - this.component = null; - this.props = {}; - }, - - onContextmenu(e) { - os.contextMenu([{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: () => { - this.$router.push(this.path); - this.close(); - } - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(this.path); - this.close(); - } - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }], e); - } - } -}); -</script> - -<style lang="scss" scoped> -.qvzfzxam { - $header-height: 58px; // TODO: どこかに集約したい - - --root-margin: 16px; - --margin: var(--marginHalf); - - > .container { - position: fixed; - width: 370px; - height: 100vh; - overflow: auto; - box-sizing: border-box; - - > .header { - display: flex; - position: sticky; - z-index: 1000; - top: 0; - height: $header-height; - width: 100%; - line-height: $header-height; - text-align: center; - font-weight: bold; - //background-color: var(--panel); - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--header); - - > ._button { - height: $header-height; - width: $header-height; - - &:hover { - color: var(--fgHighlighted); - } - } - - > .title { - flex: 1; - position: relative; - } - } - } -} -</style> - diff --git a/src/client/ui/classic.sidebar.vue b/src/client/ui/classic.sidebar.vue deleted file mode 100644 index ac061d446b..0000000000 --- a/src/client/ui/classic.sidebar.vue +++ /dev/null @@ -1,263 +0,0 @@ -<template> -<div class="npcljfve" :class="{ iconOnly }"> - <button class="item _button account" @click="openAccountMenu" v-click-anime> - <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> - </button> - <div class="post" @click="post" data-cy-open-post-form> - <MkButton class="button" gradate full rounded> - <i class="fas fa-pencil-alt fa-fw"></i><span class="text" v-if="!iconOnly">{{ $ts.note }}</span> - </MkButton> - </div> - <div class="divider"></div> - <MkA class="item index" active-class="active" to="/" exact v-click-anime> - <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> - </MkA> - <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 ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime> - <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> - <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> - </component> - </template> - <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime> - <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> - </MkA> - <button class="item _button" @click="more" v-click-anime> - <i class="fas fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> - </button> - <MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime> - <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> - </MkA> - <div class="divider"></div> - <div class="about"> - <MkA class="link" to="/about" v-click-anime> - <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/> - </MkA> - </div> - <!--<MisskeyLogo class="misskey"/>--> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { host } from '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import { openAccountMenu } from '@client/account'; -import MkButton from '@client/components/ui/button.vue'; -import { StickySidebar } from '@client/scripts/sticky-sidebar'; -import MisskeyLogo from '@/../assets/client/misskey.svg'; - -export default defineComponent({ - components: { - MkButton, - MisskeyLogo, - }, - - data() { - return { - host: host, - accounts: [], - connection: null, - menuDef: menuDef, - iconOnly: false, - settingsWindowed: false, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - - otherNavItemIndicated(): boolean { - for (const def in this.menuDef) { - if (this.menu.includes(def)) continue; - if (this.menuDef[def].indicated) return true; - } - return false; - }, - }, - - watch: { - '$store.reactiveState.menuDisplay.value'() { - this.calcViewState(); - }, - - iconOnly() { - this.$nextTick(() => { - this.$emit('change-view-mode'); - }); - }, - }, - - created() { - window.addEventListener('resize', this.calcViewState); - this.calcViewState(); - }, - - mounted() { - const sticky = new StickySidebar(this.$el.parentElement, 16); - window.addEventListener('scroll', () => { - sticky.calc(window.scrollY); - }, { passive: true }); - }, - - methods: { - calcViewState() { - this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon'); - this.settingsWindowed = (window.innerWidth > 1400); - }, - - post() { - os.post(); - }, - - search() { - search(); - }, - - more(ev) { - os.popup(import('@client/components/launch-pad.vue'), {}, { - }, 'closed'); - }, - - openAccountMenu, - } -}); -</script> - -<style lang="scss" scoped> -.npcljfve { - $ui-font-size: 1em; // TODO: どこかに集約したい - $nav-icon-only-width: 78px; // TODO: どこかに集約したい - $avatar-size: 32px; - $avatar-margin: 8px; - - padding: 0 16px; - box-sizing: border-box; - width: 260px; - - &.iconOnly { - flex: 0 0 $nav-icon-only-width; - width: $nav-icon-only-width !important; - - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - } - - > .post { - > .button { - width: 46px; - height: 46px; - padding: 0; - } - } - - > .item { - padding-left: 0; - width: 100%; - text-align: center; - font-size: $ui-font-size * 1.1; - line-height: 3.7rem; - - > i, - > .avatar { - margin-right: 0; - } - - > i { - left: 10px; - } - - > .text { - display: none; - } - } - } - - > .divider { - margin: 10px 0; - border-top: solid 0.5px var(--divider); - } - - > .post { - position: sticky; - top: 0; - z-index: 1; - padding: 16px 0; - background: var(--bg); - - > .button { - min-width: 0; - } - } - - > .about { - fill: currentColor; - padding: 8px 0 16px 0; - text-align: center; - - > .link { - display: block; - width: 32px; - margin: 0 auto; - - img { - display: block; - width: 100%; - } - } - } - - > .item { - position: relative; - display: block; - font-size: $ui-font-size; - line-height: 2.6rem; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - text-align: left; - box-sizing: border-box; - - > i { - width: 32px; - } - - > i, - > .avatar { - margin-right: $avatar-margin; - } - - > .avatar { - width: $avatar-size; - height: $avatar-size; - vertical-align: middle; - } - - > .indicator { - position: absolute; - top: 0; - left: 0; - color: var(--navIndicator); - font-size: 8px; - animation: blink 1s infinite; - } - - &:hover { - text-decoration: none; - color: var(--navHoverFg); - } - - &.active { - color: var(--navActive); - } - } -} -</style> diff --git a/src/client/ui/classic.vue b/src/client/ui/classic.vue deleted file mode 100644 index 79cdbe3af1..0000000000 --- a/src/client/ui/classic.vue +++ /dev/null @@ -1,471 +0,0 @@ -<template> -<div class="mk-app" :class="{ wallpaper, isMobile }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`"> - <XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/> - - <div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }"> - <template v-if="!isMobile"> - <div class="sidebar" v-if="!showMenuOnTop"> - <XSidebar/> - </div> - <div class="widgets left" ref="widgetsLeft" v-else> - <XWidgets @mounted="attachSticky('widgetsLeft')" :place="'left'"/> - </div> - </template> - - <main class="main" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }"> - <div class="content"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['timeline']"> - <component :is="Component" :ref="changePage"/> - </keep-alive> - </transition> - </router-view> - </MkStickyContainer> - </div> - </main> - - <div v-if="isDesktop" class="widgets right" ref="widgetsRight"> - <XWidgets @mounted="attachSticky('widgetsRight')" :place="null"/> - </div> - </div> - - <div class="buttons" v-if="isMobile"> - <button class="button nav _button" @click="showDrawerNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> - <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button> - <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> - <button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> - <button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button> - </div> - - <XDrawerSidebar ref="drawerNav" class="sidebar" v-if="isMobile"/> - - <transition name="tray-back"> - <div class="tray-back _modalBg" - v-if="widgetsShowing" - @click="widgetsShowing = false" - @touchstart.passive="widgetsShowing = false" - ></div> - </transition> - - <transition name="tray"> - <XWidgets v-if="widgetsShowing" class="tray"/> - </transition> - - <iframe v-if="$store.state.aiChanMode" class="ivnzpscs" ref="live2d" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> - - <XCommon/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent, markRaw } from 'vue'; -import { instanceName } from '@client/config'; -import { StickySidebar } from '@client/scripts/sticky-sidebar'; -import XSidebar from './classic.sidebar.vue'; -import XDrawerSidebar from '@client/ui/_common_/sidebar.vue'; -import XCommon from './_common_/common.vue'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import * as symbols from '@client/symbols'; - -const DESKTOP_THRESHOLD = 1100; -const MOBILE_THRESHOLD = 600; - -export default defineComponent({ - components: { - XCommon, - XSidebar, - XDrawerSidebar, - XHeaderMenu: defineAsyncComponent(() => import('./classic.header.vue')), - XWidgets: defineAsyncComponent(() => import('./classic.widgets.vue')), - }, - - provide() { - return { - shouldHeaderThin: this.showMenuOnTop, - }; - }, - - data() { - return { - pageInfo: null, - menuDef: menuDef, - globalHeaderHeight: 0, - isMobile: window.innerWidth <= MOBILE_THRESHOLD, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - widgetsShowing: false, - fullView: false, - wallpaper: localStorage.getItem('wallpaper') != null, - }; - }, - - computed: { - navIndicated(): boolean { - for (const def in this.menuDef) { - if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから - if (this.menuDef[def].indicated) return true; - } - return false; - }, - - showMenuOnTop(): boolean { - return !this.isMobile && this.$store.state.menuDisplay === 'top'; - } - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - - if (this.$store.state.widgets.length === 0) { - this.$store.set('widgets', [{ - name: 'calendar', - id: 'a', place: null, data: {} - }, { - name: 'notifications', - id: 'b', place: null, data: {} - }, { - name: 'trends', - id: 'c', place: null, data: {} - }]); - } - }, - - mounted() { - window.addEventListener('resize', () => { - this.isMobile = (window.innerWidth <= MOBILE_THRESHOLD); - this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD); - }, { passive: true }); - - if (this.$store.state.aiChanMode) { - const iframeRect = this.$refs.live2d.getBoundingClientRect(); - window.addEventListener('mousemove', ev => { - this.$refs.live2d.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.clientX - iframeRect.left, - y: ev.clientY - iframeRect.top, - } - }, '*'); - }, { passive: true }); - window.addEventListener('touchmove', ev => { - this.$refs.live2d.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.touches[0].clientX - iframeRect.left, - y: ev.touches[0].clientY - iframeRect.top, - } - }, '*'); - }, { passive: true }); - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - document.title = `${this.pageInfo.title} | ${instanceName}`; - } - }, - - attachSticky(ref) { - const sticky = new StickySidebar(this.$refs[ref], this.$store.state.menuDisplay === 'top' ? 0 : 16, this.$store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す - window.addEventListener('scroll', () => { - sticky.calc(window.scrollY); - }, { passive: true }); - }, - - post() { - os.post(); - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - back() { - history.back(); - }, - - showDrawerNav() { - this.$refs.drawerNav.show(); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; - if (window.getSelection().toString() !== '') return; - const path = this.$route.path; - os.contextMenu([{ - type: 'label', - text: path, - }, { - icon: this.fullView ? 'fas fa-compress' : 'fas fa-expand', - text: this.fullView ? this.$ts.quitFullView : this.$ts.fullView, - action: () => { - this.fullView = !this.fullView; - } - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(path); - } - }], e); - }, - - onAiClick(ev) { - //if (this.live2d) this.live2d.click(ev); - } - } -}); -</script> - -<style lang="scss" scoped> -.tray-enter-active, -.tray-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); -} -.tray-enter-from, -.tray-leave-active { - opacity: 0; - transform: translateX(240px); -} - -.tray-back-enter-active, -.tray-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-back-enter-from, -.tray-back-leave-active { - opacity: 0; -} - -.mk-app { - $ui-font-size: 1em; - $widgets-hide-threshold: 1200px; - $nav-icon-only-width: 78px; // TODO: どこかに集約したい - - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - - &.wallpaper { - background: var(--wallpaperOverlay); - //backdrop-filter: var(--blur, blur(4px)); - } - - &.isMobile { - > .columns { - display: block; - margin: 0; - - > .main { - margin: 0; - padding-bottom: 92px; - border: none; - width: 100%; - border-radius: 0; - } - } - } - - > .columns { - display: flex; - justify-content: center; - max-width: 100%; - //margin: 32px 0; - - &.fullView { - margin: 0; - - > .sidebar { - display: none; - } - - > .widgets { - display: none; - } - - > .main { - margin: 0; - border-radius: 0; - box-shadow: none; - width: 100%; - } - } - - > .main { - min-width: 0; - width: 750px; - margin: 0 16px 0 0; - background: var(--panel); - border-left: solid 1px var(--divider); - border-right: solid 1px var(--divider); - border-radius: 0; - overflow: clip; - --margin: 12px; - } - - > .widgets { - //--panelBorder: none; - width: 300px; - margin-top: 16px; - - @media (max-width: $widgets-hide-threshold) { - display: none; - } - - &.left { - margin-right: 16px; - } - } - - > .sidebar { - margin-top: 16px; - } - - &.withGlobalHeader { - > .main { - margin-top: 0; - border: solid 1px var(--divider); - border-radius: var(--radius); - --stickyTop: var(--globalHeaderHeight); - } - - > .widgets { - --stickyTop: var(--globalHeaderHeight); - margin-top: 0; - } - } - - @media (max-width: 850px) { - margin: 0; - - > .sidebar { - border-right: solid 0.5px var(--divider); - } - - > .main { - margin: 0; - border-radius: 0; - box-shadow: none; - width: 100%; - } - } - } - - > .buttons { - position: fixed; - z-index: 1000; - bottom: 0; - padding: 16px; - display: flex; - width: 100%; - box-sizing: border-box; - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--header); - border-top: solid 0.5px var(--divider); - - > .button { - position: relative; - flex: 1; - padding: 0; - margin: auto; - height: 64px; - border-radius: 8px; - background: var(--panel); - color: var(--fg); - - &:not(:last-child) { - margin-right: 12px; - } - - @media (max-width: 400px) { - height: 60px; - - &:not(:last-child) { - margin-right: 8px; - } - } - - &:hover { - background: var(--X2); - } - - > .indicator { - position: absolute; - top: 0; - left: 0; - color: var(--indicator); - font-size: 16px; - animation: blink 1s infinite; - } - - &:first-child { - margin-left: 0; - } - - &:last-child { - margin-right: 0; - } - - > * { - font-size: 22px; - } - - &:disabled { - cursor: default; - - > * { - opacity: 0.5; - } - } - } - } - - > .tray-back { - z-index: 1001; - } - - > .tray { - position: fixed; - top: 0; - right: 0; - z-index: 1001; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); - padding: var(--margin); - box-sizing: border-box; - overflow: auto; - background: var(--bg); - } - - > .ivnzpscs { - position: fixed; - bottom: 0; - right: 0; - width: 300px; - height: 600px; - border: none; - pointer-events: none; - } -} -</style> diff --git a/src/client/ui/classic.widgets.vue b/src/client/ui/classic.widgets.vue deleted file mode 100644 index f9584402a2..0000000000 --- a/src/client/ui/classic.widgets.vue +++ /dev/null @@ -1,84 +0,0 @@ -<template> -<div class="ddiqwdnk"> - <XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> - <MkAd class="a" :prefer="['square']"/> - - <button v-if="editMode" @click="editMode = false" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button> - <button v-else @click="editMode = true" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import XWidgets from '@client/components/widgets.vue'; - -export default defineComponent({ - components: { - XWidgets - }, - - props: { - place: { - type: String, - } - }, - - emits: ['mounted'], - - data() { - return { - editMode: false, - }; - }, - - mounted() { - this.$emit('mounted', this.$el); - }, - - methods: { - addWidget(widget) { - this.$store.set('widgets', [{ - ...widget, - place: this.place, - }, ...this.$store.state.widgets]); - }, - - removeWidget(widget) { - this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id)); - }, - - updateWidget({ id, data }) { - this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? { - ...w, - data: data - } : w)); - }, - - updateWidgets(widgets) { - this.$store.set('widgets', [ - ...this.$store.state.widgets.filter(w => w.place !== this.place), - ...widgets - ]); - } - } -}); -</script> - -<style lang="scss" scoped> -.ddiqwdnk { - position: sticky; - height: min-content; - box-sizing: border-box; - padding-bottom: 8px; - - > .widgets, - > .a { - width: 300px; - } - - > .edit { - display: block; - margin: 16px auto; - } -} -</style> diff --git a/src/client/ui/deck.vue b/src/client/ui/deck.vue deleted file mode 100644 index 4b0189ba77..0000000000 --- a/src/client/ui/deck.vue +++ /dev/null @@ -1,229 +0,0 @@ -<template> -<div class="mk-deck" :class="`${deckStore.reactiveState.columnAlign.value}`" @contextmenu.self.prevent="onContextmenu" - :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }" -> - <XSidebar ref="nav"/> - - <template v-for="ids in layout"> - <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> - <section v-if="ids.length > 1" - class="folder column" - :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" - > - <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> - </section> - <DeckColumnCore v-else - class="column" - :ref="ids[0]" - :key="ids[0]" - :column="columns.find(c => c.id === ids[0])" - @parent-focus="moveFocus(ids[0], $event)" - :style="columns.find(c => c.id === ids[0]).flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0]).width + 'px' }" - /> - </template> - - <button v-if="$i" class="nav _button" @click="showNav()"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> - <button v-if="$i" class="post _buttonPrimary" @click="post()"><i class="fas fa-pencil-alt"></i></button> - - <XCommon/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { v4 as uuid } from 'uuid'; -import { host } from '@client/config'; -import DeckColumnCore from '@client/ui/deck/column-core.vue'; -import XSidebar from '@client/ui/_common_/sidebar.vue'; -import { getScrollContainer } from '@client/scripts/scroll'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import XCommon from './_common_/common.vue'; -import { deckStore, addColumn, loadDeck } from './deck/deck-store'; - -export default defineComponent({ - components: { - XCommon, - XSidebar, - DeckColumnCore, - }, - - provide() { - return deckStore.state.navWindow ? { - navHook: (url) => { - os.pageWindow(url); - } - } : {}; - }, - - data() { - return { - deckStore, - host: host, - menuDef: menuDef, - wallpaper: localStorage.getItem('wallpaper') != null, - }; - }, - - computed: { - columns() { - return deckStore.reactiveState.columns.value; - }, - layout() { - return deckStore.reactiveState.layout.value; - }, - navIndicated(): boolean { - if (!this.$i) return false; - for (const def in this.menuDef) { - if (this.menuDef[def].indicated) return true; - } - return false; - }, - }, - - created() { - document.documentElement.style.overflowY = 'hidden'; - document.documentElement.style.scrollBehavior = 'auto'; - window.addEventListener('wheel', this.onWheel); - loadDeck(); - }, - - mounted() { - }, - - methods: { - onWheel(e) { - if (getScrollContainer(e.target) == null) { - document.documentElement.scrollLeft += e.deltaY > 0 ? 96 : -96; - } - }, - - showNav() { - this.$refs.nav.show(); - }, - - post() { - os.post(); - }, - - async addColumn(ev) { - const columns = [ - 'main', - 'widgets', - 'notifications', - 'tl', - 'antenna', - 'list', - 'mentions', - 'direct', - ]; - - const { canceled, result: column } = await os.dialog({ - title: this.$ts._deck.addColumn, - type: null, - select: { - items: columns.map(column => ({ - value: column, text: this.$t('_deck._columns.' + column) - })) - }, - showCancelButton: true - }); - if (canceled) return; - - addColumn({ - type: column, - id: uuid(), - name: this.$t('_deck._columns.' + column), - width: 330, - }); - }, - - onContextmenu(e) { - os.contextMenu([{ - text: this.$ts._deck.addColumn, - icon: null, - action: this.addColumn - }], e); - }, - } -}); -</script> - -<style lang="scss" scoped> -.mk-deck { - $nav-hide-threshold: 650px; // TODO: どこかに集約したい - - // TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい - --margin: var(--marginHalf); - - display: flex; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - flex: 1; - padding: var(--deckMargin); - - &.center { - > .column:first-of-type { - margin-left: auto; - } - - > .column:last-of-type { - margin-right: auto; - } - } - - > .column { - flex-shrink: 0; - margin-right: var(--deckMargin); - - &.folder { - display: flex; - flex-direction: column; - - > *:not(:last-child) { - margin-bottom: var(--deckMargin); - } - } - } - - > .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; - - @media (min-width: ($nav-hide-threshold + 1px)) { - display: none; - } - } - - > .post { - right: 32px; - } - - > .nav { - left: 32px; - background: var(--panel); - color: var(--fg); - - &:hover { - background: var(--X2); - } - - > .indicator { - position: absolute; - top: 0; - left: 0; - color: var(--indicator); - font-size: 16px; - animation: blink 1s infinite; - } - } -} -</style> diff --git a/src/client/ui/deck/antenna-column.vue b/src/client/ui/deck/antenna-column.vue deleted file mode 100644 index 3abd3d3a45..0000000000 --- a/src/client/ui/deck/antenna-column.vue +++ /dev/null @@ -1,80 +0,0 @@ -<template> -<XColumn :func="{ handler: setAntenna, title: $ts.selectAntenna }" :column="column" :is-stacked="isStacked"> - <template #header> - <i class="fas fa-satellite"></i><span style="margin-left: 8px;">{{ column.name }}</span> - </template> - - <XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XColumn from './column.vue'; -import XTimeline from '@client/components/timeline.vue'; -import * as os from '@client/os'; -import { updateColumn } from './deck-store'; - -export default defineComponent({ - components: { - XColumn, - XTimeline, - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - }; - }, - - watch: { - mediaOnly() { - (this.$refs.timeline as any).reload(); - } - }, - - mounted() { - if (this.column.antennaId == null) { - this.setAntenna(); - } - }, - - methods: { - async setAntenna() { - const antennas = await os.api('antennas/list'); - const { canceled, result: antenna } = await os.dialog({ - title: this.$ts.selectAntenna, - type: null, - select: { - items: antennas.map(x => ({ - value: x, text: x.name - })), - default: this.column.antennaId - }, - showCancelButton: true - }); - if (canceled) return; - updateColumn(this.column.id, { - antennaId: antenna.id - }); - }, - - focus() { - (this.$refs.timeline as any).focus(); - } - } -}); -</script> - -<style lang="scss" scoped> -</style> diff --git a/src/client/ui/deck/column-core.vue b/src/client/ui/deck/column-core.vue deleted file mode 100644 index 5393bac736..0000000000 --- a/src/client/ui/deck/column-core.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> -<!-- TODO: リファクタの余地がありそう --> -<XMainColumn v-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XMainColumn from './main-column.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 defineComponent({ - components: { - XMainColumn, - 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/ui/deck/column.vue b/src/client/ui/deck/column.vue deleted file mode 100644 index c04297e384..0000000000 --- a/src/client/ui/deck/column.vue +++ /dev/null @@ -1,408 +0,0 @@ -<template> -<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> -<section class="dnpfarvg _panel _narrow_" :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }" - @dragover.prevent.stop="onDragover" - @dragleave="onDragleave" - @drop.prevent.stop="onDrop" - v-hotkey="keymap" - :style="{ '--deckColumnHeaderHeight': deckStore.reactiveState.columnHeaderHeight.value + '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 && !isMainColumn"> - <template v-if="active"><i class="fas fa-angle-up"></i></template> - <template v-else><i class="fas fa-angle-down"></i></template> - </button> - <div class="action"> - <slot name="action"></slot> - </div> - <span class="header"><slot name="header"></slot></span> - <button v-if="func" class="menu _button" v-tooltip="func.title" @click.stop="func.handler"><i :class="func.icon || 'fas fa-cog'"></i></button> - </header> - <div ref="body" v-show="active"> - <slot></slot> - </div> -</section> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; -import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from './deck-store'; -import { deckStore } from './deck-store'; - -export default defineComponent({ - provide: { - shouldHeaderThin: true, - shouldOmitHeaderTitle: true, - }, - - props: { - column: { - type: Object, - required: false, - default: null - }, - isStacked: { - type: Boolean, - required: false, - default: false - }, - func: { - type: Object, - required: false, - default: null - }, - naked: { - type: Boolean, - required: false, - default: false - }, - indicated: { - type: Boolean, - required: false, - default: false - }, - }, - - data() { - return { - deckStore, - dragging: false, - draghover: false, - dropready: false, - }; - }, - - computed: { - isMainColumn(): boolean { - return this.column.type === 'main'; - }, - - active(): boolean { - return this.column.active !== false; - }, - - keymap(): any { - return { - 'shift+up': () => this.$parent.$emit('parent-focus', 'up'), - 'shift+down': () => this.$parent.$emit('parent-focus', 'down'), - 'shift+left': () => this.$parent.$emit('parent-focus', 'left'), - 'shift+right': () => this.$parent.$emit('parent-focus', 'right'), - }; - } - }, - - watch: { - active(v) { - this.$emit('change-active-state', v); - }, - - dragging(v) { - os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'); - } - }, - - mounted() { - os.deckGlobalEvents.on('column.dragStart', this.onOtherDragStart); - os.deckGlobalEvents.on('column.dragEnd', this.onOtherDragEnd); - }, - - beforeUnmount() { - os.deckGlobalEvents.off('column.dragStart', this.onOtherDragStart); - os.deckGlobalEvents.off('column.dragEnd', this.onOtherDragEnd); - }, - - methods: { - onOtherDragStart() { - this.dropready = true; - }, - - onOtherDragEnd() { - this.dropready = false; - }, - - toggleActive() { - if (!this.isStacked) return; - updateColumn(this.column.id, { - active: !this.column.active - }); - }, - - getMenu() { - const items = [{ - icon: 'fas fa-pencil-alt', - text: this.$ts.edit, - action: async () => { - const { canceled, result } = await os.form(this.column.name, { - name: { - type: 'string', - label: this.$ts.name, - default: this.column.name - }, - width: { - type: 'number', - label: this.$ts.width, - default: this.column.width - }, - flexible: { - type: 'boolean', - label: this.$ts.flexible, - default: this.column.flexible - } - }); - if (canceled) return; - updateColumn(this.column.id, result); - } - }, null, { - icon: 'fas fa-arrow-left', - text: this.$ts._deck.swapLeft, - action: () => { - swapLeftColumn(this.column.id); - } - }, { - icon: 'fas fa-arrow-right', - text: this.$ts._deck.swapRight, - action: () => { - swapRightColumn(this.column.id); - } - }, this.isStacked ? { - icon: 'fas fa-arrow-up', - text: this.$ts._deck.swapUp, - action: () => { - swapUpColumn(this.column.id); - } - } : undefined, this.isStacked ? { - icon: 'fas fa-arrow-down', - text: this.$ts._deck.swapDown, - action: () => { - swapDownColumn(this.column.id); - } - } : undefined, null, { - icon: 'fas fa-window-restore', - text: this.$ts._deck.stackLeft, - action: () => { - stackLeftColumn(this.column.id); - } - }, this.isStacked ? { - icon: 'fas fa-window-maximize', - text: this.$ts._deck.popRight, - action: () => { - popRightColumn(this.column.id); - } - } : undefined, null, { - icon: 'fas fa-trash-alt', - text: this.$ts.remove, - danger: true, - action: () => { - removeColumn(this.column.id); - } - }]; - - return items; - }, - - onContextmenu(e) { - os.contextMenu(this.getMenu(), e); - }, - - goTop() { - this.$refs.body.scrollTo({ - top: 0, - behavior: 'smooth' - }); - }, - - onDragstart(e) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, this.column.id); - - // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう - // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately - setTimeout(() => { - this.dragging = true; - }, 10); - }, - - onDragend(e) { - this.dragging = false; - }, - - onDragover(e) { - // 自分自身がドラッグされている場合 - if (this.dragging) { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - return; - } - - const isDeckColumn = e.dataTransfer.types[0] == _DATA_TRANSFER_DECK_COLUMN_; - - e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; - - if (!this.dragging && isDeckColumn) this.draghover = true; - }, - - onDragleave() { - this.draghover = false; - }, - - onDrop(e) { - this.draghover = false; - os.deckGlobalEvents.emit('column.dragEnd'); - - const id = e.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); - if (id != null && id != '') { - swapColumn(this.column.id, id); - } - } - } -}); -</script> - -<style lang="scss" scoped> -.dnpfarvg { - --root-margin: 10px; - - height: 100%; - overflow: hidden; - contain: content; - box-shadow: 0 0 8px 0 var(--shadow); - - &.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: var(--deckColumnHeaderHeight); - min-height: var(--deckColumnHeaderHeight); - - > header.indicated { - box-shadow: 4px 0px var(--accent) inset; - } - } - - &.naked { - background: var(--acrylicBg) !important; - -webkit-backdrop-filter: var(--blur, blur(10px)); - backdrop-filter: var(--blur, blur(10px)); - - > header { - background: transparent; - box-shadow: none; - - > button { - color: var(--fg); - } - } - } - - &.paged { - background: var(--bg) !important; - } - - > header { - position: relative; - display: flex; - z-index: 2; - line-height: var(--deckColumnHeaderHeight); - height: var(--deckColumnHeaderHeight); - 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 > ::v-deep(*), - > .menu { - z-index: 1; - width: var(--deckColumnHeaderHeight); - line-height: var(--deckColumnHeaderHeight); - 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 { - margin-left: auto; - margin-right: -16px; - } - } - - > div { - height: calc(100% - var(--deckColumnHeaderHeight)); - overflow: auto; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - box-sizing: border-box; - } -} -</style> diff --git a/src/client/ui/deck/deck-store.ts b/src/client/ui/deck/deck-store.ts deleted file mode 100644 index 6c61bf5539..0000000000 --- a/src/client/ui/deck/deck-store.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { throttle } from 'throttle-debounce'; -import { i18n } from '@client/i18n'; -import { api } from '@client/os'; -import { markRaw, watch } from 'vue'; -import { Storage } from '../../pizzax'; - -type ColumnWidget = { - name: string; - id: string; - data: Record<string, any>; -}; - -type Column = { - id: string; - type: string; - name: string | null; - width: number; - widgets?: ColumnWidget[]; - active?: boolean; -}; - -function copy<T>(x: T): T { - return JSON.parse(JSON.stringify(x)); -} - -export const deckStore = markRaw(new Storage('deck', { - profile: { - where: 'deviceAccount', - default: 'default' - }, - columns: { - where: 'deviceAccount', - default: [] as Column[] - }, - layout: { - where: 'deviceAccount', - default: [] as Column['id'][][] - }, - columnAlign: { - where: 'deviceAccount', - default: 'left' as 'left' | 'right' | 'center' - }, - alwaysShowMainColumn: { - where: 'deviceAccount', - default: true - }, - navWindow: { - where: 'deviceAccount', - default: true - }, - columnMargin: { - where: 'deviceAccount', - default: 16 - }, - columnHeaderHeight: { - where: 'deviceAccount', - default: 42 - }, -})); - -export const loadDeck = async () => { - let deck; - - try { - deck = await api('i/registry/get', { - scope: ['client', 'deck', 'profiles'], - key: deckStore.state.profile, - }); - } catch (e) { - if (e.code === 'NO_SUCH_KEY') { - // 後方互換性のため - if (deckStore.state.profile === 'default') { - saveDeck(); - return; - } - - deckStore.set('columns', [{ - id: 'a', - type: 'main', - name: i18n.locale._deck._columns.main, - width: 350, - }, { - id: 'b', - type: 'notifications', - name: i18n.locale._deck._columns.notifications, - width: 330, - }]); - deckStore.set('layout', [['a'], ['b']]); - return; - } - throw e; - } - - deckStore.set('columns', deck.columns); - deckStore.set('layout', deck.layout); -}; - -// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する -export const saveDeck = throttle(1000, () => { - api('i/registry/set', { - scope: ['client', 'deck', 'profiles'], - key: deckStore.state.profile, - value: { - columns: deckStore.reactiveState.columns.value, - layout: deckStore.reactiveState.layout.value, - } - }); -}); - -export function addColumn(column: Column) { - if (column.name == undefined) column.name = null; - deckStore.push('columns', column); - deckStore.push('layout', [column.id]); - saveDeck(); -} - -export function removeColumn(id: Column['id']) { - deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id)); - deckStore.set('layout', deckStore.state.layout - .map(ids => ids.filter(_id => _id !== id)) - .filter(ids => ids.length > 0)); - saveDeck(); -} - -export function swapColumn(a: Column['id'], b: Column['id']) { - const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) != -1); - const aY = deckStore.state.layout[aX].findIndex(id => id == a); - const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) != -1); - const bY = deckStore.state.layout[bX].findIndex(id => id == b); - const layout = copy(deckStore.state.layout); - layout[aX][aY] = b; - layout[bX][bY] = a; - deckStore.set('layout', layout); - saveDeck(); -} - -export function swapLeftColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); - deckStore.state.layout.some((ids, i) => { - if (ids.includes(id)) { - const left = deckStore.state.layout[i - 1]; - if (left) { - layout[i - 1] = deckStore.state.layout[i]; - layout[i] = left; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function swapRightColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); - deckStore.state.layout.some((ids, i) => { - if (ids.includes(id)) { - const right = deckStore.state.layout[i + 1]; - if (right) { - layout[i + 1] = deckStore.state.layout[i]; - layout[i] = right; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function swapUpColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); - const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = copy(deckStore.state.layout[idsIndex]); - ids.some((x, i) => { - if (x === id) { - const up = ids[i - 1]; - if (up) { - ids[i - 1] = id; - ids[i] = up; - - layout[idsIndex] = ids; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function swapDownColumn(id: Column['id']) { - const layout = copy(deckStore.state.layout); - const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const ids = copy(deckStore.state.layout[idsIndex]); - ids.some((x, i) => { - if (x === id) { - const down = ids[i + 1]; - if (down) { - ids[i + 1] = id; - ids[i] = down; - - layout[idsIndex] = ids; - deckStore.set('layout', layout); - } - return true; - } - }); - saveDeck(); -} - -export function stackLeftColumn(id: Column['id']) { - let layout = copy(deckStore.state.layout); - const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); - layout = layout.map(ids => ids.filter(_id => _id !== id)); - layout[i - 1].push(id); - layout = layout.filter(ids => ids.length > 0); - deckStore.set('layout', layout); - saveDeck(); -} - -export function popRightColumn(id: Column['id']) { - let layout = copy(deckStore.state.layout); - const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); - const affected = layout[i]; - layout = layout.map(ids => ids.filter(_id => _id !== id)); - layout.splice(i + 1, 0, [id]); - layout = layout.filter(ids => ids.length > 0); - deckStore.set('layout', layout); - - const columns = copy(deckStore.state.columns); - for (const column of columns) { - if (affected.includes(column.id)) { - column.active = true; - } - } - deckStore.set('columns', columns); - - saveDeck(); -} - -export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = copy(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); - if (column == null) return; - if (column.widgets == null) column.widgets = []; - column.widgets.unshift(widget); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { - const columns = copy(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); - if (column == null) return; - column.widgets = column.widgets.filter(w => w.id != widget.id); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { - const columns = copy(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); - if (column == null) return; - column.widgets = widgets; - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function updateColumnWidget(id: Column['id'], widgetId: string, data: any) { - const columns = copy(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const column = copy(deckStore.state.columns[columnIndex]); - if (column == null) return; - column.widgets = column.widgets.map(w => w.id === widgetId ? { - ...w, - data: data - } : w); - columns[columnIndex] = column; - deckStore.set('columns', columns); - saveDeck(); -} - -export function updateColumn(id: Column['id'], column: Partial<Column>) { - const columns = copy(deckStore.state.columns); - const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); - const currentColumn = copy(deckStore.state.columns[columnIndex]); - if (currentColumn == null) return; - for (const [k, v] of Object.entries(column)) { - currentColumn[k] = v; - } - columns[columnIndex] = currentColumn; - deckStore.set('columns', columns); - saveDeck(); -} diff --git a/src/client/ui/deck/direct-column.vue b/src/client/ui/deck/direct-column.vue deleted file mode 100644 index 5b4b02932b..0000000000 --- a/src/client/ui/deck/direct-column.vue +++ /dev/null @@ -1,55 +0,0 @@ -<template> -<XColumn :column="column" :is-stacked="isStacked"> - <template #header><i class="fas fa-envelope" style="margin-right: 8px;"></i>{{ column.name }}</template> - - <XNotes :pagination="pagination" @before="before()" @after="after()"/> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import Progress from '@client/scripts/loading'; -import XColumn from './column.vue'; -import XNotes from '@client/components/notes.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XColumn, - XNotes - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - pagination: { - endpoint: 'notes/mentions', - limit: 10, - params: () => ({ - visibility: 'specified' - }) - }, - } - }, - - methods: { - before() { - Progress.start(); - }, - - after() { - Progress.done(); - } - } -}); -</script> diff --git a/src/client/ui/deck/list-column.vue b/src/client/ui/deck/list-column.vue deleted file mode 100644 index 450280b863..0000000000 --- a/src/client/ui/deck/list-column.vue +++ /dev/null @@ -1,80 +0,0 @@ -<template> -<XColumn :func="{ handler: setList, title: $ts.selectList }" :column="column" :is-stacked="isStacked"> - <template #header> - <i class="fas fa-list-ul"></i><span style="margin-left: 8px;">{{ column.name }}</span> - </template> - - <XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XColumn from './column.vue'; -import XTimeline from '@client/components/timeline.vue'; -import * as os from '@client/os'; -import { updateColumn } from './deck-store'; - -export default defineComponent({ - components: { - XColumn, - XTimeline, - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - }; - }, - - watch: { - mediaOnly() { - (this.$refs.timeline as any).reload(); - } - }, - - mounted() { - if (this.column.listId == null) { - this.setList(); - } - }, - - methods: { - async setList() { - const lists = await os.api('users/lists/list'); - const { canceled, result: list } = await os.dialog({ - title: this.$ts.selectList, - type: null, - select: { - items: lists.map(x => ({ - value: x, text: x.name - })), - default: this.column.listId - }, - showCancelButton: true - }); - if (canceled) return; - updateColumn(this.column.id, { - listId: list.id - }); - }, - - focus() { - (this.$refs.timeline as any).focus(); - } - } -}); -</script> - -<style lang="scss" scoped> -</style> diff --git a/src/client/ui/deck/main-column.vue b/src/client/ui/deck/main-column.vue deleted file mode 100644 index 8e36caa3ce..0000000000 --- a/src/client/ui/deck/main-column.vue +++ /dev/null @@ -1,91 +0,0 @@ -<template> -<XColumn v-if="deckStore.state.alwaysShowMainColumn || $route.name !== 'index'" :column="column" :is-stacked="isStacked"> - <template #header> - <template v-if="pageInfo"> - <i :class="pageInfo.icon"></i> - {{ pageInfo.title }} - </template> - </template> - - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <router-view v-slot="{ Component }"> - <transition> - <keep-alive :include="['timeline']"> - <component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/> - </keep-alive> - </transition> - </router-view> - </MkStickyContainer> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XColumn from './column.vue'; -import XNotes from '@client/components/notes.vue'; -import { deckStore } from '@client/ui/deck/deck-store'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - XColumn, - XNotes - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - deckStore, - pageInfo: null, - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - back() { - history.back(); - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; - if (window.getSelection().toString() !== '') return; - const path = this.$route.path; - os.contextMenu([{ - type: 'label', - text: path, - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(path); - } - }], e); - }, - } -}); -</script> diff --git a/src/client/ui/deck/mentions-column.vue b/src/client/ui/deck/mentions-column.vue deleted file mode 100644 index c625bb3ea1..0000000000 --- a/src/client/ui/deck/mentions-column.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> -<XColumn :column="column" :is-stacked="isStacked"> - <template #header><i class="fas fa-at" style="margin-right: 8px;"></i>{{ column.name }}</template> - - <XNotes :pagination="pagination" @before="before()" @after="after()"/> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import Progress from '@client/scripts/loading'; -import XColumn from './column.vue'; -import XNotes from '@client/components/notes.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XColumn, - XNotes - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - pagination: { - endpoint: 'notes/mentions', - limit: 10, - }, - } - }, - - methods: { - before() { - Progress.start(); - }, - - after() { - Progress.done(); - } - } -}); -</script> diff --git a/src/client/ui/deck/notifications-column.vue b/src/client/ui/deck/notifications-column.vue deleted file mode 100644 index c24bf7ab10..0000000000 --- a/src/client/ui/deck/notifications-column.vue +++ /dev/null @@ -1,53 +0,0 @@ -<template> -<XColumn :column="column" :is-stacked="isStacked" :func="{ handler: func, title: $ts.notificationSetting }"> - <template #header><i class="fas fa-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> - - <XNotifications :include-types="column.includingTypes"/> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XColumn from './column.vue'; -import XNotifications from '@client/components/notifications.vue'; -import * as os from '@client/os'; -import { updateColumn } from './deck-store'; - -export default defineComponent({ - components: { - XColumn, - XNotifications - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - } - }, - - methods: { - func() { - os.popup(import('@client/components/notification-setting-window.vue'), { - includingTypes: this.column.includingTypes, - }, { - done: async (res) => { - const { includingTypes } = res; - updateColumn(this.column.id, { - includingTypes: includingTypes - }); - }, - }, 'closed'); - } - } -}); -</script> diff --git a/src/client/ui/deck/tl-column.vue b/src/client/ui/deck/tl-column.vue deleted file mode 100644 index 370f7d507f..0000000000 --- a/src/client/ui/deck/tl-column.vue +++ /dev/null @@ -1,137 +0,0 @@ -<template> -<XColumn :func="{ handler: setType, title: $ts.timeline }" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState"> - <template #header> - <i v-if="column.tl === 'home'" class="fas fa-home"></i> - <i v-else-if="column.tl === 'local'" class="fas fa-comments"></i> - <i v-else-if="column.tl === 'social'" class="fas fa-share-alt"></i> - <i v-else-if="column.tl === 'global'" class="fas fa-globe"></i> - <span style="margin-left: 8px;">{{ column.name }}</span> - </template> - - <div class="iwaalbte" v-if="disabled"> - <p> - <i class="fas fa-minus-circle"></i> - {{ $t('disabled-timeline.title') }} - </p> - <p class="desc">{{ $t('disabled-timeline.description') }}</p> - </div> - <XTimeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XColumn from './column.vue'; -import XTimeline from '@client/components/timeline.vue'; -import * as os from '@client/os'; -import { removeColumn, updateColumn } from './deck-store'; - -export default defineComponent({ - components: { - XColumn, - XTimeline, - }, - - props: { - column: { - type: Object, - required: true - }, - isStacked: { - type: Boolean, - required: true - } - }, - - data() { - return { - disabled: false, - indicated: false, - columnActive: true, - }; - }, - - watch: { - mediaOnly() { - (this.$refs.timeline as any).reload(); - } - }, - - mounted() { - if (this.column.tl == null) { - this.setType(); - } else { - this.disabled = !this.$i.isModerator && !this.$i.isAdmin && ( - this.$instance.disableLocalTimeline && ['local', 'social'].includes(this.column.tl) || - this.$instance.disableGlobalTimeline && ['global'].includes(this.column.tl)); - } - }, - - methods: { - async setType() { - const { canceled, result: src } = await os.dialog({ - title: this.$ts.timeline, - type: null, - select: { - items: [{ - value: 'home', text: this.$ts._timelines.home - }, { - value: 'local', text: this.$ts._timelines.local - }, { - value: 'social', text: this.$ts._timelines.social - }, { - value: 'global', text: this.$ts._timelines.global - }] - }, - }); - if (canceled) { - if (this.column.tl == null) { - removeColumn(this.column.id); - } - return; - } - updateColumn(this.column.id, { - tl: src - }); - }, - - 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/ui/deck/widgets-column.vue b/src/client/ui/deck/widgets-column.vue deleted file mode 100644 index 22b1a38287..0000000000 --- a/src/client/ui/deck/widgets-column.vue +++ /dev/null @@ -1,71 +0,0 @@ -<template> -<XColumn :func="{ handler: func, title: $ts.editWidgets }" :naked="true" :column="column" :is-stacked="isStacked"> - <template #header><i class="fas fa-window-maximize" style="margin-right: 8px;"></i>{{ column.name }}</template> - - <div class="wtdtxvec"> - <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> - </div> -</XColumn> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import XWidgets from '@client/components/widgets.vue'; -import XColumn from './column.vue'; -import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; - -export default defineComponent({ - components: { - XColumn, - XWidgets, - }, - - props: { - column: { - type: Object, - required: true, - }, - isStacked: { - type: Boolean, - required: true, - }, - }, - - data() { - return { - edit: false, - }; - }, - - methods: { - addWidget(widget) { - addColumnWidget(this.column.id, widget); - }, - - removeWidget(widget) { - removeColumnWidget(this.column.id, widget); - }, - - updateWidget({ id, data }) { - updateColumnWidget(this.column.id, id, data); - }, - - updateWidgets(widgets) { - setColumnWidgets(this.column.id, widgets); - }, - - func() { - this.edit = !this.edit; - } - } -}); -</script> - -<style lang="scss" scoped> -.wtdtxvec { - --margin: 8px; - --panelBorder: none; - - padding: 0 var(--margin); -} -</style> diff --git a/src/client/ui/desktop.vue b/src/client/ui/desktop.vue deleted file mode 100644 index bff43e18b5..0000000000 --- a/src/client/ui/desktop.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<div class="mk-app" :class="{ wallpaper }" @contextmenu.prevent="() => {}"> - <XSidebar ref="nav" class="sidebar"/> - - <XCommon/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { host } from '@client/config'; -import { search } from '@client/scripts/search'; -import XCommon from './_common_/common.vue'; -import * as os from '@client/os'; -import XSidebar from '@client/ui/_common_/sidebar.vue'; -import { menuDef } from '@client/menu'; -import { ColdDeviceStorage } from '@client/store'; - -export default defineComponent({ - components: { - XCommon, - XSidebar - }, - - provide() { - return { - navHook: (url) => { - os.pageWindow(url); - } - }; - }, - - data() { - return { - host: host, - menuDef: menuDef, - wallpaper: localStorage.getItem('wallpaper') != null, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - }, - - created() { - if (window.innerWidth < 1024) { - localStorage.setItem('ui', 'default'); - location.reload(); - } - }, - - methods: { - help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); - }, - } -}); -</script> - -<style lang="scss" scoped> -.mk-app { - height: 100vh; - width: 100vw; -} -</style> - -<style lang="scss"> -</style> diff --git a/src/client/ui/universal.vue b/src/client/ui/universal.vue deleted file mode 100644 index d8f1c2a934..0000000000 --- a/src/client/ui/universal.vue +++ /dev/null @@ -1,402 +0,0 @@ -<template> -<div class="mk-app" :class="{ wallpaper }"> - <XSidebar ref="nav" class="sidebar"/> - - <div class="contents" ref="contents" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }"> - <main ref="main"> - <div class="content"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['timeline']"> - <component :is="Component" :ref="changePage"/> - </keep-alive> - </transition> - </router-view> - </MkStickyContainer> - </div> - <div class="spacer"></div> - </main> - </div> - - <XSide v-if="isDesktop" class="side" ref="side"/> - - <div v-if="isDesktop" class="widgets" ref="widgets"> - <XWidgets @mounted="attachSticky"/> - </div> - - <div class="buttons" :class="{ navHidden }"> - <button class="button nav _button" @click="showNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> - <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button> - <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> - <button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> - <button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button> - </div> - - <button class="widgetButton _button" :class="{ navHidden }" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> - - <transition name="tray-back"> - <div class="tray-back _modalBg" - v-if="widgetsShowing" - @click="widgetsShowing = false" - @touchstart.passive="widgetsShowing = false" - ></div> - </transition> - - <transition name="tray"> - <XWidgets v-if="widgetsShowing" class="tray"/> - </transition> - - <XCommon/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { instanceName } from '@client/config'; -import { StickySidebar } from '@client/scripts/sticky-sidebar'; -import XSidebar from '@client/ui/_common_/sidebar.vue'; -import XCommon from './_common_/common.vue'; -import XSide from './classic.side.vue'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import * as symbols from '@client/symbols'; - -const DESKTOP_THRESHOLD = 1100; - -export default defineComponent({ - components: { - XCommon, - XSidebar, - XWidgets: defineAsyncComponent(() => import('./universal.widgets.vue')), - XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる - }, - - provide() { - return { - sideViewHook: this.isDesktop ? (url) => { - this.$refs.side.navigate(url); - } : null - }; - }, - - data() { - return { - pageInfo: null, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - menuDef: menuDef, - navHidden: false, - widgetsShowing: false, - wallpaper: localStorage.getItem('wallpaper') != null, - }; - }, - - computed: { - navIndicated(): boolean { - for (const def in this.menuDef) { - if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから - if (this.menuDef[def].indicated) return true; - } - return false; - } - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - - if (this.$store.state.widgets.length === 0) { - this.$store.set('widgets', [{ - name: 'calendar', - id: 'a', place: 'right', data: {} - }, { - name: 'notifications', - id: 'b', place: 'right', data: {} - }, { - name: 'trends', - id: 'c', place: 'right', data: {} - }]); - } - }, - - mounted() { - this.adjustUI(); - - const ro = new ResizeObserver((entries, observer) => { - this.adjustUI(); - }); - - ro.observe(this.$refs.contents); - - window.addEventListener('resize', this.adjustUI, { passive: true }); - - if (!this.isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; - }, { passive: true }); - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - document.title = `${this.pageInfo.title} | ${instanceName}`; - } - }, - - adjustUI() { - const navWidth = this.$refs.nav.$el.offsetWidth; - this.navHidden = navWidth === 0; - }, - - showNav() { - this.$refs.nav.show(); - }, - - attachSticky(el) { - const sticky = new StickySidebar(this.$refs.widgets); - window.addEventListener('scroll', () => { - sticky.calc(window.scrollY); - }, { passive: true }); - }, - - post() { - os.post(); - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - back() { - history.back(); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; - if (window.getSelection().toString() !== '') return; - const path = this.$route.path; - os.contextMenu([{ - type: 'label', - text: path, - }, { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.$refs.side.navigate(path); - } - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(path); - } - }], e); - }, - } -}); -</script> - -<style lang="scss" scoped> -.tray-enter-active, -.tray-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); -} -.tray-enter-from, -.tray-leave-active { - opacity: 0; - transform: translateX(240px); -} - -.tray-back-enter-active, -.tray-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-back-enter-from, -.tray-back-leave-active { - opacity: 0; -} - -.mk-app { - $ui-font-size: 1em; // TODO: どこかに集約したい - $widgets-hide-threshold: 1090px; - - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - display: flex; - - &.wallpaper { - background: var(--wallpaperOverlay); - //backdrop-filter: var(--blur, blur(4px)); - } - - > .sidebar { - } - - > .contents { - width: 100%; - min-width: 0; - background: var(--panel); - - > main { - min-width: 0; - - > .spacer { - height: 82px; - - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; - } - } - } - } - - > .side { - min-width: 370px; - max-width: 370px; - border-left: solid 0.5px var(--divider); - } - - > .widgets { - padding: 0 var(--margin); - border-left: solid 0.5px var(--divider); - background: var(--bg); - - @media (max-width: $widgets-hide-threshold) { - display: none; - } - } - - > .widgetButton { - display: block; - position: fixed; - z-index: 1000; - bottom: 32px; - right: 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; - background: var(--panel); - - &.navHidden { - display: none; - } - - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; - } - } - - > .buttons { - position: fixed; - z-index: 1000; - bottom: 0; - padding: 16px; - display: flex; - width: 100%; - box-sizing: border-box; - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--header); - - &:not(.navHidden) { - display: none; - } - - > .button { - position: relative; - flex: 1; - padding: 0; - margin: auto; - height: 64px; - border-radius: 8px; - background: var(--panel); - color: var(--fg); - - &:not(:last-child) { - margin-right: 12px; - } - - @media (max-width: 400px) { - height: 60px; - - &:not(:last-child) { - margin-right: 8px; - } - } - - &:hover { - background: var(--X2); - } - - > .indicator { - position: absolute; - top: 0; - left: 0; - color: var(--indicator); - font-size: 16px; - animation: blink 1s infinite; - } - - &:first-child { - margin-left: 0; - } - - &:last-child { - margin-right: 0; - } - - > * { - font-size: 22px; - } - - &:disabled { - cursor: default; - - > * { - opacity: 0.5; - } - } - } - } - - > .tray-back { - z-index: 1001; - } - - > .tray { - position: fixed; - top: 0; - right: 0; - z-index: 1001; - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - height: calc(var(--vh, 1vh) * 100); - padding: var(--margin); - box-sizing: border-box; - overflow: auto; - background: var(--bg); - } -} -</style> - -<style lang="scss"> -</style> diff --git a/src/client/ui/universal.widgets.vue b/src/client/ui/universal.widgets.vue deleted file mode 100644 index 28b14749d1..0000000000 --- a/src/client/ui/universal.widgets.vue +++ /dev/null @@ -1,79 +0,0 @@ -<template> -<div class="efzpzdvf"> - <XWidgets :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> - - <button v-if="editMode" @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button> - <button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import XWidgets from '@client/components/widgets.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XWidgets - }, - - emits: ['mounted'], - - data() { - return { - editMode: false, - }; - }, - - mounted() { - this.$emit('mounted', this.$el); - }, - - methods: { - addWidget(widget) { - this.$store.set('widgets', [{ - ...widget, - place: null, - }, ...this.$store.state.widgets]); - }, - - removeWidget(widget) { - this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id)); - }, - - updateWidget({ id, data }) { - this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? { - ...w, - data: data - } : w)); - }, - - updateWidgets(widgets) { - this.$store.set('widgets', widgets); - } - } -}); -</script> - -<style lang="scss" scoped> -.efzpzdvf { - position: sticky; - height: min-content; - min-height: 100vh; - padding: var(--margin) 0; - box-sizing: border-box; - - > * { - margin: var(--margin) 0; - width: 300px; - - &:first-child { - margin-top: 0; - } - } - - > .add { - margin: 0 auto; - } -} -</style> diff --git a/src/client/ui/visitor.vue b/src/client/ui/visitor.vue deleted file mode 100644 index ec9150d346..0000000000 --- a/src/client/ui/visitor.vue +++ /dev/null @@ -1,19 +0,0 @@ -<template> -<DesignB/> -<XCommon/> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import DesignA from './visitor/a.vue'; -import DesignB from './visitor/b.vue'; -import XCommon from './_common_/common.vue'; - -export default defineComponent({ - components: { - XCommon, - DesignA, - DesignB, - }, -}); -</script> diff --git a/src/client/ui/visitor/a.vue b/src/client/ui/visitor/a.vue deleted file mode 100644 index ed015c6b07..0000000000 --- a/src/client/ui/visitor/a.vue +++ /dev/null @@ -1,260 +0,0 @@ -<template> -<div class="mk-app"> - <div class="banner" v-if="$route.path === '/'" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> - <div> - <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> - <div class="about" v-if="meta"> - <div class="desc" v-html="meta.description || $ts.introMisskey"></div> - </div> - <div class="action"> - <button class="_button primary" @click="signup()">{{ $ts.signup }}</button> - <button class="_button" @click="signin()">{{ $ts.login }}</button> - </div> - </div> - </div> - <div class="banner-mini" v-else :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> - <div> - <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> - </div> - </div> - - <div class="main"> - <div class="contents" ref="contents" :class="{ wallpaper }"> - <header class="header" ref="header" v-show="$route.path !== '/'"> - <XHeader :info="pageInfo"/> - </header> - <main ref="main"> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <component :is="Component" :ref="changePage"/> - </transition> - </router-view> - </main> - <div class="powered-by"> - <b><MkA to="/">{{ host }}</MkA></b> - <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { host, instanceName } from '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import XHeader from './header.vue'; -import { ColdDeviceStorage } from '@client/store'; -import * as symbols from '@client/symbols'; - -const DESKTOP_THRESHOLD = 1100; - -export default defineComponent({ - components: { - XHeader, - MkPagination, - MkButton, - }, - - data() { - return { - host, - instanceName, - pageInfo: null, - meta: null, - narrow: window.innerWidth < 1280, - announcements: { - endpoint: 'announcements', - limit: 10, - }, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - }; - }, - - computed: { - keymap(): any { - return { - 'd': () => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - this.$store.set('darkMode', !this.$store.state.darkMode); - }, - 's': search, - 'h|/': this.help - }; - }, - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, - - mounted() { - if (!this.isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; - }, { passive: true }); - } - }, - - methods: { - setParallax(el) { - //new simpleParallax(el); - }, - - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.mk-app { - min-height: 100vh; - - > .banner { - position: relative; - width: 100%; - text-align: center; - background-position: center; - background-size: cover; - - > div { - height: 100%; - background: rgba(0, 0, 0, 0.3); - - * { - color: #fff; - } - - > h1 { - margin: 0; - padding: 96px 32px 0 32px; - text-shadow: 0 0 8px black; - - > .logo { - vertical-align: bottom; - max-height: 150px; - } - } - - > .about { - padding: 32px; - max-width: 580px; - margin: 0 auto; - box-sizing: border-box; - text-shadow: 0 0 8px black; - } - - > .action { - padding-bottom: 64px; - - > button { - display: inline-block; - padding: 10px 20px; - box-sizing: border-box; - text-align: center; - border-radius: 999px; - background: var(--panel); - color: var(--fg); - - &.primary { - background: var(--accent); - color: #fff; - } - - &:first-child { - margin-right: 16px; - } - } - } - } - } - - > .banner-mini { - position: relative; - width: 100%; - text-align: center; - background-position: center; - background-size: cover; - - > div { - position: relative; - z-index: 1; - height: 100%; - background: rgba(0, 0, 0, 0.3); - - * { - color: #fff !important; - } - - > header { - - } - - > h1 { - margin: 0; - padding: 32px; - text-shadow: 0 0 8px black; - - > .logo { - vertical-align: bottom; - max-height: 100px; - } - } - } - } - - > .main { - > .contents { - position: relative; - z-index: 1; - - > .header { - position: sticky; - top: 0; - left: 0; - z-index: 1000; - } - - > .powered-by { - padding: 28px; - font-size: 14px; - text-align: center; - border-top: 1px solid var(--divider); - - > small { - display: block; - margin-top: 8px; - opacity: 0.5; - } - } - } - } -} -</style> - -<style lang="scss"> -</style> diff --git a/src/client/ui/visitor/b.vue b/src/client/ui/visitor/b.vue deleted file mode 100644 index 0eefb3192a..0000000000 --- a/src/client/ui/visitor/b.vue +++ /dev/null @@ -1,282 +0,0 @@ -<template> -<div class="mk-app"> - <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a> - - <div class="side" v-if="!narrow && !root"> - <XKanban class="kanban" full/> - </div> - - <div class="main"> - <XKanban class="banner" :powered-by="root" v-if="narrow && !root"/> - - <div class="contents"> - <XHeader class="header" :info="pageInfo" v-if="!root"/> - <main> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <component :is="Component" :ref="changePage"/> - </transition> - </router-view> - </main> - <div class="powered-by" v-if="!root"> - <b><MkA to="/">{{ host }}</MkA></b> - <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> - </div> - </div> - </div> - - <transition name="tray-back"> - <div class="menu-back _modalBg" - v-if="showMenu" - @click="showMenu = false" - @touchstart.passive="showMenu = false" - ></div> - </transition> - - <transition name="tray"> - <div v-if="showMenu" class="menu"> - <MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA> - <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA> - <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> - <button class="_button" @click="signin()">{{ $ts.login }}</button> - </div> - </div> - </transition> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { host, instanceName } from '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import MkPagination from '@client/components/ui/pagination.vue'; -import XSigninDialog from '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import MkButton from '@client/components/ui/button.vue'; -import XHeader from './header.vue'; -import XKanban from './kanban.vue'; -import { ColdDeviceStorage } from '@client/store'; -import * as symbols from '@client/symbols'; - -const DESKTOP_THRESHOLD = 1100; - -export default defineComponent({ - components: { - XHeader, - XKanban, - MkPagination, - MkButton, - }, - - data() { - return { - host, - instanceName, - pageInfo: null, - meta: null, - showMenu: false, - narrow: window.innerWidth < 1280, - announcements: { - endpoint: 'announcements', - limit: 10, - }, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - }; - }, - - computed: { - keymap(): any { - return { - 'd': () => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - this.$store.set('darkMode', !this.$store.state.darkMode); - }, - 's': search, - 'h|/': this.help - }; - }, - - root(): boolean { - return this.$route.path === '/'; - }, - }, - - created() { - //document.documentElement.style.overflowY = 'scroll'; - - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, - - mounted() { - if (!this.isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; - }, { passive: true }); - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - - signin() { - os.popup(XSigninDialog, { - autoSet: true - }, {}, 'closed'); - }, - - signup() { - os.popup(XSignupDialog, { - autoSet: true - }, {}, 'closed'); - } - } -}); -</script> - -<style> -.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}} -</style> - -<style lang="scss" scoped> -.tray-enter-active, -.tray-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); -} -.tray-enter-from, -.tray-leave-active { - opacity: 0; - transform: translateX(-240px); -} - -.tray-back-enter-active, -.tray-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-back-enter-from, -.tray-back-leave-active { - opacity: 0; -} - -.mk-app { - display: flex; - min-height: 100vh; - background-position: center; - background-size: cover; - background-attachment: fixed; - - > .side { - width: 500px; - height: 100vh; - - > .kanban { - position: fixed; - top: 0; - left: 0; - width: 500px; - height: 100vh; - overflow: auto; - } - } - - > .main { - flex: 1; - min-width: 0; - - > .banner { - } - - > .contents { - position: relative; - z-index: 1; - - > .powered-by { - padding: 28px; - font-size: 14px; - text-align: center; - border-top: 1px solid var(--divider); - - > small { - display: block; - margin-top: 8px; - opacity: 0.5; - } - } - } - } - - > .menu-back { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - } - - > .menu { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 240px; - height: 100vh; - background: var(--panel); - - > .link { - display: block; - padding: 16px; - - > .icon { - margin-right: 1em; - } - } - - > .action { - padding: 16px; - - > button { - display: block; - width: 100%; - padding: 10px; - box-sizing: border-box; - text-align: center; - border-radius: 999px; - - &._button { - background: var(--panel); - } - - &:first-child { - margin-bottom: 16px; - } - } - } - } -} -</style> diff --git a/src/client/ui/visitor/header.vue b/src/client/ui/visitor/header.vue deleted file mode 100644 index 6d2ebc880f..0000000000 --- a/src/client/ui/visitor/header.vue +++ /dev/null @@ -1,228 +0,0 @@ -<template> -<div class="sqxihjet"> - <div class="wide" v-if="narrow === false"> - <div class="content"> - <MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA> - <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA> - <div class="page active link" v-if="info"> - <div class="title"> - <i v-if="info.icon" class="icon" :class="info.icon"></i> - <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> - <span v-if="info.title" class="text">{{ info.title }}</span> - <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> - </div> - <button class="_button action" v-if="info.action" @click.stop="info.action.handler"><!-- TODO --></button> - </div> - <div class="right"> - <button class="_button search" @click="search()"><i class="fas fa-search icon"></i><span>{{ $ts.search }}</span></button> - <button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button> - <button class="_button login" @click="signin()">{{ $ts.login }}</button> - </div> - </div> - </div> - <div class="narrow" v-else-if="narrow === true"> - <button class="menu _button" @click="$parent.showMenu = true"> - <i class="fas fa-bars icon"></i> - </button> - <div class="title" v-if="info"> - <i v-if="info.icon" class="icon" :class="info.icon"></i> - <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> - <span v-if="info.title" class="text">{{ info.title }}</span> - <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> - </div> - <button class="action _button" v-if="info && info.action" @click.stop="info.action.handler"> - <!-- TODO --> - </button> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XSigninDialog from '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import * as os from '@client/os'; -import { search } from '@client/scripts/search'; - -export default defineComponent({ - props: { - info: { - required: true - }, - }, - - data() { - return { - narrow: null, - showMenu: false, - }; - }, - - mounted() { - this.narrow = this.$el.clientWidth < 1300; - }, - - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true - }, {}, 'closed'); - }, - - signup() { - os.popup(XSignupDialog, { - autoSet: true - }, {}, 'closed'); - }, - - search - } -}); -</script> - -<style lang="scss" scoped> -.sqxihjet { - $height: 60px; - position: sticky; - top: 0; - left: 0; - z-index: 1000; - line-height: $height; - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--X16); - - > .wide { - > .content { - max-width: 1400px; - margin: 0 auto; - display: flex; - align-items: center; - - > .link { - $line: 3px; - display: inline-block; - padding: 0 16px; - line-height: $height - ($line * 2); - border-top: solid $line transparent; - border-bottom: solid $line transparent; - - > .icon { - margin-right: 0.5em; - } - - &.page { - border-bottom-color: var(--accent); - } - } - - > .page { - > .title { - display: inline-block; - vertical-align: bottom; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - - > .icon + .text { - margin-left: 8px; - } - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: middle; - margin-right: 8px; - pointer-events: none; - } - - &._button { - &:hover { - color: var(--fgHighlighted); - } - } - - &.selected { - box-shadow: 0 -2px 0 0 var(--accent) inset; - color: var(--fgHighlighted); - } - } - - > .action { - padding: 0 0 0 16px; - } - } - - > .right { - margin-left: auto; - - > .search { - background: var(--bg); - border-radius: 999px; - width: 230px; - line-height: $height - 20px; - margin-right: 16px; - text-align: left; - - > * { - opacity: 0.7; - } - - > .icon { - padding: 0 16px; - } - } - - > .signup { - border-radius: 999px; - padding: 0 24px; - line-height: $height - 20px; - } - - > .login { - padding: 0 16px; - } - } - } - } - - > .narrow { - display: flex; - - > .menu, - > .action { - width: $height; - height: $height; - font-size: 20px; - } - - > .title { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - text-align: center; - - > .icon + .text { - margin-left: 8px; - } - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: middle; - margin-right: 8px; - pointer-events: none; - } - } - } -} -</style> diff --git a/src/client/ui/visitor/kanban.vue b/src/client/ui/visitor/kanban.vue deleted file mode 100644 index 5fbbff3d32..0000000000 --- a/src/client/ui/visitor/kanban.vue +++ /dev/null @@ -1,256 +0,0 @@ -<template> -<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }"> - <div class="back" :class="{ transparent }"></div> - <div class="contents"> - <div class="wrapper"> - <h1 v-if="meta" :class="{ full }"> - <MkA to="/" class="link"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></MkA> - </h1> - <template v-if="full"> - <div class="about" v-if="meta"> - <div class="desc" v-html="meta.description || $ts.introMisskey"></div> - </div> - <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> - <button class="_button" @click="signin()">{{ $ts.login }}</button> - </div> - <div class="announcements panel"> - <header>{{ $ts.announcements }}</header> - <MkPagination :pagination="announcements" #default="{items}" class="list"> - <section class="item" v-for="(announcement, i) in items" :key="announcement.id"> - <div class="title">{{ announcement.title }}</div> - <div class="content"> - <Mfm :text="announcement.text"/> - <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> - </div> - </section> - </MkPagination> - </div> - <div class="powered-by" v-if="poweredBy"> - <b><MkA to="/">{{ host }}</MkA></b> - <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> - </div> - </template> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { host, instanceName } from '@client/config'; -import * as os from '@client/os'; -import MkPagination from '@client/components/ui/pagination.vue'; -import XSigninDialog from '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - MkPagination, - MkButton, - }, - - props: { - full: { - type: Boolean, - required: false, - default: false, - }, - transparent: { - type: Boolean, - required: false, - default: false, - }, - poweredBy: { - type: Boolean, - required: false, - default: false, - }, - }, - - data() { - return { - host, - instanceName, - pageInfo: null, - meta: null, - narrow: window.innerWidth < 1280, - announcements: { - endpoint: 'announcements', - limit: 10, - }, - }; - }, - - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, - - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true - }, {}, 'closed'); - }, - - signup() { - os.popup(XSignupDialog, { - autoSet: true - }, {}, 'closed'); - } - } -}); -</script> - -<style lang="scss" scoped> -.rwqkcmrc { - position: relative; - text-align: center; - background-position: center; - background-size: cover; - // TODO: パララックスにしたい - - > .back { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.3); - - &.transparent { - -webkit-backdrop-filter: var(--blur, blur(12px)); - backdrop-filter: var(--blur, blur(12px)); - } - } - - > .contents { - position: relative; - z-index: 1; - height: inherit; - overflow: auto; - - > .wrapper { - max-width: 380px; - padding: 0 16px; - box-sizing: border-box; - margin: 0 auto; - - > .panel { - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - background: rgba(0, 0, 0, 0.5); - border-radius: var(--radius); - - &, * { - color: #fff !important; - } - } - - > h1 { - display: block; - margin: 0; - padding: 32px 0 32px 0; - color: #fff; - - &.full { - padding: 64px 0 0 0; - - > .link { - > ::v-deep(.logo) { - max-height: 130px; - } - } - } - - > .link { - display: block; - - > ::v-deep(.logo) { - vertical-align: bottom; - max-height: 100px; - } - } - } - - > .about { - display: block; - margin: 24px 0; - text-align: center; - box-sizing: border-box; - text-shadow: 0 0 8px black; - color: #fff; - } - - > .action { - > button { - display: block; - width: 100%; - padding: 10px; - box-sizing: border-box; - text-align: center; - border-radius: 999px; - - &._button { - background: var(--panel); - } - - &:first-child { - margin-bottom: 16px; - } - } - } - - > .announcements { - margin: 32px 0; - text-align: left; - - > header { - padding: 12px 16px; - border-bottom: solid 1px rgba(255, 255, 255, 0.5); - } - - > .list { - max-height: 300px; - overflow: auto; - - > .item { - padding: 12px 16px; - - & + .item { - border-top: solid 1px rgba(255, 255, 255, 0.5); - } - - > .title { - font-weight: bold; - } - - > .content { - > img { - max-width: 100%; - } - } - } - } - } - - > .powered-by { - padding: 28px; - font-size: 14px; - text-align: center; - border-top: 1px solid rgba(255, 255, 255, 0.5); - color: #fff; - - > small { - display: block; - margin-top: 8px; - opacity: 0.5; - } - } - } - } -} -</style> diff --git a/src/client/ui/zen.vue b/src/client/ui/zen.vue deleted file mode 100644 index ebbf72bca7..0000000000 --- a/src/client/ui/zen.vue +++ /dev/null @@ -1,106 +0,0 @@ -<template> -<div class="mk-app"> - <div class="contents"> - <header class="header"> - <MkHeader :info="pageInfo"/> - </header> - <main ref="main"> - <div class="content"> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['timeline']"> - <component :is="Component" :ref="changePage"/> - </keep-alive> - </transition> - </router-view> - </div> - </main> - </div> - - <XCommon/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { host } from '@client/config'; -import XCommon from './_common_/common.vue'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - XCommon, - }, - - data() { - return { - host: host, - pageInfo: null, - }; - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.mk-app { - $header-height: 52px; - $ui-font-size: 1em; // TODO: どこかに集約したい - - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc(var(--vh, 1vh) * 100); - box-sizing: border-box; - - > .contents { - padding-top: $header-height; - - > .header { - position: fixed; - z-index: 1000; - top: 0; - height: $header-height; - width: 100%; - line-height: $header-height; - text-align: center; - //background-color: var(--panel); - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--header); - border-bottom: solid 0.5px var(--divider); - } - - > main { - > .content { - > * { - // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc((var(--vh, 1vh) * 100) - #{$header-height}); - } - } - } - } -} -</style> |