diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/ui/_common_ | |
| parent | wip: retention for dashboard (diff) | |
| download | misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/ui/_common_')
| -rw-r--r-- | packages/frontend/src/ui/_common_/common.vue | 139 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/navbar-for-mobile.vue | 314 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/navbar.vue | 521 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/statusbar-federation.vue | 108 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/statusbar-rss.vue | 93 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/statusbar-user-list.vue | 113 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/statusbars.vue | 92 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/stream-indicator.vue | 61 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/sw-inject.ts | 35 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/upload.vue | 129 |
10 files changed, 1605 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> |