refactoring

Resolve #7779
This commit is contained in:
syuilo
2021-11-12 02:02:25 +09:00
parent 037837b551
commit 0e4a111f81
1714 changed files with 20803 additions and 11751 deletions

View File

@ -0,0 +1,138 @@
<template>
<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
<slot></slot>
</a>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { router } from '@/router';
import { url } from '@/config';
import { popout } from '@/scripts/popout';
import { ColdDeviceStorage } from '@/store';
export default defineComponent({
inject: {
navHook: {
default: null
},
sideViewHook: {
default: null
}
},
props: {
to: {
type: String,
required: true,
},
activeClass: {
type: String,
required: false,
},
behavior: {
type: String,
required: false,
},
},
computed: {
active() {
if (this.activeClass == null) return false;
const resolved = router.resolve(this.to);
if (resolved.path == this.$route.path) return true;
if (resolved.name == null) return false;
if (this.$route.name == null) return false;
return resolved.name == this.$route.name;
}
},
methods: {
onContextmenu(e) {
if (window.getSelection().toString() !== '') return;
os.contextMenu([{
type: 'label',
text: this.to,
}, {
icon: 'fas fa-window-maximize',
text: this.$ts.openInWindow,
action: () => {
os.pageWindow(this.to);
}
}, this.sideViewHook ? {
icon: 'fas fa-columns',
text: this.$ts.openInSideView,
action: () => {
this.sideViewHook(this.to);
}
} : undefined, {
icon: 'fas fa-expand-alt',
text: this.$ts.showInPage,
action: () => {
this.$router.push(this.to);
}
}, null, {
icon: 'fas fa-external-link-alt',
text: this.$ts.openInNewTab,
action: () => {
window.open(this.to, '_blank');
}
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: () => {
copyToClipboard(`${url}${this.to}`);
}
}], e);
},
window() {
os.pageWindow(this.to);
},
modalWindow() {
os.modalPageWindow(this.to);
},
popout() {
popout(this.to);
},
nav() {
if (this.behavior === 'browser') {
location.href = this.to;
return;
}
if (this.to.startsWith('/my/messaging')) {
if (ColdDeviceStorage.get('chatOpenBehavior') === 'window') return this.window();
if (ColdDeviceStorage.get('chatOpenBehavior') === 'popout') return this.popout();
}
if (this.behavior) {
if (this.behavior === 'window') {
return this.window();
} else if (this.behavior === 'modalWindow') {
return this.modalWindow();
}
}
if (this.navHook) {
this.navHook(this.to);
} else {
if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') {
return this.sideViewHook(this.to);
}
if (this.$router.currentRoute.value.path === this.to) {
window.scroll({ top: 0, behavior: 'smooth' });
} else {
this.$router.push(this.to);
}
}
}
}
});
</script>

View File

@ -0,0 +1,38 @@
<template>
<span class="mk-acct">
<span class="name">@{{ user.username }}</span>
<span class="host" v-if="user.host || detail || $store.state.showFullAcct">@{{ user.host || host }}</span>
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { toUnicode } from 'punycode/';
import { host } from '@/config';
export default defineComponent({
props: {
user: {
type: Object,
required: true
},
detail: {
type: Boolean,
default: false
},
},
data() {
return {
host: toUnicode(host),
};
}
});
</script>
<style lang="scss" scoped>
.mk-acct {
> .host {
opacity: 0.5;
}
}
</style>

View File

@ -0,0 +1,200 @@
<template>
<div class="qiivuoyo" v-if="ad">
<div class="main" :class="ad.place" v-if="!showMenu">
<a :href="ad.url" target="_blank">
<img :src="ad.imageUrl">
<button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button>
</a>
</div>
<div class="menu" v-else>
<div class="body">
<div>Ads by {{ host }}</div>
<!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
<MkButton v-if="ad.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">
import { defineComponent, ref } from 'vue';
import { Instance, instance } from '@/instance';
import { host } from '@/config';
import MkButton from '@/components/ui/button.vue';
import { defaultStore } from '@/store';
import * as os from '@/os';
export default defineComponent({
components: {
MkButton
},
props: {
prefer: {
type: Array,
required: true
},
specify: {
type: Object,
required: false
},
},
setup(props) {
const showMenu = ref(false);
const toggleMenu = () => {
showMenu.value = !showMenu.value;
};
const choseAd = (): Instance['ads'][number] | null => {
if (props.specify) {
return props.specify as Instance['ads'][number];
}
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());
const reduceFrequency = () => {
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;
};
return {
ad: chosen,
showMenu,
toggleMenu,
host,
reduceFrequency,
};
}
});
</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;
}
> .menu {
position: absolute;
top: 0;
right: 0;
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,163 @@
<template>
<span class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</span>
<MkA class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</MkA>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
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/user-online-indicator.vue';
export default defineComponent({
components: {
MkUserOnlineIndicator
},
props: {
user: {
type: Object,
required: true
},
target: {
required: false,
default: null
},
disableLink: {
required: false,
default: false
},
disablePreview: {
required: false,
default: false
},
showIndicator: {
required: false,
default: false
}
},
emits: ['click'],
computed: {
cat(): boolean {
return this.user.isCat;
},
url(): string {
return this.$store.state.disableShowingAnimatedImages
? getStaticImageUrl(this.user.avatarUrl)
: this.user.avatarUrl;
},
},
watch: {
'user.avatarBlurhash'() {
if (this.$el == null) return;
this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
}
},
mounted() {
this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
},
methods: {
onClick(e) {
this.$emit('click', e);
},
acct,
userPage
}
});
</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,125 @@
<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" :title="alt" decoding="async"/>
<span v-else-if="char && useOsNativeEmojis">{{ char }}</span>
<span v-else>{{ emoji }}</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import { twemojiSvgBase } from '@/scripts/twemoji-base';
export default defineComponent({
props: {
emoji: {
type: String,
required: true
},
normal: {
type: Boolean,
required: false,
default: false
},
noStyle: {
type: Boolean,
required: false,
default: false
},
customEmojis: {
required: false
},
isReaction: {
type: Boolean,
default: false
},
},
data() {
return {
url: null,
char: null,
customEmoji: null
}
},
computed: {
isCustom(): boolean {
return this.emoji.startsWith(':');
},
alt(): string {
return this.customEmoji ? `:${this.customEmoji.name}:` : this.char;
},
useOsNativeEmojis(): boolean {
return this.$store.state.useOsNativeEmojis && !this.isReaction;
},
ce() {
return this.customEmojis || this.$instance?.emojis || [];
}
},
watch: {
ce: {
handler() {
if (this.isCustom) {
const customEmoji = this.ce.find(x => x.name === this.emoji.substr(1, this.emoji.length - 2));
if (customEmoji) {
this.customEmoji = customEmoji;
this.url = this.$store.state.disableShowingAnimatedImages
? getStaticImageUrl(customEmoji.url)
: customEmoji.url;
}
}
},
immediate: true
},
},
created() {
if (!this.isCustom) {
this.char = this.emoji;
}
if (this.char) {
let codes = Array.from(this.char).map(x => x.codePointAt(0).toString(16));
if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
codes = codes.filter(x => x && x.length);
this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`;
}
},
});
</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,46 @@
<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="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</p>
<MkButton @click="() => $emit('retry')" class="button">{{ $ts.retry }}</MkButton>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
export default defineComponent({
components: {
MkButton,
},
data() {
return {
};
},
});
</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,360 @@
<template>
<div class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick" ref="el">
<template v-if="info">
<div class="titleContainer" @click="showTabsPopup" v-if="!hideTitle">
<MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
<i v-else-if="info.icon" class="icon" :class="info.icon"></i>
<div class="title">
<MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/>
<div v-else-if="info.title" class="title">{{ info.title }}</div>
<div class="subtitle" v-if="!narrow && info.subtitle">
{{ info.subtitle }}
</div>
<div class="subtitle activeTab" v-if="narrow && hasTabs">
{{ info.tabs.find(tab => tab.active)?.title }}
<i class="chevron fas fa-chevron-down"></i>
</div>
</div>
</div>
<div class="tabs" v-if="!narrow || hideTitle">
<button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title">
<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
</button>
</div>
</template>
<div class="buttons right">
<template v-if="info && info.actions && !narrow">
<template v-for="action in info.actions">
<MkButton class="fullButton" v-if="action.asFullButton" @click.stop="action.handler" primary><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
<button v-else class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button>
</template>
</template>
<button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue';
import * as tinycolor from 'tinycolor2';
import { popupMenu } from '@/os';
import { url } from '@/config';
import { scrollToTop } from '@/scripts/scroll';
import MkButton from '@/components/ui/button.vue';
import { i18n } from '@/i18n';
import { globalEvents } from '@/events';
export default defineComponent({
components: {
MkButton
},
props: {
info: {
type: Object as PropType<{
actions?: {}[];
tabs?: {}[];
}>,
required: true
},
menu: {
required: false
},
thin: {
required: false,
default: false
},
},
setup(props) {
const el = ref<HTMLElement>(null);
const bg = ref(null);
const narrow = ref(false);
const height = ref(0);
const hasTabs = computed(() => {
return props.info.tabs && props.info.tabs.length > 0;
});
const shouldShowMenu = computed(() => {
if (props.info == null) return false;
if (props.info.actions != null && narrow.value) return true;
if (props.info.menu != null) return true;
if (props.info.share != null) return true;
if (props.menu != null) return true;
return false;
});
const share = () => {
navigator.share({
url: url + props.info.path,
...props.info.share,
});
};
const showMenu = (ev: MouseEvent) => {
let menu = props.info.menu ? props.info.menu() : [];
if (narrow.value && props.info.actions) {
menu = [...props.info.actions.map(x => ({
text: x.text,
icon: x.icon,
action: x.handler
})), menu.length > 0 ? null : undefined, ...menu];
}
if (props.info.share) {
if (menu.length > 0) menu.push(null);
menu.push({
text: i18n.locale.share,
icon: 'fas fa-share-alt',
action: share
});
}
if (props.menu) {
if (menu.length > 0) menu.push(null);
menu = menu.concat(props.menu);
}
popupMenu(menu, ev.currentTarget || ev.target);
};
const showTabsPopup = (ev: MouseEvent) => {
if (!hasTabs.value) return;
if (!narrow.value) return;
ev.preventDefault();
ev.stopPropagation();
const menu = props.info.tabs.map(tab => ({
text: tab.title,
icon: tab.icon,
action: tab.onClick,
}));
popupMenu(menu, ev.currentTarget || ev.target);
};
const preventDrag = (ev: TouchEvent) => {
ev.stopPropagation();
};
const onClick = () => {
scrollToTop(el.value, { behavior: 'smooth' });
};
const calcBg = () => {
const rawBg = props.info?.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();
};
onMounted(() => {
calcBg();
globalEvents.on('themeChanged', calcBg);
onUnmounted(() => {
globalEvents.off('themeChanged', calcBg);
});
if (el.value.parentElement) {
narrow.value = el.value.parentElement.offsetWidth < 500;
const ro = new ResizeObserver((entries, observer) => {
if (el.value) {
narrow.value = el.value.parentElement.offsetWidth < 500;
}
});
ro.observe(el.value.parentElement);
onUnmounted(() => {
ro.disconnect();
});
}
});
return {
el,
bg,
narrow,
height,
hasTabs,
shouldShowMenu,
share,
showMenu,
showTabsPopup,
preventDrag,
onClick,
hideTitle: inject('shouldOmitHeaderTitle', false),
thin_: props.thin || inject('shouldHeaderThin', false)
};
},
});
</script>
<style lang="scss" scoped>
.fdidabkb {
--height: 60px;
display: flex;
position: sticky;
top: var(--stickyTop, 0);
z-index: 1000;
width: 100%;
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
border-bottom: solid 0.5px var(--divider);
&.thin {
--height: 50px;
> .buttons {
> .button {
font-size: 0.9em;
}
}
}
&.slim {
text-align: center;
> .titleContainer {
flex: 1;
margin: 0 auto;
margin-left: var(--height);
> *:first-child {
margin-left: auto;
}
> *:last-child {
margin-right: auto;
}
}
}
> .buttons {
--margin: 8px;
display: flex;
align-items: center;
height: var(--height);
margin: 0 var(--margin);
&.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;
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;
}
> .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 {
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;
&:after {
content: "";
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
margin: 0 auto;
width: 100%;
height: 3px;
background: var(--accent);
}
}
> .icon + .title {
margin-left: 8px;
}
}
}
}
</style>

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

View File

@ -0,0 +1,92 @@
<template>
<div class="yxspomdl" :class="{ inline, colored, mini }">
<div class="ring"></div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
inline: {
type: Boolean,
required: false,
default: false
},
colored: {
type: Boolean,
required: false,
default: true
},
mini: {
type: Boolean,
required: false,
default: false
},
}
});
</script>
<style lang="scss" scoped>
@keyframes ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.yxspomdl {
padding: 32px;
text-align: center;
cursor: wait;
--size: 48px;
&.colored {
color: var(--accent);
}
&.inline {
display: inline;
padding: 0;
--size: 32px;
}
&.mini {
padding: 16px;
--size: 32px;
}
> .ring {
position: relative;
display: inline-block;
vertical-align: middle;
&:before,
&:after {
content: " ";
display: block;
box-sizing: border-box;
width: var(--size);
height: var(--size);
border-radius: 50%;
border: solid 4px;
}
&:before {
border-color: currentColor;
opacity: 0.3;
}
&:after {
position: absolute;
top: 0;
border-color: currentColor transparent transparent transparent;
animation: ring 0.5s linear infinite;
}
}
}
</style>

View File

@ -0,0 +1,157 @@
<template>
<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MfmCore from '@/components/mfm';
export default defineComponent({
components: {
MfmCore
}
});
</script>
<style lang="scss">
._mfm_blur_ {
filter: blur(6px);
transition: filter 0.3s;
&:hover {
filter: blur(0px);
}
}
@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,76 @@
<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">
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
export default defineComponent({
props: {
contentMax: {
type: Number,
required: false,
default: null,
}
},
setup(props, context) {
let ro: ResizeObserver;
const root = ref<HTMLElement>(null);
const content = ref<HTMLElement>(null);
const margin = ref(0);
const adjust = (rect: { width: number; height: number; }) => {
if (rect.width > (props.contentMax || 500)) {
margin.value = 32;
} else {
margin.value = 12;
}
};
onMounted(() => {
ro = new ResizeObserver((entries) => {
/* iOSが対応していない
adjust({
width: entries[0].borderBoxSize[0].inlineSize,
height: entries[0].borderBoxSize[0].blockSize,
});
*/
adjust({
width: root.value.offsetWidth,
height: root.value.offsetHeight,
});
});
ro.observe(root.value);
if (props.contentMax) {
content.value.style.maxWidth = `${props.contentMax}px`;
}
});
onUnmounted(() => {
ro.disconnect();
});
return {
root,
content,
margin,
};
},
});
</script>
<style lang="scss" module>
.root {
box-sizing: border-box;
width: 100%;
}
.content {
margin: 0 auto;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div ref="rootEl">
<slot name="header"></slot>
<div ref="bodyEl">
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
export default defineComponent({
props: {
autoSticky: {
type: Boolean,
required: false,
default: false,
},
},
setup(props, context) {
const rootEl = ref<HTMLElement>(null);
const bodyEl = ref<HTMLElement>(null);
const calc = () => {
const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
const header = rootEl.value.children[0];
if (header === bodyEl.value) {
bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
} else {
bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
if (props.autoSticky) {
header.style.setProperty('--stickyTop', currentStickyTop);
header.style.position = 'sticky';
header.style.top = 'var(--stickyTop)';
header.style.zIndex = '1';
}
}
};
onMounted(() => {
calc();
const observer = new MutationObserver(() => {
setTimeout(() => {
calc();
}, 100);
});
observer.observe(rootEl.value, {
attributes: false,
childList: true,
subtree: false,
});
onUnmounted(() => {
observer.disconnect();
});
});
return {
rootEl,
bodyEl,
};
},
});
</script>
<style lang="scss" module>
</style>

View File

@ -0,0 +1,73 @@
<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">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
time: {
type: [Date, String],
required: true
},
mode: {
type: String,
default: 'relative'
}
},
data() {
return {
tickId: null,
now: new Date()
};
},
computed: {
_time(): Date {
return typeof this.time == 'string' ? new Date(this.time) : this.time;
},
absolute(): string {
return this._time.toLocaleString();
},
relative(): string {
const time = this._time;
const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
return (
ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? this.$ts._ago.justNow :
ago < -1 ? this.$ts._ago.future :
this.$ts._ago.unknown);
}
},
created() {
if (this.mode == 'relative' || this.mode == 'detail') {
this.tickId = window.requestAnimationFrame(this.tick);
}
},
unmounted() {
if (this.mode === 'relative' || this.mode === 'detail') {
window.clearTimeout(this.tickId);
}
},
methods: {
tick() {
// TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
this.now = new Date();
this.tickId = setTimeout(() => {
window.requestAnimationFrame(this.tick);
}, 10000);
}
}
});
</script>

View File

@ -0,0 +1,142 @@
<template>
<component :is="self ? 'MkA' : 'a'" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
@contextmenu.stop="() => {}"
>
<template v-if="!self">
<span class="schema">{{ schema }}//</span>
<span class="hostname">{{ hostname }}</span>
<span class="port" v-if="port != ''">:{{ port }}</span>
</template>
<template v-if="pathname === '/' && self">
<span class="self">{{ hostname }}</span>
</template>
<span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span>
<span class="query">{{ query }}</span>
<span class="hash">{{ hash }}</span>
<i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { toUnicode as decodePunycode } from 'punycode/';
import { url as local } from '@/config';
import { isDeviceTouch } from '@/scripts/is-device-touch';
import * as os from '@/os';
export default defineComponent({
props: {
url: {
type: String,
required: true,
},
rel: {
type: String,
required: false,
}
},
data() {
const self = this.url.startsWith(local);
return {
local,
schema: null as string | null,
hostname: null as string | null,
port: null as string | null,
pathname: null as string | null,
query: null as string | null,
hash: null as string | null,
self: self,
attr: self ? 'to' : 'href',
target: self ? null : '_blank',
showTimer: null,
hideTimer: null,
checkTimer: null,
close: null,
};
},
created() {
const url = new URL(this.url);
this.schema = url.protocol;
this.hostname = decodePunycode(url.hostname);
this.port = url.port;
this.pathname = decodeURIComponent(url.pathname);
this.query = decodeURIComponent(url.search);
this.hash = decodeURIComponent(url.hash);
},
methods: {
async showPreview() {
if (!document.body.contains(this.$el)) return;
if (this.close) return;
const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), {
url: this.url,
source: this.$el
});
this.close = () => {
dispose();
};
this.checkTimer = setInterval(() => {
if (!document.body.contains(this.$el)) this.closePreview();
}, 1000);
},
closePreview() {
if (this.close) {
clearInterval(this.checkTimer);
this.close();
this.close = null;
}
},
onMouseover() {
if (isDeviceTouch) return;
clearTimeout(this.showTimer);
clearTimeout(this.hideTimer);
this.showTimer = setTimeout(this.showPreview, 500);
},
onMouseleave() {
if (isDeviceTouch) return;
clearTimeout(this.showTimer);
clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(this.closePreview, 500);
}
}
});
</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,20 @@
<template>
<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
user: {
type: Object,
required: true
},
nowrap: {
type: Boolean,
default: true
},
}
});
</script>