<!-- SPDX-FileCopyrightText: syuilo and other misskey contributors SPDX-License-Identifier: AGPL-3.0-only --> <template> <div :class="$style.root"> <XSidebar v-if="!isMobile" :class="$style.sidebar"/> <MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu"> <template #header> <div> <XAnnouncements v-if="$i" :class="$style.announcements"/> <XStatusBars :class="$style.statusbars"/> </div> </template> <RouterView/> <div :class="$style.spacer"></div> </MkStickyContainer> <div v-if="isDesktop" :class="$style.widgets"> <XWidgets/> </div> <button v-if="!isDesktop && !isMobile" :class="$style.widgetButton" class="_button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> <div v-if="isMobile" ref="navFooter" :class="$style.nav"> <button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button> <button :class="$style.navButton" class="_button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button> <button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')"><i :class="$style.navButtonIcon" class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button> <button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ti ti-apps"></i></button> <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button> </div> <Transition :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''" :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menuDrawerBg" class="_modalBg" @click="drawerMenuShowing = false" @touchstart.passive="drawerMenuShowing = false" ></div> </Transition> <Transition :enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''" :enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''" > <div v-if="drawerMenuShowing" :class="$style.menuDrawer"> <XDrawerMenu/> </div> </Transition> <Transition :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveActive : ''" :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_enterFrom : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawerBg_leaveTo : ''" > <div v-if="widgetsShowing" :class="$style.widgetsDrawerBg" class="_modalBg" @click="widgetsShowing = false" @touchstart.passive="widgetsShowing = false" ></div> </Transition> <Transition :enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''" :leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''" :enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''" :leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''" > <div v-if="widgetsShowing" :class="$style.widgetsDrawer"> <button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button> <XWidgets/> </div> </Transition> <XCommon/> </div> </template> <script lang="ts" setup> import { defineAsyncComponent, provide, onMounted, computed, ref, ComputedRef, watch, shallowRef, Ref } from 'vue'; import XCommon from './_common_/common.vue'; import type MkStickyContainer from '@/components/global/MkStickyContainer.vue'; import { instanceName } from '@/config.js'; import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; import { navbarItemDef } from '@/navbar.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { mainRouter } from '@/router.js'; import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { miLocalStorage } from '@/local-storage.js'; import { CURRENT_STICKY_BOTTOM } from '@/const.js'; import { useScrollPositionManager } from '@/nirax.js'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); const DESKTOP_THRESHOLD = 1100; const MOBILE_THRESHOLD = 500; // デスクトップでウィンドウを狭くしたときモバイルUIが表示されて欲しいことはあるので deviceKind === 'desktop' の判定は行わない const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD); const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); window.addEventListener('resize', () => { isMobile.value = deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD; }); let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); const widgetsShowing = $ref(false); const navFooter = $shallowRef<HTMLElement>(); const contents = shallowRef<InstanceType<typeof MkStickyContainer>>(); provide('router', mainRouter); provideMetadataReceiver((info) => { pageMetadata = info; if (pageMetadata.value) { document.title = `${pageMetadata.value.title} | ${instanceName}`; } }); const menuIndicated = computed(() => { for (const def in navbarItemDef) { if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから if (navbarItemDef[def].indicated) return true; } return false; }); const drawerMenuShowing = ref(false); mainRouter.on('change', () => { drawerMenuShowing.value = false; }); if (window.innerWidth > 1024) { const tempUI = miLocalStorage.getItem('ui_temp'); if (tempUI) { miLocalStorage.setItem('ui', tempUI); miLocalStorage.removeItem('ui_temp'); location.reload(); } } defaultStore.loaded.then(() => { if (defaultStore.state.widgets.length === 0) { defaultStore.set('widgets', [{ name: 'calendar', id: 'a', place: 'right', data: {}, }, { name: 'notifications', id: 'b', place: 'right', data: {}, }, { name: 'trends', id: 'c', place: 'right', data: {}, }]); } }); onMounted(() => { if (!isDesktop.value) { window.addEventListener('resize', () => { if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop.value = true; }, { passive: true }); } }); const onContextmenu = (ev) => { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; if (el.parentElement) { return isLink(el.parentElement); } }; if (isLink(ev.target)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; if (window.getSelection()?.toString() !== '') return; const path = mainRouter.getCurrentPath(); os.contextMenu([{ type: 'label', text: path, }, { icon: 'ti ti-window-maximize', text: i18n.ts.openInWindow, action: () => { os.pageWindow(path); }, }], ev); }; function top() { contents.value.rootEl.scrollTo({ top: 0, behavior: 'smooth', }); } let navFooterHeight = $ref(0); provide<Ref<number>>(CURRENT_STICKY_BOTTOM, $$(navFooterHeight)); watch($$(navFooter), () => { if (navFooter) { navFooterHeight = navFooter.offsetHeight; document.body.style.setProperty('--stickyBottom', `${navFooterHeight}px`); document.body.style.setProperty('--minBottomSpacing', 'var(--minBottomSpacingMobile)'); } else { navFooterHeight = 0; document.body.style.setProperty('--stickyBottom', '0px'); document.body.style.setProperty('--minBottomSpacing', '0px'); } }, { immediate: true, }); useScrollPositionManager(() => contents.value.rootEl, mainRouter); </script> <style> html, body { width: 100%; height: 100%; overflow: clip; position: fixed; top: 0; left: 0; overscroll-behavior: none; } #misskey_app { width: 100%; height: 100%; overflow: clip; position: absolute; top: 0; left: 0; } </style> <style lang="scss" module> $ui-font-size: 1em; // TODO: どこかに集約したい $widgets-hide-threshold: 1090px; .transition_menuDrawerBg_enterActive, .transition_menuDrawerBg_leaveActive { opacity: 1; transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); } .transition_menuDrawerBg_enterFrom, .transition_menuDrawerBg_leaveTo { opacity: 0; } .transition_menuDrawer_enterActive, .transition_menuDrawer_leaveActive { 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); } .transition_menuDrawer_enterFrom, .transition_menuDrawer_leaveTo { opacity: 0; transform: translateX(-240px); } .transition_widgetsDrawerBg_enterActive, .transition_widgetsDrawerBg_leaveActive { opacity: 1; transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); } .transition_widgetsDrawerBg_enterFrom, .transition_widgetsDrawerBg_leaveTo { opacity: 0; } .transition_widgetsDrawer_enterActive, .transition_widgetsDrawer_leaveActive { 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); } .transition_widgetsDrawer_enterFrom, .transition_widgetsDrawer_leaveTo { opacity: 0; transform: translateX(240px); } .root { height: 100dvh; overflow: clip; contain: strict; box-sizing: border-box; display: flex; } .sidebar { border-right: solid 0.5px var(--divider); } .contents { flex: 1; height: 100%; min-width: 0; overflow: auto; overflow-y: scroll; overscroll-behavior: contain; background: var(--bg); } .widgets { width: 350px; height: 100%; box-sizing: border-box; overflow: auto; padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)); 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); } .widgetsDrawerBg { z-index: 1001; } .widgetsDrawer { position: fixed; top: 0; right: 0; z-index: 1001; width: 310px; height: 100dvh; padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)) !important; box-sizing: border-box; overflow: auto; overscroll-behavior: contain; background: var(--bg); } .widgetsCloseButton { padding: 8px; display: block; margin: 0 auto; } @media (min-width: 370px) { .widgetsCloseButton { display: none; } } .nav { position: fixed; z-index: 1000; bottom: 0; left: 0; padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; display: grid; grid-template-columns: 1fr 1fr 1fr 1fr 1fr; grid-gap: 8px; width: 100%; box-sizing: border-box; -webkit-backdrop-filter: var(--blur, blur(24px)); backdrop-filter: var(--blur, blur(24px)); background-color: var(--header); border-top: solid 0.5px var(--divider); } .navButton { position: relative; padding: 0; aspect-ratio: 1; width: 100%; max-width: 60px; margin: auto; border-radius: 100%; background: var(--panel); color: var(--fg); &:hover { background: var(--panelHighlight); } &:active { background: var(--X2); } } .postButton { composes: navButton; background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); color: var(--fgOnAccent); &:hover { background: linear-gradient(90deg, var(--X8), var(--X8)); } &:active { background: linear-gradient(90deg, var(--X8), var(--X8)); } } .navButtonIcon { font-size: 18px; vertical-align: middle; } .navButtonIndicator { position: absolute; top: 0; left: 0; color: var(--indicator); font-size: 16px; animation: blink 1s infinite; } .menuDrawerBg { z-index: 1001; } .menuDrawer { position: fixed; top: 0; left: 0; z-index: 1001; height: 100dvh; width: 240px; box-sizing: border-box; contain: strict; overflow: auto; overscroll-behavior: contain; background: var(--navBg); } .statusbars { position: sticky; top: 0; left: 0; } .spacer { height: calc(var(--minBottomSpacing)); } </style>