diff options
Diffstat (limited to 'packages/frontend/src/ui')
34 files changed, 5650 insertions, 0 deletions
diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue new file mode 100644 index 0000000000..7f3fc0e4af --- /dev/null +++ b/packages/frontend/src/ui/_common_/common.vue @@ -0,0 +1,139 @@ +<template> +<component + :is="popup.component" + v-for="popup in popups" + :key="popup.id" + v-bind="popup.props" + v-on="popup.events" +/> + +<XUpload v-if="uploads.length > 0"/> + +<XStreamIndicator/> + +<div v-if="pendingApiRequestsCount > 0" id="wait"></div> + +<div v-if="dev" id="devTicker"><span>DEV BUILD</span></div> + +<div v-if="$i && $i.isBot" id="botWarn"><span>{{ i18n.ts.loggedInAsBot }}</span></div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { swInject } from './sw-inject'; +import { popup, popups, pendingApiRequestsCount } from '@/os'; +import { uploads } from '@/scripts/upload'; +import * as sound from '@/scripts/sound'; +import { $i } from '@/account'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); +const XUpload = defineAsyncComponent(() => import('./upload.vue')); + +const dev = _DEV_; + +const onNotification = notification => { + if ($i.mutingNotificationTypes.includes(notification.type)) return; + + if (document.visibilityState === 'visible') { + stream.send('readNotification', { + id: notification.id, + }); + + popup(defineAsyncComponent(() => import('@/components/MkNotificationToast.vue')), { + notification, + }, {}, 'closed'); + } + + sound.play('notification'); +}; + +if ($i) { + const connection = stream.useChannel('main', null, 'UI'); + connection.on('notification', onNotification); + + //#region Listen message from SW + if ('serviceWorker' in navigator) { + swInject(); + } +} +</script> + +<style lang="scss"> +@keyframes dev-ticker-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes progress-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#wait { + display: block; + position: fixed; + z-index: 4000000; + 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; + } +} + +#botWarn { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 100%; + height: max-content; + text-align: center; + z-index: 2147483647; + color: #ff0; + background: rgba(0, 0, 0, 0.5); + padding: 4px 7px; + font-size: 14px; + pointer-events: none; + user-select: none; + + > span { + animation: dev-ticker-blink 2s infinite; + } +} + +#devTicker { + position: fixed; + top: 0; + left: 0; + z-index: 2147483647; + color: #ff0; + background: rgba(0, 0, 0, 0.5); + padding: 4px 5px; + font-size: 14px; + pointer-events: none; + user-select: none; + + > span { + animation: dev-ticker-blink 2s infinite; + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue new file mode 100644 index 0000000000..50b28de063 --- /dev/null +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -0,0 +1,314 @@ +<template> +<div class="kmwsukvl"> + <div class="body"> + <div class="top"> + <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <button v-click-anime class="item _button instance" @click="openInstanceMenu"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + </button> + </div> + <div class="middle"> + <MkA v-click-anime class="item index" active-class="active" to="/" exact> + <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> + </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 class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> + <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </button> + <MkA v-click-anime class="item" active-class="active" to="/settings"> + <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> + </MkA> + </div> + <div class="bottom"> + <button class="item _button post" data-cy-open-post-form @click="os.post"> + <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span> + </button> + <button v-click-anime class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { openAccountMenu as openAccountMenu_ } from '@/account'; +import { defaultStore } from '@/store'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const menu = toRef(defaultStore.state, 'menu'); +const otherMenuItemIndicated = computed(() => { + for (const def in navbarItemDef) { + if (menu.value.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} + +function openInstanceMenu(ev: MouseEvent) { + os.popupMenu([{ + text: instance.name ?? host, + type: 'label', + }, { + type: 'link', + text: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', + to: '/about', + }, { + type: 'link', + text: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', + to: '/about#emojis', + }, { + type: 'link', + text: i18n.ts.federation, + icon: 'ti ti-whirl', + to: '/about#federation', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'ti ti-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'ti ti-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'ti ti-terminal-2', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'ti ti-terminal-2', + }, null, { + text: i18n.ts.document, + icon: 'ti ti-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more() { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.kmwsukvl { + > .body { + display: flex; + flex-direction: column; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } + + > .instance { + position: relative; + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; + + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + 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); + } + } + + > .icon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; + } + + > .text { + position: relative; + } + } + + > .account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; + + > .avatar { + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 24px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > .icon { + position: relative; + width: 32px; + margin-right: 8px; + } + + > .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); + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue new file mode 100644 index 0000000000..b82da15f13 --- /dev/null +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -0,0 +1,521 @@ +<template> +<div class="mvcprjjd" :class="{ iconOnly }"> + <div class="body"> + <div class="top"> + <div class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"></div> + <button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + </button> + </div> + <div class="middle"> + <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.timeline" class="item index" active-class="active" to="/" exact> + <i class="icon ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span> + </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.noDelay.right="i18n.ts[navbarItemDef[item].title]" + class="item _button" + :class="[item, { active: navbarItemDef[item].active }]" + active-class="active" + :to="navbarItemDef[item].to" + v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}" + > + <i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip.noDelay.right="i18n.ts.controlPanel" class="item" active-class="active" to="/admin"> + <i class="icon ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="icon ti ti-grid-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon _indicatorCircle"></i></span> + </button> + <MkA v-click-anime v-tooltip.noDelay.right="i18n.ts.settings" class="item" active-class="active" to="/settings"> + <i class="icon ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span> + </MkA> + </div> + <div class="bottom"> + <button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post"> + <i class="icon ti ti-pencil ti-fw"></i><span class="text">{{ i18n.ts.note }}</span> + </button> + <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, watch } from 'vue'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { host } from '@/config'; + +const iconOnly = ref(false); + +const menu = computed(() => defaultStore.state.menu); +const otherMenuItemIndicated = computed(() => { + for (const def in navbarItemDef) { + if (menu.value.includes(def)) continue; + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +const calcViewState = () => { + iconOnly.value = (window.innerWidth <= 1279) || (defaultStore.state.menuDisplay === 'sideIcon'); +}; + +calcViewState(); + +window.addEventListener('resize', calcViewState); + +watch(defaultStore.reactiveState.menuDisplay, () => { + calcViewState(); +}); + +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} + +function openInstanceMenu(ev: MouseEvent) { + os.popupMenu([{ + text: instance.name ?? host, + type: 'label', + }, { + type: 'link', + text: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', + to: '/about', + }, { + type: 'link', + text: i18n.ts.customEmojis, + icon: 'ti ti-mood-happy', + to: '/about#emojis', + }, { + type: 'link', + text: i18n.ts.federation, + icon: 'ti ti-whirl', + to: '/about#federation', + }, null, { + type: 'parent', + text: i18n.ts.help, + icon: 'ti ti-question-circle', + children: [{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'ti ti-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'ti ti-terminal-2', + }, { + type: 'link', + to: '/api-console', + text: 'API Console', + icon: 'ti ti-terminal-2', + }, null, { + text: i18n.ts.document, + icon: 'ti ti-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], + }, { + type: 'link', + text: i18n.ts.aboutMisskey, + to: '/about-misskey', + }], ev.currentTarget ?? ev.target, { + align: 'left', + }); +} + +function more(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + }, { + }, 'closed'); +} +</script> + +<style lang="scss" scoped> +.mvcprjjd { + $nav-width: 250px; + $nav-icon-only-width: 80px; + + flex: 0 0 $nav-width; + width: $nav-width; + box-sizing: border-box; + + > .body { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: $nav-icon-only-width; + height: 100dvh; + box-sizing: border-box; + overflow: auto; + overflow-x: clip; + background: var(--navBg); + contain: strict; + display: flex; + flex-direction: column; + } + + &:not(.iconOnly) { + > .body { + width: $nav-width; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 15%, rgba(0,0,0,0.75) 100%); + } + + > .instance { + position: relative; + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + position: relative; + display: block; + width: 100%; + height: 40px; + color: var(--fgOnAccent); + font-weight: bold; + text-align: left; + + &:before { + content: ""; + display: block; + width: calc(100% - 38px); + height: 100%; + 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); + } + } + + > .icon { + position: relative; + margin-left: 30px; + margin-right: 8px; + width: 32px; + } + + > .text { + position: relative; + } + } + + > .account { + position: relative; + display: flex; + align-items: center; + padding-left: 30px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + margin-top: 16px; + + > .avatar { + position: relative; + width: 32px; + aspect-ratio: 1; + margin-right: 8px; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 30px; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > .icon { + position: relative; + width: 32px; + margin-right: 8px; + } + + > .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 { + color: var(--accent); + + &:before { + content: ""; + display: block; + width: calc(100% - 34px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + } + } + } + } + } + + &.iconOnly { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width; + + > .body { + width: $nav-icon-only-width; + + > .top { + position: sticky; + top: 0; + z-index: 1; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .instance { + display: block; + text-align: center; + width: 100%; + + > .icon { + display: inline-block; + width: 30px; + aspect-ratio: 1; + } + } + } + + > .bottom { + position: sticky; + bottom: 0; + padding: 20px 0; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + + > .post { + display: block; + position: relative; + width: 100%; + height: 52px; + margin-bottom: 16px; + text-align: center; + + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 52px; + aspect-ratio: 1/1; + border-radius: 100%; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + + > .icon { + position: relative; + color: var(--fgOnAccent); + } + + > .text { + display: none; + } + } + + > .account { + display: block; + text-align: center; + width: 100%; + + > .avatar { + display: inline-block; + width: 38px; + aspect-ratio: 1; + } + + > .text { + display: none; + } + } + } + + > .middle { + flex: 1; + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + border-top: solid 0.5px var(--divider); + } + + > .item { + display: block; + position: relative; + padding: 18px 0; + width: 100%; + text-align: center; + + > .icon { + display: block; + margin: 0 auto; + opacity: 0.7; + } + + > .text { + display: none; + } + + > .indicator { + position: absolute; + top: 6px; + left: 24px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover, &.active { + text-decoration: none; + color: var(--accent); + + &:before { + content: ""; + display: block; + height: 100%; + aspect-ratio: 1; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + + > .icon, > .text { + opacity: 1; + } + } + } + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue new file mode 100644 index 0000000000..24fc4f6f6d --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -0,0 +1,108 @@ +<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 class="icon" :src="getInstanceIcon(instance)" 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/MkMarquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy'; + +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, +}); + +function getInstanceIcon(instance): string { + return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png'; +} +</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: 5em; + + > .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/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue new file mode 100644 index 0000000000..e7f88e4984 --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -0,0 +1,93 @@ +<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/MkMarquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { shuffle } from '@/scripts/shuffle'; + +const props = defineProps<{ + url?: string; + shuffle?: boolean; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const items = ref([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { + res.json().then(feed => { + if (props.shuffle) { + shuffle(feed.items); + } + 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 3em; + background: currentColor; + opacity: 0.3; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue new file mode 100644 index 0000000000..f4d989c387 --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -0,0 +1,113 @@ +<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 class="text" :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/MkMarquee.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++; + }); +}; + +watch(() => props.userListId, tick); + +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; + } + + > .text { + > .text { + display: inline-block; + vertical-align: bottom; + } + } + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 3em; + background: currentColor; + opacity: 0; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue new file mode 100644 index 0000000000..114ca5be8c --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -0,0 +1,92 @@ +<template> +<div class="dlrsnxqu"> + <div + v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, { + verySmall: x.size === 'verySmall', + small: x.size === 'small', + medium: x.size === 'medium', + large: x.size === 'large', + veryLarge: x.size === 'veryLarge', + }]" + > + <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" :shuffle="x.props.shuffle"/> + <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 { + font-size: 15px; + background: var(--panel); + + > .item { + --height: 24px; + --nameMargin: 10px; + font-size: 0.85em; + + &.verySmall { + --nameMargin: 7px; + --height: 16px; + font-size: 0.75em; + } + + &.small { + --nameMargin: 8px; + --height: 20px; + font-size: 0.8em; + } + + &.large { + --nameMargin: 12px; + --height: 26px; + font-size: 0.875em; + } + + &.veryLarge { + --nameMargin: 14px; + --height: 30px; + font-size: 0.9em; + } + + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: clip; + contain: strict; + + > .name { + padding: 0 var(--nameMargin); + font-weight: bold; + color: var(--accent); + + &:empty { + display: none; + } + } + + > .body { + min-width: 0; + flex: 1; + } + + &.black { + background: #000; + color: #fff; + } + } +} +</style> diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue new file mode 100644 index 0000000000..a855de8ab9 --- /dev/null +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -0,0 +1,61 @@ +<template> +<div v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" class="nsbbhtug" @click="resetDisconnected"> + <div>{{ i18n.ts.disconnectedFromServer }}</div> + <div class="command"> + <button class="_textButton" @click="reload">{{ i18n.ts.reload }}</button> + <button class="_textButton">{{ i18n.ts.doNothing }}</button> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onUnmounted } from 'vue'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; + +let hasDisconnected = $ref(false); + +function onDisconnected() { + hasDisconnected = true; +} + +function resetDisconnected() { + hasDisconnected = false; +} + +function reload() { + location.reload(); +} + +stream.on('_disconnected_', onDisconnected); + +onUnmounted(() => { + stream.off('_disconnected_', onDisconnected); +}); +</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/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts new file mode 100644 index 0000000000..8676d2d48d --- /dev/null +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -0,0 +1,35 @@ +import { inject } from 'vue'; +import { post } from '@/os'; +import { $i, login } from '@/account'; +import { defaultStore } from '@/store'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; +import { mainRouter } from '@/router'; + +export function swInject() { + navigator.serviceWorker.addEventListener('message', ev => { + if (_DEV_) { + console.log('sw msg', ev.data); + } + + if (ev.data.type !== 'order') return; + + if (ev.data.loginId !== $i?.id) { + return getAccountFromId(ev.data.loginId).then(account => { + if (!account) return; + return login(account.token, ev.data.url); + }); + } + + switch (ev.data.order) { + case 'post': + return post(ev.data.options); + case 'push': + if (mainRouter.currentRoute.value.path === ev.data.url) { + return window.scroll({ top: 0, behavior: 'smooth' }); + } + return mainRouter.push(ev.data.url); + default: + return; + } + }); +} diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue new file mode 100644 index 0000000000..70882bd251 --- /dev/null +++ b/packages/frontend/src/ui/_common_/upload.vue @@ -0,0 +1,129 @@ +<template> +<div class="mk-uploader _acrylic" :style="{ zIndex }"> + <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"><MkLoading :em="true"/>{{ ctx.name }}</p> + <p class="status"> + <span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span> + <span v-if="ctx.progressValue !== undefined" class="kb">{{ 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 v-if="ctx.progressValue !== undefined" class="percentage">{{ 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" setup> +import { } from 'vue'; +import * as os from '@/os'; +import { uploads } from '@/scripts/upload'; +import { i18n } from '@/i18n'; + +const zIndex = os.claimZIndex('high'); +</script> + +<style lang="scss" scoped> +.mk-uploader { + position: fixed; + 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/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue new file mode 100644 index 0000000000..46d79e6355 --- /dev/null +++ b/packages/frontend/src/ui/classic.header.vue @@ -0,0 +1,217 @@ +<template> +<div class="azykntjl"> + <div class="body"> + <div class="left"> + <MkA v-click-anime v-tooltip="$ts.timeline" class="item index" active-class="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="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="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"><i class="_indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime v-tooltip="$ts.controlPanel" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : 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"><i class="_indicatorCircle"></i></span> + </button> + </div> + <div class="right"> + <MkA v-click-anime v-tooltip="$ts.settings" class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : 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="post"> + <MkButton class="button" gradate full rounded> + <i class="ti ti-pencil ti-fw"></i> + </MkButton> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { openAccountMenu } from '@/account'; +import MkButton from '@/components/MkButton.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + + data() { + return { + host: host, + accounts: [], + connection: null, + navbarItemDef: navbarItemDef, + settingsWindowed: false, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + + otherNavItemIndicated(): boolean { + for (const def in this.navbarItemDef) { + if (this.menu.includes(def)) continue; + if (this.navbarItemDef[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(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + anchor: { x: 'center', y: 'bottom' }, + }, { + }, 'closed'); + }, + + openAccountMenu: (ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, + }, +}); +</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/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue new file mode 100644 index 0000000000..dac09ea703 --- /dev/null +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -0,0 +1,268 @@ +<template> +<div class="npcljfve" :class="{ iconOnly }"> + <button v-click-anime class="item _button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + <div class="post" data-cy-open-post-form @click="post"> + <MkButton class="button" gradate full rounded> + <i class="ti ti-pencil ti-fw"></i><span v-if="!iconOnly" class="text">{{ $ts.note }}</span> + </MkButton> + </div> + <div class="divider"></div> + <MkA v-click-anime class="item index" active-class="active" to="/" exact> + <i class="ti ti-home ti-fw"></i><span class="text">{{ $ts.timeline }}</span> + </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 class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"> + <i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span> + <span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null"> + <i class="ti ti-dashboard ti-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + </MkA> + <button v-click-anime class="item _button" @click="more"> + <i class="ti ti-dots ti-fw"></i><span class="text">{{ $ts.more }}</span> + <span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span> + </button> + <MkA v-click-anime class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null"> + <i class="ti ti-settings ti-fw"></i><span class="text">{{ $ts.settings }}</span> + </MkA> + <div class="divider"></div> + <div class="about"> + <MkA v-click-anime class="link" to="/about"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/> + </MkA> + </div> + <!--<MisskeyLogo class="misskey"/>--> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { openAccountMenu } from '@/account'; +import MkButton from '@/components/MkButton.vue'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +//import MisskeyLogo from '@assets/client/misskey.svg'; + +export default defineComponent({ + components: { + MkButton, + //MisskeyLogo, + }, + + data() { + return { + host: host, + accounts: [], + connection: null, + navbarItemDef: navbarItemDef, + iconOnly: false, + settingsWindowed: false, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + + otherNavItemIndicated(): boolean { + for (const def in this.navbarItemDef) { + if (this.menu.includes(def)) continue; + if (this.navbarItemDef[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(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), { + src: ev.currentTarget ?? ev.target, + }, {}, 'closed'); + }, + + openAccountMenu: (ev) => { + openAccountMenu({ + withExtraOperation: true, + }, ev); + }, + }, +}); +</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/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue new file mode 100644 index 0000000000..0e726c11ed --- /dev/null +++ b/packages/frontend/src/ui/classic.vue @@ -0,0 +1,320 @@ +<template> +<div class="gbhvwtnk" :class="{ wallpaper }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`"> + <XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/> + + <div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }"> + <div v-if="!showMenuOnTop" class="sidebar"> + <XSidebar/> + </div> + <div v-else ref="widgetsLeft" class="widgets left"> + <XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/> + </div> + + <main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> + <div class="content"> + <RouterView/> + </div> + </main> + + <div v-if="isDesktop" ref="widgetsRight" class="widgets right"> + <XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/> + </div> + </div> + + <transition :name="$store.state.animation ? 'tray-back' : ''"> + <div + v-if="widgetsShowing" + class="tray-back _modalBg" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'tray' : ''"> + <XWidgets v-if="widgetsShowing" class="tray"/> + </transition> + + <iframe v-if="$store.state.aiChanMode" ref="live2d" class="ivnzpscs" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, markRaw, ComputedRef, ref, onMounted, provide } from 'vue'; +import XSidebar from './classic.sidebar.vue'; +import XCommon from './_common_/common.vue'; +import { instanceName } from '@/config'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); +const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue')); + +const DESKTOP_THRESHOLD = 1100; + +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let widgetsShowing = $ref(false); +let fullView = $ref(false); +let globalHeaderHeight = $ref(0); +const wallpaper = localStorage.getItem('wallpaper') != null; +const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top'); +let live2d = $ref<HTMLIFrameElement>(); +let widgetsLeft = $ref(); +let widgetsRight = $ref(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); +provide('shouldHeaderThin', showMenuOnTop); +provide('shouldSpacerMin', true); + +function attachSticky(el) { + const sticky = new StickySidebar(el, defaultStore.state.menuDisplay === 'top' ? 0 : 16, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); +} + +function top() { + window.scroll({ top: 0, behavior: 'smooth' }); +} + +function onContextmenu(ev: MouseEvent) { + 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: fullView ? 'fas fa-compress' : 'fas fa-expand', + text: fullView ? i18n.ts.quitFullView : i18n.ts.fullView, + action: () => { + fullView = !fullView; + }, + }, { + icon: 'ti ti-window-maximize', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(path); + }, + }], ev); +} + +function onAiClick(ev) { + //if (this.live2d) this.live2d.click(ev); +} + +if (window.innerWidth < 1024) { + localStorage.setItem('ui', 'default'); + location.reload(); +} + +document.documentElement.style.overflowY = 'scroll'; + +if (defaultStore.state.widgets.length === 0) { + defaultStore.set('widgets', [{ + name: 'calendar', + id: 'a', place: null, data: {}, + }, { + name: 'notifications', + id: 'b', place: null, data: {}, + }, { + name: 'trends', + id: 'c', place: null, data: {}, + }]); +} + +onMounted(() => { + window.addEventListener('resize', () => { + isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD); + }, { passive: true }); + + if (defaultStore.state.aiChanMode) { + const iframeRect = live2d.getBoundingClientRect(); + window.addEventListener('mousemove', ev => { + live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + }, + }, '*'); + }, { passive: true }); + window.addEventListener('touchmove', ev => { + live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.touches[0].clientX - iframeRect.left, + y: ev.touches[0].clientY - iframeRect.top, + }, + }, '*'); + }, { passive: true }); + } +}); +</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; +} + +.gbhvwtnk { + $ui-font-size: 1em; + $widgets-hide-threshold: 1200px; + + min-height: 100dvh; + box-sizing: border-box; + + &.wallpaper { + background: var(--wallpaperOverlay); + //backdrop-filter: var(--blur, blur(4px)); + } + + > .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%; + } + } + } + + > .tray-back { + z-index: 1001; + } + + > .tray { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + height: 100dvh; + 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/packages/frontend/src/ui/classic.widgets.vue b/packages/frontend/src/ui/classic.widgets.vue new file mode 100644 index 0000000000..163ec982ce --- /dev/null +++ b/packages/frontend/src/ui/classic.widgets.vue @@ -0,0 +1,84 @@ +<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" class="_textButton edit" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ $ts.editWidgetsExit }}</button> + <button v-else class="_textButton edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/MkWidgets.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, + } : 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/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue new file mode 100644 index 0000000000..f3415cfd09 --- /dev/null +++ b/packages/frontend/src/ui/deck.vue @@ -0,0 +1,435 @@ +<template> +<div + class="mk-deck" :class="[{ isMobile }]" +> + <XSidebar v-if="!isMobile"/> + + <div class="main"> + <XStatusBars class="statusbars"/> + <div ref="columnsEl" class="columns" :class="deckStore.reactiveState.columnAlign.value" @contextmenu.self.prevent="onContextmenu"> + <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 v-if="layout.length === 0" class="intro _panel"> + <div>{{ i18n.ts._deck.introduction }}</div> + <MkButton primary class="add" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton> + <div>{{ i18n.ts._deck.introduction2 }}</div> + </div> + <div class="sideMenu"> + <div class="top"> + <button v-tooltip.noDelay.left="`${i18n.ts._deck.profile}: ${deckStore.state.profile}`" class="_button button" @click="changeProfile"><i class="ti ti-caret-down"></i></button> + <button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" class="_button button" @click="deleteProfile"><i class="ti ti-trash"></i></button> + </div> + <div class="middle"> + <button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" class="_button button" @click="addColumn"><i class="ti ti-plus"></i></button> + </div> + <div class="bottom"> + <button v-tooltip.noDelay.left="i18n.ts.settings" class="_button button settings" @click="showSettings"><i class="ti ti-settings"></i></button> + </div> + </div> + </div> + </div> + + <div v-if="isMobile" class="buttons"> + <button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button home _button" @click="mainRouter.push('/')"><i class="ti ti-home"></i></button> + <button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button> + </div> + + <transition :name="$store.state.animation ? 'menu-back' : ''"> + <div + v-if="drawerMenuShowing" + class="menu-back _modalBg" + @click="drawerMenuShowing = false" + @touchstart.passive="drawerMenuShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'menu' : ''"> + <XDrawerMenu v-if="drawerMenuShowing" class="menu"/> + </transition> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +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, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store'; +import DeckColumnCore from '@/ui/deck/column-core.vue'; +import XSidebar from '@/ui/_common_/navbar.vue'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import MkButton from '@/components/MkButton.vue'; +import { getScrollContainer } from '@/scripts/scroll'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { unisonReload } from '@/scripts/unison-reload'; +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); + +mainRouter.navHook = (path, flag): boolean => { + if (flag === 'forcePage') return false; + const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main'); + if (deckStore.state.navWindow || noMainColumn) { + os.pageWindow(path); + return true; + } + return false; +}; + +const isMobile = ref(window.innerWidth <= 500); +window.addEventListener('resize', () => { + isMobile.value = window.innerWidth <= 500; +}); + +const drawerMenuShowing = ref(false); + +const route = 'TODO'; +watch(route, () => { + drawerMenuShowing.value = false; +}); + +const columns = deckStore.reactiveState.columns; +const layout = deckStore.reactiveState.layout; +const menuIndicated = computed(() => { + if ($i == null) return false; + for (const def in navbarItemDef) { + if (navbarItemDef[def].indicated) return true; + } + return false; +}); + +function showSettings() { + os.pageWindow('/settings/deck'); +} + +let columnsEl = $ref<HTMLElement>(); + +const addColumn = async (ev) => { + const columns = [ + 'main', + 'widgets', + 'notifications', + 'tl', + 'antenna', + 'list', + 'mentions', + 'direct', + ]; + + const { canceled, result: column } = await os.select({ + title: i18n.ts._deck.addColumn, + items: columns.map(column => ({ + value: column, text: i18n.t('_deck._columns.' + column), + })), + }); + if (canceled) return; + + addColumnToStore({ + type: column, + id: uuid(), + name: i18n.t('_deck._columns.' + column), + width: 330, + }); +}; + +const onContextmenu = (ev) => { + os.contextMenu([{ + text: i18n.ts._deck.addColumn, + action: addColumn, + }], ev); +}; + +document.documentElement.style.overflowY = 'hidden'; +document.documentElement.style.scrollBehavior = 'auto'; +window.addEventListener('wheel', (ev) => { + 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(); + +function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { + // TODO?? +} + +function changeProfile(ev: MouseEvent) { + const items = ref([{ + text: deckStore.state.profile, + active: true.valueOf, + }]); + getProfiles().then(profiles => { + items.value = [{ + text: deckStore.state.profile, + active: true.valueOf, + }, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({ + text: k, + action: () => { + deckStore.set('profile', k); + unisonReload(); + }, + }))), null, { + text: i18n.ts._deck.newProfile, + icon: 'ti ti-plus', + action: async () => { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts._deck.profile, + allowEmpty: false, + }); + if (canceled) return; + + deckStore.set('profile', name); + unisonReload(); + }, + }]; + }); + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function deleteProfile() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: deckStore.state.profile }), + }); + if (canceled) return; + + deleteProfile_(deckStore.state.profile); + deckStore.set('profile', 'default'); + unisonReload(); +} +</script> + +<style lang="scss" scoped> +.menu-enter-active, +.menu-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); +} +.menu-enter-from, +.menu-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.menu-back-enter-active, +.menu-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.menu-back-enter-from, +.menu-back-leave-active { + opacity: 0; +} + +.mk-deck { + $nav-hide-threshold: 650px; // TODO: どこかに集約したい + + // TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい + --margin: var(--marginHalf); + + --deckDividerThickness: 5px; + + display: flex; + height: 100dvh; + box-sizing: border-box; + flex: 1; + + &.isMobile { + padding-bottom: 100px; + } + + > .main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + + > .columns { + flex: 1; + display: flex; + overflow-x: auto; + overflow-y: clip; + + &.center { + > .column:first-of-type { + margin-left: auto; + } + + > .column:last-of-type { + margin-right: auto; + } + } + + > .column { + flex-shrink: 0; + border-right: solid var(--deckDividerThickness) var(--deckDivider); + + &:first-of-type { + border-left: solid var(--deckDividerThickness) var(--deckDivider); + } + + &.folder { + display: flex; + flex-direction: column; + + > *:not(:last-of-type) { + border-bottom: solid var(--deckDividerThickness) var(--deckDivider); + } + } + } + + > .intro { + padding: 32px; + height: min-content; + text-align: center; + margin: auto; + + > .add { + margin: 1em auto; + } + } + + > .sideMenu { + flex-shrink: 0; + margin-right: 0; + margin-left: auto; + display: flex; + flex-direction: column; + justify-content: center; + width: 32px; + + > .top, > .middle, > .bottom { + > .button { + display: block; + width: 100%; + aspect-ratio: 1; + } + } + + > .top { + margin-bottom: auto; + } + + > .middle { + margin-top: auto; + margin-bottom: auto; + } + + > .bottom { + margin-top: auto; + } + } + } + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + left: 0; + padding: 16px; + display: flex; + width: 100%; + box-sizing: border-box; + + > .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: 20px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + } + } + + > .menu-back { + z-index: 1001; + } + + > .menu { + 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); + } +} +</style> diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue new file mode 100644 index 0000000000..ba14530662 --- /dev/null +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -0,0 +1,70 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i class="ti ti-antenna"></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" setup> +import { onMounted } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let timeline = $ref<InstanceType<typeof XTimeline>>(); + +onMounted(() => { + if (props.column.antennaId == null) { + setAntenna(); + } +}); + +async function setAntenna() { + const antennas = await os.api('antennas/list'); + const { canceled, result: antenna } = await os.select({ + title: i18n.ts.selectAntenna, + items: antennas.map(x => ({ + value: x, text: x.name, + })), + default: props.column.antennaId, + }); + if (canceled) return; + updateColumn(props.column.id, { + antennaId: antenna.id, + }); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.selectAntenna, + action: setAntenna, +}]; + +/* +function focus() { + timeline.focus(); +} + +defineExpose({ + focus, +}); +*/ +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue new file mode 100644 index 0000000000..30c0dc5e1c --- /dev/null +++ b/packages/frontend/src/ui/deck/column-core.vue @@ -0,0 +1,34 @@ +<template> +<!-- TODO: リファクタの余地がありそう --> +<div v-if="!column">たぶん見えちゃいけないやつ</div> +<XMainColumn v-else-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" setup> +import { } 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'; +import { Column } from './deck-store'; + +defineProps<{ + column?: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); +</script> diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue new file mode 100644 index 0000000000..2a99b621e6 --- /dev/null +++ b/packages/frontend/src/ui/deck/column.vue @@ -0,0 +1,398 @@ +<template> +<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> +<section + v-hotkey="keymap" class="dnpfarvg _narrow_" + :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }" + @dragover.prevent.stop="onDragover" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" +> + <header + :class="{ indicated }" + draggable="true" + @click="goTop" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + > + <button v-if="isStacked && !isMainColumn" class="toggleActive _button" @click="toggleActive"> + <template v-if="active"><i class="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + <div class="action"> + <slot name="action"></slot> + </div> + <span class="header"><slot name="header"></slot></span> + <button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button> + </header> + <div v-show="active" ref="body"> + <slot></slot> + </div> +</section> +</template> + +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, provide, Ref, watch } from 'vue'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column, deckStore } from './deck-store'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { MenuItem } from '@/types/menu'; + +provide('shouldHeaderThin', true); +provide('shouldOmitHeaderTitle', true); +provide('shouldSpacerMin', true); + +const props = withDefaults(defineProps<{ + column: Column; + isStacked?: boolean; + naked?: boolean; + indicated?: boolean; + menu?: MenuItem[]; +}>(), { + isStacked: false, + naked: false, + indicated: false, +}); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; + (ev: 'change-active-state', v: boolean): void; +}>(); + +let body = $ref<HTMLDivElement>(); + +let dragging = $ref(false); +watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd')); + +let draghover = $ref(false); +let dropready = $ref(false); + +const isMainColumn = $computed(() => props.column.type === 'main'); +const active = $computed(() => props.column.active !== false); +watch($$(active), v => emit('change-active-state', v)); + +const keymap = $computed(() => ({ + 'shift+up': () => emit('parent-focus', 'up'), + 'shift+down': () => emit('parent-focus', 'down'), + 'shift+left': () => emit('parent-focus', 'left'), + 'shift+right': () => emit('parent-focus', 'right'), +})); + +onMounted(() => { + os.deckGlobalEvents.on('column.dragStart', onOtherDragStart); + os.deckGlobalEvents.on('column.dragEnd', onOtherDragEnd); +}); + +onBeforeUnmount(() => { + os.deckGlobalEvents.off('column.dragStart', onOtherDragStart); + os.deckGlobalEvents.off('column.dragEnd', onOtherDragEnd); +}); + +function onOtherDragStart() { + dropready = true; +} + +function onOtherDragEnd() { + dropready = false; +} + +function toggleActive() { + if (!props.isStacked) return; + updateColumn(props.column.id, { + active: !props.column.active, + }); +} + +function getMenu() { + let items = [{ + icon: 'ti ti-settings', + text: i18n.ts._deck.configureColumn, + action: async () => { + const { canceled, result } = await os.form(props.column.name, { + name: { + type: 'string', + label: i18n.ts.name, + default: props.column.name, + }, + width: { + type: 'number', + label: i18n.ts.width, + default: props.column.width, + }, + flexible: { + type: 'boolean', + label: i18n.ts.flexible, + default: props.column.flexible, + }, + }); + if (canceled) return; + updateColumn(props.column.id, result); + }, + }, { + type: 'parent', + text: i18n.ts.move + '...', + icon: 'ti ti-arrows-move', + children: [{ + icon: 'ti ti-arrow-left', + text: i18n.ts._deck.swapLeft, + action: () => { + swapLeftColumn(props.column.id); + }, + }, { + icon: 'ti ti-arrow-right', + text: i18n.ts._deck.swapRight, + action: () => { + swapRightColumn(props.column.id); + }, + }, props.isStacked ? { + icon: 'ti ti-arrow-up', + text: i18n.ts._deck.swapUp, + action: () => { + swapUpColumn(props.column.id); + }, + } : undefined, props.isStacked ? { + icon: 'ti ti-arrow-down', + text: i18n.ts._deck.swapDown, + action: () => { + swapDownColumn(props.column.id); + }, + } : undefined], + }, { + icon: 'ti ti-stack-2', + text: i18n.ts._deck.stackLeft, + action: () => { + stackLeftColumn(props.column.id); + }, + }, props.isStacked ? { + icon: 'ti ti-window-maximize', + text: i18n.ts._deck.popRight, + action: () => { + popRightColumn(props.column.id); + }, + } : undefined, null, { + icon: 'ti ti-trash', + text: i18n.ts.remove, + danger: true, + action: () => { + removeColumn(props.column.id); + }, + }]; + + if (props.menu) { + items.unshift(null); + items = props.menu.concat(items); + } + + return items; +} + +function showSettingsMenu(ev: MouseEvent) { + os.popupMenu(getMenu(), ev.currentTarget ?? ev.target); +} + +function onContextmenu(ev: MouseEvent) { + os.contextMenu(getMenu(), ev); +} + +function goTop() { + body.scrollTo({ + top: 0, + behavior: 'smooth', + }); +} + +function onDragstart(ev) { + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id); + + // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう + // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately + window.setTimeout(() => { + dragging = true; + }, 10); +} + +function onDragend(ev) { + dragging = false; +} + +function onDragover(ev) { + // 自分自身がドラッグされている場合 + if (dragging) { + // 自分自身にはドロップさせない + ev.dataTransfer.dropEffect = 'none'; + } else { + const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_; + + ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; + + if (isDeckColumn) draghover = true; + } +} + +function onDragleave() { + draghover = false; +} + +function onDrop(ev) { + draghover = false; + os.deckGlobalEvents.emit('column.dragEnd'); + + const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); + if (id != null && id !== '') { + swapColumn(props.column.id, id); + } +} +</script> + +<style lang="scss" scoped> +.dnpfarvg { + --root-margin: 10px; + --deckColumnHeaderHeight: 40px; + + height: 100%; + overflow: hidden; + contain: strict; + + &.draghover { + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + } + } + + &.dragging { + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + opacity: 0.5; + } + } + + &.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); + 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-y: auto; + overflow-x: hidden; // Safari does not supports clip + overflow-x: clip; + -webkit-overflow-scrolling: touch; + box-sizing: border-box; + background-color: var(--bg); + } +} +</style> diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts new file mode 100644 index 0000000000..56db7398e5 --- /dev/null +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -0,0 +1,296 @@ +import { throttle } from 'throttle-debounce'; +import { markRaw } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import { Storage } from '../../pizzax'; +import { i18n } from '@/i18n'; +import { api } from '@/os'; +import { deepClone } from '@/scripts/clone'; + +type ColumnWidget = { + name: string; + id: string; + data: Record<string, any>; +}; + +export type Column = { + id: string; + type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct'; + name: string | null; + width: number; + widgets?: ColumnWidget[]; + active?: boolean; + flexible?: boolean; + antennaId?: string; + listId?: string; + includingTypes?: typeof notificationTypes[number][]; + tl?: 'home' | 'local' | 'social' | 'global'; +}; + +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, + }, +})); + +export const loadDeck = async () => { + let deck; + + try { + deck = await api('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + }); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') { + // 後方互換性のため + if (deckStore.state.profile === 'default') { + saveDeck(); + return; + } + + deckStore.set('columns', []); + deckStore.set('layout', []); + return; + } + throw err; + } + + 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 async function getProfiles(): Promise<string[]> { + return await api('i/registry/keys', { + scope: ['client', 'deck', 'profiles'], + }); +} + +export async function deleteProfile(key: string): Promise<void> { + return await api('i/registry/remove', { + scope: ['client', 'deck', 'profiles'], + key: key, + }); +} + +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 = deepClone(deckStore.state.layout); + layout[aX][aY] = b; + layout[bX][bY] = a; + deckStore.set('layout', layout); + saveDeck(); +} + +export function swapLeftColumn(id: Column['id']) { + const layout = deepClone(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 = deepClone(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 = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(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 = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(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 = deepClone(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 = deepClone(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 = deepClone(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 = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(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 = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(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 = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(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, widgetData: any) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.map(w => w.id === widgetId ? { + ...w, + data: widgetData, + } : w); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumn(id: Column['id'], column: Partial<Column>) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const currentColumn = deepClone(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/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue new file mode 100644 index 0000000000..75b018cacd --- /dev/null +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -0,0 +1,31 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import XNotes from '@/components/MkNotes.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +const pagination = { + endpoint: 'notes/mentions' as const, + limit: 10, + params: { + visibility: 'specified', + }, +}; +</script> diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue new file mode 100644 index 0000000000..d9f3f7b4e7 --- /dev/null +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -0,0 +1,58 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i class="ti ti-list"></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" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let timeline = $ref<InstanceType<typeof XTimeline>>(); + +if (props.column.listId == null) { + setList(); +} + +async function setList() { + const lists = await os.api('users/lists/list'); + const { canceled, result: list } = await os.select({ + title: i18n.ts.selectList, + items: lists.map(x => ({ + value: x, text: x.name, + })), + default: props.column.listId, + }); + if (canceled) return; + updateColumn(props.column.id, { + listId: list.id, + }); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.selectList, + action: setList, +}]; +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue new file mode 100644 index 0000000000..0c66172397 --- /dev/null +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -0,0 +1,68 @@ +<template> +<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <template v-if="pageMetadata?.value"> + <i :class="pageMetadata?.value.icon"></i> + {{ pageMetadata?.value.title }} + </template> + </template> + + <RouterView @contextmenu.stop="onContextmenu"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { ComputedRef, provide } from 'vue'; +import XColumn from './column.vue'; +import { deckStore, Column } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); + +/* +function back() { + history.back(); +} +*/ +function onContextmenu(ev: MouseEvent) { + if (!ev.target) return; + + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target as HTMLElement)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return; + if (window.getSelection()?.toString() !== '') return; + const path = mainRouter.currentRoute.value.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: 'ti ti-window-maximize', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(path); + }, + }], ev); +} +</script> diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue new file mode 100644 index 0000000000..16962956a0 --- /dev/null +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -0,0 +1,28 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import XNotes from '@/components/MkNotes.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +const pagination = { + endpoint: 'notes/mentions' as const, + limit: 10, +}; +</script> diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue new file mode 100644 index 0000000000..9d133035fe --- /dev/null +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -0,0 +1,44 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotifications :include-types="column.includingTypes"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XNotifications from '@/components/MkNotifications.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +function func() { + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { + includingTypes: props.column.includingTypes, + }, { + done: async (res) => { + const { includingTypes } = res; + updateColumn(props.column.id, { + includingTypes: includingTypes, + }); + }, + }, 'closed'); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.notificationSetting, + action: func, +}]; +</script> diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue new file mode 100644 index 0000000000..49b29145ff --- /dev/null +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -0,0 +1,119 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i v-if="column.tl === 'home'" class="ti ti-home"></i> + <i v-else-if="column.tl === 'local'" class="ti ti-messages"></i> + <i v-else-if="column.tl === 'social'" class="ti ti-share"></i> + <i v-else-if="column.tl === 'global'" class="ti ti-world"></i> + <span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <div v-if="disabled" class="iwaalbte"> + <p> + <i class="ti ti-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" :key="column.tl" :src="column.tl" @after="() => emit('loaded')" @queue="queueUpdated" @note="onNote"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XColumn from './column.vue'; +import { removeColumn, updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let disabled = $ref(false); +let indicated = $ref(false); +let columnActive = $ref(true); + +onMounted(() => { + if (props.column.tl == null) { + setType(); + } else if ($i) { + disabled = !$i.isModerator && !$i.isAdmin && ( + instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) || + instance.disableGlobalTimeline && ['global'].includes(props.column.tl)); + } +}); + +async function setType() { + const { canceled, result: src } = await os.select({ + title: i18n.ts.timeline, + items: [{ + value: 'home' as const, text: i18n.ts._timelines.home, + }, { + value: 'local' as const, text: i18n.ts._timelines.local, + }, { + value: 'social' as const, text: i18n.ts._timelines.social, + }, { + value: 'global' as const, text: i18n.ts._timelines.global, + }], + }); + if (canceled) { + if (props.column.tl == null) { + removeColumn(props.column.id); + } + return; + } + updateColumn(props.column.id, { + tl: src, + }); +} + +function queueUpdated(q) { + if (columnActive) { + indicated = q !== 0; + } +} + +function onNote() { + if (!columnActive) { + indicated = true; + } +} + +function onChangeActiveState(state) { + columnActive = state; + + if (columnActive) { + indicated = false; + } +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.timeline, + action: setType, +}]; +</script> + +<style lang="scss" scoped> +.iwaalbte { + text-align: center; + + > p { + margin: 16px; + + &.desc { + font-size: 14px; + } + } +} +</style> diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue new file mode 100644 index 0000000000..fc61d18ff6 --- /dev/null +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -0,0 +1,69 @@ +<template> +<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <div class="wtdtxvec"> + <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> + <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" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; +import XWidgets from '@/components/MkWidgets.vue'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let edit = $ref(false); + +function addWidget(widget) { + addColumnWidget(props.column.id, widget); +} + +function removeWidget(widget) { + removeColumnWidget(props.column.id, widget); +} + +function updateWidget({ id, data }) { + updateColumnWidget(props.column.id, id, data); +} + +function updateWidgets(widgets) { + setColumnWidgets(props.column.id, widgets); +} + +function func() { + edit = !edit; +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.editWidgets, + action: func, +}]; +</script> + +<style lang="scss" scoped> +.wtdtxvec { + --margin: 8px; + --panelBorder: none; + + padding: 0 var(--margin); + + > .intro { + padding: 16px; + text-align: center; + } +} +</style> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue new file mode 100644 index 0000000000..b91bf476e8 --- /dev/null +++ b/packages/frontend/src/ui/universal.vue @@ -0,0 +1,390 @@ +<template> +<div class="dkgtipfy" :class="{ wallpaper }"> + <XSidebar v-if="!isMobile" class="sidebar"/> + + <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="$style.spacer"></div> + </main> + </MkStickyContainer> + + <div v-if="isDesktop" ref="widgetsEl" class="widgets"> + <XWidgets @mounted="attachSticky"/> + </div> + + <button v-if="!isDesktop && !isMobile" class="widgetButton _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> + + <div v-if="isMobile" class="buttons"> + <button class="button nav _button" @click="drawerMenuShowing = true"><i class="ti ti-menu-2"></i><span v-if="menuIndicated" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button home _button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i class="ti ti-home"></i></button> + <button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="ti ti-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="_indicatorCircle"></i></span></button> + <button class="button widget _button" @click="widgetsShowing = true"><i class="ti ti-apps"></i></button> + <button class="button post _button" @click="os.post()"><i class="ti ti-pencil"></i></button> + </div> + + <transition :name="$store.state.animation ? 'menuDrawer-back' : ''"> + <div + v-if="drawerMenuShowing" + class="menuDrawer-back _modalBg" + @click="drawerMenuShowing = false" + @touchstart.passive="drawerMenuShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'menuDrawer' : ''"> + <XDrawerMenu v-if="drawerMenuShowing" class="menuDrawer"/> + </transition> + + <transition :name="$store.state.animation ? 'widgetsDrawer-back' : ''"> + <div + v-if="widgetsShowing" + class="widgetsDrawer-back _modalBg" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'widgetsDrawer' : ''"> + <XWidgets v-if="widgetsShowing" class="widgetsDrawer"/> + </transition> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, provide, onMounted, computed, ref, watch, ComputedRef } from 'vue'; +import XCommon from './_common_/common.vue'; +import { instanceName } from '@/config'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { navbarItemDef } from '@/navbar'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { Router } from '@/nirax'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { deviceKind } from '@/scripts/device-kind'; +const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); +const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.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 widgetsEl = $ref<HTMLElement>(); +const widgetsShowing = $ref(false); + +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; +}); + +document.documentElement.style.overflowY = 'scroll'; + +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); +}; + +const attachSticky = (el) => { + const sticky = new StickySidebar(widgetsEl); + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); +}; + +function top() { + window.scroll({ top: 0, behavior: 'smooth' }); +} + +const wallpaper = localStorage.getItem('wallpaper') != null; +</script> + +<style lang="scss" scoped> +.widgetsDrawer-enter-active, +.widgetsDrawer-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); +} +.widgetsDrawer-enter-from, +.widgetsDrawer-leave-active { + opacity: 0; + transform: translateX(240px); +} + +.widgetsDrawer-back-enter-active, +.widgetsDrawer-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.widgetsDrawer-back-enter-from, +.widgetsDrawer-back-leave-active { + opacity: 0; +} + +.menuDrawer-enter-active, +.menuDrawer-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); +} +.menuDrawer-enter-from, +.menuDrawer-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.menuDrawer-back-enter-active, +.menuDrawer-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.menuDrawer-back-enter-from, +.menuDrawer-back-leave-active { + opacity: 0; +} + +.dkgtipfy { + $ui-font-size: 1em; // TODO: どこかに集約したい + $widgets-hide-threshold: 1090px; + + min-height: 100dvh; + box-sizing: border-box; + display: flex; + + &.wallpaper { + background: var(--wallpaperOverlay); + //backdrop-filter: var(--blur, blur(4px)); + } + + > .sidebar { + border-right: solid 0.5px var(--divider); + } + + > .contents { + width: 100%; + min-width: 0; + background: var(--bg); + } + + > .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); + } + + > .widgetsDrawer-back { + z-index: 1001; + } + + > .widgetsDrawer { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + height: 100dvh; + padding: var(--margin); + box-sizing: border-box; + overflow: auto; + overscroll-behavior: contain; + background: var(--bg); + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + left: 0; + padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 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: 20px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + } + } + + > .menuDrawer-back { + 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); + } +} +</style> + +<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> diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue new file mode 100644 index 0000000000..33fb492836 --- /dev/null +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -0,0 +1,71 @@ +<template> +<div class="efzpzdvf"> + <XWidgets :edit="editMode" :widgets="defaultStore.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> + + <button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> + <button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XWidgets from '@/components/MkWidgets.vue'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; + +const emit = defineEmits<{ + (ev: 'mounted', el: Element): void; +}>(); + +let editMode = $ref(false); +let rootEl = $ref<HTMLDivElement>(); + +onMounted(() => { + emit('mounted', rootEl); +}); + +function addWidget(widget) { + defaultStore.set('widgets', [{ + ...widget, + place: null, + }, ...defaultStore.state.widgets]); +} + +function removeWidget(widget) { + defaultStore.set('widgets', defaultStore.state.widgets.filter(w => w.id !== widget.id)); +} + +function updateWidget({ id, data }) { + defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? { + ...w, + data, + } : w)); +} + +function updateWidgets(widgets) { + defaultStore.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/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue new file mode 100644 index 0000000000..ec9150d346 --- /dev/null +++ b/packages/frontend/src/ui/visitor.vue @@ -0,0 +1,19 @@ +<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/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue new file mode 100644 index 0000000000..f8db7a9d09 --- /dev/null +++ b/packages/frontend/src/ui/visitor/a.vue @@ -0,0 +1,259 @@ +<template> +<div class="mk-app"> + <div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div> + <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + <div v-if="meta" class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> + <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 v-else class="banner-mini" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div> + <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + </div> + </div> + + <div class="main"> + <div ref="contents" class="contents" :class="{ wallpaper }"> + <header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <RouterView/> + </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 XHeader from './header.vue'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/MkPagination.vue'; +import MkButton from '@/components/MkButton.vue'; +import { ColdDeviceStorage } from '@/store'; +import { mainRouter } from '@/router'; + +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, + }, + mainRouter, + 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: { + // @ThatOneCalculator: Are these methods even used? + // I can't find references to them anywhere else in the code... + + // setParallax(el) { + // new simpleParallax(el); + // }, + + changePage(page) { + if (page == null) return; + // eslint-disable-next-line no-undef + if (page[symbols.PAGE_INFO]) { + // eslint-disable-next-line no-undef + 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'); + }, + }, +}); +</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/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue new file mode 100644 index 0000000000..275008a8f8 --- /dev/null +++ b/packages/frontend/src/ui/visitor/b.vue @@ -0,0 +1,248 @@ +<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 v-if="!narrow && !root" class="side"> + <XKanban class="kanban" full/> + </div> + + <div class="main"> + <XKanban v-if="narrow && !root" class="banner" :powered-by="root"/> + + <div class="contents"> + <XHeader v-if="!root" class="header" :info="pageInfo"/> + <main> + <RouterView/> + </main> + <div v-if="!root" 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> + + <transition :name="$store.state.animation ? 'tray-back' : ''"> + <div + v-if="showMenu" + class="menu-back _modalBg" + @click="showMenu = false" + @touchstart.passive="showMenu = false" + ></div> + </transition> + + <transition :name="$store.state.animation ? 'tray' : ''"> + <div v-if="showMenu" class="menu"> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash 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="ti ti-device-tv 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" setup> +import { ComputedRef, onMounted, provide } from 'vue'; +import XHeader from './header.vue'; +import XKanban from './kanban.vue'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/MkPagination.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +const DESKTOP_THRESHOLD = 1100; + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); + +const announcements = { + endpoint: 'announcements', + limit: 10, +}; +let showMenu = $ref(false); +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); +let narrow = $ref(window.innerWidth < 1280); +let meta = $ref(); + +const keymap = $computed(() => { + return { + 'd': () => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': search, + }; +}); + +const root = $computed(() => mainRouter.currentRoute.value.name === 'index'); + +os.api('meta', { detail: true }).then(res => { + meta = res; +}); + +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} + +onMounted(() => { + if (!isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true; + }, { passive: true }); + } +}); + +defineExpose({ + showMenu: $$(showMenu), +}); +</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/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue new file mode 100644 index 0000000000..7300b12a75 --- /dev/null +++ b/packages/frontend/src/ui/visitor/header.vue @@ -0,0 +1,228 @@ +<template> +<div class="sqxihjet"> + <div v-if="narrow === false" class="wide"> + <div class="content"> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ $ts.home }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash 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="ti ti-device-tv icon"></i>{{ $ts.channel }}</MkA> + <div v-if="info" class="page active link"> + <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 v-if="info.action" class="_button action" @click.stop="info.action.handler"><!-- TODO --></button> + </div> + <div class="right"> + <button class="_button search" @click="search()"><i class="ti ti-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 v-else-if="narrow === true" class="narrow"> + <button class="menu _button" @click="$parent.showMenu = true"> + <i class="ti ti-menu-2 icon"></i> + </button> + <div v-if="info" 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 v-if="info && info.action" class="action _button" @click.stop="info.action.handler"> + <!-- TODO --> + </button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import * as os from '@/os'; +import { search } from '@/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/packages/frontend/src/ui/visitor/kanban.vue b/packages/frontend/src/ui/visitor/kanban.vue new file mode 100644 index 0000000000..51e47f277d --- /dev/null +++ b/packages/frontend/src/ui/visitor/kanban.vue @@ -0,0 +1,257 @@ +<!-- eslint-disable vue/no-v-html --> +<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 v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA> + </h1> + <template v-if="full"> + <div v-if="meta" class="about"> + <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 v-slot="{items}" :pagination="announcements" class="list"> + <section v-for="announcement in items" :key="announcement.id" class="item"> + <div class="title">{{ announcement.title }}</div> + <div class="content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/> + </div> + </section> + </MkPagination> + </div> + <div v-if="poweredBy" 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> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import MkPagination from '@/components/MkPagination.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.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/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue new file mode 100644 index 0000000000..84c96a1dae --- /dev/null +++ b/packages/frontend/src/ui/zen.vue @@ -0,0 +1,34 @@ +<template> +<div class="mk-app"> + <RouterView/> + + <XCommon/> +</div> +</template> + +<script lang="ts" setup> +import { provide, ComputedRef } from 'vue'; +import XCommon from './_common_/common.vue'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { instanceName } from '@/config'; + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); + +document.documentElement.style.overflowY = 'scroll'; +</script> + +<style lang="scss" scoped> +.mk-app { + min-height: 100dvh; + box-sizing: border-box; +} +</style> |