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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {
// mousedownonClick
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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]()));
},
});