rename: client -> frontend
This commit is contained in:
139
packages/frontend/src/ui/_common_/common.vue
Normal file
139
packages/frontend/src/ui/_common_/common.vue
Normal file
@@ -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>
|
314
packages/frontend/src/ui/_common_/navbar-for-mobile.vue
Normal file
314
packages/frontend/src/ui/_common_/navbar-for-mobile.vue
Normal file
@@ -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>
|
521
packages/frontend/src/ui/_common_/navbar.vue
Normal file
521
packages/frontend/src/ui/_common_/navbar.vue
Normal file
@@ -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>
|
108
packages/frontend/src/ui/_common_/statusbar-federation.vue
Normal file
108
packages/frontend/src/ui/_common_/statusbar-federation.vue
Normal file
@@ -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>
|
93
packages/frontend/src/ui/_common_/statusbar-rss.vue
Normal file
93
packages/frontend/src/ui/_common_/statusbar-rss.vue
Normal file
@@ -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>
|
113
packages/frontend/src/ui/_common_/statusbar-user-list.vue
Normal file
113
packages/frontend/src/ui/_common_/statusbar-user-list.vue
Normal file
@@ -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>
|
92
packages/frontend/src/ui/_common_/statusbars.vue
Normal file
92
packages/frontend/src/ui/_common_/statusbars.vue
Normal file
@@ -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>
|
61
packages/frontend/src/ui/_common_/stream-indicator.vue
Normal file
61
packages/frontend/src/ui/_common_/stream-indicator.vue
Normal file
@@ -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>
|
35
packages/frontend/src/ui/_common_/sw-inject.ts
Normal file
35
packages/frontend/src/ui/_common_/sw-inject.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
129
packages/frontend/src/ui/_common_/upload.vue
Normal file
129
packages/frontend/src/ui/_common_/upload.vue
Normal file
@@ -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>
|
Reference in New Issue
Block a user