rename: client -> frontend

This commit is contained in:
syuilo
2022-12-27 14:36:33 +09:00
parent db6fff6f26
commit 9384f5399d
592 changed files with 111 additions and 111 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;
}
});
}

View 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>