summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/global
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
commit9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch)
treece5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/components/global
parentwip: retention for dashboard (diff)
downloadsharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz
sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2
sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/components/global')
-rw-r--r--packages/frontend/src/components/global/MkA.vue102
-rw-r--r--packages/frontend/src/components/global/MkAcct.vue27
-rw-r--r--packages/frontend/src/components/global/MkAd.vue186
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue143
-rw-r--r--packages/frontend/src/components/global/MkEllipsis.vue34
-rw-r--r--packages/frontend/src/components/global/MkEmoji.vue81
-rw-r--r--packages/frontend/src/components/global/MkError.vue36
-rw-r--r--packages/frontend/src/components/global/MkLoading.vue101
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue191
-rw-r--r--packages/frontend/src/components/global/MkPageHeader.vue368
-rw-r--r--packages/frontend/src/components/global/MkSpacer.vue96
-rw-r--r--packages/frontend/src/components/global/MkStickyContainer.vue66
-rw-r--r--packages/frontend/src/components/global/MkTime.vue56
-rw-r--r--packages/frontend/src/components/global/MkUrl.vue89
-rw-r--r--packages/frontend/src/components/global/MkUserName.vue15
-rw-r--r--packages/frontend/src/components/global/RouterView.vue61
-rw-r--r--packages/frontend/src/components/global/i18n.ts42
17 files changed, 1694 insertions, 0 deletions
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
new file mode 100644
index 0000000000..5a0ba0d8d3
--- /dev/null
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -0,0 +1,102 @@
+<template>
+<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
+ <slot></slot>
+</a>
+</template>
+
+<script lang="ts" setup>
+import { inject } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { url } from '@/config';
+import { popout as popout_ } from '@/scripts/popout';
+import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
+
+const props = withDefaults(defineProps<{
+ to: string;
+ activeClass?: null | string;
+ behavior?: null | 'window' | 'browser' | 'modalWindow';
+}>(), {
+ activeClass: null,
+ behavior: null,
+});
+
+const router = useRouter();
+
+const active = $computed(() => {
+ if (props.activeClass == null) return false;
+ const resolved = router.resolve(props.to);
+ if (resolved == null) return false;
+ if (resolved.route.path === router.currentRoute.value.path) return true;
+ if (resolved.route.name == null) return false;
+ if (router.currentRoute.value.name == null) return false;
+ return resolved.route.name === router.currentRoute.value.name;
+});
+
+function onContextmenu(ev) {
+ const selection = window.getSelection();
+ if (selection && selection.toString() !== '') return;
+ os.contextMenu([{
+ type: 'label',
+ text: props.to,
+ }, {
+ icon: 'ti ti-app-window',
+ text: i18n.ts.openInWindow,
+ action: () => {
+ os.pageWindow(props.to);
+ },
+ }, {
+ icon: 'ti ti-player-eject',
+ text: i18n.ts.showInPage,
+ action: () => {
+ router.push(props.to, 'forcePage');
+ },
+ }, null, {
+ icon: 'ti ti-external-link',
+ text: i18n.ts.openInNewTab,
+ action: () => {
+ window.open(props.to, '_blank');
+ },
+ }, {
+ icon: 'ti ti-link',
+ text: i18n.ts.copyLink,
+ action: () => {
+ copyToClipboard(`${url}${props.to}`);
+ },
+ }], ev);
+}
+
+function openWindow() {
+ os.pageWindow(props.to);
+}
+
+function modalWindow() {
+ os.modalPageWindow(props.to);
+}
+
+function popout() {
+ popout_(props.to);
+}
+
+function nav(ev: MouseEvent) {
+ if (props.behavior === 'browser') {
+ location.href = props.to;
+ return;
+ }
+
+ if (props.behavior) {
+ if (props.behavior === 'window') {
+ return openWindow();
+ } else if (props.behavior === 'modalWindow') {
+ return modalWindow();
+ }
+ }
+
+ if (ev.shiftKey) {
+ return openWindow();
+ }
+
+ router.push(props.to, ev.ctrlKey ? 'forcePage' : null);
+}
+</script>
diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue
new file mode 100644
index 0000000000..c3e806b5fb
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAcct.vue
@@ -0,0 +1,27 @@
+<template>
+<span class="mk-acct">
+ <span class="name">@{{ user.username }}</span>
+ <span v-if="user.host || detail || $store.state.showFullAcct" class="host">@{{ user.host || host }}</span>
+</span>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import { toUnicode } from 'punycode/';
+import { host as hostRaw } from '@/config';
+
+defineProps<{
+ user: misskey.entities.UserDetailed;
+ detail?: boolean;
+}>();
+
+const host = toUnicode(hostRaw);
+</script>
+
+<style lang="scss" scoped>
+.mk-acct {
+ > .host {
+ opacity: 0.5;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
new file mode 100644
index 0000000000..a80efb142c
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -0,0 +1,186 @@
+<template>
+<div v-if="chosen" class="qiivuoyo">
+ <div v-if="!showMenu" class="main" :class="chosen.place">
+ <a :href="chosen.url" target="_blank">
+ <img :src="chosen.imageUrl">
+ <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="ti ti-info-circle info-circle"></span></button>
+ </a>
+ </div>
+ <div v-else class="menu">
+ <div class="body">
+ <div>Ads by {{ host }}</div>
+ <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
+ <MkButton v-if="chosen.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
+ <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
+ </div>
+ </div>
+</div>
+<div v-else></div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import { instance } from '@/instance';
+import { host } from '@/config';
+import MkButton from '@/components/MkButton.vue';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+
+type Ad = (typeof instance)['ads'][number];
+
+const props = defineProps<{
+ prefer: string[];
+ specify?: Ad;
+}>();
+
+const showMenu = ref(false);
+const toggleMenu = (): void => {
+ showMenu.value = !showMenu.value;
+};
+
+const choseAd = (): Ad | null => {
+ if (props.specify) {
+ return props.specify;
+ }
+
+ const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
+ ...ad,
+ ratio: 0,
+ } : ad);
+
+ let ads = allAds.filter(ad => props.prefer.includes(ad.place));
+
+ if (ads.length === 0) {
+ ads = allAds.filter(ad => ad.place === 'square');
+ }
+
+ const lowPriorityAds = ads.filter(ad => ad.ratio === 0);
+ ads = ads.filter(ad => ad.ratio !== 0);
+
+ if (ads.length === 0) {
+ if (lowPriorityAds.length !== 0) {
+ return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)];
+ } else {
+ return null;
+ }
+ }
+
+ const totalFactor = ads.reduce((a, b) => a + b.ratio, 0);
+ const r = Math.random() * totalFactor;
+
+ let stackedFactor = 0;
+ for (const ad of ads) {
+ if (r >= stackedFactor && r <= stackedFactor + ad.ratio) {
+ return ad;
+ } else {
+ stackedFactor += ad.ratio;
+ }
+ }
+
+ return null;
+};
+
+const chosen = ref(choseAd());
+
+function reduceFrequency(): void {
+ if (chosen.value == null) return;
+ if (defaultStore.state.mutedAds.includes(chosen.value.id)) return;
+ defaultStore.push('mutedAds', chosen.value.id);
+ os.success();
+ chosen.value = choseAd();
+ showMenu.value = false;
+}
+</script>
+
+<style lang="scss" scoped>
+.qiivuoyo {
+ background-size: auto auto;
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px );
+
+ > .main {
+ text-align: center;
+
+ > a {
+ display: inline-block;
+ position: relative;
+ vertical-align: bottom;
+
+ &:hover {
+ > img {
+ filter: contrast(120%);
+ }
+ }
+
+ > img {
+ display: block;
+ object-fit: contain;
+ margin: auto;
+ border-radius: 5px;
+ }
+
+ > .menu {
+ position: absolute;
+ top: 1px;
+ right: 1px;
+
+ > .info-circle {
+ border: 3px solid var(--panel);
+ border-radius: 50%;
+ background: var(--panel);
+ }
+ }
+ }
+
+ &.square {
+ > a ,
+ > a > img {
+ max-width: min(300px, 100%);
+ max-height: 300px;
+ }
+ }
+
+ &.horizontal {
+ padding: 8px;
+
+ > a ,
+ > a > img {
+ max-width: min(600px, 100%);
+ max-height: 80px;
+ }
+ }
+
+ &.horizontal-big {
+ padding: 8px;
+
+ > a ,
+ > a > img {
+ max-width: min(600px, 100%);
+ max-height: 250px;
+ }
+ }
+
+ &.vertical {
+ > a ,
+ > a > img {
+ max-width: min(100px, 100%);
+ }
+ }
+ }
+
+ > .menu {
+ padding: 8px;
+ text-align: center;
+
+ > .body {
+ padding: 8px;
+ margin: 0 auto;
+ max-width: 400px;
+ border: solid 1px var(--divider);
+
+ > .button {
+ margin: 8px auto;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
new file mode 100644
index 0000000000..5f3e3c176d
--- /dev/null
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -0,0 +1,143 @@
+<template>
+<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick">
+ <img class="inner" :src="url" decoding="async"/>
+ <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</span>
+<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
+ <img class="inner" :src="url" decoding="async"/>
+ <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+import { acct, userPage } from '@/filters/user';
+import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue';
+import { defaultStore } from '@/store';
+
+const props = withDefaults(defineProps<{
+ user: misskey.entities.User;
+ target?: string | null;
+ disableLink?: boolean;
+ disablePreview?: boolean;
+ showIndicator?: boolean;
+}>(), {
+ target: null,
+ disableLink: false,
+ disablePreview: false,
+ showIndicator: false,
+});
+
+const emit = defineEmits<{
+ (ev: 'click', v: MouseEvent): void;
+}>();
+
+const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(props.user.avatarUrl)
+ : props.user.avatarUrl);
+
+function onClick(ev: MouseEvent) {
+ emit('click', ev);
+}
+
+let color = $ref();
+
+watch(() => props.user.avatarBlurhash, () => {
+ color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
+}, {
+ immediate: true,
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes earwiggleleft {
+ from { transform: rotate(37.6deg) skew(30deg); }
+ 25% { transform: rotate(10deg) skew(30deg); }
+ 50% { transform: rotate(20deg) skew(30deg); }
+ 75% { transform: rotate(0deg) skew(30deg); }
+ to { transform: rotate(37.6deg) skew(30deg); }
+}
+
+@keyframes earwiggleright {
+ from { transform: rotate(-37.6deg) skew(-30deg); }
+ 30% { transform: rotate(-10deg) skew(-30deg); }
+ 55% { transform: rotate(-20deg) skew(-30deg); }
+ 75% { transform: rotate(0deg) skew(-30deg); }
+ to { transform: rotate(-37.6deg) skew(-30deg); }
+}
+
+.eiwwqkts {
+ position: relative;
+ display: inline-block;
+ vertical-align: bottom;
+ flex-shrink: 0;
+ border-radius: 100%;
+ line-height: 16px;
+
+ > .inner {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ top: 0;
+ border-radius: 100%;
+ z-index: 1;
+ overflow: hidden;
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+ }
+
+ > .indicator {
+ position: absolute;
+ z-index: 1;
+ bottom: 0;
+ left: 0;
+ width: 20%;
+ height: 20%;
+ }
+
+ &.square {
+ border-radius: 20%;
+
+ > .inner {
+ border-radius: 20%;
+ }
+ }
+
+ &.cat {
+ &:before, &:after {
+ background: #df548f;
+ border: solid 4px currentColor;
+ box-sizing: border-box;
+ content: '';
+ display: inline-block;
+ height: 50%;
+ width: 50%;
+ }
+
+ &:before {
+ border-radius: 0 75% 75%;
+ transform: rotate(37.5deg) skew(30deg);
+ }
+
+ &:after {
+ border-radius: 75% 0 75% 75%;
+ transform: rotate(-37.5deg) skew(-30deg);
+ }
+
+ &:hover {
+ &:before {
+ animation: earwiggleleft 1s infinite;
+ }
+
+ &:after {
+ animation: earwiggleright 1s infinite;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue
new file mode 100644
index 0000000000..0a46f486d6
--- /dev/null
+++ b/packages/frontend/src/components/global/MkEllipsis.vue
@@ -0,0 +1,34 @@
+<template>
+ <span class="mk-ellipsis">
+ <span>.</span><span>.</span><span>.</span>
+ </span>
+</template>
+
+<style lang="scss" scoped>
+.mk-ellipsis {
+ > span {
+ animation: ellipsis 1.4s infinite ease-in-out both;
+
+ &:nth-child(1) {
+ animation-delay: 0s;
+ }
+
+ &:nth-child(2) {
+ animation-delay: 0.16s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.32s;
+ }
+ }
+}
+
+@keyframes ellipsis {
+ 0%, 80%, 100% {
+ opacity: 1;
+ }
+ 40% {
+ opacity: 0;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue
new file mode 100644
index 0000000000..ce1299a39f
--- /dev/null
+++ b/packages/frontend/src/components/global/MkEmoji.vue
@@ -0,0 +1,81 @@
+<template>
+<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/>
+<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/>
+<span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span>
+<span v-else>{{ emoji }}</span>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import { CustomEmoji } from 'misskey-js/built/entities';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
+import { defaultStore } from '@/store';
+import { instance } from '@/instance';
+import { getEmojiName } from '@/scripts/emojilist';
+
+const props = defineProps<{
+ emoji: string;
+ normal?: boolean;
+ noStyle?: boolean;
+ customEmojis?: CustomEmoji[];
+ isReaction?: boolean;
+}>();
+
+const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
+
+const isCustom = computed(() => props.emoji.startsWith(':'));
+const char = computed(() => isCustom.value ? undefined : props.emoji);
+const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native' && !props.isReaction);
+const ce = computed(() => props.customEmojis ?? instance.emojis ?? []);
+const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : undefined);
+const url = computed(() => {
+ if (char.value) {
+ return char2path(char.value);
+ } else {
+ const rawUrl = (customEmoji.value as CustomEmoji).url;
+ return defaultStore.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(rawUrl)
+ : rawUrl;
+ }
+});
+const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value);
+
+// Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter
+function computeTitle(event: PointerEvent): void {
+ const title = customEmoji.value
+ ? `:${customEmoji.value.name}:`
+ : (getEmojiName(char.value as string) ?? char.value as string);
+ (event.target as HTMLElement).title = title;
+}
+</script>
+
+<style lang="scss" scoped>
+.mk-emoji {
+ height: 1.25em;
+ vertical-align: -0.25em;
+
+ &.custom {
+ height: 2.5em;
+ vertical-align: middle;
+ transition: transform 0.2s ease;
+
+ &:hover {
+ transform: scale(1.2);
+ }
+
+ &.normal {
+ height: 1.25em;
+ vertical-align: -0.25em;
+
+ &:hover {
+ transform: none;
+ }
+ }
+ }
+
+ &.noStyle {
+ height: auto !important;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue
new file mode 100644
index 0000000000..e135d4184b
--- /dev/null
+++ b/packages/frontend/src/components/global/MkError.vue
@@ -0,0 +1,36 @@
+<template>
+<transition :name="$store.state.animation ? 'zoom' : ''" appear>
+ <div class="mjndxjcg">
+ <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+ <p><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
+ <MkButton class="button" @click="() => $emit('retry')">{{ i18n.ts.retry }}</MkButton>
+ </div>
+</transition>
+</template>
+
+<script lang="ts" setup>
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+</script>
+
+<style lang="scss" scoped>
+.mjndxjcg {
+ padding: 32px;
+ text-align: center;
+
+ > p {
+ margin: 0 0 8px 0;
+ }
+
+ > .button {
+ margin: 0 auto;
+ }
+
+ > img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue
new file mode 100644
index 0000000000..64e12e3b44
--- /dev/null
+++ b/packages/frontend/src/components/global/MkLoading.vue
@@ -0,0 +1,101 @@
+<template>
+<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini, [$style.em]: em }]">
+ <div :class="$style.container">
+ <svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+ <g transform="matrix(1.125,0,0,1.125,12,12)">
+ <circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+ </g>
+ </svg>
+ <svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
+ <g transform="matrix(1.125,0,0,1.125,12,12)">
+ <path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
+ </g>
+ </svg>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = withDefaults(defineProps<{
+ inline?: boolean;
+ colored?: boolean;
+ mini?: boolean;
+ em?: boolean;
+}>(), {
+ inline: false,
+ colored: true,
+ mini: false,
+ em: false,
+});
+</script>
+
+<style lang="scss" module>
+@keyframes spinner {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.root {
+ padding: 32px;
+ text-align: center;
+ cursor: wait;
+
+ --size: 38px;
+
+ &.colored {
+ color: var(--accent);
+ }
+
+ &.inline {
+ display: inline;
+ padding: 0;
+ --size: 32px;
+ }
+
+ &.mini {
+ padding: 16px;
+ --size: 32px;
+ }
+
+ &.em {
+ display: inline-block;
+ vertical-align: middle;
+ padding: 0;
+ --size: 1em;
+ }
+}
+
+.container {
+ position: relative;
+ width: var(--size);
+ height: var(--size);
+ margin: 0 auto;
+}
+
+.spinner {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: var(--size);
+ height: var(--size);
+ fill-rule: evenodd;
+ clip-rule: evenodd;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-miterlimit: 1.5;
+}
+
+.bg {
+ opacity: 0.275;
+}
+
+.fg {
+ animation: spinner 0.5s linear infinite;
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue
new file mode 100644
index 0000000000..70d0108e9f
--- /dev/null
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue
@@ -0,0 +1,191 @@
+<template>
+<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :customEmojis="customEmojis" :isNote="isNote" class="havbbuyv" :class="{ nowrap }"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MfmCore from '@/components/mfm';
+
+const props = withDefaults(defineProps<{
+ text: string;
+ plain?: boolean;
+ nowrap?: boolean;
+ author?: any;
+ customEmojis?: any;
+ isNote?: boolean;
+}>(), {
+ plain: false,
+ nowrap: false,
+ author: null,
+ isNote: true,
+});
+</script>
+
+<style lang="scss">
+._mfm_blur_ {
+ filter: blur(6px);
+ transition: filter 0.3s;
+
+ &:hover {
+ filter: blur(0px);
+ }
+}
+
+.mfm-x2 {
+ --mfm-zoom-size: 200%;
+}
+
+.mfm-x3 {
+ --mfm-zoom-size: 400%;
+}
+
+.mfm-x4 {
+ --mfm-zoom-size: 600%;
+}
+
+.mfm-x2, .mfm-x3, .mfm-x4 {
+ font-size: var(--mfm-zoom-size);
+
+ .mfm-x2, .mfm-x3, .mfm-x4 {
+ /* only half effective */
+ font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
+
+ .mfm-x2, .mfm-x3, .mfm-x4 {
+ /* disabled */
+ font-size: 100%;
+ }
+ }
+}
+
+@keyframes mfm-spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes mfm-spinX {
+ 0% { transform: perspective(128px) rotateX(0deg); }
+ 100% { transform: perspective(128px) rotateX(360deg); }
+}
+
+@keyframes mfm-spinY {
+ 0% { transform: perspective(128px) rotateY(0deg); }
+ 100% { transform: perspective(128px) rotateY(360deg); }
+}
+
+@keyframes mfm-jump {
+ 0% { transform: translateY(0); }
+ 25% { transform: translateY(-16px); }
+ 50% { transform: translateY(0); }
+ 75% { transform: translateY(-8px); }
+ 100% { transform: translateY(0); }
+}
+
+@keyframes mfm-bounce {
+ 0% { transform: translateY(0) scale(1, 1); }
+ 25% { transform: translateY(-16px) scale(1, 1); }
+ 50% { transform: translateY(0) scale(1, 1); }
+ 75% { transform: translateY(0) scale(1.5, 0.75); }
+ 100% { transform: translateY(0) scale(1, 1); }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-twitch {
+ 0% { transform: translate(7px, -2px) }
+ 5% { transform: translate(-3px, 1px) }
+ 10% { transform: translate(-7px, -1px) }
+ 15% { transform: translate(0px, -1px) }
+ 20% { transform: translate(-8px, 6px) }
+ 25% { transform: translate(-4px, -3px) }
+ 30% { transform: translate(-4px, -6px) }
+ 35% { transform: translate(-8px, -8px) }
+ 40% { transform: translate(4px, 6px) }
+ 45% { transform: translate(-3px, 1px) }
+ 50% { transform: translate(2px, -10px) }
+ 55% { transform: translate(-7px, 0px) }
+ 60% { transform: translate(-2px, 4px) }
+ 65% { transform: translate(3px, -8px) }
+ 70% { transform: translate(6px, 7px) }
+ 75% { transform: translate(-7px, -2px) }
+ 80% { transform: translate(-7px, -8px) }
+ 85% { transform: translate(9px, 3px) }
+ 90% { transform: translate(-3px, -2px) }
+ 95% { transform: translate(-10px, 2px) }
+ 100% { transform: translate(-2px, -6px) }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-shake {
+ 0% { transform: translate(-3px, -1px) rotate(-8deg) }
+ 5% { transform: translate(0px, -1px) rotate(-10deg) }
+ 10% { transform: translate(1px, -3px) rotate(0deg) }
+ 15% { transform: translate(1px, 1px) rotate(11deg) }
+ 20% { transform: translate(-2px, 1px) rotate(1deg) }
+ 25% { transform: translate(-1px, -2px) rotate(-2deg) }
+ 30% { transform: translate(-1px, 2px) rotate(-3deg) }
+ 35% { transform: translate(2px, 1px) rotate(6deg) }
+ 40% { transform: translate(-2px, -3px) rotate(-9deg) }
+ 45% { transform: translate(0px, -1px) rotate(-12deg) }
+ 50% { transform: translate(1px, 2px) rotate(10deg) }
+ 55% { transform: translate(0px, -3px) rotate(8deg) }
+ 60% { transform: translate(1px, -1px) rotate(8deg) }
+ 65% { transform: translate(0px, -1px) rotate(-7deg) }
+ 70% { transform: translate(-1px, -3px) rotate(6deg) }
+ 75% { transform: translate(0px, -2px) rotate(4deg) }
+ 80% { transform: translate(-2px, -1px) rotate(3deg) }
+ 85% { transform: translate(1px, -3px) rotate(-10deg) }
+ 90% { transform: translate(1px, 0px) rotate(3deg) }
+ 95% { transform: translate(-2px, 0px) rotate(-3deg) }
+ 100% { transform: translate(2px, 1px) rotate(2deg) }
+}
+
+@keyframes mfm-rubberBand {
+ from { transform: scale3d(1, 1, 1); }
+ 30% { transform: scale3d(1.25, 0.75, 1); }
+ 40% { transform: scale3d(0.75, 1.25, 1); }
+ 50% { transform: scale3d(1.15, 0.85, 1); }
+ 65% { transform: scale3d(0.95, 1.05, 1); }
+ 75% { transform: scale3d(1.05, 0.95, 1); }
+ to { transform: scale3d(1, 1, 1); }
+}
+
+@keyframes mfm-rainbow {
+ 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
+ 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
+}
+</style>
+
+<style lang="scss" scoped>
+.havbbuyv {
+ white-space: pre-wrap;
+
+ &.nowrap {
+ white-space: pre;
+ word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ ::v-deep(.quote) {
+ display: block;
+ margin: 8px;
+ padding: 6px 0 6px 12px;
+ color: var(--fg);
+ border-left: solid 3px var(--fg);
+ opacity: 0.7;
+ }
+
+ ::v-deep(pre) {
+ font-size: 0.8em;
+ }
+
+ > ::v-deep(code) {
+ font-size: 0.8em;
+ word-break: break-all;
+ padding: 4px 6px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue
new file mode 100644
index 0000000000..a228dfe883
--- /dev/null
+++ b/packages/frontend/src/components/global/MkPageHeader.vue
@@ -0,0 +1,368 @@
+<template>
+<div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick">
+ <div v-if="narrow" class="buttons left">
+ <MkAvatar v-if="props.displayMyAvatar && $i" class="avatar" :user="$i" :disable-preview="true"/>
+ </div>
+ <template v-if="metadata">
+ <div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup">
+ <MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/>
+ <i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+ <div class="title">
+ <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/>
+ <div v-else-if="metadata.title" class="title">{{ metadata.title }}</div>
+ <div v-if="!narrow && metadata.subtitle" class="subtitle">
+ {{ metadata.subtitle }}
+ </div>
+ <div v-if="narrow && hasTabs" class="subtitle activeTab">
+ {{ tabs.find(tab => tab.key === props.tab)?.title }}
+ <i class="chevron ti ti-chevron-down"></i>
+ </div>
+ </div>
+ </div>
+ <div v-if="!narrow || hideTitle" class="tabs">
+ <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
+ <i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+ <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+ </button>
+ <div ref="tabHighlightEl" class="highlight"></div>
+ </div>
+ </template>
+ <div class="buttons right">
+ <template v-for="action in actions">
+ <button v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+ </template>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { scrollToTop } from '@/scripts/scroll';
+import { globalEvents } from '@/events';
+import { injectPageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+
+type Tab = {
+ key: string;
+ title: string;
+ icon?: string;
+ iconOnly?: boolean;
+ onClick?: (ev: MouseEvent) => void;
+};
+
+const props = withDefaults(defineProps<{
+ tabs?: Tab[];
+ tab?: string;
+ actions?: {
+ text: string;
+ icon: string;
+ highlighted?: boolean;
+ handler: (ev: MouseEvent) => void;
+ }[];
+ thin?: boolean;
+ displayMyAvatar?: boolean;
+}>(), {
+ tabs: () => ([] as Tab[])
+});
+
+const emit = defineEmits<{
+ (ev: 'update:tab', key: string);
+}>();
+
+const metadata = injectPageMetadata();
+
+const hideTitle = inject('shouldOmitHeaderTitle', false);
+const thin_ = props.thin || inject('shouldHeaderThin', false);
+
+const el = $ref<HTMLElement | undefined>(undefined);
+const tabRefs: Record<string, HTMLElement | null> = {};
+const tabHighlightEl = $ref<HTMLElement | null>(null);
+const bg = ref<string | undefined>(undefined);
+let narrow = $ref(false);
+const hasTabs = $computed(() => props.tabs.length > 0);
+const hasActions = $computed(() => props.actions && props.actions.length > 0);
+const show = $computed(() => {
+ return !hideTitle || hasTabs || hasActions;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+ if (!hasTabs) return;
+ if (!narrow) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ const menu = props.tabs.map(tab => ({
+ text: tab.title,
+ icon: tab.icon,
+ active: tab.key != null && tab.key === props.tab,
+ action: (ev) => {
+ onTabClick(tab, ev);
+ },
+ }));
+ popupMenu(menu, (ev.currentTarget ?? ev.target) as HTMLElement);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+ ev.stopPropagation();
+};
+
+const onClick = () => {
+ if (el) {
+ scrollToTop(el as HTMLElement, { behavior: 'smooth' });
+ }
+};
+
+function onTabMousedown(tab: Tab, ev: MouseEvent): void {
+ // ユーザビリティの観点からmousedown時にはonClickは呼ばない
+ if (tab.key) {
+ emit('update:tab', tab.key);
+ }
+}
+
+function onTabClick(tab: Tab, ev: MouseEvent): void {
+ if (tab.onClick) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ tab.onClick(ev);
+ }
+ if (tab.key) {
+ emit('update:tab', tab.key);
+ }
+}
+
+const calcBg = () => {
+ const rawBg = metadata?.bg || 'var(--bg)';
+ const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ tinyBg.setAlpha(0.85);
+ bg.value = tinyBg.toRgbString();
+};
+
+let ro: ResizeObserver | null;
+
+onMounted(() => {
+ calcBg();
+ globalEvents.on('themeChanged', calcBg);
+
+ watch(() => [props.tab, props.tabs], () => {
+ nextTick(() => {
+ const tabEl = props.tab ? tabRefs[props.tab] : undefined;
+ if (tabEl && tabHighlightEl && tabEl.parentElement) {
+ // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+ // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+ const parentRect = tabEl.parentElement.getBoundingClientRect();
+ const rect = tabEl.getBoundingClientRect();
+ tabHighlightEl.style.width = rect.width + 'px';
+ tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+ }
+ });
+ }, {
+ immediate: true,
+ });
+
+ if (el && el.parentElement) {
+ narrow = el.parentElement.offsetWidth < 500;
+ ro = new ResizeObserver((entries, observer) => {
+ if (el.parentElement && document.body.contains(el as HTMLElement)) {
+ narrow = el.parentElement.offsetWidth < 500;
+ }
+ });
+ ro.observe(el.parentElement as HTMLElement);
+ }
+});
+
+onUnmounted(() => {
+ globalEvents.off('themeChanged', calcBg);
+ if (ro) ro.disconnect();
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkb {
+ --height: 52px;
+ display: flex;
+ width: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-bottom: solid 0.5px var(--divider);
+ contain: strict;
+ height: var(--height);
+
+ &.thin {
+ --height: 42px;
+
+ > .buttons {
+ > .button {
+ font-size: 0.9em;
+ }
+ }
+ }
+
+ &.slim {
+ text-align: center;
+
+ > .titleContainer {
+ flex: 1;
+ margin: 0 auto;
+
+ > *:first-child {
+ margin-left: auto;
+ }
+
+ > *:last-child {
+ margin-right: auto;
+ }
+ }
+ }
+
+ > .buttons {
+ --margin: 8px;
+ display: flex;
+ align-items: center;
+ min-width: var(--height);
+ height: var(--height);
+ margin: 0 var(--margin);
+
+ &.left {
+ margin-right: auto;
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: bottom;
+ margin: 0 8px;
+ pointer-events: none;
+ }
+ }
+
+ &.right {
+ margin-left: auto;
+ }
+
+ &:empty {
+ width: var(--height);
+ }
+
+ > .button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: calc(var(--height) - (var(--margin) * 2));
+ width: calc(var(--height) - (var(--margin) * 2));
+ box-sizing: border-box;
+ position: relative;
+ border-radius: 5px;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &.highlighted {
+ color: var(--accent);
+ }
+ }
+
+ > .fullButton {
+ & + .fullButton {
+ margin-left: 12px;
+ }
+ }
+ }
+
+ > .titleContainer {
+ display: flex;
+ align-items: center;
+ max-width: 400px;
+ overflow: auto;
+ white-space: nowrap;
+ text-align: left;
+ font-weight: bold;
+ flex-shrink: 0;
+ margin-left: 24px;
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: bottom;
+ margin: 0 8px;
+ pointer-events: none;
+ }
+
+ > .icon {
+ margin-right: 8px;
+ width: 16px;
+ text-align: center;
+ }
+
+ > .title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.1;
+
+ > .subtitle {
+ opacity: 0.6;
+ font-size: 0.8em;
+ font-weight: normal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.activeTab {
+ text-align: center;
+
+ > .chevron {
+ display: inline-block;
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+ }
+
+ > .tabs {
+ position: relative;
+ margin-left: 16px;
+ font-size: 0.8em;
+ overflow: auto;
+ white-space: nowrap;
+
+ > .tab {
+ display: inline-block;
+ position: relative;
+ padding: 0 10px;
+ height: 100%;
+ font-weight: normal;
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &.active {
+ opacity: 1;
+ }
+
+ > .icon + .title {
+ margin-left: 8px;
+ }
+ }
+
+ > .highlight {
+ position: absolute;
+ bottom: 0;
+ height: 3px;
+ background: var(--accent);
+ border-radius: 999px;
+ transition: all 0.2s ease;
+ pointer-events: none;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue
new file mode 100644
index 0000000000..b3a42d77e7
--- /dev/null
+++ b/packages/frontend/src/components/global/MkSpacer.vue
@@ -0,0 +1,96 @@
+<template>
+<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }">
+ <div ref="content" :class="$style.content">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { inject, onMounted, onUnmounted, ref } from 'vue';
+import { deviceKind } from '@/scripts/device-kind';
+
+const props = withDefaults(defineProps<{
+ contentMax?: number | null;
+ marginMin?: number;
+ marginMax?: number;
+}>(), {
+ contentMax: null,
+ marginMin: 12,
+ marginMax: 24,
+});
+
+let ro: ResizeObserver;
+let root = $ref<HTMLElement>();
+let content = $ref<HTMLElement>();
+let margin = $ref(0);
+const widthHistory = [null, null] as [number | null, number | null];
+const heightHistory = [null, null] as [number | null, number | null];
+const shouldSpacerMin = inject('shouldSpacerMin', false);
+
+const adjust = (rect: { width: number; height: number; }) => {
+ if (shouldSpacerMin || deviceKind === 'smartphone') {
+ margin = props.marginMin;
+ return;
+ }
+
+ if (rect.width > (props.contentMax ?? 0) || (rect.width > 360 && window.innerWidth > 400)) {
+ margin = props.marginMax;
+ } else {
+ margin = props.marginMin;
+ }
+};
+
+onMounted(() => {
+ ro = new ResizeObserver((entries) => {
+ /* iOSが対応していない
+ adjust({
+ width: entries[0].borderBoxSize[0].inlineSize,
+ height: entries[0].borderBoxSize[0].blockSize,
+ });
+ */
+
+ const width = root!.offsetWidth;
+ const height = root!.offsetHeight;
+
+ //#region Prevent infinite resizing
+ // https://github.com/misskey-dev/misskey/issues/9076
+ const pastWidth = widthHistory.pop();
+ widthHistory.unshift(width);
+ const pastHeight = heightHistory.pop();
+ heightHistory.unshift(height);
+
+
+ if (pastWidth === width && pastHeight === height) {
+ return;
+ }
+ //#endregion
+
+ adjust({
+ width,
+ height,
+ });
+ });
+ ro.observe(root!);
+
+ if (props.contentMax) {
+ content!.style.maxWidth = `${props.contentMax}px`;
+ }
+});
+
+onUnmounted(() => {
+ ro.disconnect();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ box-sizing: border-box;
+ width: 100%;
+}
+
+.content {
+ margin: 0 auto;
+ container-type: inline-size;
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
new file mode 100644
index 0000000000..44f4f065a6
--- /dev/null
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -0,0 +1,66 @@
+<template>
+<div ref="rootEl">
+ <div ref="headerEl">
+ <slot name="header"></slot>
+ </div>
+ <div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+// なんか動かない
+//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
+const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
+</script>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue';
+
+const rootEl = $ref<HTMLElement>();
+const headerEl = $ref<HTMLElement>();
+const bodyEl = $ref<HTMLElement>();
+
+let headerHeight = $ref<string | undefined>();
+let childStickyTop = $ref(0);
+const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0));
+provide(CURRENT_STICKY_TOP, $$(childStickyTop));
+
+const calc = () => {
+ childStickyTop = parentStickyTop.value + headerEl.offsetHeight;
+ headerHeight = headerEl.offsetHeight.toString();
+};
+
+const observer = new ResizeObserver(() => {
+ window.setTimeout(() => {
+ calc();
+ }, 100);
+});
+
+onMounted(() => {
+ calc();
+
+ watch(parentStickyTop, calc);
+
+ watch($$(childStickyTop), () => {
+ bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`);
+ }, {
+ immediate: true,
+ });
+
+ headerEl.style.position = 'sticky';
+ headerEl.style.top = 'var(--stickyTop, 0)';
+ headerEl.style.zIndex = '1000';
+
+ observer.observe(headerEl);
+});
+
+onUnmounted(() => {
+ observer.disconnect();
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue
new file mode 100644
index 0000000000..f72b153f56
--- /dev/null
+++ b/packages/frontend/src/components/global/MkTime.vue
@@ -0,0 +1,56 @@
+<template>
+<time :title="absolute">
+ <template v-if="mode === 'relative'">{{ relative }}</template>
+ <template v-else-if="mode === 'absolute'">{{ absolute }}</template>
+ <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
+</time>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted } from 'vue';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+ time: Date | string;
+ mode?: 'relative' | 'absolute' | 'detail';
+}>(), {
+ mode: 'relative',
+});
+
+const _time = typeof props.time === 'string' ? new Date(props.time) : props.time;
+const absolute = _time.toLocaleString();
+
+let now = $shallowRef(new Date());
+const relative = $computed(() => {
+ const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
+ return (
+ ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
+ ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
+ ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) :
+ ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) :
+ ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) :
+ ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
+ ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
+ ago >= -1 ? i18n.ts._ago.justNow :
+ i18n.ts._ago.future);
+});
+
+function tick() {
+ // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
+ now = new Date();
+
+ tickId = window.setTimeout(() => {
+ window.requestAnimationFrame(tick);
+ }, 10000);
+}
+
+let tickId: number;
+
+if (props.mode === 'relative' || props.mode === 'detail') {
+ tickId = window.requestAnimationFrame(tick);
+
+ onUnmounted(() => {
+ window.cancelAnimationFrame(tickId);
+ });
+}
+</script>
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
new file mode 100644
index 0000000000..9f5be96224
--- /dev/null
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -0,0 +1,89 @@
+<template>
+<component
+ :is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel" :target="target"
+ @contextmenu.stop="() => {}"
+>
+ <template v-if="!self">
+ <span class="schema">{{ schema }}//</span>
+ <span class="hostname">{{ hostname }}</span>
+ <span v-if="port != ''" class="port">:{{ port }}</span>
+ </template>
+ <template v-if="pathname === '/' && self">
+ <span class="self">{{ hostname }}</span>
+ </template>
+ <span v-if="pathname != ''" class="pathname">{{ self ? pathname.substring(1) : pathname }}</span>
+ <span class="query">{{ query }}</span>
+ <span class="hash">{{ hash }}</span>
+ <i v-if="target === '_blank'" class="ti ti-external-link icon"></i>
+</component>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, ref } from 'vue';
+import { toUnicode as decodePunycode } from 'punycode/';
+import { url as local } from '@/config';
+import * as os from '@/os';
+import { useTooltip } from '@/scripts/use-tooltip';
+import { safeURIDecode } from '@/scripts/safe-uri-decode';
+
+const props = defineProps<{
+ url: string;
+ rel?: string;
+}>();
+
+const self = props.url.startsWith(local);
+const url = new URL(props.url);
+const el = ref();
+
+useTooltip(el, (showing) => {
+ os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
+ showing,
+ url: props.url,
+ source: el.value,
+ }, {}, 'closed');
+});
+
+const schema = url.protocol;
+const hostname = decodePunycode(url.hostname);
+const port = url.port;
+const pathname = safeURIDecode(url.pathname);
+const query = safeURIDecode(url.search);
+const hash = safeURIDecode(url.hash);
+const attr = self ? 'to' : 'href';
+const target = self ? null : '_blank';
+</script>
+
+<style lang="scss" scoped>
+.ieqqeuvs {
+ word-break: break-all;
+
+ > .icon {
+ padding-left: 2px;
+ font-size: .9em;
+ }
+
+ > .self {
+ font-weight: bold;
+ }
+
+ > .schema {
+ opacity: 0.5;
+ }
+
+ > .hostname {
+ font-weight: bold;
+ }
+
+ > .pathname {
+ opacity: 0.8;
+ }
+
+ > .query {
+ opacity: 0.5;
+ }
+
+ > .hash {
+ font-style: italic;
+ }
+}
+</style>
diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue
new file mode 100644
index 0000000000..090de3df30
--- /dev/null
+++ b/packages/frontend/src/components/global/MkUserName.vue
@@ -0,0 +1,15 @@
+<template>
+<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import * as misskey from 'misskey-js';
+
+const props = withDefaults(defineProps<{
+ user: misskey.entities.User;
+ nowrap?: boolean;
+}>(), {
+ nowrap: true,
+});
+</script>
diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue
new file mode 100644
index 0000000000..e21a57471c
--- /dev/null
+++ b/packages/frontend/src/components/global/RouterView.vue
@@ -0,0 +1,61 @@
+<template>
+<KeepAlive :max="defaultStore.state.numberOfPageCache">
+ <Suspense>
+ <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
+
+ <template #fallback>
+ <MkLoading/>
+ </template>
+ </Suspense>
+</KeepAlive>
+</template>
+
+<script lang="ts" setup>
+import { inject, nextTick, onBeforeUnmount, onMounted, onUnmounted, provide, watch } from 'vue';
+import { Resolved, Router } from '@/nirax';
+import { defaultStore } from '@/store';
+
+const props = defineProps<{
+ router?: Router;
+}>();
+
+const router = props.router ?? inject('router');
+
+if (router == null) {
+ throw new Error('no router provided');
+}
+
+const currentDepth = inject('routerCurrentDepth', 0);
+provide('routerCurrentDepth', currentDepth + 1);
+
+function resolveNested(current: Resolved, d = 0): Resolved | null {
+ if (d === currentDepth) {
+ return current;
+ } else {
+ if (current.child) {
+ return resolveNested(current.child, d + 1);
+ } else {
+ return null;
+ }
+ }
+}
+
+const current = resolveNested(router.current)!;
+let currentPageComponent = $shallowRef(current.route.component);
+let currentPageProps = $ref(current.props);
+let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
+
+function onChange({ resolved, key: newKey }) {
+ const current = resolveNested(resolved);
+ if (current == null) return;
+ currentPageComponent = current.route.component;
+ currentPageProps = current.props;
+ key = current.route.path + JSON.stringify(Object.fromEntries(current.props));
+}
+
+router.addListener('change', onChange);
+
+onBeforeUnmount(() => {
+ router.removeListener('change', onChange);
+});
+</script>
diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts
new file mode 100644
index 0000000000..1fd293ba10
--- /dev/null
+++ b/packages/frontend/src/components/global/i18n.ts
@@ -0,0 +1,42 @@
+import { h, defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ src: {
+ type: String,
+ required: true,
+ },
+ tag: {
+ type: String,
+ required: false,
+ default: 'span',
+ },
+ textTag: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ render() {
+ let str = this.src;
+ const parsed = [] as (string | { arg: string; })[];
+ while (true) {
+ const nextBracketOpen = str.indexOf('{');
+ const nextBracketClose = str.indexOf('}');
+
+ if (nextBracketOpen === -1) {
+ parsed.push(str);
+ break;
+ } else {
+ if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen));
+ parsed.push({
+ arg: str.substring(nextBracketOpen + 1, nextBracketClose),
+ });
+ }
+
+ str = str.substr(nextBracketClose + 1);
+ }
+
+ return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]()));
+ },
+});