From 44c85aff86cfa97797880e9b246ea4c75dc82984 Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Sun, 3 Jul 2022 14:40:02 +0900 Subject: [PATCH] feat(client): status bar (experimental) --- locales/ja-JP.yml | 2 + .../components/global/sticky-container.vue | 8 +- packages/client/src/pages/settings/index.vue | 6 + .../pages/settings/statusbars.statusbar.vue | 122 ++++++++++++++++++ .../client/src/pages/settings/statusbars.vue | 61 +++++++++ packages/client/src/store.ts | 13 ++ .../src/ui/_common_/statusbar-federation.vue | 103 +++++++++++++++ .../client/src/ui/_common_/statusbar-rss.vue | 88 +++++++++++++ .../src/ui/_common_/statusbar-user-list.vue | 104 +++++++++++++++ .../client/src/ui/_common_/statusbars.vue | 75 +++++++++++ packages/client/src/ui/deck.vue | 86 +++++++----- packages/client/src/ui/universal.vue | 41 +++--- 12 files changed, 658 insertions(+), 51 deletions(-) create mode 100644 packages/client/src/pages/settings/statusbars.statusbar.vue create mode 100644 packages/client/src/pages/settings/statusbars.vue create mode 100644 packages/client/src/ui/_common_/statusbar-federation.vue create mode 100644 packages/client/src/ui/_common_/statusbar-rss.vue create mode 100644 packages/client/src/ui/_common_/statusbar-user-list.vue create mode 100644 packages/client/src/ui/_common_/statusbars.vue diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d333ac29df..01d0016883 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -864,6 +864,8 @@ numberOfPageCache: "ページキャッシュ数" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" logoutConfirm: "ログアウトしますか?" lastActiveDate: "最終利用日時" +statusbar: "ステータスバー" +pleaseSelect: "選択してください" _emailUnavailable: used: "既に使用されています" diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue index 2603fac55d..44f4f065a6 100644 --- a/packages/client/src/components/global/sticky-container.vue +++ b/packages/client/src/components/global/sticky-container.vue @@ -9,11 +9,15 @@ </div> </template> +<script lang="ts"> +// なんか動かない +//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP'); +const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP'; +</script> + <script lang="ts" setup> import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue'; -const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP'); - const rootEl = $ref<HTMLElement>(); const headerEl = $ref<HTMLElement>(); const bodyEl = $ref<HTMLElement>(); diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue index 8e445a77d7..76410ec12f 100644 --- a/packages/client/src/pages/settings/index.vue +++ b/packages/client/src/pages/settings/index.vue @@ -113,6 +113,11 @@ const menuDef = computed(() => [{ text: i18n.ts.theme, to: '/settings/theme', active: props.initialPage === 'theme', + }, { + icon: 'fas fa-list-ul', + text: i18n.ts.statusbar, + to: '/settings/statusbars', + active: props.initialPage === 'statusbars', }, { icon: 'fas fa-list-ul', text: i18n.ts.menu, @@ -221,6 +226,7 @@ const component = computed(() => { case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); case 'menu': return defineAsyncComponent(() => import('./menu.vue')); + case 'statusbars': return defineAsyncComponent(() => import('./statusbars.vue')); case 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue')); case 'deck': return defineAsyncComponent(() => import('./deck.vue')); diff --git a/packages/client/src/pages/settings/statusbars.statusbar.vue b/packages/client/src/pages/settings/statusbars.statusbar.vue new file mode 100644 index 0000000000..ad2fa557a3 --- /dev/null +++ b/packages/client/src/pages/settings/statusbars.statusbar.vue @@ -0,0 +1,122 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock"> + <template #label>{{ i18n.ts.type }}</template> + <option value="rss">RSS</option> + <option value="federation">Federation</option> + <option value="userList">User list timeline</option> + </FormSelect> + + <MkInput v-model="statusbar.name" class="_formBlock"> + <template #label>Name</template> + </MkInput> + + <MkSwitch v-model="statusbar.black" class="_formBlock"> + <template #label>Black</template> + </MkSwitch> + + <template v-if="statusbar.type === 'rss'"> + <MkInput v-model="statusbar.props.url" class="_formBlock" type="url"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="statusbar.props.refreshIntervalSec" class="_formBlock" type="number"> + <template #label>Refresh interval</template> + </MkInput> + <MkInput v-model="statusbar.props.marqueeDuration" class="_formBlock" type="number"> + <template #label>Duration</template> + </MkInput> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>Reverse</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'federation'"> + <MkInput v-model="statusbar.props.refreshIntervalSec" class="_formBlock" type="number"> + <template #label>Refresh interval</template> + </MkInput> + <MkInput v-model="statusbar.props.marqueeDuration" class="_formBlock" type="number"> + <template #label>Duration</template> + </MkInput> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>Reverse</template> + </MkSwitch> + <MkSwitch v-model="statusbar.props.colored" class="_formBlock"> + <template #label>Colored</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'userList' && userLists != null"> + <FormSelect v-model="statusbar.props.userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> + </FormSelect> + <MkInput v-model="statusbar.props.refreshIntervalSec" class="_formBlock" type="number"> + <template #label>Refresh interval</template> + </MkInput> + <MkInput v-model="statusbar.props.marqueeDuration" class="_formBlock" type="number"> + <template #label>Duration</template> + </MkInput> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>Reverse</template> + </MkSwitch> + </template> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton @click="save">save</FormButton> + <FormButton danger @click="del">Delete</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive, ref, watch } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + _id: string; + userLists: any[] | null; +}>(); + +const statusbar = reactive(JSON.parse(JSON.stringify(defaultStore.state.statusbars.find(x => x.id === props._id)))); + +watch(() => statusbar.type, () => { + if (statusbar.type === 'rss') { + statusbar.name = 'NEWS'; + statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } else if (statusbar.type === 'federation') { + statusbar.name = 'FEDERATION'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + statusbar.props.colored = false; + } else if (statusbar.type === 'userList') { + statusbar.name = 'LIST TL'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } +}); + +async function save() { + const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); + const statusbars = JSON.parse(JSON.stringify(defaultStore.state.statusbars)); + statusbars[i] = JSON.parse(JSON.stringify(statusbar)); + defaultStore.set('statusbars', statusbars); +} + +function del() { + defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); +} +</script> diff --git a/packages/client/src/pages/settings/statusbars.vue b/packages/client/src/pages/settings/statusbars.vue new file mode 100644 index 0000000000..dea5e0ffd4 --- /dev/null +++ b/packages/client/src/pages/settings/statusbars.vue @@ -0,0 +1,61 @@ +<template> +<div class="_formRoot"> + <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock"> + <template #label>{{ x.type ?? i18n.ts.notSet }}</template> + <template #suffix>{{ x.name }}</template> + <XStatusbar :_id="x.id" :user-lists="userLists"/> + </FormFolder> + <FormButton @click="add">add</FormButton> + <FormRadios v-model="statusbarSize" class="_formBlock"> + <template #label>Size</template> + <option value="verySmall">{{ i18n.ts.small }}+</option> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + </FormRadios> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XStatusbar from './statusbars.statusbar.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const statusbarSize = computed(defaultStore.makeGetterSetter('statusbarSize')); +const statusbars = defaultStore.reactiveState.statusbars; + +let userLists = $ref(); + +onMounted(() => { + os.api('users/lists/list').then(res => { + userLists = res; + }); +}); + +async function add() { + defaultStore.push('statusbars', { + id: uuid(), + type: null, + black: false, + props: {}, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.statusbar, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', +}); +</script> diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index 94d9d91385..cde907017d 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -88,6 +88,19 @@ export const defaultStore = markRaw(new Storage('base', { where: 'deviceAccount', default: false, }, + statusbars: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + type: string; + props: Record<string, any>; + }[], + }, + statusbarSize: { + where: 'deviceAccount', + default: 'medium', + }, widgets: { where: 'deviceAccount', default: [] as { diff --git a/packages/client/src/ui/_common_/statusbar-federation.vue b/packages/client/src/ui/_common_/statusbar-federation.vue new file mode 100644 index 0000000000..87b954b900 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-federation.vue @@ -0,0 +1,103 @@ +<template> +<span v-if="!fetching" class="nmidsaqw"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }"> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <MkA :to="`/instance-info/${instance.host}`" class="host _monospace"> + {{ instance.host }} + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/marquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; + +const props = defineProps<{ + display?: 'marquee' | 'oneByOne'; + colored?: boolean; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const instances = ref<misskey.entities.Instance[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 30, + }).then(res => { + instances.value = res; + fetching.value = false; + key++; + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.nmidsaqw { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-block; + vertical-align: bottom; + margin-right: 3em; + + > .icon { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 1em; + } + + > .host { + vertical-align: bottom; + } + + &.colored { + padding-right: 1em; + color: #fff; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbar-rss.vue b/packages/client/src/ui/_common_/statusbar-rss.vue new file mode 100644 index 0000000000..ddfc6faaab --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-rss.vue @@ -0,0 +1,88 @@ +<template> +<span v-if="!fetching" class="xbhtxfms"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="item in items" class="item"> + <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import MarqueeText from '@/components/marquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; + +const props = defineProps<{ + url?: string; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const items = ref([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { + res.json().then(feed => { + items.value = feed.items; + fetching.value = false; + key++; + }); + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.xbhtxfms { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .divider { + display: inline-block; + width: 0.5px; + height: var(--height); + margin: 0 1em; + background: currentColor; + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbar-user-list.vue b/packages/client/src/ui/_common_/statusbar-user-list.vue new file mode 100644 index 0000000000..01240dc6bc --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-user-list.vue @@ -0,0 +1,104 @@ +<template> +<span v-if="!fetching" class="osdsvwzy"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="note in notes" :key="note.id" class="item"> + <img class="avatar" :src="note.user.avatarUrl" decoding="async"/> + <MkA class="text" :to="notePage(note)"> + <Mfm :text="getNoteSummary(note)" :plain="true" :nowrap="true" :custom-emojis="note.emojis"/> + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/marquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; + +const props = defineProps<{ + userListId?: string; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const notes = ref<misskey.entities.Note[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + if (props.userListId == null) return; + os.api('notes/user-list-timeline', { + listId: props.userListId, + }).then(res => { + notes.value = res; + fetching.value = false; + key++; + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.osdsvwzy { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .avatar { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 8px; + } + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 1em; + background: currentColor; + opacity: 0; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbars.vue b/packages/client/src/ui/_common_/statusbars.vue new file mode 100644 index 0000000000..86d2812f59 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbars.vue @@ -0,0 +1,75 @@ +<template> +<div + class="dlrsnxqu" :class="{ + verySmall: defaultStore.reactiveState.statusbarSize.value === 'verySmall', + small: defaultStore.reactiveState.statusbarSize.value === 'small', + medium: defaultStore.reactiveState.statusbarSize.value === 'medium', + large: defaultStore.reactiveState.statusbarSize.value === 'large' + }" +> + <div v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="{ black: x.black }"> + <span class="name">{{ x.name }}</span> + <XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url"/> + <XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> + <XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue')); +const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue')); +const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')); +</script> + +<style lang="scss" scoped> +.dlrsnxqu { + --height: 24px; + background: var(--panel); + font-size: 0.85em; + + &.verySmall { + --height: 16px; + font-size: 0.75em; + } + + &.small { + --height: 20px; + font-size: 0.8em; + } + + &.large { + --height: 26px; + font-size: 0.875em; + } + + > .item { + display: inline-flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; + + > .name { + padding: 0 6px; + font-weight: bold; + color: var(--accent); + } + + > .body { + min-width: 0; + flex: 1; + } + + &.black { + background: #000; + color: #fff; + } + } +} +</style> diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue index b3b9ddd556..111cf8022c 100644 --- a/packages/client/src/ui/deck.vue +++ b/packages/client/src/ui/deck.vue @@ -5,26 +5,31 @@ > <XSidebar v-if="!isMobile"/> - <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 - :ref="ids[0]" - :key="ids[0]" - class="column" - :column="columns.find(c => c.id === ids[0])" - :is-stacked="false" - :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" - @parent-focus="moveFocus(ids[0], $event)" - /> - </template> + <div class="main"> + <XStatusBars class="statusbars"/> + <div ref="columnsEl" class="columns"> + <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 + :ref="ids[0]" + :key="ids[0]" + class="column" + :column="columns.find(c => c.id === ids[0])" + :is-stacked="false" + :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" + @parent-focus="moveFocus(ids[0], $event)" + /> + </template> + </div> + </div> <div v-if="isMobile" class="buttons"> <button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> @@ -51,7 +56,7 @@ </template> <script lang="ts" setup> -import { computed, provide, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue'; import { v4 as uuid } from 'uuid'; import XCommon from './_common_/common.vue'; import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store'; @@ -64,6 +69,7 @@ import { menuDef } from '@/menu'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { mainRouter } from '@/router'; +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); if (deckStore.state.navWindow) { mainRouter.navHook = (path) => { @@ -94,6 +100,8 @@ const menuIndicated = computed(() => { return false; }); +let columnsEl = $ref<HTMLElement>(); + const addColumn = async (ev) => { const columns = [ 'main', @@ -134,8 +142,10 @@ provide('shouldSpacerMin', true); document.documentElement.style.overflowY = 'hidden'; document.documentElement.style.scrollBehavior = 'auto'; window.addEventListener('wheel', (ev) => { - if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { - document.documentElement.scrollLeft += ev.deltaY; + if (ev.target === columnsEl && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; + } else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; } }); loadDeck(); @@ -179,7 +189,6 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { height: calc(var(--vh, 1vh) * 100); box-sizing: border-box; flex: 1; - padding: var(--deckMargin); &.center { > .column:first-of-type { @@ -195,16 +204,31 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { padding-bottom: 100px; } - > .column { - flex-shrink: 0; - margin-right: var(--deckMargin); + > .main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; - &.folder { + > .columns { display: flex; - flex-direction: column; + flex: 1; + padding: var(--deckMargin); + overflow-x: auto; + overflow-y: clip; - > *:not(:last-child) { - margin-bottom: var(--deckMargin); + > .column { + flex-shrink: 0; + margin-right: var(--deckMargin); + + &.folder { + display: flex; + flex-direction: column; + + > *:not(:last-child) { + margin-bottom: var(--deckMargin); + } + } } } } diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue index 3614f7de53..8c48510a44 100644 --- a/packages/client/src/ui/universal.vue +++ b/packages/client/src/ui/universal.vue @@ -2,14 +2,15 @@ <div class="dkgtipfy" :class="{ wallpaper }"> <XSidebar v-if="!isMobile" class="sidebar"/> - <div class="contents" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> - <main> - <div class="content"> + <MkStickyContainer class="contents"> + <template #header><XStatusBars :class="$style.statusbars"/></template> + <main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> + <div :class="$style.content"> <RouterView/> </div> - <div class="spacer"></div> + <div :class="$style.spacer"></div> </main> - </div> + </MkStickyContainer> <div v-if="isDesktop" ref="widgetsEl" class="widgets"> <XWidgets @mounted="attachSticky"/> @@ -71,6 +72,7 @@ import { mainRouter } from '@/router'; import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/sidebar.vue')); +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const DESKTOP_THRESHOLD = 1100; const MOBILE_THRESHOLD = 500; @@ -235,18 +237,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null; width: 100%; min-width: 0; background: var(--bg); - - > main { - min-width: 0; - - > .spacer { - height: calc(env(safe-area-inset-bottom, 0px) + 96px); - - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; - } - } - } } > .widgets { @@ -396,5 +386,20 @@ const wallpaper = localStorage.getItem('wallpaper') != null; } </style> -<style lang="scss"> +<style lang="scss" module> +.statusbars { + position: sticky; + top: 0; + left: 0; +} + +.spacer { + $widgets-hide-threshold: 1090px; + + height: calc(env(safe-area-inset-bottom, 0px) + 96px); + + @media (min-width: ($widgets-hide-threshold + 1px)) { + display: none; + } +} </style>