summaryrefslogtreecommitdiff
path: root/packages/frontend/src/ui
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-03-30 20:44:00 +0900
committersyuilo <4439005+syuilo@users.noreply.github.com>2025-03-30 20:44:00 +0900
commit87a723897611fff9b5ddda8a8bec3cdb427a21dc (patch)
tree808415a4dfd20dfd3ff1a6456c47ed55e1f8c300 /packages/frontend/src/ui
parentperf(frontend): tweak MkRange (diff)
downloadsharkey-87a723897611fff9b5ddda8a8bec3cdb427a21dc.tar.gz
sharkey-87a723897611fff9b5ddda8a8bec3cdb427a21dc.tar.bz2
sharkey-87a723897611fff9b5ddda8a8bec3cdb427a21dc.zip
enhance(frontend): デッキのオプションを追加
Diffstat (limited to 'packages/frontend/src/ui')
-rw-r--r--packages/frontend/src/ui/_common_/navbar-h.vue214
-rw-r--r--packages/frontend/src/ui/deck.vue166
2 files changed, 324 insertions, 56 deletions
diff --git a/packages/frontend/src/ui/_common_/navbar-h.vue b/packages/frontend/src/ui/_common_/navbar-h.vue
new file mode 100644
index 0000000000..c93935dd26
--- /dev/null
+++ b/packages/frontend/src/ui/_common_/navbar-h.vue
@@ -0,0 +1,214 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="azykntjl">
+ <div class="body">
+ <div class="left">
+ <button v-click-anime class="item _button instance" @click="openInstanceMenu">
+ <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" draggable="false"/>
+ </button>
+ <MkA v-click-anime v-tooltip="i18n.ts.timeline" class="item index" activeClass="active" to="/" exact>
+ <i class="ti ti-home ti-fw"></i>
+ </MkA>
+ <template v-for="item in menu">
+ <div v-if="item === '-'" class="divider"></div>
+ <component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
+ <i class="ti-fw" :class="navbarItemDef[item].icon"></i>
+ <span v-if="navbarItemDef[item].indicated" class="indicator _blink"><i class="_indicatorCircle"></i></span>
+ </component>
+ </template>
+ <div class="divider"></div>
+ <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="i18n.ts.controlPanel" class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null">
+ <i class="ti ti-dashboard ti-fw"></i>
+ </MkA>
+ <button v-click-anime class="item _button" @click="more">
+ <i class="ti ti-dots ti-fw"></i>
+ <span v-if="otherNavItemIndicated" class="indicator _blink"><i class="_indicatorCircle"></i></span>
+ </button>
+ </div>
+ <div class="right">
+ <MkA v-click-anime v-tooltip="i18n.ts.settings" class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null">
+ <i class="ti ti-settings ti-fw"></i>
+ </MkA>
+ <button v-click-anime class="item _button account" @click="openAccountMenu">
+ <MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/>
+ </button>
+ <div class="post" @click="os.post()">
+ <MkButton class="button" gradate full rounded>
+ <i class="ti ti-pencil ti-fw"></i>
+ </MkButton>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
+import { openInstanceMenu } from './common.js';
+import * as os from '@/os.js';
+import { navbarItemDef } from '@/navbar.js';
+import MkButton from '@/components/MkButton.vue';
+import { instance } from '@/instance.js';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
+import { openAccountMenu as openAccountMenu_ } from '@/accounts.js';
+import { $i } from '@/i.js';
+
+const WINDOW_THRESHOLD = 1400;
+
+const settingsWindowed = ref(window.innerWidth > WINDOW_THRESHOLD);
+const menu = ref(prefer.s.menu);
+// const menuDisplay = computed(store.makeGetterSetter('menuDisplay'));
+const otherNavItemIndicated = computed<boolean>(() => {
+ for (const def in navbarItemDef) {
+ if (menu.value.includes(def)) continue;
+ if (navbarItemDef[def].indicated) return true;
+ }
+ return false;
+});
+
+function more(ev: MouseEvent) {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
+ src: ev.currentTarget ?? ev.target,
+ anchor: { x: 'center', y: 'bottom' },
+ }, {
+ closed: () => dispose(),
+ });
+}
+
+function openAccountMenu(ev: MouseEvent) {
+ openAccountMenu_({
+ withExtraOperation: true,
+ }, ev);
+}
+
+onMounted(() => {
+ window.addEventListener('resize', () => {
+ settingsWindowed.value = (window.innerWidth >= WINDOW_THRESHOLD);
+ }, { passive: true });
+});
+
+</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(from var(--MI_THEME-bg) srgb r g b / 0.75);
+ -webkit-backdrop-filter: var(--MI-blur, blur(15px));
+ backdrop-filter: var(--MI-blur, blur(15px));
+
+ > .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(--MI_THEME-navIndicator);
+ font-size: 8px;
+ }
+
+ &:hover {
+ text-decoration: none;
+ color: var(--MI_THEME-navHoverFg);
+ }
+
+ &.active {
+ color: var(--MI_THEME-navActive);
+ }
+ }
+
+ > .divider {
+ display: inline-block;
+ height: 16px;
+ margin: 0 10px;
+ border-right: solid 0.5px var(--MI_THEME-divider);
+ }
+
+ > .instance {
+ display: inline-block;
+ position: relative;
+ width: 56px;
+ height: 100%;
+ vertical-align: bottom;
+
+ > img {
+ display: inline-block;
+ width: 24px;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ margin: auto;
+ }
+ }
+
+ > .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/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index 3f3bc32fad..3de8137404 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -4,37 +4,43 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div :class="[$style.root, { [$style.rootIsMobile]: isMobile }]">
- <XSidebar v-if="!isMobile"/>
+<div :class="[$style.root, { [$style.withWallpaper]: withWallpaper }]">
+ <XSidebar v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'left'"/>
<div :class="$style.main">
+ <XNavbarH v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'top'"/>
+
<XAnnouncements v-if="$i"/>
<XStatusBars/>
- <div ref="columnsEl" :class="[$style.sections, { [$style.center]: prefer.r['deck.columnAlign'].value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel">
- <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
- <section
- v-for="ids in layout"
- :class="$style.section"
- :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' }"
- @wheel.self="onWheel"
- >
- <component
- :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn"
- v-for="id in ids"
- :ref="id"
- :key="id"
- :class="$style.column"
- :column="columns.find(c => c.id === id)!"
- :isStacked="ids.length > 1"
- @headerWheel="onWheel"
- />
- </section>
- <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding">
- <div>{{ i18n.ts._deck.introduction }}</div>
- <MkButton primary style="margin: 1em auto;" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton>
- <div>{{ i18n.ts._deck.introduction2 }}</div>
+
+ <div :class="$style.columnsWrapper">
+ <div ref="columnsEl" :class="[$style.columns, { [$style.center]: prefer.r['deck.columnAlign'].value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel">
+ <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+ <section
+ v-for="ids in layout"
+ :class="$style.section"
+ :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' }"
+ @wheel.self="onWheel"
+ >
+ <component
+ :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn"
+ v-for="id in ids"
+ :ref="id"
+ :key="id"
+ :class="$style.column"
+ :column="columns.find(c => c.id === id)!"
+ :isStacked="ids.length > 1"
+ @headerWheel="onWheel"
+ />
+ </section>
+ <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding">
+ <div>{{ i18n.ts._deck.introduction }}</div>
+ <MkButton primary style="margin: 1em auto;" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton>
+ <div>{{ i18n.ts._deck.introduction2 }}</div>
+ </div>
</div>
- <div :class="$style.sideMenu">
+
+ <div v-if="prefer.r['deck.menuPosition'].value === 'right'" :class="$style.sideMenu">
<div :class="$style.sideMenuTop">
<button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.sideMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button>
<button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.sideMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button>
@@ -47,18 +53,33 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
- </div>
- <div v-if="isMobile" :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" class="_blink"><i class="_indicatorCircle"></i></span></button>
- <button :class="$style.navButton" class="_button" @click="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" class="_blink">
- <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span>
- </span>
- </button>
- <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button>
+ <div v-if="prefer.r['deck.menuPosition'].value === 'bottom'" :class="$style.bottomMenu">
+ <div :class="$style.bottomMenuLeft">
+ <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${prefer.s['deck.profile']}`" :class="$style.bottomMenuButton" class="_button" @click="switchProfileMenu"><i class="ti ti-caret-down"></i></button>
+ <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" :class="$style.bottomMenuButton" class="_button" @click="deleteProfile"><i class="ti ti-trash"></i></button>
+ </div>
+ <div :class="$style.bottomMenuMiddle">
+ <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" :class="$style.bottomMenuButton" class="_button" @click="addColumn"><i class="ti ti-plus"></i></button>
+ </div>
+ <div :class="$style.bottomMenuRight">
+ <button v-tooltip.noDelay.left="i18n.ts.settings" :class="$style.bottomMenuButton" class="_button" @click="showSettings"><i class="ti ti-settings"></i></button>
+ </div>
+ </div>
+
+ <XNavbarH v-if="!isMobile && prefer.r['deck.navbarPosition'].value === 'bottom'"/>
+
+ <div v-if="isMobile" :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" class="_blink"><i class="_indicatorCircle"></i></span></button>
+ <button :class="$style.navButton" class="_button" @click="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" class="_blink">
+ <span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span>
+ </span>
+ </button>
+ <button :class="$style.postButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button>
+ </div>
</div>
<Transition
@@ -92,10 +113,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { computed, defineAsyncComponent, ref, useTemplateRef } from 'vue';
+import { computed, defineAsyncComponent, ref, useTemplateRef, watch } from 'vue';
import { v4 as uuid } from 'uuid';
import XCommon from './_common_/common.vue';
import XSidebar from '@/ui/_common_/navbar.vue';
+import XNavbarH from '@/ui/_common_/navbar-h.vue';
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
@@ -116,6 +138,8 @@ import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import { mainRouter } from '@/router.js';
import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
+import { miLocalStorage } from '@/local-storage.js';
+
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
@@ -148,7 +172,9 @@ window.addEventListener('resize', () => {
});
const snapScroll = deviceKind === 'smartphone' || deviceKind === 'tablet';
+const withWallpaper = miLocalStorage.getItem('wallpaper') != null;
const drawerMenuShowing = ref(false);
+const gap = prefer.r['deck.columnGap'];
/*
const route = 'TODO';
@@ -249,16 +275,18 @@ async function deleteProfile() {
--MI-margin: var(--MI-marginHalf);
- --columnGap: 6px;
+ --columnGap: v-bind("gap + 'px'");
display: flex;
height: 100dvh;
box-sizing: border-box;
flex: 1;
-}
-.rootIsMobile {
- padding-bottom: 100px;
+ &.withWallpaper {
+ .main {
+ background: transparent;
+ }
+ }
}
.main {
@@ -266,15 +294,23 @@ async function deleteProfile() {
min-width: 0;
display: flex;
flex-direction: column;
+ background: var(--MI_THEME-deckBg);
}
-.sections {
+.columnsWrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+}
+
+.columns {
flex: 1;
display: flex;
overflow-x: auto;
overflow-y: clip;
overscroll-behavior: contain;
- background: var(--MI_THEME-deckBg);
+ padding: var(--columnGap);
+ gap: var(--columnGap);
&.center {
> .section:first-of-type {
@@ -294,15 +330,10 @@ async function deleteProfile() {
.section {
display: flex;
flex-direction: column;
- scroll-snap-align: start;
flex-shrink: 0;
- padding-top: var(--columnGap);
- padding-bottom: var(--columnGap);
- padding-left: var(--columnGap);
-
- > .column:not(:last-of-type) {
- margin-bottom: var(--columnGap);
- }
+ gap: var(--columnGap);
+ scroll-snap-align: start;
+ scroll-margin-left: var(--columnGap);
}
.onboarding {
@@ -341,6 +372,33 @@ async function deleteProfile() {
margin-top: auto;
}
+.bottomMenu {
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ height: 32px;
+}
+
+.bottomMenuButton {
+ display: block;
+ height: 100%;
+ aspect-ratio: 1;
+}
+
+.bottomMenuLeft {
+ margin-right: auto;
+}
+
+.bottomMenuMiddle {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.bottomMenuRight {
+ margin-left: auto;
+}
+
.menuBg {
z-index: 1001;
}
@@ -360,10 +418,6 @@ async function deleteProfile() {
}
.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;