Merge branch 'develop' into swn
This commit is contained in:
		@@ -15,6 +15,7 @@
 | 
			
		||||
### Changes
 | 
			
		||||
- Room機能が削除されました
 | 
			
		||||
  - 後日別リポジトリとして復活予定です
 | 
			
		||||
- Chat UIが削除されました
 | 
			
		||||
 | 
			
		||||
### Improvements
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
<template>
 | 
			
		||||
<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')">
 | 
			
		||||
<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
 | 
			
		||||
	<template #header>
 | 
			
		||||
		<i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i>
 | 
			
		||||
		<I18n :src="$ts.reportAbuseOf" tag="span">
 | 
			
		||||
		<I18n :src="i18n.locale.reportAbuseOf" tag="span">
 | 
			
		||||
			<template #name>
 | 
			
		||||
				<b><MkAcct :user="user"/></b>
 | 
			
		||||
			</template>
 | 
			
		||||
@@ -11,65 +11,51 @@
 | 
			
		||||
	<div class="dpvffvvy _monolithic_">
 | 
			
		||||
		<div class="_section">
 | 
			
		||||
			<MkTextarea v-model="comment">
 | 
			
		||||
				<template #label>{{ $ts.details }}</template>
 | 
			
		||||
				<template #caption>{{ $ts.fillAbuseReportDescription }}</template>
 | 
			
		||||
				<template #label>{{ i18n.locale.details }}</template>
 | 
			
		||||
				<template #caption>{{ i18n.locale.fillAbuseReportDescription }}</template>
 | 
			
		||||
			</MkTextarea>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div class="_section">
 | 
			
		||||
			<MkButton primary full :disabled="comment.length === 0" @click="send">{{ $ts.send }}</MkButton>
 | 
			
		||||
			<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.locale.send }}</MkButton>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</XWindow>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, markRaw } from 'vue';
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import XWindow from '@/components/ui/window.vue';
 | 
			
		||||
import MkTextarea from '@/components/form/textarea.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XWindow,
 | 
			
		||||
		MkTextarea,
 | 
			
		||||
		MkButton,
 | 
			
		||||
	},
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	user: Misskey.entities.User;
 | 
			
		||||
	initialComment?: string;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		user: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
		initialComment: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(e: 'closed'): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	emits: ['closed'],
 | 
			
		||||
const window = ref<InstanceType<typeof XWindow>>();
 | 
			
		||||
const comment = ref(props.initialComment || '');
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			comment: this.initialComment || '',
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		send() {
 | 
			
		||||
			os.apiWithDialog('users/report-abuse', {
 | 
			
		||||
				userId: this.user.id,
 | 
			
		||||
				comment: this.comment,
 | 
			
		||||
			}, undefined, res => {
 | 
			
		||||
				os.alert({
 | 
			
		||||
					type: 'success',
 | 
			
		||||
					text: this.$ts.abuseReported
 | 
			
		||||
				});
 | 
			
		||||
				this.$refs.window.close();
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
function send() {
 | 
			
		||||
	os.apiWithDialog('users/report-abuse', {
 | 
			
		||||
		userId: props.user.id,
 | 
			
		||||
		comment: comment.value,
 | 
			
		||||
	}, undefined).then(res => {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'success',
 | 
			
		||||
			text: i18n.locale.abuseReported
 | 
			
		||||
		});
 | 
			
		||||
		window.value?.close();
 | 
			
		||||
		emit('closed');
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
 
 | 
			
		||||
@@ -40,106 +40,64 @@
 | 
			
		||||
</svg>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
 | 
			
		||||
import * as tinycolor from 'tinycolor2';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		thickness: {
 | 
			
		||||
			type: Number,
 | 
			
		||||
			default: 0.1
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
withDefaults(defineProps<{
 | 
			
		||||
	thickness: number;
 | 
			
		||||
}>(), {
 | 
			
		||||
	thickness: 0.1,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			now: new Date(),
 | 
			
		||||
			enabled: true,
 | 
			
		||||
const now = ref(new Date());
 | 
			
		||||
const enabled = ref(true);
 | 
			
		||||
const graduationsPadding = ref(0.5);
 | 
			
		||||
const handsPadding = ref(1);
 | 
			
		||||
const handsTailLength = ref(0.7);
 | 
			
		||||
const hHandLengthRatio = ref(0.75);
 | 
			
		||||
const mHandLengthRatio = ref(1);
 | 
			
		||||
const sHandLengthRatio = ref(1);
 | 
			
		||||
const computedStyle = getComputedStyle(document.documentElement);
 | 
			
		||||
 | 
			
		||||
			graduationsPadding: 0.5,
 | 
			
		||||
			handsPadding: 1,
 | 
			
		||||
			handsTailLength: 0.7,
 | 
			
		||||
			hHandLengthRatio: 0.75,
 | 
			
		||||
			mHandLengthRatio: 1,
 | 
			
		||||
			sHandLengthRatio: 1,
 | 
			
		||||
 | 
			
		||||
			computedStyle: getComputedStyle(document.documentElement)
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		dark(): boolean {
 | 
			
		||||
			return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		majorGraduationColor(): string {
 | 
			
		||||
			return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
 | 
			
		||||
		},
 | 
			
		||||
		minorGraduationColor(): string {
 | 
			
		||||
			return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		sHandColor(): string {
 | 
			
		||||
			return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
 | 
			
		||||
		},
 | 
			
		||||
		mHandColor(): string {
 | 
			
		||||
			return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString();
 | 
			
		||||
		},
 | 
			
		||||
		hHandColor(): string {
 | 
			
		||||
			return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		s(): number {
 | 
			
		||||
			return this.now.getSeconds();
 | 
			
		||||
		},
 | 
			
		||||
		m(): number {
 | 
			
		||||
			return this.now.getMinutes();
 | 
			
		||||
		},
 | 
			
		||||
		h(): number {
 | 
			
		||||
			return this.now.getHours();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		hAngle(): number {
 | 
			
		||||
			return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6;
 | 
			
		||||
		},
 | 
			
		||||
		mAngle(): number {
 | 
			
		||||
			return Math.PI * (this.m + this.s / 60) / 30;
 | 
			
		||||
		},
 | 
			
		||||
		sAngle(): number {
 | 
			
		||||
			return Math.PI * this.s / 30;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		graduations(): any {
 | 
			
		||||
			const angles = [];
 | 
			
		||||
			for (let i = 0; i < 60; i++) {
 | 
			
		||||
				const angle = Math.PI * i / 30;
 | 
			
		||||
				angles.push(angle);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return angles;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		const update = () => {
 | 
			
		||||
			if (this.enabled) {
 | 
			
		||||
				this.tick();
 | 
			
		||||
				setTimeout(update, 1000);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
		update();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.enabled = false;
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		tick() {
 | 
			
		||||
			this.now = new Date();
 | 
			
		||||
		}
 | 
			
		||||
const dark = computed(() => tinycolor(computedStyle.getPropertyValue('--bg')).isDark());
 | 
			
		||||
const majorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)');
 | 
			
		||||
const minorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)');
 | 
			
		||||
const sHandColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)');
 | 
			
		||||
const mHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--fg')).toHexString());
 | 
			
		||||
const hHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--accent')).toHexString());
 | 
			
		||||
const s = computed(() => now.value.getSeconds());
 | 
			
		||||
const m = computed(() => now.value.getMinutes());
 | 
			
		||||
const h = computed(() => now.value.getHours());
 | 
			
		||||
const hAngle = computed(() => Math.PI * (h.value % 12 + (m.value + s.value / 60) / 60) / 6);
 | 
			
		||||
const mAngle = computed(() => Math.PI * (m.value + s.value / 60) / 30);
 | 
			
		||||
const sAngle = computed(() => Math.PI * s.value / 30);
 | 
			
		||||
const graduations = computed(() => {
 | 
			
		||||
	const angles: number[] = [];
 | 
			
		||||
	for (let i = 0; i < 60; i++) {
 | 
			
		||||
		const angle = Math.PI * i / 30;
 | 
			
		||||
		angles.push(angle);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return angles;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function tick() {
 | 
			
		||||
	now.value = new Date();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	const update = () => {
 | 
			
		||||
		if (enabled.value) {
 | 
			
		||||
			tick();
 | 
			
		||||
			setTimeout(update, 1000);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
	update();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onBeforeUnmount(() => {
 | 
			
		||||
	enabled.value = false;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
 | 
			
		||||
<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
 | 
			
		||||
	<ol v-if="type === 'user'" ref="suggests" class="users">
 | 
			
		||||
		<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
 | 
			
		||||
			<img class="avatar" :src="user.avatarUrl"/>
 | 
			
		||||
@@ -8,7 +8,7 @@
 | 
			
		||||
			</span>
 | 
			
		||||
			<span class="username">@{{ acct(user) }}</span>
 | 
			
		||||
		</li>
 | 
			
		||||
		<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ $ts.selectUser }}</li>
 | 
			
		||||
		<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.locale.selectUser }}</li>
 | 
			
		||||
	</ol>
 | 
			
		||||
	<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
 | 
			
		||||
		<li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown">
 | 
			
		||||
@@ -17,8 +17,8 @@
 | 
			
		||||
	</ol>
 | 
			
		||||
	<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
 | 
			
		||||
		<li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
 | 
			
		||||
			<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
 | 
			
		||||
			<span v-else-if="!$store.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
 | 
			
		||||
			<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
 | 
			
		||||
			<span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
 | 
			
		||||
			<span v-else class="emoji">{{ emoji.emoji }}</span>
 | 
			
		||||
			<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
 | 
			
		||||
			<span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span>
 | 
			
		||||
@@ -33,15 +33,17 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, markRaw } from 'vue';
 | 
			
		||||
import { emojilist } from '@/scripts/emojilist';
 | 
			
		||||
import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
 | 
			
		||||
import contains from '@/scripts/contains';
 | 
			
		||||
import { twemojiSvgBase } from '@/scripts/twemoji-base';
 | 
			
		||||
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
 | 
			
		||||
import { acct } from '@/filters/user';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { instance } from '@/instance';
 | 
			
		||||
import { MFM_TAGS } from '@/scripts/mfm-tags';
 | 
			
		||||
import { defaultStore } from '@/store';
 | 
			
		||||
import { emojilist } from '@/scripts/emojilist';
 | 
			
		||||
import { instance } from '@/instance';
 | 
			
		||||
import { twemojiSvgBase } from '@/scripts/twemoji-base';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
type EmojiDef = {
 | 
			
		||||
	emoji: string;
 | 
			
		||||
@@ -54,16 +56,14 @@ type EmojiDef = {
 | 
			
		||||
const lib = emojilist.filter(x => x.category !== 'flags');
 | 
			
		||||
 | 
			
		||||
const char2file = (char: string) => {
 | 
			
		||||
	let codes = Array.from(char).map(x => x.codePointAt(0).toString(16));
 | 
			
		||||
	let codes = Array.from(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);
 | 
			
		||||
	return codes.join('-');
 | 
			
		||||
	return codes.filter(x => x && x.length).join('-');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const emjdb: EmojiDef[] = lib.map(x => ({
 | 
			
		||||
	emoji: x.char,
 | 
			
		||||
	name: x.name,
 | 
			
		||||
	aliasOf: null,
 | 
			
		||||
	url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
@@ -112,291 +112,270 @@ emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
 | 
			
		||||
const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		type: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
export default {
 | 
			
		||||
	emojiDb,
 | 
			
		||||
	emojiDefinitions,
 | 
			
		||||
	emojilist,
 | 
			
		||||
	customEmojis,
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
		q: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false,
 | 
			
		||||
		},
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	type: string;
 | 
			
		||||
	q: string | null;
 | 
			
		||||
	textarea: HTMLTextAreaElement;
 | 
			
		||||
	close: () => void;
 | 
			
		||||
	x: number;
 | 
			
		||||
	y: number;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
		textarea: {
 | 
			
		||||
			type: HTMLTextAreaElement,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(e: 'done', v: { type: string; value: any }): void;
 | 
			
		||||
	(e: 'closed'): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
		close: {
 | 
			
		||||
			type: Function,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
const suggests = ref<Element>();
 | 
			
		||||
const rootEl = ref<HTMLDivElement>();
 | 
			
		||||
 | 
			
		||||
		x: {
 | 
			
		||||
			type: Number,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
const users = ref<any[]>([]);
 | 
			
		||||
const hashtags = ref<any[]>([]);
 | 
			
		||||
const emojis = ref<(EmojiDef)[]>([]);
 | 
			
		||||
const items = ref<Element[] | HTMLCollection>([]);
 | 
			
		||||
const mfmTags = ref<string[]>([]);
 | 
			
		||||
const select = ref(-1);
 | 
			
		||||
const zIndex = os.claimZIndex('high');
 | 
			
		||||
 | 
			
		||||
		y: {
 | 
			
		||||
			type: Number,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
function complete(type: string, value: any) {
 | 
			
		||||
	emit('done', { type, value });
 | 
			
		||||
	emit('closed');
 | 
			
		||||
	if (type === 'emoji') {
 | 
			
		||||
		let recents = defaultStore.state.recentlyUsedEmojis;
 | 
			
		||||
		recents = recents.filter((e: any) => e !== value);
 | 
			
		||||
		recents.unshift(value);
 | 
			
		||||
		defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	emits: ['done', 'closed'],
 | 
			
		||||
function setPosition() {
 | 
			
		||||
	if (!rootEl.value) return;
 | 
			
		||||
	if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
 | 
			
		||||
		rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
 | 
			
		||||
	} else {
 | 
			
		||||
		rootEl.value.style.left = `${props.x}px`;
 | 
			
		||||
	}
 | 
			
		||||
	if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
 | 
			
		||||
		rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
 | 
			
		||||
		rootEl.value.style.marginTop = '0';
 | 
			
		||||
	} else {
 | 
			
		||||
		rootEl.value.style.top = props.y + 'px';
 | 
			
		||||
		rootEl.value.style.marginTop = 'calc(1em + 8px)';
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			getStaticImageUrl,
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			users: [],
 | 
			
		||||
			hashtags: [],
 | 
			
		||||
			emojis: [],
 | 
			
		||||
			items: [],
 | 
			
		||||
			mfmTags: [],
 | 
			
		||||
			select: -1,
 | 
			
		||||
			zIndex: os.claimZIndex('high'),
 | 
			
		||||
function exec() {
 | 
			
		||||
	select.value = -1;
 | 
			
		||||
	if (suggests.value) {
 | 
			
		||||
		for (const el of Array.from(items.value)) {
 | 
			
		||||
			el.removeAttribute('data-selected');
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	updated() {
 | 
			
		||||
		this.setPosition();
 | 
			
		||||
		this.items = (this.$refs.suggests as Element | undefined)?.children || [];
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.setPosition();
 | 
			
		||||
 | 
			
		||||
		this.textarea.addEventListener('keydown', this.onKeydown);
 | 
			
		||||
 | 
			
		||||
		for (const el of Array.from(document.querySelectorAll('body *'))) {
 | 
			
		||||
			el.addEventListener('mousedown', this.onMousedown);
 | 
			
		||||
	}
 | 
			
		||||
	if (props.type === 'user') {
 | 
			
		||||
		if (!props.q) {
 | 
			
		||||
			users.value = [];
 | 
			
		||||
			fetching.value = false;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.$nextTick(() => {
 | 
			
		||||
			this.exec();
 | 
			
		||||
		const cacheKey = `autocomplete:user:${props.q}`;
 | 
			
		||||
		const cache = sessionStorage.getItem(cacheKey);
 | 
			
		||||
 | 
			
		||||
			this.$watch('q', () => {
 | 
			
		||||
				this.$nextTick(() => {
 | 
			
		||||
					this.exec();
 | 
			
		||||
		if (cache) {
 | 
			
		||||
			const users = JSON.parse(cache);
 | 
			
		||||
			users.value = users;
 | 
			
		||||
			fetching.value = false;
 | 
			
		||||
		} else {
 | 
			
		||||
			os.api('users/search-by-username-and-host', {
 | 
			
		||||
				username: props.q,
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				detail: false
 | 
			
		||||
			}).then(searchedUsers => {
 | 
			
		||||
				users.value = searchedUsers as any[];
 | 
			
		||||
				fetching.value = false;
 | 
			
		||||
				// キャッシュ
 | 
			
		||||
				sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	} else if (props.type === 'hashtag') {
 | 
			
		||||
		if (!props.q || props.q == '') {
 | 
			
		||||
			hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]');
 | 
			
		||||
			fetching.value = false;
 | 
			
		||||
		} else {
 | 
			
		||||
			const cacheKey = `autocomplete:hashtag:${props.q}`;
 | 
			
		||||
			const cache = sessionStorage.getItem(cacheKey);
 | 
			
		||||
			if (cache) {
 | 
			
		||||
				const hashtags = JSON.parse(cache);
 | 
			
		||||
				hashtags.value = hashtags;
 | 
			
		||||
				fetching.value = false;
 | 
			
		||||
			} else {
 | 
			
		||||
				os.api('hashtags/search', {
 | 
			
		||||
					query: props.q,
 | 
			
		||||
					limit: 30
 | 
			
		||||
				}).then(searchedHashtags => {
 | 
			
		||||
					hashtags.value = searchedHashtags as any[];
 | 
			
		||||
					fetching.value = false;
 | 
			
		||||
					// キャッシュ
 | 
			
		||||
					sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else if (props.type === 'emoji') {
 | 
			
		||||
		if (!props.q || props.q == '') {
 | 
			
		||||
			// 最近使った絵文字をサジェスト
 | 
			
		||||
			emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x) as EmojiDef[];
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const matched: EmojiDef[] = [];
 | 
			
		||||
		const max = 30;
 | 
			
		||||
 | 
			
		||||
		emojiDb.some(x => {
 | 
			
		||||
			if (x.name.startsWith(props.q || '') && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
 | 
			
		||||
			return matched.length == max;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (matched.length < max) {
 | 
			
		||||
			emojiDb.some(x => {
 | 
			
		||||
				if (x.name.startsWith(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
 | 
			
		||||
				return matched.length == max;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (matched.length < max) {
 | 
			
		||||
			emojiDb.some(x => {
 | 
			
		||||
				if (x.name.includes(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
 | 
			
		||||
				return matched.length == max;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		emojis.value = matched;
 | 
			
		||||
	} else if (props.type === 'mfmTag') {
 | 
			
		||||
		if (!props.q || props.q == '') {
 | 
			
		||||
			mfmTags.value = MFM_TAGS;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q || ''));
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function onMousedown(e: Event) {
 | 
			
		||||
	if (!contains(rootEl.value, e.target) && (rootEl.value != e.target)) props.close();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function onKeydown(e: KeyboardEvent) {
 | 
			
		||||
	const cancel = () => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		e.stopPropagation();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	switch (e.key) {
 | 
			
		||||
		case 'Enter':
 | 
			
		||||
			if (select.value !== -1) {
 | 
			
		||||
				cancel();
 | 
			
		||||
				(items.value[select.value] as any).click();
 | 
			
		||||
			} else {
 | 
			
		||||
				props.close();
 | 
			
		||||
			}
 | 
			
		||||
			break;
 | 
			
		||||
 | 
			
		||||
		case 'Escape':
 | 
			
		||||
			cancel();
 | 
			
		||||
			props.close();
 | 
			
		||||
			break;
 | 
			
		||||
 | 
			
		||||
		case 'ArrowUp':
 | 
			
		||||
			if (select.value !== -1) {
 | 
			
		||||
				cancel();
 | 
			
		||||
				selectPrev();
 | 
			
		||||
			} else {
 | 
			
		||||
				props.close();
 | 
			
		||||
			}
 | 
			
		||||
			break;
 | 
			
		||||
 | 
			
		||||
		case 'Tab':
 | 
			
		||||
		case 'ArrowDown':
 | 
			
		||||
			cancel();
 | 
			
		||||
			selectNext();
 | 
			
		||||
			break;
 | 
			
		||||
 | 
			
		||||
		default:
 | 
			
		||||
			e.stopPropagation();
 | 
			
		||||
			props.textarea.focus();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function selectNext() {
 | 
			
		||||
	if (++select.value >= items.value.length) select.value = 0;
 | 
			
		||||
	if (items.value.length === 0) select.value = -1;
 | 
			
		||||
	applySelect();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function selectPrev() {
 | 
			
		||||
	if (--select.value < 0) select.value = items.value.length - 1;
 | 
			
		||||
	applySelect();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function applySelect() {
 | 
			
		||||
	for (const el of Array.from(items.value)) {
 | 
			
		||||
		el.removeAttribute('data-selected');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (select.value !== -1) {
 | 
			
		||||
		items.value[select.value].setAttribute('data-selected', 'true');
 | 
			
		||||
		(items.value[select.value] as any).focus();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function chooseUser() {
 | 
			
		||||
	props.close();
 | 
			
		||||
	os.selectUser().then(user => {
 | 
			
		||||
		complete('user', user);
 | 
			
		||||
		props.textarea.focus();
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onUpdated(() => {
 | 
			
		||||
	setPosition();
 | 
			
		||||
	items.value = suggests.value?.children || [];
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	setPosition();
 | 
			
		||||
 | 
			
		||||
	props.textarea.addEventListener('keydown', onKeydown);
 | 
			
		||||
 | 
			
		||||
	for (const el of Array.from(document.querySelectorAll('body *'))) {
 | 
			
		||||
		el.addEventListener('mousedown', onMousedown);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	nextTick(() => {
 | 
			
		||||
		exec();
 | 
			
		||||
 | 
			
		||||
		watch(() => props.q, () => {
 | 
			
		||||
			nextTick(() => {
 | 
			
		||||
				exec();
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.textarea.removeEventListener('keydown', this.onKeydown);
 | 
			
		||||
onBeforeUnmount(() => {
 | 
			
		||||
	props.textarea.removeEventListener('keydown', onKeydown);
 | 
			
		||||
 | 
			
		||||
		for (const el of Array.from(document.querySelectorAll('body *'))) {
 | 
			
		||||
			el.removeEventListener('mousedown', this.onMousedown);
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		complete(type, value) {
 | 
			
		||||
			this.$emit('done', { type, value });
 | 
			
		||||
			this.$emit('closed');
 | 
			
		||||
 | 
			
		||||
			if (type === 'emoji') {
 | 
			
		||||
				let recents = this.$store.state.recentlyUsedEmojis;
 | 
			
		||||
				recents = recents.filter((e: any) => e !== value);
 | 
			
		||||
				recents.unshift(value);
 | 
			
		||||
				this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		setPosition() {
 | 
			
		||||
			if (this.x + this.$el.offsetWidth > window.innerWidth) {
 | 
			
		||||
				this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
 | 
			
		||||
			} else {
 | 
			
		||||
				this.$el.style.left = this.x + 'px';
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (this.y + this.$el.offsetHeight > window.innerHeight) {
 | 
			
		||||
				this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
 | 
			
		||||
				this.$el.style.marginTop = '0';
 | 
			
		||||
			} else {
 | 
			
		||||
				this.$el.style.top = this.y + 'px';
 | 
			
		||||
				this.$el.style.marginTop = 'calc(1em + 8px)';
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		exec() {
 | 
			
		||||
			this.select = -1;
 | 
			
		||||
			if (this.$refs.suggests) {
 | 
			
		||||
				for (const el of Array.from(this.items)) {
 | 
			
		||||
					el.removeAttribute('data-selected');
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (this.type === 'user') {
 | 
			
		||||
				if (this.q == null) {
 | 
			
		||||
					this.users = [];
 | 
			
		||||
					this.fetching = false;
 | 
			
		||||
					return;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				const cacheKey = `autocomplete:user:${this.q}`;
 | 
			
		||||
				const cache = sessionStorage.getItem(cacheKey);
 | 
			
		||||
				if (cache) {
 | 
			
		||||
					const users = JSON.parse(cache);
 | 
			
		||||
					this.users = users;
 | 
			
		||||
					this.fetching = false;
 | 
			
		||||
				} else {
 | 
			
		||||
					os.api('users/search-by-username-and-host', {
 | 
			
		||||
						username: this.q,
 | 
			
		||||
						limit: 10,
 | 
			
		||||
						detail: false
 | 
			
		||||
					}).then(users => {
 | 
			
		||||
						this.users = users;
 | 
			
		||||
						this.fetching = false;
 | 
			
		||||
 | 
			
		||||
						// キャッシュ
 | 
			
		||||
						sessionStorage.setItem(cacheKey, JSON.stringify(users));
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			} else if (this.type === 'hashtag') {
 | 
			
		||||
				if (this.q == null || this.q == '') {
 | 
			
		||||
					this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
 | 
			
		||||
					this.fetching = false;
 | 
			
		||||
				} else {
 | 
			
		||||
					const cacheKey = `autocomplete:hashtag:${this.q}`;
 | 
			
		||||
					const cache = sessionStorage.getItem(cacheKey);
 | 
			
		||||
					if (cache) {
 | 
			
		||||
						const hashtags = JSON.parse(cache);
 | 
			
		||||
						this.hashtags = hashtags;
 | 
			
		||||
						this.fetching = false;
 | 
			
		||||
					} else {
 | 
			
		||||
						os.api('hashtags/search', {
 | 
			
		||||
							query: this.q,
 | 
			
		||||
							limit: 30
 | 
			
		||||
						}).then(hashtags => {
 | 
			
		||||
							this.hashtags = hashtags;
 | 
			
		||||
							this.fetching = false;
 | 
			
		||||
 | 
			
		||||
							// キャッシュ
 | 
			
		||||
							sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
 | 
			
		||||
						});
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			} else if (this.type === 'emoji') {
 | 
			
		||||
				if (this.q == null || this.q == '') {
 | 
			
		||||
					// 最近使った絵文字をサジェスト
 | 
			
		||||
					this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null);
 | 
			
		||||
					return;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				const matched = [];
 | 
			
		||||
				const max = 30;
 | 
			
		||||
 | 
			
		||||
				emojiDb.some(x => {
 | 
			
		||||
					if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
 | 
			
		||||
					return matched.length == max;
 | 
			
		||||
				});
 | 
			
		||||
				if (matched.length < max) {
 | 
			
		||||
					emojiDb.some(x => {
 | 
			
		||||
						if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
 | 
			
		||||
						return matched.length == max;
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
				if (matched.length < max) {
 | 
			
		||||
					emojiDb.some(x => {
 | 
			
		||||
						if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
 | 
			
		||||
						return matched.length == max;
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				this.emojis = matched;
 | 
			
		||||
			} else if (this.type === 'mfmTag') {
 | 
			
		||||
				if (this.q == null || this.q == '') {
 | 
			
		||||
					this.mfmTags = MFM_TAGS;
 | 
			
		||||
					return;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q));
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onMousedown(e) {
 | 
			
		||||
			if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onKeydown(e) {
 | 
			
		||||
			const cancel = () => {
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
				e.stopPropagation();
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			switch (e.which) {
 | 
			
		||||
				case 10: // [ENTER]
 | 
			
		||||
				case 13: // [ENTER]
 | 
			
		||||
					if (this.select !== -1) {
 | 
			
		||||
						cancel();
 | 
			
		||||
						(this.items[this.select] as any).click();
 | 
			
		||||
					} else {
 | 
			
		||||
						this.close();
 | 
			
		||||
					}
 | 
			
		||||
					break;
 | 
			
		||||
 | 
			
		||||
				case 27: // [ESC]
 | 
			
		||||
					cancel();
 | 
			
		||||
					this.close();
 | 
			
		||||
					break;
 | 
			
		||||
 | 
			
		||||
				case 38: // [↑]
 | 
			
		||||
					if (this.select !== -1) {
 | 
			
		||||
						cancel();
 | 
			
		||||
						this.selectPrev();
 | 
			
		||||
					} else {
 | 
			
		||||
						this.close();
 | 
			
		||||
					}
 | 
			
		||||
					break;
 | 
			
		||||
 | 
			
		||||
				case 9: // [TAB]
 | 
			
		||||
				case 40: // [↓]
 | 
			
		||||
					cancel();
 | 
			
		||||
					this.selectNext();
 | 
			
		||||
					break;
 | 
			
		||||
 | 
			
		||||
				default:
 | 
			
		||||
					e.stopPropagation();
 | 
			
		||||
					this.textarea.focus();
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		selectNext() {
 | 
			
		||||
			if (++this.select >= this.items.length) this.select = 0;
 | 
			
		||||
			if (this.items.length === 0) this.select = -1;
 | 
			
		||||
			this.applySelect();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		selectPrev() {
 | 
			
		||||
			if (--this.select < 0) this.select = this.items.length - 1;
 | 
			
		||||
			this.applySelect();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		applySelect() {
 | 
			
		||||
			for (const el of Array.from(this.items)) {
 | 
			
		||||
				el.removeAttribute('data-selected');
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (this.select !== -1) {
 | 
			
		||||
				this.items[this.select].setAttribute('data-selected', 'true');
 | 
			
		||||
				(this.items[this.select] as any).focus();
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		chooseUser() {
 | 
			
		||||
			this.close();
 | 
			
		||||
			os.selectUser().then(user => {
 | 
			
		||||
				this.complete('user', user);
 | 
			
		||||
				this.textarea.focus();
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		acct
 | 
			
		||||
	for (const el of Array.from(document.querySelectorAll('body *'))) {
 | 
			
		||||
		el.removeEventListener('mousedown', onMousedown);
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,14 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div>
 | 
			
		||||
	<span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span>
 | 
			
		||||
	<div ref="captcha"></div>
 | 
			
		||||
	<span v-if="!available">{{ i18n.locale.waiting }}<MkEllipsis/></span>
 | 
			
		||||
	<div ref="captchaEl"></div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, PropType } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
 | 
			
		||||
import { defaultStore } from '@/store';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
type Captcha = {
 | 
			
		||||
	render(container: string | Node, options: {
 | 
			
		||||
@@ -14,7 +16,7 @@ type Captcha = {
 | 
			
		||||
	}): string;
 | 
			
		||||
	remove(id: string): void;
 | 
			
		||||
	execute(id: string): void;
 | 
			
		||||
	reset(id: string): void;
 | 
			
		||||
	reset(id?: string): void;
 | 
			
		||||
	getResponse(id: string): string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -29,95 +31,87 @@ declare global {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		provider: {
 | 
			
		||||
			type: String as PropType<CaptchaProvider>,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
		sitekey: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
		modelValue: {
 | 
			
		||||
			type: String,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	provider: CaptchaProvider;
 | 
			
		||||
	sitekey: string;
 | 
			
		||||
	modelValue?: string | null;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			available: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(e: 'update:modelValue', v: string | null): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		variable(): string {
 | 
			
		||||
			switch (this.provider) {
 | 
			
		||||
				case 'hcaptcha': return 'hcaptcha';
 | 
			
		||||
				case 'recaptcha': return 'grecaptcha';
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		loaded(): boolean {
 | 
			
		||||
			return !!window[this.variable];
 | 
			
		||||
		},
 | 
			
		||||
		src(): string {
 | 
			
		||||
			const endpoint = ({
 | 
			
		||||
				hcaptcha: 'https://hcaptcha.com/1',
 | 
			
		||||
				recaptcha: 'https://www.recaptcha.net/recaptcha',
 | 
			
		||||
			} as Record<CaptchaProvider, string>)[this.provider];
 | 
			
		||||
const available = ref(false);
 | 
			
		||||
 | 
			
		||||
			return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
 | 
			
		||||
		},
 | 
			
		||||
		captcha(): Captcha {
 | 
			
		||||
			return window[this.variable] || {} as unknown as Captcha;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
const captchaEl = ref<HTMLDivElement | undefined>();
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		if (this.loaded) {
 | 
			
		||||
			this.available = true;
 | 
			
		||||
		} else {
 | 
			
		||||
			(document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
 | 
			
		||||
				async: true,
 | 
			
		||||
				id: this.provider,
 | 
			
		||||
				src: this.src,
 | 
			
		||||
			})))
 | 
			
		||||
				.addEventListener('load', () => this.available = true);
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (this.available) {
 | 
			
		||||
			this.requestRender();
 | 
			
		||||
		} else {
 | 
			
		||||
			this.$watch('available', this.requestRender);
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.reset();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		reset() {
 | 
			
		||||
			if (this.captcha?.reset) this.captcha.reset();
 | 
			
		||||
		},
 | 
			
		||||
		requestRender() {
 | 
			
		||||
			if (this.captcha.render && this.$refs.captcha instanceof Element) {
 | 
			
		||||
				this.captcha.render(this.$refs.captcha, {
 | 
			
		||||
					sitekey: this.sitekey,
 | 
			
		||||
					theme: this.$store.state.darkMode ? 'dark' : 'light',
 | 
			
		||||
					callback: this.callback,
 | 
			
		||||
					'expired-callback': this.callback,
 | 
			
		||||
					'error-callback': this.callback,
 | 
			
		||||
				});
 | 
			
		||||
			} else {
 | 
			
		||||
				setTimeout(this.requestRender.bind(this), 1);
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		callback(response?: string) {
 | 
			
		||||
			this.$emit('update:modelValue', typeof response == 'string' ? response : null);
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
const variable = computed(() => {
 | 
			
		||||
	switch (props.provider) {
 | 
			
		||||
		case 'hcaptcha': return 'hcaptcha';
 | 
			
		||||
		case 'recaptcha': return 'grecaptcha';
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const loaded = computed(() => !!window[variable.value]);
 | 
			
		||||
 | 
			
		||||
const src = computed(() => {
 | 
			
		||||
	const endpoint = ({
 | 
			
		||||
		hcaptcha: 'https://hcaptcha.com/1',
 | 
			
		||||
		recaptcha: 'https://www.recaptcha.net/recaptcha',
 | 
			
		||||
	} as Record<CaptchaProvider, string>)[props.provider];
 | 
			
		||||
 | 
			
		||||
	return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
 | 
			
		||||
 | 
			
		||||
if (loaded.value) {
 | 
			
		||||
	available.value = true;
 | 
			
		||||
} else {
 | 
			
		||||
	(document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
 | 
			
		||||
		async: true,
 | 
			
		||||
		id: props.provider,
 | 
			
		||||
		src: src.value,
 | 
			
		||||
	})))
 | 
			
		||||
		.addEventListener('load', () => available.value = true);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function reset() {
 | 
			
		||||
	if (captcha.value?.reset) captcha.value.reset();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function requestRender() {
 | 
			
		||||
	if (captcha.value.render && captchaEl.value instanceof Element) {
 | 
			
		||||
		captcha.value.render(captchaEl.value, {
 | 
			
		||||
			sitekey: props.sitekey,
 | 
			
		||||
			theme: defaultStore.state.darkMode ? 'dark' : 'light',
 | 
			
		||||
			callback: callback,
 | 
			
		||||
			'expired-callback': callback,
 | 
			
		||||
			'error-callback': callback,
 | 
			
		||||
		});
 | 
			
		||||
	} else {
 | 
			
		||||
		setTimeout(requestRender, 1);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function callback(response?: string) {
 | 
			
		||||
	emit('update:modelValue', typeof response == 'string' ? response : null);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	if (available.value) {
 | 
			
		||||
		requestRender();
 | 
			
		||||
	} else {
 | 
			
		||||
		watch(available, requestRender);
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onBeforeUnmount(() => {
 | 
			
		||||
	reset();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
	reset,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -6,66 +6,54 @@
 | 
			
		||||
>
 | 
			
		||||
	<template v-if="!wait">
 | 
			
		||||
		<template v-if="isFollowing">
 | 
			
		||||
			<span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
 | 
			
		||||
			<span v-if="full">{{ i18n.locale.unfollow }}</span><i class="fas fa-minus"></i>
 | 
			
		||||
		</template>
 | 
			
		||||
		<template v-else>
 | 
			
		||||
			<span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
 | 
			
		||||
			<span v-if="full">{{ i18n.locale.follow }}</span><i class="fas fa-plus"></i>
 | 
			
		||||
		</template>
 | 
			
		||||
	</template>
 | 
			
		||||
	<template v-else>
 | 
			
		||||
		<span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
 | 
			
		||||
		<span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
 | 
			
		||||
	</template>
 | 
			
		||||
</button>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		channel: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		full: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			isFollowing: this.channel.isFollowing,
 | 
			
		||||
			wait: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		async onClick() {
 | 
			
		||||
			this.wait = true;
 | 
			
		||||
 | 
			
		||||
			try {
 | 
			
		||||
				if (this.isFollowing) {
 | 
			
		||||
					await os.api('channels/unfollow', {
 | 
			
		||||
						channelId: this.channel.id
 | 
			
		||||
					});
 | 
			
		||||
					this.isFollowing = false;
 | 
			
		||||
				} else {
 | 
			
		||||
					await os.api('channels/follow', {
 | 
			
		||||
						channelId: this.channel.id
 | 
			
		||||
					});
 | 
			
		||||
					this.isFollowing = true;
 | 
			
		||||
				}
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				console.error(e);
 | 
			
		||||
			} finally {
 | 
			
		||||
				this.wait = false;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	channel: Record<string, any>;
 | 
			
		||||
	full?: boolean;
 | 
			
		||||
}>(), {
 | 
			
		||||
	full: false,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const isFollowing = ref<boolean>(props.channel.isFollowing);
 | 
			
		||||
const wait = ref(false);
 | 
			
		||||
 | 
			
		||||
async function onClick() {
 | 
			
		||||
	wait.value = true;
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		if (isFollowing.value) {
 | 
			
		||||
			await os.api('channels/unfollow', {
 | 
			
		||||
				channelId: props.channel.id
 | 
			
		||||
			});
 | 
			
		||||
			isFollowing.value = false;
 | 
			
		||||
		} else {
 | 
			
		||||
			await os.api('channels/follow', {
 | 
			
		||||
				channelId: props.channel.id
 | 
			
		||||
			});
 | 
			
		||||
			isFollowing.value = true;
 | 
			
		||||
		}
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		console.error(e);
 | 
			
		||||
	} finally {
 | 
			
		||||
		wait.value = false;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@
 | 
			
		||||
		<div class="status">
 | 
			
		||||
			<div>
 | 
			
		||||
				<i class="fas fa-users fa-fw"></i>
 | 
			
		||||
				<I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;">
 | 
			
		||||
				<I18n :src="i18n.locale._channel.usersCount" tag="span" style="margin-left: 4px;">
 | 
			
		||||
					<template #n>
 | 
			
		||||
						<b>{{ channel.usersCount }}</b>
 | 
			
		||||
					</template>
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<i class="fas fa-pencil-alt fa-fw"></i>
 | 
			
		||||
				<I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;">
 | 
			
		||||
				<I18n :src="i18n.locale._channel.notesCount" tag="span" style="margin-left: 4px;">
 | 
			
		||||
					<template #n>
 | 
			
		||||
						<b>{{ channel.notesCount }}</b>
 | 
			
		||||
					</template>
 | 
			
		||||
@@ -27,37 +27,26 @@
 | 
			
		||||
	</article>
 | 
			
		||||
	<footer>
 | 
			
		||||
		<span v-if="channel.lastNotedAt">
 | 
			
		||||
			{{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
 | 
			
		||||
			{{ i18n.locale.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
 | 
			
		||||
		</span>
 | 
			
		||||
	</footer>
 | 
			
		||||
</MkA>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed } from 'vue';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		channel: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	channel: Record<string, any>;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		bannerStyle() {
 | 
			
		||||
			if (this.channel.bannerUrl) {
 | 
			
		||||
				return { backgroundImage: `url(${this.channel.bannerUrl})` };
 | 
			
		||||
			} else {
 | 
			
		||||
				return { backgroundColor: '#4c5e6d' };
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
const bannerStyle = computed(() => {
 | 
			
		||||
	if (props.channel.bannerUrl) {
 | 
			
		||||
		return { backgroundImage: `url(${props.channel.bannerUrl})` };
 | 
			
		||||
	} else {
 | 
			
		||||
		return { backgroundColor: '#4c5e6d' };
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,33 +3,17 @@
 | 
			
		||||
<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed } from 'vue';
 | 
			
		||||
import 'prismjs';
 | 
			
		||||
import 'prismjs/themes/prism-okaidia.css';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		code: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		lang: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		inline: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	computed: {
 | 
			
		||||
		prismLang() {
 | 
			
		||||
			return Prism.languages[this.lang] ? this.lang : 'js';
 | 
			
		||||
		},
 | 
			
		||||
		html() {
 | 
			
		||||
			return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	code: string;
 | 
			
		||||
	lang?: string;
 | 
			
		||||
	inline?: boolean;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
 | 
			
		||||
const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,26 +2,14 @@
 | 
			
		||||
<XCode :code="code" :lang="lang" :inline="inline"/>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { defineAsyncComponent } from 'vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCode: defineAsyncComponent(() => import('./code-core.vue'))
 | 
			
		||||
	},
 | 
			
		||||
	props: {
 | 
			
		||||
		code: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		lang: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		inline: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
defineProps<{
 | 
			
		||||
	code: string;
 | 
			
		||||
	lang?: string;
 | 
			
		||||
	inline?: boolean;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const XCode = defineAsyncComponent(() => import('./code-core.vue'));
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
<template>
 | 
			
		||||
<button class="nrvgflfu _button" @click="toggle">
 | 
			
		||||
	<b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b>
 | 
			
		||||
	<b>{{ modelValue ? i18n.locale._cw.hide : i18n.locale._cw.show }}</b>
 | 
			
		||||
	<span v-if="!modelValue">{{ label }}</span>
 | 
			
		||||
</button>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, h, PropType, TransitionGroup } from 'vue';
 | 
			
		||||
import MkAd from '@/components/global/ad.vue';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
import { defaultStore } from '@/store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
@@ -30,29 +32,29 @@ export default defineComponent({
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		getDateText(time: string) {
 | 
			
		||||
	setup(props, { slots, expose }) {
 | 
			
		||||
		function getDateText(time: string) {
 | 
			
		||||
			const date = new Date(time).getDate();
 | 
			
		||||
			const month = new Date(time).getMonth() + 1;
 | 
			
		||||
			return this.$t('monthAndDay', {
 | 
			
		||||
			return i18n.t('monthAndDay', {
 | 
			
		||||
				month: month.toString(),
 | 
			
		||||
				day: date.toString()
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (this.items.length === 0) return;
 | 
			
		||||
		if (props.items.length === 0) return;
 | 
			
		||||
 | 
			
		||||
		const renderChildren = () => this.items.map((item, i) => {
 | 
			
		||||
			const el = this.$slots.default({
 | 
			
		||||
		const renderChildren = () => props.items.map((item, i) => {
 | 
			
		||||
			if (!slots || !slots.default) return;
 | 
			
		||||
 | 
			
		||||
			const el = slots.default({
 | 
			
		||||
				item: item
 | 
			
		||||
			})[0];
 | 
			
		||||
			if (el.key == null && item.id) el.key = item.id;
 | 
			
		||||
 | 
			
		||||
			if (
 | 
			
		||||
				i != this.items.length - 1 &&
 | 
			
		||||
				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
 | 
			
		||||
				i != props.items.length - 1 &&
 | 
			
		||||
				new Date(item.createdAt).getDate() != new Date(props.items[i + 1].createdAt).getDate()
 | 
			
		||||
			) {
 | 
			
		||||
				const separator = h('div', {
 | 
			
		||||
					class: 'separator',
 | 
			
		||||
@@ -64,10 +66,10 @@ export default defineComponent({
 | 
			
		||||
						h('i', {
 | 
			
		||||
							class: 'fas fa-angle-up icon',
 | 
			
		||||
						}),
 | 
			
		||||
						this.getDateText(item.createdAt)
 | 
			
		||||
						getDateText(item.createdAt)
 | 
			
		||||
					]),
 | 
			
		||||
					h('span', [
 | 
			
		||||
						this.getDateText(this.items[i + 1].createdAt),
 | 
			
		||||
						getDateText(props.items[i + 1].createdAt),
 | 
			
		||||
						h('i', {
 | 
			
		||||
							class: 'fas fa-angle-down icon',
 | 
			
		||||
						})
 | 
			
		||||
@@ -76,7 +78,7 @@ export default defineComponent({
 | 
			
		||||
 | 
			
		||||
				return [el, separator];
 | 
			
		||||
			} else {
 | 
			
		||||
				if (this.ad && item._shouldInsertAd_) {
 | 
			
		||||
				if (props.ad && item._shouldInsertAd_) {
 | 
			
		||||
					return [h(MkAd, {
 | 
			
		||||
						class: 'a', // advertiseの意(ブロッカー対策)
 | 
			
		||||
						key: item.id + ':ad',
 | 
			
		||||
@@ -88,18 +90,19 @@ export default defineComponent({
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? {
 | 
			
		||||
			class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
 | 
			
		||||
			name: 'list',
 | 
			
		||||
			tag: 'div',
 | 
			
		||||
			'data-direction': this.direction,
 | 
			
		||||
			'data-reversed': this.reversed ? 'true' : 'false',
 | 
			
		||||
		} : {
 | 
			
		||||
			class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
 | 
			
		||||
		}, {
 | 
			
		||||
			default: renderChildren
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
		return () => h(
 | 
			
		||||
			defaultStore.state.animation ? TransitionGroup : 'div',
 | 
			
		||||
			defaultStore.state.animation ? {
 | 
			
		||||
					class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
 | 
			
		||||
					name: 'list',
 | 
			
		||||
					tag: 'div',
 | 
			
		||||
					'data-direction': props.direction,
 | 
			
		||||
					'data-reversed': props.reversed ? 'true' : 'false',
 | 
			
		||||
				} : {
 | 
			
		||||
					class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
 | 
			
		||||
				},
 | 
			
		||||
			{ default: renderChildren });
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
		</div>
 | 
			
		||||
		<header v-if="title"><Mfm :text="title"/></header>
 | 
			
		||||
		<div v-if="text" class="body"><Mfm :text="text"/></div>
 | 
			
		||||
		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown">
 | 
			
		||||
		<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
 | 
			
		||||
			<template v-if="input.type === 'password'" #prefix><i class="fas fa-lock"></i></template>
 | 
			
		||||
		</MkInput>
 | 
			
		||||
		<MkSelect v-if="select" v-model="selectedValue" autofocus>
 | 
			
		||||
@@ -38,118 +38,107 @@
 | 
			
		||||
</MkModal>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
 | 
			
		||||
import MkModal from '@/components/ui/modal.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import MkInput from '@/components/form/input.vue';
 | 
			
		||||
import MkSelect from '@/components/form/select.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkModal,
 | 
			
		||||
		MkButton,
 | 
			
		||||
		MkInput,
 | 
			
		||||
		MkSelect,
 | 
			
		||||
	},
 | 
			
		||||
type Input = {
 | 
			
		||||
	type: HTMLInputElement['type'];
 | 
			
		||||
	placeholder?: string | null;
 | 
			
		||||
	default: any | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		type: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: 'info'
 | 
			
		||||
		},
 | 
			
		||||
		title: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		text: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		input: {
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		select: {
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		icon: {
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		actions: {
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		showOkButton: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			default: true
 | 
			
		||||
		},
 | 
			
		||||
		showCancelButton: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		cancelableByBgClick: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			default: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
type Select = {
 | 
			
		||||
	items: {
 | 
			
		||||
		value: string;
 | 
			
		||||
		text: string;
 | 
			
		||||
	}[];
 | 
			
		||||
	groupedItems: {
 | 
			
		||||
		label: string;
 | 
			
		||||
		items: {
 | 
			
		||||
			value: string;
 | 
			
		||||
			text: string;
 | 
			
		||||
		}[];
 | 
			
		||||
	}[];
 | 
			
		||||
	default: string | null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	emits: ['done', 'closed'],
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
 | 
			
		||||
	title: string;
 | 
			
		||||
	text?: string;
 | 
			
		||||
	input?: Input;
 | 
			
		||||
	select?: Select;
 | 
			
		||||
	icon?: string;
 | 
			
		||||
	actions?: {
 | 
			
		||||
		text: string;
 | 
			
		||||
		primary?: boolean,
 | 
			
		||||
		callback: (...args: any[]) => void;
 | 
			
		||||
	}[];
 | 
			
		||||
	showOkButton?: boolean;
 | 
			
		||||
	showCancelButton?: boolean;
 | 
			
		||||
	cancelableByBgClick?: boolean;
 | 
			
		||||
}>(), {
 | 
			
		||||
	type: 'info',
 | 
			
		||||
	showOkButton: true,
 | 
			
		||||
	showCancelButton: false,
 | 
			
		||||
	cancelableByBgClick: true,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			inputValue: this.input && this.input.default ? this.input.default : null,
 | 
			
		||||
			selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(e: 'done', v: { canceled: boolean; result: any }): void;
 | 
			
		||||
	(e: 'closed'): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		document.addEventListener('keydown', this.onKeydown);
 | 
			
		||||
	},
 | 
			
		||||
const modal = ref<InstanceType<typeof MkModal>>();
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		document.removeEventListener('keydown', this.onKeydown);
 | 
			
		||||
	},
 | 
			
		||||
const inputValue = ref(props.input?.default || null);
 | 
			
		||||
const selectedValue = ref(props.select?.default || null);
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		done(canceled, result?) {
 | 
			
		||||
			this.$emit('done', { canceled, result });
 | 
			
		||||
			this.$refs.modal.close();
 | 
			
		||||
		},
 | 
			
		||||
function done(canceled: boolean, result?) {
 | 
			
		||||
	emit('done', { canceled, result });
 | 
			
		||||
	modal.value?.close();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		async ok() {
 | 
			
		||||
			if (!this.showOkButton) return;
 | 
			
		||||
async function ok() {
 | 
			
		||||
	if (!props.showOkButton) return;
 | 
			
		||||
 | 
			
		||||
			const result =
 | 
			
		||||
				this.input ? this.inputValue :
 | 
			
		||||
				this.select ? this.selectedValue :
 | 
			
		||||
				true;
 | 
			
		||||
			this.done(false, result);
 | 
			
		||||
		},
 | 
			
		||||
	const result =
 | 
			
		||||
		props.input ? inputValue.value :
 | 
			
		||||
		props.select ? selectedValue.value :
 | 
			
		||||
		true;
 | 
			
		||||
	done(false, result);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		cancel() {
 | 
			
		||||
			this.done(true);
 | 
			
		||||
		},
 | 
			
		||||
function cancel() {
 | 
			
		||||
	done(true);
 | 
			
		||||
}
 | 
			
		||||
/*
 | 
			
		||||
function onBgClick() {
 | 
			
		||||
	if (props.cancelableByBgClick) cancel();
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
function onKeydown(e: KeyboardEvent) {
 | 
			
		||||
	if (e.key === 'Escape') cancel();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		onBgClick() {
 | 
			
		||||
			if (this.cancelableByBgClick) {
 | 
			
		||||
				this.cancel();
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onKeydown(e) {
 | 
			
		||||
			if (e.which === 27) { // ESC
 | 
			
		||||
				this.cancel();
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onInputKeydown(e) {
 | 
			
		||||
			if (e.which === 13) { // Enter
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
				e.stopPropagation();
 | 
			
		||||
				this.ok();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
function onInputKeydown(e: KeyboardEvent) {
 | 
			
		||||
	if (e.key === 'Enter') {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		e.stopPropagation();
 | 
			
		||||
		ok();
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	document.addEventListener('keydown', onKeydown);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onBeforeUnmount(() => {
 | 
			
		||||
	document.removeEventListener('keydown', onKeydown);
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,44 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<FormSlot>
 | 
			
		||||
	<template #label><slot name="label"></slot></template>
 | 
			
		||||
	<div class="abcaccfa">
 | 
			
		||||
		<slot :items="items"></slot>
 | 
			
		||||
		<div v-if="empty" key="_empty_" class="empty">
 | 
			
		||||
			<slot name="empty"></slot>
 | 
			
		||||
		</div>
 | 
			
		||||
		<MkButton v-show="more" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
 | 
			
		||||
			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
			<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
		</MkButton>
 | 
			
		||||
	</div>
 | 
			
		||||
</FormSlot>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import FormSlot from './slot.vue';
 | 
			
		||||
import paging from '@/scripts/paging';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkButton,
 | 
			
		||||
		FormSlot,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mixins: [
 | 
			
		||||
		paging({}),
 | 
			
		||||
	],
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		pagination: {
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.abcaccfa {
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,114 +1,48 @@
 | 
			
		||||
<template>
 | 
			
		||||
<transition name="fade" mode="out-in">
 | 
			
		||||
	<MkLoading v-if="fetching"/>
 | 
			
		||||
 | 
			
		||||
	<MkError v-else-if="error" @retry="init()"/>
 | 
			
		||||
 | 
			
		||||
	<div v-else-if="empty" class="_fullinfo">
 | 
			
		||||
		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 | 
			
		||||
		<div>{{ $ts.noNotes }}</div>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<div v-else class="giivymft" :class="{ noGap }">
 | 
			
		||||
		<div v-show="more && reversed" style="margin-bottom: var(--margin);">
 | 
			
		||||
			<MkButton style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreFeature">
 | 
			
		||||
				<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
				<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
			</MkButton>
 | 
			
		||||
<MkPagination ref="pagingComponent" :pagination="pagination">
 | 
			
		||||
	<template #empty>
 | 
			
		||||
		<div class="_fullinfo">
 | 
			
		||||
			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 | 
			
		||||
			<div>{{ $ts.noNotes }}</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
		<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes">
 | 
			
		||||
			<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
 | 
			
		||||
		</XList>
 | 
			
		||||
 | 
			
		||||
		<div v-show="more && !reversed" style="margin-top: var(--margin);">
 | 
			
		||||
			<MkButton v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
 | 
			
		||||
				<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
				<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
			</MkButton>
 | 
			
		||||
	<template #default="{ items: notes }">
 | 
			
		||||
		<div class="giivymft" :class="{ noGap }">
 | 
			
		||||
			<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
 | 
			
		||||
				<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
 | 
			
		||||
			</XList>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</transition>
 | 
			
		||||
	</template>
 | 
			
		||||
</MkPagination>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import paging from '@/scripts/paging';
 | 
			
		||||
import XNote from './note.vue';
 | 
			
		||||
import XList from './date-separated-list.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import XNote from '@/components/note.vue';
 | 
			
		||||
import XList from '@/components/date-separated-list.vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import { Paging } from '@/components/ui/pagination.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNote, XList, MkButton,
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	pagination: Paging;
 | 
			
		||||
	noGap?: boolean;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 | 
			
		||||
 | 
			
		||||
const updated = (oldValue, newValue) => {
 | 
			
		||||
	pagingComponent.value?.updateItem(oldValue.id, () => newValue);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
	prepend: (note) => {
 | 
			
		||||
		pagingComponent.value?.prepend(note);
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mixins: [
 | 
			
		||||
		paging({
 | 
			
		||||
			before: (self) => {
 | 
			
		||||
				self.$emit('before');
 | 
			
		||||
			},
 | 
			
		||||
 | 
			
		||||
			after: (self, e) => {
 | 
			
		||||
				self.$emit('after', e);
 | 
			
		||||
			}
 | 
			
		||||
		}),
 | 
			
		||||
	],
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		pagination: {
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		prop: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		noGap: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	emits: ['before', 'after'],
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		notes(): any[] {
 | 
			
		||||
			return this.prop ? this.items.map(item => item[this.prop]) : this.items;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		reversed(): boolean {
 | 
			
		||||
			return this.pagination.reversed;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		updated(oldValue, newValue) {
 | 
			
		||||
			const i = this.notes.findIndex(n => n === oldValue);
 | 
			
		||||
			if (this.prop) {
 | 
			
		||||
				this.items[i][this.prop] = newValue;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.items[i] = newValue;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.notes.focus();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.fade-enter-active,
 | 
			
		||||
.fade-leave-active {
 | 
			
		||||
	transition: opacity 0.125s ease;
 | 
			
		||||
}
 | 
			
		||||
.fade-enter-from,
 | 
			
		||||
.fade-leave-to {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.giivymft {
 | 
			
		||||
	&.noGap {
 | 
			
		||||
		> .notes {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,180 +1,84 @@
 | 
			
		||||
<template>
 | 
			
		||||
<transition name="fade" mode="out-in">
 | 
			
		||||
	<MkLoading v-if="fetching"/>
 | 
			
		||||
<MkPagination ref="pagingComponent" :pagination="pagination">
 | 
			
		||||
	<template #empty>
 | 
			
		||||
		<div class="_fullinfo">
 | 
			
		||||
			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 | 
			
		||||
			<div>{{ $ts.noNotifications }}</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
	<MkError v-else-if="error" @retry="init()"/>
 | 
			
		||||
 | 
			
		||||
	<p v-else-if="empty" class="mfcuwfyp">{{ $ts.noNotifications }}</p>
 | 
			
		||||
 | 
			
		||||
	<div v-else>
 | 
			
		||||
		<XList v-slot="{ item: notification }" class="elsfgstc" :items="items" :no-gap="true">
 | 
			
		||||
			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification.note, $event)"/>
 | 
			
		||||
	<template #default="{ items: notifications }">
 | 
			
		||||
		<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
 | 
			
		||||
			<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification, $event)"/>
 | 
			
		||||
			<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
 | 
			
		||||
		</XList>
 | 
			
		||||
 | 
			
		||||
		<MkButton v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" primary style="margin: var(--margin) auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
 | 
			
		||||
			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
			<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
		</MkButton>
 | 
			
		||||
	</div>
 | 
			
		||||
</transition>
 | 
			
		||||
	</template>
 | 
			
		||||
</MkPagination>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, PropType, markRaw } from 'vue';
 | 
			
		||||
import paging from '@/scripts/paging';
 | 
			
		||||
import XNotification from './notification.vue';
 | 
			
		||||
import XList from './date-separated-list.vue';
 | 
			
		||||
import XNote from './note.vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { defineComponent, PropType, markRaw, onUnmounted, onMounted, computed, ref } from 'vue';
 | 
			
		||||
import { notificationTypes } from 'misskey-js';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import { Paging } from '@/components/ui/pagination.vue';
 | 
			
		||||
import XNotification from '@/components/notification.vue';
 | 
			
		||||
import XList from '@/components/date-separated-list.vue';
 | 
			
		||||
import XNote from '@/components/note.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotification,
 | 
			
		||||
		XList,
 | 
			
		||||
		XNote,
 | 
			
		||||
		MkButton,
 | 
			
		||||
	},
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	includeTypes?: PropType<typeof notificationTypes[number][]>;
 | 
			
		||||
	unreadOnly?: boolean;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	mixins: [
 | 
			
		||||
		paging({}),
 | 
			
		||||
	],
 | 
			
		||||
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		includeTypes: {
 | 
			
		||||
			type: Array as PropType<typeof notificationTypes[number][]>,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: null,
 | 
			
		||||
		},
 | 
			
		||||
		unreadOnly: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
const allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)));
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			connection: null,
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'i/notifications',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
					includeTypes: this.allIncludeTypes || undefined,
 | 
			
		||||
					unreadOnly: this.unreadOnly,
 | 
			
		||||
				})
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
const pagination: Paging = {
 | 
			
		||||
	endpoint: 'i/notifications' as const,
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	params: computed(() => ({
 | 
			
		||||
		includeTypes: allIncludeTypes.value || undefined,
 | 
			
		||||
		unreadOnly: props.unreadOnly,
 | 
			
		||||
	})),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		allIncludeTypes() {
 | 
			
		||||
			return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		includeTypes: {
 | 
			
		||||
			handler() {
 | 
			
		||||
				this.reload();
 | 
			
		||||
			},
 | 
			
		||||
			deep: true
 | 
			
		||||
		},
 | 
			
		||||
		unreadOnly: {
 | 
			
		||||
			handler() {
 | 
			
		||||
				this.reload();
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		// TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、
 | 
			
		||||
		// mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す
 | 
			
		||||
		'$i.mutingNotificationTypes': {
 | 
			
		||||
			handler() {
 | 
			
		||||
				if (this.includeTypes === null) {
 | 
			
		||||
					this.reload();
 | 
			
		||||
				}
 | 
			
		||||
			},
 | 
			
		||||
			deep: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.connection = markRaw(stream.useChannel('main'));
 | 
			
		||||
		this.connection.on('notification', this.onNotification);
 | 
			
		||||
 | 
			
		||||
		this.connection.on('readAllNotifications', () => {
 | 
			
		||||
			for (const item of this.queue) {
 | 
			
		||||
				item.isRead = true;
 | 
			
		||||
			}
 | 
			
		||||
			for (const item of this.items) {
 | 
			
		||||
				item.isRead = true;
 | 
			
		||||
			}
 | 
			
		||||
const onNotification = (notification) => {
 | 
			
		||||
	const isMuted = !allIncludeTypes.value.includes(notification.type);
 | 
			
		||||
	if (isMuted || document.visibilityState === 'visible') {
 | 
			
		||||
		stream.send('readNotification', {
 | 
			
		||||
			id: notification.id
 | 
			
		||||
		});
 | 
			
		||||
		this.connection.on('readNotifications', notificationIds => {
 | 
			
		||||
			for (let i = 0; i < this.queue.length; i++) {
 | 
			
		||||
				if (notificationIds.includes(this.queue[i].id)) {
 | 
			
		||||
					this.queue[i].isRead = true;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			for (let i = 0; i < this.items.length; i++) {
 | 
			
		||||
				if (notificationIds.includes(this.items[i].id)) {
 | 
			
		||||
					this.items[i].isRead = true;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		onNotification(notification) {
 | 
			
		||||
			const isMuted = !this.allIncludeTypes.includes(notification.type);
 | 
			
		||||
			if (isMuted || document.visibilityState === 'visible') {
 | 
			
		||||
				stream.send('readNotification', {
 | 
			
		||||
					id: notification.id
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (!isMuted) {
 | 
			
		||||
				this.prepend({
 | 
			
		||||
					...notification,
 | 
			
		||||
					isRead: document.visibilityState === 'visible'
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		noteUpdated(oldValue, newValue) {
 | 
			
		||||
			const i = this.items.findIndex(n => n.note === oldValue);
 | 
			
		||||
			this.items[i] = {
 | 
			
		||||
				...this.items[i],
 | 
			
		||||
				note: newValue
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!isMuted) {
 | 
			
		||||
		pagingComponent.value.prepend({
 | 
			
		||||
			...notification,
 | 
			
		||||
			isRead: document.visibilityState === 'visible'
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const noteUpdated = (item, note) => {
 | 
			
		||||
	pagingComponent.value?.updateItem(item.id, old => ({
 | 
			
		||||
		...old,
 | 
			
		||||
		note: note,
 | 
			
		||||
	}));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	const connection = stream.useChannel('main');
 | 
			
		||||
	connection.on('notification', onNotification);
 | 
			
		||||
	onUnmounted(() => {
 | 
			
		||||
		connection.dispose();
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.fade-enter-active,
 | 
			
		||||
.fade-leave-active {
 | 
			
		||||
	transition: opacity 0.125s ease;
 | 
			
		||||
}
 | 
			
		||||
.fade-enter-from,
 | 
			
		||||
.fade-leave-to {
 | 
			
		||||
	opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mfcuwfyp {
 | 
			
		||||
	margin: 0;
 | 
			
		||||
	padding: 16px;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
	color: var(--fg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.elsfgstc {
 | 
			
		||||
	background: var(--panel);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,184 +1,143 @@
 | 
			
		||||
<template>
 | 
			
		||||
<XNotes ref="tl" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/>
 | 
			
		||||
<XNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, markRaw } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref, computed, provide, onUnmounted } from 'vue';
 | 
			
		||||
import XNotes from './notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import * as sound from '@/scripts/sound';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotes
 | 
			
		||||
	},
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	src: string;
 | 
			
		||||
	list?: string;
 | 
			
		||||
	antenna?: string;
 | 
			
		||||
	channel?: string;
 | 
			
		||||
	sound?: boolean;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			inChannel: this.src === 'channel'
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(e: 'note'): void;
 | 
			
		||||
	(e: 'queue', count: number): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		src: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		list: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		antenna: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		channel: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		sound: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
provide('inChannel', computed(() => props.src === 'channel'));
 | 
			
		||||
 | 
			
		||||
	emits: ['note', 'queue', 'before', 'after'],
 | 
			
		||||
const tlComponent = ref<InstanceType<typeof XNotes>>();
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			connection: null,
 | 
			
		||||
			connection2: null,
 | 
			
		||||
			pagination: null,
 | 
			
		||||
			baseQuery: {
 | 
			
		||||
				includeMyRenotes: this.$store.state.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.showLocalRenotes
 | 
			
		||||
			},
 | 
			
		||||
			query: {},
 | 
			
		||||
			date: null
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
const prepend = note => {
 | 
			
		||||
	tlComponent.value.prepend(note);
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		const prepend = note => {
 | 
			
		||||
			(this.$refs.tl as any).prepend(note);
 | 
			
		||||
	emit('note');
 | 
			
		||||
 | 
			
		||||
			this.$emit('note');
 | 
			
		||||
 | 
			
		||||
			if (this.sound) {
 | 
			
		||||
				sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const onUserAdded = () => {
 | 
			
		||||
			(this.$refs.tl as any).reload();
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const onUserRemoved = () => {
 | 
			
		||||
			(this.$refs.tl as any).reload();
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const onChangeFollowing = () => {
 | 
			
		||||
			if (!this.$refs.tl.backed) {
 | 
			
		||||
				this.$refs.tl.reload();
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		let endpoint;
 | 
			
		||||
 | 
			
		||||
		if (this.src == 'antenna') {
 | 
			
		||||
			endpoint = 'antennas/notes';
 | 
			
		||||
			this.query = {
 | 
			
		||||
				antennaId: this.antenna
 | 
			
		||||
			};
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('antenna', {
 | 
			
		||||
				antennaId: this.antenna
 | 
			
		||||
			}));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'home') {
 | 
			
		||||
			endpoint = 'notes/timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('homeTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
 | 
			
		||||
			this.connection2 = markRaw(stream.useChannel('main'));
 | 
			
		||||
			this.connection2.on('follow', onChangeFollowing);
 | 
			
		||||
			this.connection2.on('unfollow', onChangeFollowing);
 | 
			
		||||
		} else if (this.src == 'local') {
 | 
			
		||||
			endpoint = 'notes/local-timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('localTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'social') {
 | 
			
		||||
			endpoint = 'notes/hybrid-timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('hybridTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'global') {
 | 
			
		||||
			endpoint = 'notes/global-timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('globalTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'mentions') {
 | 
			
		||||
			endpoint = 'notes/mentions';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('main'));
 | 
			
		||||
			this.connection.on('mention', prepend);
 | 
			
		||||
		} else if (this.src == 'directs') {
 | 
			
		||||
			endpoint = 'notes/mentions';
 | 
			
		||||
			this.query = {
 | 
			
		||||
				visibility: 'specified'
 | 
			
		||||
			};
 | 
			
		||||
			const onNote = note => {
 | 
			
		||||
				if (note.visibility == 'specified') {
 | 
			
		||||
					prepend(note);
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('main'));
 | 
			
		||||
			this.connection.on('mention', onNote);
 | 
			
		||||
		} else if (this.src == 'list') {
 | 
			
		||||
			endpoint = 'notes/user-list-timeline';
 | 
			
		||||
			this.query = {
 | 
			
		||||
				listId: this.list
 | 
			
		||||
			};
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('userList', {
 | 
			
		||||
				listId: this.list
 | 
			
		||||
			}));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
			this.connection.on('userAdded', onUserAdded);
 | 
			
		||||
			this.connection.on('userRemoved', onUserRemoved);
 | 
			
		||||
		} else if (this.src == 'channel') {
 | 
			
		||||
			endpoint = 'channels/timeline';
 | 
			
		||||
			this.query = {
 | 
			
		||||
				channelId: this.channel
 | 
			
		||||
			};
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('channel', {
 | 
			
		||||
				channelId: this.channel
 | 
			
		||||
			}));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.pagination = {
 | 
			
		||||
			endpoint: endpoint,
 | 
			
		||||
			limit: 10,
 | 
			
		||||
			params: init => ({
 | 
			
		||||
				untilDate: this.date?.getTime(),
 | 
			
		||||
				...this.baseQuery, ...this.query
 | 
			
		||||
			})
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
		if (this.connection2) this.connection2.dispose();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.tl.focus();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		timetravel(date?: Date) {
 | 
			
		||||
			this.date = date;
 | 
			
		||||
			this.$refs.tl.reload();
 | 
			
		||||
		}
 | 
			
		||||
	if (props.sound) {
 | 
			
		||||
		sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note');
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const onUserAdded = () => {
 | 
			
		||||
	tlComponent.value.reload();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const onUserRemoved = () => {
 | 
			
		||||
	tlComponent.value.reload();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const onChangeFollowing = () => {
 | 
			
		||||
	if (!tlComponent.value.backed) {
 | 
			
		||||
		tlComponent.value.reload();
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
let endpoint;
 | 
			
		||||
let query;
 | 
			
		||||
let connection;
 | 
			
		||||
let connection2;
 | 
			
		||||
 | 
			
		||||
if (props.src === 'antenna') {
 | 
			
		||||
	endpoint = 'antennas/notes';
 | 
			
		||||
	query = {
 | 
			
		||||
		antennaId: props.antenna
 | 
			
		||||
	};
 | 
			
		||||
	connection = stream.useChannel('antenna', {
 | 
			
		||||
		antennaId: props.antenna
 | 
			
		||||
	});
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
} else if (props.src === 'home') {
 | 
			
		||||
	endpoint = 'notes/timeline';
 | 
			
		||||
	connection = stream.useChannel('homeTimeline');
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
 | 
			
		||||
	connection2 = stream.useChannel('main');
 | 
			
		||||
	connection2.on('follow', onChangeFollowing);
 | 
			
		||||
	connection2.on('unfollow', onChangeFollowing);
 | 
			
		||||
} else if (props.src === 'local') {
 | 
			
		||||
	endpoint = 'notes/local-timeline';
 | 
			
		||||
	connection = stream.useChannel('localTimeline');
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
} else if (props.src === 'social') {
 | 
			
		||||
	endpoint = 'notes/hybrid-timeline';
 | 
			
		||||
	connection = stream.useChannel('hybridTimeline');
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
} else if (props.src === 'global') {
 | 
			
		||||
	endpoint = 'notes/global-timeline';
 | 
			
		||||
	connection = stream.useChannel('globalTimeline');
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
} else if (props.src === 'mentions') {
 | 
			
		||||
	endpoint = 'notes/mentions';
 | 
			
		||||
	connection = stream.useChannel('main');
 | 
			
		||||
	connection.on('mention', prepend);
 | 
			
		||||
} else if (props.src === 'directs') {
 | 
			
		||||
	endpoint = 'notes/mentions';
 | 
			
		||||
	query = {
 | 
			
		||||
		visibility: 'specified'
 | 
			
		||||
	};
 | 
			
		||||
	const onNote = note => {
 | 
			
		||||
		if (note.visibility == 'specified') {
 | 
			
		||||
			prepend(note);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
	connection = stream.useChannel('main');
 | 
			
		||||
	connection.on('mention', onNote);
 | 
			
		||||
} else if (props.src === 'list') {
 | 
			
		||||
	endpoint = 'notes/user-list-timeline';
 | 
			
		||||
	query = {
 | 
			
		||||
		listId: props.list
 | 
			
		||||
	};
 | 
			
		||||
	connection = stream.useChannel('userList', {
 | 
			
		||||
		listId: props.list
 | 
			
		||||
	});
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
	connection.on('userAdded', onUserAdded);
 | 
			
		||||
	connection.on('userRemoved', onUserRemoved);
 | 
			
		||||
} else if (props.src === 'channel') {
 | 
			
		||||
	endpoint = 'channels/timeline';
 | 
			
		||||
	query = {
 | 
			
		||||
		channelId: props.channel
 | 
			
		||||
	};
 | 
			
		||||
	connection = stream.useChannel('channel', {
 | 
			
		||||
		channelId: props.channel
 | 
			
		||||
	});
 | 
			
		||||
	connection.on('note', prepend);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const pagination = {
 | 
			
		||||
	endpoint: endpoint,
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	params: query,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
	connection.dispose();
 | 
			
		||||
	if (connection2) connection2.dispose();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/* TODO
 | 
			
		||||
const timetravel = (date?: Date) => {
 | 
			
		||||
	this.date = date;
 | 
			
		||||
	this.$refs.tl.reload();
 | 
			
		||||
};
 | 
			
		||||
*/
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -13,43 +13,267 @@
 | 
			
		||||
		</slot>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<div v-else class="cxiknjgy">
 | 
			
		||||
	<div v-else ref="rootEl">
 | 
			
		||||
		<slot :items="items"></slot>
 | 
			
		||||
		<div v-show="more" key="_more_" class="more _gap">
 | 
			
		||||
			<MkButton v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
 | 
			
		||||
				<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
				<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
		<div v-show="more" key="_more_" class="cxiknjgy _gap">
 | 
			
		||||
			<MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
 | 
			
		||||
				{{ $ts.loadMore }}
 | 
			
		||||
			</MkButton>
 | 
			
		||||
			<MkLoading v-else class="loading"/>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</transition>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import MkButton from './button.vue';
 | 
			
		||||
import paging from '@/scripts/paging';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue';
 | 
			
		||||
import * as misskey from 'misskey-js';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkButton
 | 
			
		||||
	},
 | 
			
		||||
const SECOND_FETCH_LIMIT = 30;
 | 
			
		||||
 | 
			
		||||
	mixins: [
 | 
			
		||||
		paging({}),
 | 
			
		||||
	],
 | 
			
		||||
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
 | 
			
		||||
	endpoint: E;
 | 
			
		||||
	limit: number;
 | 
			
		||||
	params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>;
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		pagination: {
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	/**
 | 
			
		||||
	 * 検索APIのような、ページング不可なエンドポイントを利用する場合
 | 
			
		||||
	 * (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
 | 
			
		||||
	 */
 | 
			
		||||
	noPaging?: boolean;
 | 
			
		||||
 | 
			
		||||
		disableAutoLoad: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false,
 | 
			
		||||
	/**
 | 
			
		||||
	 * items 配列の中身を逆順にする(新しい方が最後)
 | 
			
		||||
	 */
 | 
			
		||||
	reversed?: boolean;
 | 
			
		||||
 | 
			
		||||
	offsetMode?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	pagination: Paging;
 | 
			
		||||
	disableAutoLoad?: boolean;
 | 
			
		||||
	displayLimit?: number;
 | 
			
		||||
}>(), {
 | 
			
		||||
	displayLimit: 30,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(e: 'queue', count: number): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
type Item = { id: string; [another: string]: unknown; };
 | 
			
		||||
 | 
			
		||||
const rootEl = ref<HTMLElement>();
 | 
			
		||||
const items = ref<Item[]>([]);
 | 
			
		||||
const queue = ref<Item[]>([]);
 | 
			
		||||
const offset = ref(0);
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
const moreFetching = ref(false);
 | 
			
		||||
const inited = ref(false);
 | 
			
		||||
const more = ref(false);
 | 
			
		||||
const backed = ref(false); // 遡り中か否か
 | 
			
		||||
const isBackTop = ref(false);
 | 
			
		||||
const empty = computed(() => items.value.length === 0 && !fetching.value && inited.value);
 | 
			
		||||
const error = computed(() => !fetching.value && !inited.value);
 | 
			
		||||
 | 
			
		||||
const init = async (): Promise<void> => {
 | 
			
		||||
	queue.value = [];
 | 
			
		||||
	fetching.value = true;
 | 
			
		||||
	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
 | 
			
		||||
	await os.api(props.pagination.endpoint, {
 | 
			
		||||
		...params,
 | 
			
		||||
		limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1,
 | 
			
		||||
	}).then(res => {
 | 
			
		||||
		for (let i = 0; i < res.length; i++) {
 | 
			
		||||
			const item = res[i];
 | 
			
		||||
			markRaw(item);
 | 
			
		||||
			if (props.pagination.reversed) {
 | 
			
		||||
				if (i === res.length - 2) item._shouldInsertAd_ = true;
 | 
			
		||||
			} else {
 | 
			
		||||
				if (i === 3) item._shouldInsertAd_ = true;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
		if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) {
 | 
			
		||||
			res.pop();
 | 
			
		||||
			items.value = props.pagination.reversed ? [...res].reverse() : res;
 | 
			
		||||
			more.value = true;
 | 
			
		||||
		} else {
 | 
			
		||||
			items.value = props.pagination.reversed ? [...res].reverse() : res;
 | 
			
		||||
			more.value = false;
 | 
			
		||||
		}
 | 
			
		||||
		offset.value = res.length;
 | 
			
		||||
		inited.value = true;
 | 
			
		||||
		fetching.value = false;
 | 
			
		||||
	}, e => {
 | 
			
		||||
		fetching.value = false;
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const reload = (): void => {
 | 
			
		||||
	items.value = [];
 | 
			
		||||
	init();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const fetchMore = async (): Promise<void> => {
 | 
			
		||||
	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
 | 
			
		||||
	moreFetching.value = true;
 | 
			
		||||
	backed.value = true;
 | 
			
		||||
	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
 | 
			
		||||
	await os.api(props.pagination.endpoint, {
 | 
			
		||||
		...params,
 | 
			
		||||
		limit: SECOND_FETCH_LIMIT + 1,
 | 
			
		||||
		...(props.pagination.offsetMode ? {
 | 
			
		||||
			offset: offset.value,
 | 
			
		||||
		} : {
 | 
			
		||||
			untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
 | 
			
		||||
		}),
 | 
			
		||||
	}).then(res => {
 | 
			
		||||
		for (let i = 0; i < res.length; i++) {
 | 
			
		||||
			const item = res[i];
 | 
			
		||||
			markRaw(item);
 | 
			
		||||
			if (props.pagination.reversed) {
 | 
			
		||||
				if (i === res.length - 9) item._shouldInsertAd_ = true;
 | 
			
		||||
			} else {
 | 
			
		||||
				if (i === 10) item._shouldInsertAd_ = true;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if (res.length > SECOND_FETCH_LIMIT) {
 | 
			
		||||
			res.pop();
 | 
			
		||||
			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
 | 
			
		||||
			more.value = true;
 | 
			
		||||
		} else {
 | 
			
		||||
			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
 | 
			
		||||
			more.value = false;
 | 
			
		||||
		}
 | 
			
		||||
		offset.value += res.length;
 | 
			
		||||
		moreFetching.value = false;
 | 
			
		||||
	}, e => {
 | 
			
		||||
		moreFetching.value = false;
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const fetchMoreAhead = async (): Promise<void> => {
 | 
			
		||||
	if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
 | 
			
		||||
	moreFetching.value = true;
 | 
			
		||||
	const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
 | 
			
		||||
	await os.api(props.pagination.endpoint, {
 | 
			
		||||
		...params,
 | 
			
		||||
		limit: SECOND_FETCH_LIMIT + 1,
 | 
			
		||||
		...(props.pagination.offsetMode ? {
 | 
			
		||||
			offset: offset.value,
 | 
			
		||||
		} : {
 | 
			
		||||
			sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
 | 
			
		||||
		}),
 | 
			
		||||
	}).then(res => {
 | 
			
		||||
		for (const item of res) {
 | 
			
		||||
			markRaw(item);
 | 
			
		||||
		}
 | 
			
		||||
		if (res.length > SECOND_FETCH_LIMIT) {
 | 
			
		||||
			res.pop();
 | 
			
		||||
			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
 | 
			
		||||
			more.value = true;
 | 
			
		||||
		} else {
 | 
			
		||||
			items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);
 | 
			
		||||
			more.value = false;
 | 
			
		||||
		}
 | 
			
		||||
		offset.value += res.length;
 | 
			
		||||
		moreFetching.value = false;
 | 
			
		||||
	}, e => {
 | 
			
		||||
		moreFetching.value = false;
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const prepend = (item: Item): void => {
 | 
			
		||||
	if (rootEl.value == null) return;
 | 
			
		||||
 | 
			
		||||
	if (props.pagination.reversed) {
 | 
			
		||||
		const container = getScrollContainer(rootEl.value);
 | 
			
		||||
		if (container == null) return; // TODO?
 | 
			
		||||
 | 
			
		||||
		const pos = getScrollPosition(rootEl.value);
 | 
			
		||||
		const viewHeight = container.clientHeight;
 | 
			
		||||
		const height = container.scrollHeight;
 | 
			
		||||
		const isBottom = (pos + viewHeight > height - 32);
 | 
			
		||||
		if (isBottom) {
 | 
			
		||||
			// オーバーフローしたら古いアイテムは捨てる
 | 
			
		||||
			if (items.value.length >= props.displayLimit) {
 | 
			
		||||
				// このやり方だとVue 3.2以降アニメーションが動かなくなる
 | 
			
		||||
				//items.value = items.value.slice(-props.displayLimit);
 | 
			
		||||
				while (items.value.length >= props.displayLimit) {
 | 
			
		||||
					items.value.shift();
 | 
			
		||||
				}
 | 
			
		||||
				more.value = true;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		items.value.push(item);
 | 
			
		||||
		// TODO
 | 
			
		||||
	} else {
 | 
			
		||||
		const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value));
 | 
			
		||||
 | 
			
		||||
		if (isTop) {
 | 
			
		||||
			// Prepend the item
 | 
			
		||||
			items.value.unshift(item);
 | 
			
		||||
 | 
			
		||||
			// オーバーフローしたら古いアイテムは捨てる
 | 
			
		||||
			if (items.value.length >= props.displayLimit) {
 | 
			
		||||
				// このやり方だとVue 3.2以降アニメーションが動かなくなる
 | 
			
		||||
				//this.items = items.value.slice(0, props.displayLimit);
 | 
			
		||||
				while (items.value.length >= props.displayLimit) {
 | 
			
		||||
					items.value.pop();
 | 
			
		||||
				}
 | 
			
		||||
				more.value = true;
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			queue.value.push(item);
 | 
			
		||||
			onScrollTop(rootEl.value, () => {
 | 
			
		||||
				for (const item of queue.value) {
 | 
			
		||||
					prepend(item);
 | 
			
		||||
				}
 | 
			
		||||
				queue.value = [];
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const append = (item: Item): void => {
 | 
			
		||||
	items.value.push(item);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => {
 | 
			
		||||
	const i = items.value.findIndex(item => item.id === id);
 | 
			
		||||
	items.value[i] = replacer(items.value[i]);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
if (props.pagination.params && isRef(props.pagination.params)) {
 | 
			
		||||
	watch(props.pagination.params, init, { deep: true });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(queue, (a, b) => {
 | 
			
		||||
	if (a.length === 0 && b.length === 0) return;
 | 
			
		||||
	emit('queue', queue.value.length);
 | 
			
		||||
}, { deep: true });
 | 
			
		||||
 | 
			
		||||
init();
 | 
			
		||||
 | 
			
		||||
onActivated(() => {
 | 
			
		||||
	isBackTop.value = false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onDeactivated(() => {
 | 
			
		||||
	isBackTop.value = window.scrollY === 0;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
	items,
 | 
			
		||||
	reload,
 | 
			
		||||
	fetchMoreAhead,
 | 
			
		||||
	prepend,
 | 
			
		||||
	append,
 | 
			
		||||
	updateItem,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@@ -64,11 +288,9 @@ export default defineComponent({
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.cxiknjgy {
 | 
			
		||||
	> .more > .button {
 | 
			
		||||
	> .button {
 | 
			
		||||
		margin-left: auto;
 | 
			
		||||
		margin-right: auto;
 | 
			
		||||
		height: 48px;
 | 
			
		||||
		min-width: 150px;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,91 +1,39 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkError v-if="error" @retry="init()"/>
 | 
			
		||||
<MkPagination ref="pagingComponent" :pagination="pagination">
 | 
			
		||||
	<template #empty>
 | 
			
		||||
		<div class="_fullinfo">
 | 
			
		||||
			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 | 
			
		||||
			<div>{{ $ts.noUsers }}</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
<div v-else class="efvhhmdq _isolated">
 | 
			
		||||
	<div v-if="empty" class="no-users">
 | 
			
		||||
		<p>{{ $ts.noUsers }}</p>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="users">
 | 
			
		||||
		<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
 | 
			
		||||
	</div>
 | 
			
		||||
	<button v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" class="more" :class="{ fetching: moreFetching }" :disabled="moreFetching" @click="fetchMore">
 | 
			
		||||
		<template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }}
 | 
			
		||||
	</button>
 | 
			
		||||
</div>
 | 
			
		||||
	<template #default="{ items: users }">
 | 
			
		||||
		<div class="efvhhmdq">
 | 
			
		||||
			<MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/>
 | 
			
		||||
		</div>
 | 
			
		||||
	</template>
 | 
			
		||||
</MkPagination>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import paging from '@/scripts/paging';
 | 
			
		||||
import MkUserInfo from './user-info.vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import MkUserInfo from '@/components/user-info.vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import { Paging } from '@/components/ui/pagination.vue';
 | 
			
		||||
import { userPage } from '@/filters/user';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkUserInfo,
 | 
			
		||||
	},
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	pagination: Paging;
 | 
			
		||||
	noGap?: boolean;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	mixins: [
 | 
			
		||||
		paging({}),
 | 
			
		||||
	],
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		pagination: {
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		extract: {
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		expanded: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			default: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		users() {
 | 
			
		||||
			return this.extract ? this.extract(this.items) : this.items;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		userPage
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.efvhhmdq {
 | 
			
		||||
	> .no-users {
 | 
			
		||||
		text-align: center;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .users {
 | 
			
		||||
		display: grid;
 | 
			
		||||
		grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
 | 
			
		||||
		grid-gap: var(--margin);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .more {
 | 
			
		||||
		display: block;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		padding: 16px;
 | 
			
		||||
 | 
			
		||||
		&:hover {
 | 
			
		||||
			background: rgba(#000, 0.025);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&:active {
 | 
			
		||||
			background: rgba(#000, 0.05);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&.fetching {
 | 
			
		||||
			cursor: wait;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> i {
 | 
			
		||||
			margin-right: 4px;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	display: grid;
 | 
			
		||||
	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
 | 
			
		||||
	grid-gap: var(--margin);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@
 | 
			
		||||
			<MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton>
 | 
			
		||||
		</header>
 | 
			
		||||
		<XDraggable
 | 
			
		||||
			v-model="_widgets"
 | 
			
		||||
			v-model="widgets_"
 | 
			
		||||
			item-key="id"
 | 
			
		||||
			animation="150"
 | 
			
		||||
		>
 | 
			
		||||
@@ -18,7 +18,7 @@
 | 
			
		||||
				<div class="customize-container">
 | 
			
		||||
					<button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button>
 | 
			
		||||
					<button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button>
 | 
			
		||||
					<component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" @updateProps="updateWidget(element.id, $event)"/>
 | 
			
		||||
					<component :ref="el => widgetRefs[element.id] = el" :is="`mkw-${element.name}`" :widget="element" @updateProps="updateWidget(element.id, $event)"/>
 | 
			
		||||
				</div>
 | 
			
		||||
			</template>
 | 
			
		||||
		</XDraggable>
 | 
			
		||||
@@ -28,7 +28,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { defineComponent, defineAsyncComponent, reactive, ref, computed } from 'vue';
 | 
			
		||||
import { v4 as uuid } from 'uuid';
 | 
			
		||||
import MkSelect from '@/components/form/select.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
@@ -54,50 +54,47 @@ export default defineComponent({
 | 
			
		||||
 | 
			
		||||
	emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'],
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			widgetAdderSelected: null,
 | 
			
		||||
			widgetDefs,
 | 
			
		||||
			settings: {},
 | 
			
		||||
	setup(props, context) {
 | 
			
		||||
		const widgetRefs = reactive({});
 | 
			
		||||
		const configWidget = (id: string) => {
 | 
			
		||||
			widgetRefs[id].configure();
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
		const widgetAdderSelected = ref(null);
 | 
			
		||||
		const addWidget = () => {
 | 
			
		||||
			if (widgetAdderSelected.value == null) return;
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		_widgets: {
 | 
			
		||||
			get() {
 | 
			
		||||
				return this.widgets;
 | 
			
		||||
			},
 | 
			
		||||
			set(value) {
 | 
			
		||||
				this.$emit('updateWidgets', value);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		configWidget(id) {
 | 
			
		||||
			this.settings[id]();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		addWidget() {
 | 
			
		||||
			if (this.widgetAdderSelected == null) return;
 | 
			
		||||
 | 
			
		||||
			this.$emit('addWidget', {
 | 
			
		||||
				name: this.widgetAdderSelected,
 | 
			
		||||
			context.emit('addWidget', {
 | 
			
		||||
				name: widgetAdderSelected.value,
 | 
			
		||||
				id: uuid(),
 | 
			
		||||
				data: {}
 | 
			
		||||
				data: {},
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			this.widgetAdderSelected = null;
 | 
			
		||||
		},
 | 
			
		||||
			widgetAdderSelected.value = null;
 | 
			
		||||
		};
 | 
			
		||||
		const removeWidget = (widget) => {
 | 
			
		||||
			context.emit('removeWidget', widget);
 | 
			
		||||
		};
 | 
			
		||||
		const updateWidget = (id, data) => {
 | 
			
		||||
			context.emit('updateWidget', { id, data });
 | 
			
		||||
		};
 | 
			
		||||
		const widgets_ = computed({
 | 
			
		||||
			get: () => props.widgets,
 | 
			
		||||
			set: (value) => {
 | 
			
		||||
				context.emit('updateWidgets', value);
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		removeWidget(widget) {
 | 
			
		||||
			this.$emit('removeWidget', widget);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidget(id, data) {
 | 
			
		||||
			this.$emit('updateWidget', { id, data });
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
		return {
 | 
			
		||||
			widgetRefs,
 | 
			
		||||
			configWidget,
 | 
			
		||||
			widgetAdderSelected,
 | 
			
		||||
			widgetDefs,
 | 
			
		||||
			addWidget,
 | 
			
		||||
			removeWidget,
 | 
			
		||||
			updateWidget,
 | 
			
		||||
			widgets_,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -171,7 +171,6 @@ const app = createApp(await (
 | 
			
		||||
	!$i                               ? import('@/ui/visitor.vue') :
 | 
			
		||||
	ui === 'deck'                     ? import('@/ui/deck.vue') :
 | 
			
		||||
	ui === 'desktop'                  ? import('@/ui/desktop.vue') :
 | 
			
		||||
	ui === 'chat'                     ? import('@/ui/chat/index.vue') :
 | 
			
		||||
	ui === 'classic'                  ? import('@/ui/classic.vue') :
 | 
			
		||||
	import('@/ui/universal.vue')
 | 
			
		||||
).then(x => x.default));
 | 
			
		||||
 
 | 
			
		||||
@@ -198,13 +198,6 @@ export const menuDef = reactive({
 | 
			
		||||
					localStorage.setItem('ui', 'classic');
 | 
			
		||||
					unisonReload();
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				text: 'Chat (β)',
 | 
			
		||||
				active: ui === 'chat',
 | 
			
		||||
				action: () => {
 | 
			
		||||
					localStorage.setItem('ui', 'chat');
 | 
			
		||||
					unisonReload();
 | 
			
		||||
				}
 | 
			
		||||
			}, /*{
 | 
			
		||||
				text: i18n.locale.desktop + ' (β)',
 | 
			
		||||
				active: ui === 'desktop',
 | 
			
		||||
 
 | 
			
		||||
@@ -62,7 +62,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import MkInput from '@/components/form/input.vue';
 | 
			
		||||
@@ -97,29 +97,15 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'admin/abuse-user-reports',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					state: this.state,
 | 
			
		||||
					reporterOrigin: this.reporterOrigin,
 | 
			
		||||
					targetUserOrigin: this.targetUserOrigin,
 | 
			
		||||
				}),
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		state() {
 | 
			
		||||
			this.$refs.reports.reload();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		reporterOrigin() {
 | 
			
		||||
			this.$refs.reports.reload();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		targetUserOrigin() {
 | 
			
		||||
			this.$refs.reports.reload();
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.$emit('info', this[symbols.PAGE_INFO]);
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -55,7 +55,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import MkInput from '@/components/form/input.vue';
 | 
			
		||||
import MkSelect from '@/components/form/select.vue';
 | 
			
		||||
@@ -97,27 +97,15 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'admin/drive/files',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					type: (this.type && this.type !== '') ? this.type : null,
 | 
			
		||||
					origin: this.origin,
 | 
			
		||||
					hostname: (this.hostname && this.hostname !== '') ? this.hostname : null,
 | 
			
		||||
				}),
 | 
			
		||||
					hostname: (this.searchHost && this.searchHost !== '') ? this.searchHost : null,
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		type() {
 | 
			
		||||
			this.$refs.files.reload();
 | 
			
		||||
		},
 | 
			
		||||
		origin() {
 | 
			
		||||
			this.$refs.files.reload();
 | 
			
		||||
		},
 | 
			
		||||
		searchHost() {
 | 
			
		||||
			this.$refs.files.reload();
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.$emit('info', this[symbols.PAGE_INFO]);
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,7 @@
 | 
			
		||||
				<template #prefix>@</template>
 | 
			
		||||
				<template #label>{{ $ts.username }}</template>
 | 
			
		||||
			</MkInput>
 | 
			
		||||
			<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params().origin === 'local'" @update:modelValue="$refs.users.reload()">
 | 
			
		||||
			<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
 | 
			
		||||
				<template #prefix>@</template>
 | 
			
		||||
				<template #label>{{ $ts.host }}</template>
 | 
			
		||||
			</MkInput>
 | 
			
		||||
@@ -62,7 +62,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import MkInput from '@/components/form/input.vue';
 | 
			
		||||
import MkSelect from '@/components/form/select.vue';
 | 
			
		||||
@@ -112,30 +112,18 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'admin/show-users',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					sort: this.sort,
 | 
			
		||||
					state: this.state,
 | 
			
		||||
					origin: this.origin,
 | 
			
		||||
					username: this.searchUsername,
 | 
			
		||||
					hostname: this.searchHost,
 | 
			
		||||
				}),
 | 
			
		||||
				})),
 | 
			
		||||
				offsetMode: true
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		sort() {
 | 
			
		||||
			this.$refs.users.reload();
 | 
			
		||||
		},
 | 
			
		||||
		state() {
 | 
			
		||||
			this.$refs.users.reload();
 | 
			
		||||
		},
 | 
			
		||||
		origin() {
 | 
			
		||||
			this.$refs.users.reload();
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	async mounted() {
 | 
			
		||||
		this.$emit('info', this[symbols.PAGE_INFO]);
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -69,9 +69,9 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'channels/timeline',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					channelId: this.channelId,
 | 
			
		||||
				})
 | 
			
		||||
				}))
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -52,9 +52,9 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'clips/notes',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					clipId: this.clipId,
 | 
			
		||||
				})
 | 
			
		||||
				}))
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,42 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkSpacer :content-max="800">
 | 
			
		||||
	<XNotes :pagination="pagination" :detail="true" :prop="'note'"/>
 | 
			
		||||
	<MkPagination ref="pagingComponent" :pagination="pagination">
 | 
			
		||||
		<template #empty>
 | 
			
		||||
			<div class="_fullinfo">
 | 
			
		||||
				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 | 
			
		||||
				<div>{{ $ts.noNotes }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</template>
 | 
			
		||||
 | 
			
		||||
		<template #default="{ items }">
 | 
			
		||||
			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
 | 
			
		||||
				<XNote :key="item.id" :note="item.note" :class="$style.note" @update:note="noteUpdated(item, $event)"/>
 | 
			
		||||
			</XList>
 | 
			
		||||
		</template>
 | 
			
		||||
	</MkPagination>
 | 
			
		||||
</MkSpacer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import XNotes from '@/components/notes.vue';
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import XNote from '@/components/note.vue';
 | 
			
		||||
import XList from '@/components/date-separated-list.vue';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
const pagination = {
 | 
			
		||||
	endpoint: 'i/favorites',
 | 
			
		||||
	endpoint: 'i/favorites' as const,
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	params: () => ({
 | 
			
		||||
	}),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 | 
			
		||||
 | 
			
		||||
const noteUpdated = (item, note) => {
 | 
			
		||||
	pagingComponent.value?.updateItem(item.id, old => ({
 | 
			
		||||
		...old,
 | 
			
		||||
		note: note,
 | 
			
		||||
	}));
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose({
 | 
			
		||||
@@ -24,3 +47,10 @@ defineExpose({
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
.note {
 | 
			
		||||
	background: var(--panel);
 | 
			
		||||
	border-radius: var(--radius);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -96,7 +96,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
import MkInput from '@/components/form/input.vue';
 | 
			
		||||
import MkSelect from '@/components/form/select.vue';
 | 
			
		||||
@@ -130,7 +130,7 @@ export default defineComponent({
 | 
			
		||||
				endpoint: 'federation/instances',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				offsetMode: true,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					sort: this.sort,
 | 
			
		||||
					host: this.host != '' ? this.host : null,
 | 
			
		||||
					...(
 | 
			
		||||
@@ -141,7 +141,7 @@ export default defineComponent({
 | 
			
		||||
						this.state === 'blocked' ? { blocked: true } :
 | 
			
		||||
						this.state === 'notResponding' ? { notResponding: true } :
 | 
			
		||||
						{})
 | 
			
		||||
				})
 | 
			
		||||
				}))
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -95,9 +95,9 @@ export default defineComponent({
 | 
			
		||||
			otherPostsPagination: {
 | 
			
		||||
				endpoint: 'users/gallery/posts',
 | 
			
		||||
				limit: 6,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					userId: this.post.user.id
 | 
			
		||||
				})
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
			post: null,
 | 
			
		||||
			error: null,
 | 
			
		||||
 
 | 
			
		||||
@@ -108,9 +108,9 @@ export default defineComponent({
 | 
			
		||||
			otherPostsPagination: {
 | 
			
		||||
				endpoint: 'users/pages',
 | 
			
		||||
				limit: 6,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					userId: this.page.user.id
 | 
			
		||||
				})
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -25,18 +25,12 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'notes/search',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					query: this.$route.query.q,
 | 
			
		||||
					channelId: this.$route.query.channel,
 | 
			
		||||
				})
 | 
			
		||||
				}))
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		$route() {
 | 
			
		||||
			(this.$refs.notes as any).reload();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@
 | 
			
		||||
	
 | 
			
		||||
	<FormSection>
 | 
			
		||||
		<template #label>{{ $ts.signinHistory }}</template>
 | 
			
		||||
		<FormPagination :pagination="pagination">
 | 
			
		||||
		<MkPagination :pagination="pagination">
 | 
			
		||||
			<template v-slot="{items}">
 | 
			
		||||
				<div>
 | 
			
		||||
					<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
 | 
			
		||||
@@ -25,7 +25,7 @@
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</template>
 | 
			
		||||
		</FormPagination>
 | 
			
		||||
		</MkPagination>
 | 
			
		||||
	</FormSection>
 | 
			
		||||
 | 
			
		||||
	<FormSection>
 | 
			
		||||
@@ -42,7 +42,7 @@ import { defineComponent } from 'vue';
 | 
			
		||||
import FormSection from '@/components/form/section.vue';
 | 
			
		||||
import FormSlot from '@/components/form/slot.vue';
 | 
			
		||||
import FormButton from '@/components/ui/button.vue';
 | 
			
		||||
import FormPagination from '@/components/form/pagination.vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import X2fa from './2fa.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
@@ -51,7 +51,7 @@ export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		FormSection,
 | 
			
		||||
		FormButton,
 | 
			
		||||
		FormPagination,
 | 
			
		||||
		MkPagination,
 | 
			
		||||
		FormSlot,
 | 
			
		||||
		X2fa,
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import XNotes from '@/components/notes.vue';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
@@ -30,17 +30,11 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'notes/search-by-tag',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					tag: this.tag,
 | 
			
		||||
				})
 | 
			
		||||
				}))
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		tag() {
 | 
			
		||||
			(this.$refs.notes as any).reload();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div>
 | 
			
		||||
	<MkPagination v-slot="{items}" ref="list" :pagination="pagination" class="mk-following-or-followers">
 | 
			
		||||
	<MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers">
 | 
			
		||||
		<div class="users _isolated">
 | 
			
		||||
			<MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/>
 | 
			
		||||
		</div>
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import MkUserInfo from '@/components/user-info.vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
 | 
			
		||||
@@ -32,25 +32,22 @@ export default defineComponent({
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers',
 | 
			
		||||
			followingPagination: {
 | 
			
		||||
				endpoint: 'users/following',
 | 
			
		||||
				limit: 20,
 | 
			
		||||
				params: {
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					userId: this.user.id,
 | 
			
		||||
				}
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
			followersPagination: {
 | 
			
		||||
				endpoint: 'users/followers',
 | 
			
		||||
				limit: 20,
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					userId: this.user.id,
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		type() {
 | 
			
		||||
			this.$refs.list.reload();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		user() {
 | 
			
		||||
			this.$refs.list.reload();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
 | 
			
		||||
@@ -31,18 +31,12 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'users/gallery/posts',
 | 
			
		||||
				limit: 6,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					userId: this.user.id
 | 
			
		||||
				})
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		user() {
 | 
			
		||||
			this.$refs.list.reload();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,60 +1,36 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div v-sticky-container class="yrzkoczt">
 | 
			
		||||
	<MkTab v-model="with_" class="tab">
 | 
			
		||||
	<MkTab v-model="include" class="tab">
 | 
			
		||||
		<option :value="null">{{ $ts.notes }}</option>
 | 
			
		||||
		<option value="replies">{{ $ts.notesAndReplies }}</option>
 | 
			
		||||
		<option value="files">{{ $ts.withFiles }}</option>
 | 
			
		||||
	</MkTab>
 | 
			
		||||
	<XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
 | 
			
		||||
	<XNotes ref="timeline" :no-gap="true" :pagination="pagination"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref, computed } from 'vue';
 | 
			
		||||
import * as misskey from 'misskey-js';
 | 
			
		||||
import XNotes from '@/components/notes.vue';
 | 
			
		||||
import MkTab from '@/components/tab.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotes,
 | 
			
		||||
		MkTab,
 | 
			
		||||
	},
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	user: misskey.entities.UserDetailed;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		user: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
const include = ref<string | null>(null);
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			date: null,
 | 
			
		||||
			with_: null,
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'users/notes',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: init => ({
 | 
			
		||||
					userId: this.user.id,
 | 
			
		||||
					includeReplies: this.with_ === 'replies',
 | 
			
		||||
					withFiles: this.with_ === 'files',
 | 
			
		||||
					untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
 | 
			
		||||
				})
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		user() {
 | 
			
		||||
			this.$refs.timeline.reload();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		with_() {
 | 
			
		||||
			this.$refs.timeline.reload();
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
const pagination = {
 | 
			
		||||
	endpoint: 'users/notes' as const,
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	params: computed(() => ({
 | 
			
		||||
		userId: props.user.id,
 | 
			
		||||
		includeReplies: include.value === 'replies',
 | 
			
		||||
		withFiles: include.value === 'files',
 | 
			
		||||
	})),
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import MkPagePreview from '@/components/page-preview.vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
 | 
			
		||||
@@ -29,18 +29,12 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'users/pages',
 | 
			
		||||
				limit: 20,
 | 
			
		||||
				params: {
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					userId: this.user.id,
 | 
			
		||||
				}
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		user() {
 | 
			
		||||
			this.$refs.list.reload();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import MkPagination from '@/components/ui/pagination.vue';
 | 
			
		||||
import MkNote from '@/components/note.vue';
 | 
			
		||||
import MkReactionIcon from '@/components/reaction-icon.vue';
 | 
			
		||||
@@ -38,18 +38,12 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'users/reactions',
 | 
			
		||||
				limit: 20,
 | 
			
		||||
				params: {
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					userId: this.user.id,
 | 
			
		||||
				}
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		user() {
 | 
			
		||||
			this.$refs.list.reload();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,11 +21,39 @@ export type FormItem = {
 | 
			
		||||
	default: string | null;
 | 
			
		||||
	hidden?: boolean;
 | 
			
		||||
	enum: string[];
 | 
			
		||||
} | {
 | 
			
		||||
	label?: string;
 | 
			
		||||
	type: 'radio';
 | 
			
		||||
	default: unknown | null;
 | 
			
		||||
	hidden?: boolean;
 | 
			
		||||
	options: {
 | 
			
		||||
		label: string;
 | 
			
		||||
		value: unknown;
 | 
			
		||||
	}[];
 | 
			
		||||
} | {
 | 
			
		||||
	label?: string;
 | 
			
		||||
	type: 'object';
 | 
			
		||||
	default: Record<string, unknown> | null;
 | 
			
		||||
	hidden: true;
 | 
			
		||||
} | {
 | 
			
		||||
	label?: string;
 | 
			
		||||
	type: 'array';
 | 
			
		||||
	default: unknown[] | null;
 | 
			
		||||
	hidden?: boolean;
 | 
			
		||||
	hidden: true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Form = Record<string, FormItem>;
 | 
			
		||||
 | 
			
		||||
type GetItemType<Item extends FormItem> =
 | 
			
		||||
	Item['type'] extends 'string' ? string :
 | 
			
		||||
	Item['type'] extends 'number' ? number :
 | 
			
		||||
	Item['type'] extends 'boolean' ? boolean :
 | 
			
		||||
	Item['type'] extends 'radio' ? unknown :
 | 
			
		||||
	Item['type'] extends 'enum' ? string :
 | 
			
		||||
	Item['type'] extends 'array' ? unknown[] :
 | 
			
		||||
	Item['type'] extends 'object' ? Record<string, unknown>
 | 
			
		||||
	: never;
 | 
			
		||||
 | 
			
		||||
export type GetFormResultType<F extends Form> = {
 | 
			
		||||
	[P in keyof F]: GetItemType<F[P]>;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,246 +0,0 @@
 | 
			
		||||
import { markRaw } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
 | 
			
		||||
 | 
			
		||||
const SECOND_FETCH_LIMIT = 30;
 | 
			
		||||
 | 
			
		||||
// reversed: items 配列の中身を逆順にする(新しい方が最後)
 | 
			
		||||
 | 
			
		||||
export default (opts) => ({
 | 
			
		||||
	emits: ['queue'],
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			items: [],
 | 
			
		||||
			queue: [],
 | 
			
		||||
			offset: 0,
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			moreFetching: false,
 | 
			
		||||
			inited: false,
 | 
			
		||||
			more: false,
 | 
			
		||||
			backed: false, // 遡り中か否か
 | 
			
		||||
			isBackTop: false,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		empty(): boolean {
 | 
			
		||||
			return this.items.length === 0 && !this.fetching && this.inited;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		error(): boolean {
 | 
			
		||||
			return !this.fetching && !this.inited;
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		pagination: {
 | 
			
		||||
			handler() {
 | 
			
		||||
				this.init();
 | 
			
		||||
			},
 | 
			
		||||
			deep: true
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		queue: {
 | 
			
		||||
			handler(a, b) {
 | 
			
		||||
				if (a.length === 0 && b.length === 0) return;
 | 
			
		||||
				this.$emit('queue', this.queue.length);
 | 
			
		||||
			},
 | 
			
		||||
			deep: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		opts.displayLimit = opts.displayLimit || 30;
 | 
			
		||||
		this.init();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	activated() {
 | 
			
		||||
		this.isBackTop = false;
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	deactivated() {
 | 
			
		||||
		this.isBackTop = window.scrollY === 0;
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		reload() {
 | 
			
		||||
			this.items = [];
 | 
			
		||||
			this.init();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		replaceItem(finder, data) {
 | 
			
		||||
			const i = this.items.findIndex(finder);
 | 
			
		||||
			this.items[i] = data;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeItem(finder) {
 | 
			
		||||
			const i = this.items.findIndex(finder);
 | 
			
		||||
			this.items.splice(i, 1);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async init() {
 | 
			
		||||
			this.queue = [];
 | 
			
		||||
			this.fetching = true;
 | 
			
		||||
			if (opts.before) opts.before(this);
 | 
			
		||||
			let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
 | 
			
		||||
			if (params && params.then) params = await params;
 | 
			
		||||
			if (params === null) return;
 | 
			
		||||
			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
 | 
			
		||||
			await os.api(endpoint, {
 | 
			
		||||
				...params,
 | 
			
		||||
				limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
 | 
			
		||||
			}).then(items => {
 | 
			
		||||
				for (let i = 0; i < items.length; i++) {
 | 
			
		||||
					const item = items[i];
 | 
			
		||||
					markRaw(item);
 | 
			
		||||
					if (this.pagination.reversed) {
 | 
			
		||||
						if (i === items.length - 2) item._shouldInsertAd_ = true;
 | 
			
		||||
					} else {
 | 
			
		||||
						if (i === 3) item._shouldInsertAd_ = true;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
 | 
			
		||||
					items.pop();
 | 
			
		||||
					this.items = this.pagination.reversed ? [...items].reverse() : items;
 | 
			
		||||
					this.more = true;
 | 
			
		||||
				} else {
 | 
			
		||||
					this.items = this.pagination.reversed ? [...items].reverse() : items;
 | 
			
		||||
					this.more = false;
 | 
			
		||||
				}
 | 
			
		||||
				this.offset = items.length;
 | 
			
		||||
				this.inited = true;
 | 
			
		||||
				this.fetching = false;
 | 
			
		||||
				if (opts.after) opts.after(this, null);
 | 
			
		||||
			}, e => {
 | 
			
		||||
				this.fetching = false;
 | 
			
		||||
				if (opts.after) opts.after(this, e);
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async fetchMore() {
 | 
			
		||||
			if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
 | 
			
		||||
			this.moreFetching = true;
 | 
			
		||||
			this.backed = true;
 | 
			
		||||
			let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
 | 
			
		||||
			if (params && params.then) params = await params;
 | 
			
		||||
			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
 | 
			
		||||
			await os.api(endpoint, {
 | 
			
		||||
				...params,
 | 
			
		||||
				limit: SECOND_FETCH_LIMIT + 1,
 | 
			
		||||
				...(this.pagination.offsetMode ? {
 | 
			
		||||
					offset: this.offset,
 | 
			
		||||
				} : {
 | 
			
		||||
					untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
 | 
			
		||||
				}),
 | 
			
		||||
			}).then(items => {
 | 
			
		||||
				for (let i = 0; i < items.length; i++) {
 | 
			
		||||
					const item = items[i];
 | 
			
		||||
					markRaw(item);
 | 
			
		||||
					if (this.pagination.reversed) {
 | 
			
		||||
						if (i === items.length - 9) item._shouldInsertAd_ = true;
 | 
			
		||||
					} else {
 | 
			
		||||
						if (i === 10) item._shouldInsertAd_ = true;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				if (items.length > SECOND_FETCH_LIMIT) {
 | 
			
		||||
					items.pop();
 | 
			
		||||
					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
 | 
			
		||||
					this.more = true;
 | 
			
		||||
				} else {
 | 
			
		||||
					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
 | 
			
		||||
					this.more = false;
 | 
			
		||||
				}
 | 
			
		||||
				this.offset += items.length;
 | 
			
		||||
				this.moreFetching = false;
 | 
			
		||||
			}, e => {
 | 
			
		||||
				this.moreFetching = false;
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async fetchMoreFeature() {
 | 
			
		||||
			if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
 | 
			
		||||
			this.moreFetching = true;
 | 
			
		||||
			let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
 | 
			
		||||
			if (params && params.then) params = await params;
 | 
			
		||||
			const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
 | 
			
		||||
			await os.api(endpoint, {
 | 
			
		||||
				...params,
 | 
			
		||||
				limit: SECOND_FETCH_LIMIT + 1,
 | 
			
		||||
				...(this.pagination.offsetMode ? {
 | 
			
		||||
					offset: this.offset,
 | 
			
		||||
				} : {
 | 
			
		||||
					sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
 | 
			
		||||
				}),
 | 
			
		||||
			}).then(items => {
 | 
			
		||||
				for (const item of items) {
 | 
			
		||||
					markRaw(item);
 | 
			
		||||
				}
 | 
			
		||||
				if (items.length > SECOND_FETCH_LIMIT) {
 | 
			
		||||
					items.pop();
 | 
			
		||||
					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
 | 
			
		||||
					this.more = true;
 | 
			
		||||
				} else {
 | 
			
		||||
					this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
 | 
			
		||||
					this.more = false;
 | 
			
		||||
				}
 | 
			
		||||
				this.offset += items.length;
 | 
			
		||||
				this.moreFetching = false;
 | 
			
		||||
			}, e => {
 | 
			
		||||
				this.moreFetching = false;
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		prepend(item) {
 | 
			
		||||
			if (this.pagination.reversed) {
 | 
			
		||||
				const container = getScrollContainer(this.$el);
 | 
			
		||||
				const pos = getScrollPosition(this.$el);
 | 
			
		||||
				const viewHeight = container.clientHeight;
 | 
			
		||||
				const height = container.scrollHeight;
 | 
			
		||||
				const isBottom = (pos + viewHeight > height - 32);
 | 
			
		||||
				if (isBottom) {
 | 
			
		||||
					// オーバーフローしたら古いアイテムは捨てる
 | 
			
		||||
					if (this.items.length >= opts.displayLimit) {
 | 
			
		||||
						// このやり方だとVue 3.2以降アニメーションが動かなくなる
 | 
			
		||||
						//this.items = this.items.slice(-opts.displayLimit);
 | 
			
		||||
						while (this.items.length >= opts.displayLimit) {
 | 
			
		||||
							this.items.shift();
 | 
			
		||||
						}
 | 
			
		||||
						this.more = true;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				this.items.push(item);
 | 
			
		||||
				// TODO
 | 
			
		||||
			} else {
 | 
			
		||||
				const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
 | 
			
		||||
 | 
			
		||||
				if (isTop) {
 | 
			
		||||
					// Prepend the item
 | 
			
		||||
					this.items.unshift(item);
 | 
			
		||||
 | 
			
		||||
					// オーバーフローしたら古いアイテムは捨てる
 | 
			
		||||
					if (this.items.length >= opts.displayLimit) {
 | 
			
		||||
						// このやり方だとVue 3.2以降アニメーションが動かなくなる
 | 
			
		||||
						//this.items = this.items.slice(0, opts.displayLimit);
 | 
			
		||||
						while (this.items.length >= opts.displayLimit) {
 | 
			
		||||
							this.items.pop();
 | 
			
		||||
						}
 | 
			
		||||
						this.more = true;
 | 
			
		||||
					}
 | 
			
		||||
				} else {
 | 
			
		||||
					this.queue.push(item);
 | 
			
		||||
					onScrollTop(this.$el, () => {
 | 
			
		||||
						for (const item of this.queue) {
 | 
			
		||||
							this.prepend(item);
 | 
			
		||||
						}
 | 
			
		||||
						this.queue = [];
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		append(item) {
 | 
			
		||||
			this.items.push(item);
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
@@ -1,157 +0,0 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, h, PropType, TransitionGroup } from 'vue';
 | 
			
		||||
import MkAd from '@/components/global/ad.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		items: {
 | 
			
		||||
			type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
 | 
			
		||||
			required: true,
 | 
			
		||||
		},
 | 
			
		||||
		reversed: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		ad: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		const getDateText = (time: string) => {
 | 
			
		||||
			const date = new Date(time).getDate();
 | 
			
		||||
			const month = new Date(time).getMonth() + 1;
 | 
			
		||||
			return this.$t('monthAndDay', {
 | 
			
		||||
				month: month.toString(),
 | 
			
		||||
				day: date.toString()
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return h(this.reversed ? 'div' : TransitionGroup, {
 | 
			
		||||
			class: 'hmjzthxl',
 | 
			
		||||
			name: this.reversed ? 'list-reversed' : 'list',
 | 
			
		||||
			tag: 'div',
 | 
			
		||||
		}, this.items.map((item, i) => {
 | 
			
		||||
			const el = this.$slots.default({
 | 
			
		||||
				item: item
 | 
			
		||||
			})[0];
 | 
			
		||||
			if (el.key == null && item.id) el.key = item.id;
 | 
			
		||||
 | 
			
		||||
			if (
 | 
			
		||||
				i != this.items.length - 1 &&
 | 
			
		||||
				new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
 | 
			
		||||
			) {
 | 
			
		||||
				const separator = h('div', {
 | 
			
		||||
					class: 'separator',
 | 
			
		||||
					key: item.id + ':separator',
 | 
			
		||||
				}, h('p', {
 | 
			
		||||
					class: 'date'
 | 
			
		||||
				}, [
 | 
			
		||||
					h('span', [
 | 
			
		||||
						h('i', {
 | 
			
		||||
							class: 'fas fa-angle-up icon',
 | 
			
		||||
						}),
 | 
			
		||||
						getDateText(item.createdAt)
 | 
			
		||||
					]),
 | 
			
		||||
					h('span', [
 | 
			
		||||
						getDateText(this.items[i + 1].createdAt),
 | 
			
		||||
						h('i', {
 | 
			
		||||
							class: 'fas fa-angle-down icon',
 | 
			
		||||
						})
 | 
			
		||||
					])
 | 
			
		||||
				]));
 | 
			
		||||
 | 
			
		||||
				return [el, separator];
 | 
			
		||||
			} else {
 | 
			
		||||
				if (this.ad && item._shouldInsertAd_) {
 | 
			
		||||
					return [h(MkAd, {
 | 
			
		||||
						class: 'a', // advertiseの意(ブロッカー対策)
 | 
			
		||||
						key: item.id + ':ad',
 | 
			
		||||
						prefer: ['horizontal', 'horizontal-big'],
 | 
			
		||||
					}), el];
 | 
			
		||||
				} else {
 | 
			
		||||
					return el;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}));
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
.hmjzthxl {
 | 
			
		||||
	> .list-move {
 | 
			
		||||
		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
	}
 | 
			
		||||
	> .list-enter-active {
 | 
			
		||||
		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
	}
 | 
			
		||||
	> .list-enter-from {
 | 
			
		||||
		opacity: 0;
 | 
			
		||||
		transform: translateY(-64px);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .list-reversed-enter-active, > .list-reversed-leave-active {
 | 
			
		||||
		transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
 | 
			
		||||
	}
 | 
			
		||||
	> .list-reversed-enter-from {
 | 
			
		||||
		opacity: 0;
 | 
			
		||||
		transform: translateY(64px);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
.hmjzthxl {
 | 
			
		||||
	> .separator {
 | 
			
		||||
		text-align: center;
 | 
			
		||||
		position: relative;
 | 
			
		||||
 | 
			
		||||
		&:before {
 | 
			
		||||
			content: "";
 | 
			
		||||
			display: block;
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			top: 50%;
 | 
			
		||||
			left: 0;
 | 
			
		||||
			right: 0;
 | 
			
		||||
			margin: auto;
 | 
			
		||||
			width: calc(100% - 32px);
 | 
			
		||||
			height: 1px;
 | 
			
		||||
			background: var(--divider);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .date {
 | 
			
		||||
			display: inline-block;
 | 
			
		||||
			position: relative;
 | 
			
		||||
			margin: 0;
 | 
			
		||||
			padding: 0 16px;
 | 
			
		||||
			line-height: 32px;
 | 
			
		||||
			text-align: center;
 | 
			
		||||
			font-size: 12px;
 | 
			
		||||
			color: var(--dateLabelFg);
 | 
			
		||||
			background: var(--panel);
 | 
			
		||||
 | 
			
		||||
			> span {
 | 
			
		||||
				&:first-child {
 | 
			
		||||
					margin-right: 8px;
 | 
			
		||||
 | 
			
		||||
					> .icon {
 | 
			
		||||
						margin-right: 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&:last-child {
 | 
			
		||||
					margin-left: 8px;
 | 
			
		||||
 | 
			
		||||
					> .icon {
 | 
			
		||||
						margin-left: 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,62 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="acemodlh _monospace">
 | 
			
		||||
	<div>
 | 
			
		||||
		<span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div>
 | 
			
		||||
		<span v-text="hh"></span>
 | 
			
		||||
		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 | 
			
		||||
		<span v-text="mm"></span>
 | 
			
		||||
		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 | 
			
		||||
		<span v-text="ss"></span>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			clock: null,
 | 
			
		||||
			y: null,
 | 
			
		||||
			m: null,
 | 
			
		||||
			d: null,
 | 
			
		||||
			hh: null,
 | 
			
		||||
			mm: null,
 | 
			
		||||
			ss: null,
 | 
			
		||||
			showColon: true,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		this.tick();
 | 
			
		||||
		this.clock = setInterval(this.tick, 1000);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		tick() {
 | 
			
		||||
			const now = new Date();
 | 
			
		||||
			this.y = now.getFullYear().toString();
 | 
			
		||||
			this.m = (now.getMonth() + 1).toString().padStart(2, '0');
 | 
			
		||||
			this.d = now.getDate().toString().padStart(2, '0');
 | 
			
		||||
			this.hh = now.getHours().toString().padStart(2, '0');
 | 
			
		||||
			this.mm = now.getMinutes().toString().padStart(2, '0');
 | 
			
		||||
			this.ss = now.getSeconds().toString().padStart(2, '0');
 | 
			
		||||
			this.showColon = now.getSeconds() % 2 === 0;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.acemodlh {
 | 
			
		||||
	opacity: 0.7;
 | 
			
		||||
	font-size: 0.85em;
 | 
			
		||||
	line-height: 1em;
 | 
			
		||||
	text-align: center;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,463 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="mk-app" @contextmenu.self.prevent="onContextmenu">
 | 
			
		||||
	<XSidebar ref="menu" class="menu" :default-hidden="true"/>
 | 
			
		||||
 | 
			
		||||
	<div class="nav">
 | 
			
		||||
		<header class="header">
 | 
			
		||||
			<div class="left">
 | 
			
		||||
				<button class="_button account" @click="openAccountMenu">
 | 
			
		||||
					<MkAvatar :user="$i" class="avatar"/><!--<MkAcct class="text" :user="$i"/>-->
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="right">
 | 
			
		||||
				<MkA v-tooltip="$ts.messaging" class="item" to="/my/messaging"><i class="fas fa-comments icon"></i><span v-if="$i.hasUnreadMessagingMessage" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
				<MkA v-tooltip="$ts.directNotes" class="item" to="/my/messages"><i class="fas fa-envelope icon"></i><span v-if="$i.hasUnreadSpecifiedNotes" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
				<MkA v-tooltip="$ts.mentions" class="item" to="/my/mentions"><i class="fas fa-at icon"></i><span v-if="$i.hasUnreadMentions" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
				<MkA v-tooltip="$ts.notifications" class="item" to="/my/notifications"><i class="fas fa-bell icon"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></MkA>
 | 
			
		||||
			</div>
 | 
			
		||||
		</header>
 | 
			
		||||
		<div class="body">
 | 
			
		||||
			<div class="container">
 | 
			
		||||
				<div class="header">{{ $ts.timeline }}</div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><i class="fas fa-home icon"></i>{{ $ts._timelines.home }}</MkA>
 | 
			
		||||
					<MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><i class="fas fa-comments icon"></i>{{ $ts._timelines.local }}</MkA>
 | 
			
		||||
					<MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><i class="fas fa-share-alt icon"></i>{{ $ts._timelines.social }}</MkA>
 | 
			
		||||
					<MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><i class="fas fa-globe icon"></i>{{ $ts._timelines.global }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-if="followedChannels" class="container">
 | 
			
		||||
				<div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-if="featuredChannels" class="container">
 | 
			
		||||
				<div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-if="lists" class="container">
 | 
			
		||||
				<div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><i class="fas fa-list-ul icon"></i>{{ list.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-if="antennas" class="container">
 | 
			
		||||
				<div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><i class="fas fa-plus"></i></button></div>
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><i class="fas fa-satellite icon"></i>{{ antenna.name }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="container">
 | 
			
		||||
				<div class="body">
 | 
			
		||||
					<MkA to="/my/favorites" class="item"><i class="fas fa-star icon"></i>{{ $ts.favorites }}</MkA>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<MkAd class="a" :prefer="['square']"/>
 | 
			
		||||
		</div>
 | 
			
		||||
		<footer class="footer">
 | 
			
		||||
			<div class="left">
 | 
			
		||||
				<button class="_button menu" @click="showMenu">
 | 
			
		||||
					<i class="fas fa-bars icon"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="right">
 | 
			
		||||
				<button v-tooltip="$ts.search" class="_button item search" @click="search">
 | 
			
		||||
					<i class="fas fa-search icon"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
				<MkA v-tooltip="$ts.settings" class="item" to="/settings"><i class="fas fa-cog icon"></i></MkA>
 | 
			
		||||
			</div>
 | 
			
		||||
		</footer>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<main class="main" @contextmenu.stop="onContextmenu">
 | 
			
		||||
		<header class="header">
 | 
			
		||||
			<MkHeader class="header" :info="pageInfo" :menu="menu" :center="false" @click="onHeaderClick"/>
 | 
			
		||||
		</header>
 | 
			
		||||
		<router-view v-slot="{ Component }">
 | 
			
		||||
			<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
 | 
			
		||||
				<keep-alive :include="['timeline']">
 | 
			
		||||
					<component :is="Component" :ref="changePage" class="body"/>
 | 
			
		||||
				</keep-alive>
 | 
			
		||||
			</transition>
 | 
			
		||||
		</router-view>
 | 
			
		||||
	</main>
 | 
			
		||||
 | 
			
		||||
	<XSide ref="side" class="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/>
 | 
			
		||||
	<div class="side widgets" :class="{ sideViewOpening }">
 | 
			
		||||
		<XWidgets/>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<XCommon/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { instanceName, url } from '@/config';
 | 
			
		||||
import XSidebar from '@/ui/_common_/sidebar.vue';
 | 
			
		||||
import XWidgets from './widgets.vue';
 | 
			
		||||
import XCommon from '../_common_/common.vue';
 | 
			
		||||
import XSide from './side.vue';
 | 
			
		||||
import XHeaderClock from './header-clock.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { router } from '@/router';
 | 
			
		||||
import { menuDef } from '@/menu';
 | 
			
		||||
import { search } from '@/scripts/search';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
 | 
			
		||||
import { store } from './store';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
import { openAccountMenu } from '@/account';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XCommon,
 | 
			
		||||
		XSidebar,
 | 
			
		||||
		XWidgets,
 | 
			
		||||
		XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
 | 
			
		||||
		XHeaderClock,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			sideViewHook: (path) => {
 | 
			
		||||
				this.$refs.side.navigate(path);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			lists: null,
 | 
			
		||||
			antennas: null,
 | 
			
		||||
			followedChannels: null,
 | 
			
		||||
			featuredChannels: null,
 | 
			
		||||
			currentChannel: null,
 | 
			
		||||
			menuDef: menuDef,
 | 
			
		||||
			sideViewOpening: false,
 | 
			
		||||
			instanceName,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		menu() {
 | 
			
		||||
			return [{
 | 
			
		||||
				icon: 'fas fa-columns',
 | 
			
		||||
				text: this.$ts.openInSideView,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$refs.side.navigate(this.$route.path);
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(this.$route.path);
 | 
			
		||||
				}
 | 
			
		||||
			}];
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		if (window.innerWidth < 1024) {
 | 
			
		||||
			localStorage.setItem('ui', 'default');
 | 
			
		||||
			location.reload();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		os.api('users/lists/list').then(lists => {
 | 
			
		||||
			this.lists = lists;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		os.api('antennas/list').then(antennas => {
 | 
			
		||||
			this.antennas = antennas;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		os.api('channels/followed', { limit: 20 }).then(channels => {
 | 
			
		||||
			this.followedChannels = channels;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		// TODO: pagination
 | 
			
		||||
		os.api('channels/featured', { limit: 20 }).then(channels => {
 | 
			
		||||
			this.featuredChannels = channels;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			console.log(page);
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
				document.title = `${this.pageInfo.title} | ${instanceName}`;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showMenu() {
 | 
			
		||||
			this.$refs.menu.show();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		post() {
 | 
			
		||||
			os.post();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		search() {
 | 
			
		||||
			search();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			history.back();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		top() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onTransition() {
 | 
			
		||||
			if (window._scroll) window._scroll();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onHeaderClick() {
 | 
			
		||||
			window.scroll({ top: 0, behavior: 'smooth' });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			const isLink = (el: HTMLElement) => {
 | 
			
		||||
				if (el.tagName === 'A') return true;
 | 
			
		||||
				if (el.parentElement) {
 | 
			
		||||
					return isLink(el.parentElement);
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
			if (isLink(e.target)) return;
 | 
			
		||||
			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
 | 
			
		||||
			if (window.getSelection().toString() !== '') return;
 | 
			
		||||
			const path = this.$route.path;
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-columns',
 | 
			
		||||
				text: this.$ts.openInSideView,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$refs.side.navigate(path);
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(path);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		openAccountMenu,
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mk-app {
 | 
			
		||||
	$header-height: 54px; // TODO: どこかに集約したい
 | 
			
		||||
	$ui-font-size: 1em; // TODO: どこかに集約したい
 | 
			
		||||
 | 
			
		||||
	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 | 
			
		||||
	height: calc(var(--vh, 1vh) * 100);
 | 
			
		||||
	display: flex;
 | 
			
		||||
 | 
			
		||||
	> .nav {
 | 
			
		||||
		display: flex;
 | 
			
		||||
		flex-direction: column;
 | 
			
		||||
		width: 250px;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
		border-right: solid 4px var(--divider);
 | 
			
		||||
 | 
			
		||||
		> .header, > .footer {
 | 
			
		||||
			$padding: 8px;
 | 
			
		||||
			display: flex;
 | 
			
		||||
			align-items: center;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			padding: $padding;
 | 
			
		||||
			box-sizing: border-box;
 | 
			
		||||
			user-select: none;
 | 
			
		||||
 | 
			
		||||
			&.header {
 | 
			
		||||
				border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			&.footer {
 | 
			
		||||
				border-top: solid 0.5px var(--divider);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .left, > .right {
 | 
			
		||||
				> .item, > .menu {
 | 
			
		||||
					display: inline-flex;
 | 
			
		||||
					vertical-align: middle;
 | 
			
		||||
					height: ($header-height - ($padding * 2));
 | 
			
		||||
					width: ($header-height - ($padding * 2));
 | 
			
		||||
					box-sizing: border-box;
 | 
			
		||||
					//opacity: 0.6;
 | 
			
		||||
					position: relative;
 | 
			
		||||
					border-radius: 5px;
 | 
			
		||||
 | 
			
		||||
					&:hover {
 | 
			
		||||
						background: rgba(0, 0, 0, 0.05);
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .icon {
 | 
			
		||||
						margin: auto;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .indicator {
 | 
			
		||||
						position: absolute;
 | 
			
		||||
						top: 8px;
 | 
			
		||||
						right: 8px;
 | 
			
		||||
						color: var(--indicator);
 | 
			
		||||
						font-size: 8px;
 | 
			
		||||
						line-height: 8px;
 | 
			
		||||
						animation: blink 1s infinite;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .left {
 | 
			
		||||
				flex: 1;
 | 
			
		||||
				min-width: 0;
 | 
			
		||||
 | 
			
		||||
				> .account {
 | 
			
		||||
					display: flex;
 | 
			
		||||
					align-items: center;
 | 
			
		||||
					padding: 0 8px;
 | 
			
		||||
 | 
			
		||||
					> .avatar {
 | 
			
		||||
						width: 26px;
 | 
			
		||||
						height: 26px;
 | 
			
		||||
						margin-right: 8px;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> .text {
 | 
			
		||||
						white-space: nowrap;
 | 
			
		||||
						overflow: hidden;
 | 
			
		||||
						text-overflow: ellipsis;
 | 
			
		||||
						font-size: 0.9em;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .right {
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
			flex: 1;
 | 
			
		||||
			min-width: 0;
 | 
			
		||||
			overflow: auto;
 | 
			
		||||
 | 
			
		||||
			> .container {
 | 
			
		||||
				margin-top: 8px;
 | 
			
		||||
				margin-bottom: 8px;
 | 
			
		||||
 | 
			
		||||
				& + .container {
 | 
			
		||||
					margin-top: 16px;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .header {
 | 
			
		||||
					display: flex;
 | 
			
		||||
					font-size: 0.9em;
 | 
			
		||||
					padding: 8px 16px;
 | 
			
		||||
					position: sticky;
 | 
			
		||||
					top: 0;
 | 
			
		||||
					background: var(--X17);
 | 
			
		||||
					-webkit-backdrop-filter: var(--blur, blur(8px));
 | 
			
		||||
					backdrop-filter: var(--blur, blur(8px));
 | 
			
		||||
					z-index: 1;
 | 
			
		||||
					color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
					> .add {
 | 
			
		||||
						margin-left: auto;
 | 
			
		||||
						color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
						&:hover {
 | 
			
		||||
							color: var(--fg);
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .body {
 | 
			
		||||
					padding: 0 8px;
 | 
			
		||||
 | 
			
		||||
					> .item {
 | 
			
		||||
						display: block;
 | 
			
		||||
						padding: 6px 8px;
 | 
			
		||||
						border-radius: 4px;
 | 
			
		||||
						white-space: nowrap;
 | 
			
		||||
						overflow: hidden;
 | 
			
		||||
						text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
						&:hover {
 | 
			
		||||
							text-decoration: none;
 | 
			
		||||
							background: rgba(0, 0, 0, 0.05);
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						&.active, &.active:hover {
 | 
			
		||||
							background: var(--accent);
 | 
			
		||||
							color: #fff !important;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						&.read {
 | 
			
		||||
							color: var(--fgTransparent);
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						> .icon {
 | 
			
		||||
							margin-right: 8px;
 | 
			
		||||
							opacity: 0.6;
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .a {
 | 
			
		||||
				margin: 12px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		display: flex;
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		flex-direction: column;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
		height: 100vh;
 | 
			
		||||
		position: relative;
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
 | 
			
		||||
		> .header {
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			background-color: var(--panel);
 | 
			
		||||
			border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
			user-select: none;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			box-sizing: border-box;
 | 
			
		||||
			overflow: auto;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .side {
 | 
			
		||||
		width: 350px;
 | 
			
		||||
		border-left: solid 4px var(--divider);
 | 
			
		||||
		background: var(--panel);
 | 
			
		||||
 | 
			
		||||
		&.widgets.sideViewOpening {
 | 
			
		||||
			@media (max-width: 1400px) {
 | 
			
		||||
				display: none;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,99 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<header class="dehvdgxo">
 | 
			
		||||
	<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
 | 
			
		||||
		<MkUserName :user="note.user"/>
 | 
			
		||||
	</MkA>
 | 
			
		||||
	<span v-if="note.user.isBot" class="is-bot">bot</span>
 | 
			
		||||
	<span class="username"><MkAcct :user="note.user"/></span>
 | 
			
		||||
	<div class="info">
 | 
			
		||||
		<MkA class="created-at" :to="notePage(note)">
 | 
			
		||||
			<MkTime :time="note.createdAt"/>
 | 
			
		||||
		</MkA>
 | 
			
		||||
		<span v-if="note.visibility !== 'public'" class="visibility">
 | 
			
		||||
			<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
 | 
			
		||||
			<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
 | 
			
		||||
			<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
 | 
			
		||||
		</span>
 | 
			
		||||
		<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
 | 
			
		||||
	</div>
 | 
			
		||||
</header>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { notePage } from '@/filters/note';
 | 
			
		||||
import { userPage } from '@/filters/user';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		notePage,
 | 
			
		||||
		userPage
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.dehvdgxo {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: baseline;
 | 
			
		||||
	white-space: nowrap;
 | 
			
		||||
	font-size: 0.9em;
 | 
			
		||||
 | 
			
		||||
	> .name {
 | 
			
		||||
		display: block;
 | 
			
		||||
		margin: 0 .5em 0 0;
 | 
			
		||||
		padding: 0;
 | 
			
		||||
		overflow: hidden;
 | 
			
		||||
		font-size: 1em;
 | 
			
		||||
		font-weight: bold;
 | 
			
		||||
		text-decoration: none;
 | 
			
		||||
		text-overflow: ellipsis;
 | 
			
		||||
 | 
			
		||||
		&:hover {
 | 
			
		||||
			text-decoration: underline;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .is-bot {
 | 
			
		||||
		flex-shrink: 0;
 | 
			
		||||
		align-self: center;
 | 
			
		||||
		margin: 0 .5em 0 0;
 | 
			
		||||
		padding: 1px 6px;
 | 
			
		||||
		font-size: 80%;
 | 
			
		||||
		border: solid 0.5px var(--divider);
 | 
			
		||||
		border-radius: 3px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .username {
 | 
			
		||||
		margin: 0 .5em 0 0;
 | 
			
		||||
		overflow: hidden;
 | 
			
		||||
		text-overflow: ellipsis;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .info {
 | 
			
		||||
		font-size: 0.9em;
 | 
			
		||||
		opacity: 0.7;
 | 
			
		||||
 | 
			
		||||
		> .visibility {
 | 
			
		||||
			margin-left: 8px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .localOnly {
 | 
			
		||||
			margin-left: 8px;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,112 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="hduudsxk">
 | 
			
		||||
	<MkAvatar class="avatar" :user="note.user"/>
 | 
			
		||||
	<div class="main">
 | 
			
		||||
		<XNoteHeader class="header" :note="note" :mini="true"/>
 | 
			
		||||
		<div class="body">
 | 
			
		||||
			<p v-if="note.cw != null" class="cw">
 | 
			
		||||
				<span v-if="note.cw != ''" class="text">{{ note.cw }}</span>
 | 
			
		||||
				<XCwButton v-model="showContent" :note="note"/>
 | 
			
		||||
			</p>
 | 
			
		||||
			<div v-show="note.cw == null || showContent" class="content">
 | 
			
		||||
				<XSubNote-content class="text" :note="note"/>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XNoteHeader from './note-header.vue';
 | 
			
		||||
import XSubNoteContent from './sub-note-content.vue';
 | 
			
		||||
import XCwButton from '@/components/cw-button.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNoteHeader,
 | 
			
		||||
		XSubNoteContent,
 | 
			
		||||
		XCwButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			showContent: false
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.hduudsxk {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	margin: 0;
 | 
			
		||||
	padding: 0;
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
	font-size: 0.95em;
 | 
			
		||||
 | 
			
		||||
	> .avatar {
 | 
			
		||||
 | 
			
		||||
		@media (min-width: 350px) {
 | 
			
		||||
			margin: 0 10px 0 0;
 | 
			
		||||
			width: 44px;
 | 
			
		||||
			height: 44px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		@media (min-width: 500px) {
 | 
			
		||||
			margin: 0 12px 0 0;
 | 
			
		||||
			width: 48px;
 | 
			
		||||
			height: 48px;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .avatar {
 | 
			
		||||
		flex-shrink: 0;
 | 
			
		||||
		display: block;
 | 
			
		||||
		margin: 0 10px 0 0;
 | 
			
		||||
		width: 40px;
 | 
			
		||||
		height: 40px;
 | 
			
		||||
		border-radius: 8px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
 | 
			
		||||
		> .header {
 | 
			
		||||
			margin-bottom: 2px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
 | 
			
		||||
			> .cw {
 | 
			
		||||
				cursor: default;
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 0;
 | 
			
		||||
				padding: 0;
 | 
			
		||||
				overflow-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
				> .text {
 | 
			
		||||
					margin-right: 8px;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .content {
 | 
			
		||||
				> .text {
 | 
			
		||||
					cursor: default;
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					padding: 0;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,137 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="wrpstxzv" :class="{ children }">
 | 
			
		||||
	<div class="main">
 | 
			
		||||
		<MkAvatar class="avatar" :user="note.user"/>
 | 
			
		||||
		<div class="body">
 | 
			
		||||
			<XNoteHeader class="header" :note="note" :mini="true"/>
 | 
			
		||||
			<div class="body">
 | 
			
		||||
				<p v-if="note.cw != null" class="cw">
 | 
			
		||||
					<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
 | 
			
		||||
					<XCwButton v-model="showContent" :note="note"/>
 | 
			
		||||
				</p>
 | 
			
		||||
				<div v-show="note.cw == null || showContent" class="content">
 | 
			
		||||
					<XSubNote-content class="text" :note="note"/>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XNoteHeader from './note-header.vue';
 | 
			
		||||
import XSubNoteContent from './sub-note-content.vue';
 | 
			
		||||
import XCwButton from '@/components/cw-button.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	name: 'XSub',
 | 
			
		||||
 | 
			
		||||
	components: {
 | 
			
		||||
		XNoteHeader,
 | 
			
		||||
		XSubNoteContent,
 | 
			
		||||
		XCwButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		detail: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		children: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		// TODO
 | 
			
		||||
		truncate: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			default: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			showContent: false,
 | 
			
		||||
			replies: [],
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		if (this.detail) {
 | 
			
		||||
			os.api('notes/children', {
 | 
			
		||||
				noteId: this.note.id,
 | 
			
		||||
				limit: 5
 | 
			
		||||
			}).then(replies => {
 | 
			
		||||
				this.replies = replies;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.wrpstxzv {
 | 
			
		||||
	padding: 16px 16px;
 | 
			
		||||
	font-size: 0.8em;
 | 
			
		||||
 | 
			
		||||
	&.children {
 | 
			
		||||
		padding: 10px 0 0 16px;
 | 
			
		||||
		font-size: 1em;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .main {
 | 
			
		||||
		display: flex;
 | 
			
		||||
 | 
			
		||||
		> .avatar {
 | 
			
		||||
			flex-shrink: 0;
 | 
			
		||||
			display: block;
 | 
			
		||||
			margin: 0 8px 0 0;
 | 
			
		||||
			width: 36px;
 | 
			
		||||
			height: 36px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .body {
 | 
			
		||||
			flex: 1;
 | 
			
		||||
			min-width: 0;
 | 
			
		||||
 | 
			
		||||
			> .header {
 | 
			
		||||
				margin-bottom: 2px;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .body {
 | 
			
		||||
				> .cw {
 | 
			
		||||
					cursor: default;
 | 
			
		||||
					display: block;
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					padding: 0;
 | 
			
		||||
					overflow-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
					> .text {
 | 
			
		||||
						margin-right: 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .content {
 | 
			
		||||
					> .text {
 | 
			
		||||
						margin: 0;
 | 
			
		||||
						padding: 0;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .reply {
 | 
			
		||||
		border-left: solid 0.5px var(--divider);
 | 
			
		||||
		margin-top: 10px;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,94 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="">
 | 
			
		||||
	<div v-if="empty" class="_fullinfo">
 | 
			
		||||
		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 | 
			
		||||
		<div>{{ $ts.noNotes }}</div>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<MkLoading v-if="fetching"/>
 | 
			
		||||
 | 
			
		||||
	<MkError v-if="error" @retry="init()"/>
 | 
			
		||||
 | 
			
		||||
	<div v-show="more && reversed" style="margin-bottom: var(--margin);">
 | 
			
		||||
		<MkButton style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
 | 
			
		||||
			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
			<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
		</MkButton>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
	<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :ad="true">
 | 
			
		||||
		<XNote :key="note._featuredId_ || note._prId_ || note.id" :note="note" @update:note="updated(note, $event)"/>
 | 
			
		||||
	</XList>
 | 
			
		||||
 | 
			
		||||
	<div v-show="more && !reversed" style="margin-top: var(--margin);">
 | 
			
		||||
		<MkButton v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
 | 
			
		||||
			<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
 | 
			
		||||
			<template v-if="moreFetching"><MkLoading inline/></template>
 | 
			
		||||
		</MkButton>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import paging from '@/scripts/paging';
 | 
			
		||||
import XNote from './note.vue';
 | 
			
		||||
import XList from './date-separated-list.vue';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNote, XList, MkButton,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mixins: [
 | 
			
		||||
		paging({
 | 
			
		||||
			before: (self) => {
 | 
			
		||||
				self.$emit('before');
 | 
			
		||||
			},
 | 
			
		||||
 | 
			
		||||
			after: (self, e) => {
 | 
			
		||||
				self.$emit('after', e);
 | 
			
		||||
			}
 | 
			
		||||
		}),
 | 
			
		||||
	],
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		pagination: {
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		prop: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	emits: ['before', 'after'],
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		notes(): any[] {
 | 
			
		||||
			return this.prop ? this.items.map(item => item[this.prop]) : this.items;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		reversed(): boolean {
 | 
			
		||||
			return this.pagination.reversed;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		updated(oldValue, newValue) {
 | 
			
		||||
			const i = this.notes.findIndex(n => n === oldValue);
 | 
			
		||||
			if (this.prop) {
 | 
			
		||||
				this.items[i][this.prop] = newValue;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.items[i] = newValue;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.notes.focus();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,259 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div v-if="channel" class="hhizbblb">
 | 
			
		||||
	<div v-if="date" class="info">
 | 
			
		||||
		<MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div ref="body" class="tl">
 | 
			
		||||
		<div v-if="queue > 0" class="new" :style="{ width: width + 'px', bottom: bottom + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
 | 
			
		||||
		<XNotes ref="tl" v-follow="true" class="tl" :pagination="pagination" @queue="queueUpdated"/>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="bottom">
 | 
			
		||||
		<div v-if="typers.length > 0" class="typers">
 | 
			
		||||
			<I18n :src="$ts.typingUsers" text-tag="span" class="users">
 | 
			
		||||
				<template #users>
 | 
			
		||||
					<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
 | 
			
		||||
				</template>
 | 
			
		||||
			</I18n>
 | 
			
		||||
			<MkEllipsis/>
 | 
			
		||||
		</div>
 | 
			
		||||
		<XPostForm :channel="channel"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { computed, defineComponent, markRaw } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import XNotes from '../notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import * as sound from '@/scripts/sound';
 | 
			
		||||
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
 | 
			
		||||
import follow from '@/directives/follow-append';
 | 
			
		||||
import XPostForm from '../post-form.vue';
 | 
			
		||||
import MkInfo from '@/components/ui/info.vue';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotes,
 | 
			
		||||
		XPostForm,
 | 
			
		||||
		MkInfo,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	directives: {
 | 
			
		||||
		follow
 | 
			
		||||
	},
 | 
			
		||||
	
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			inChannel: true
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		channelId: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			channel: null as Misskey.entities.Channel | null,
 | 
			
		||||
			connection: null,
 | 
			
		||||
			pagination: null,
 | 
			
		||||
			baseQuery: {
 | 
			
		||||
				includeMyRenotes: this.$store.state.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.showLocalRenotes
 | 
			
		||||
			},
 | 
			
		||||
			queue: 0,
 | 
			
		||||
			width: 0,
 | 
			
		||||
			top: 0,
 | 
			
		||||
			bottom: 0,
 | 
			
		||||
			typers: [],
 | 
			
		||||
			date: null,
 | 
			
		||||
			[symbols.PAGE_INFO]: computed(() => ({
 | 
			
		||||
				title: this.channel ? this.channel.name : '-',
 | 
			
		||||
				subtitle: this.channel ? this.channel.description : '-',
 | 
			
		||||
				icon: 'fas fa-satellite-dish',
 | 
			
		||||
				actions: [{
 | 
			
		||||
					icon: this.channel?.isFollowing ? 'fas fa-star' : 'far fa-star',
 | 
			
		||||
					text: this.channel?.isFollowing ? this.$ts.unfollow : this.$ts.follow,
 | 
			
		||||
					highlighted: this.channel?.isFollowing,
 | 
			
		||||
					handler: this.toggleChannelFollow
 | 
			
		||||
				}, {
 | 
			
		||||
					icon: 'fas fa-search',
 | 
			
		||||
					text: this.$ts.inChannelSearch,
 | 
			
		||||
					handler: this.inChannelSearch
 | 
			
		||||
				}, {
 | 
			
		||||
					icon: 'fas fa-calendar-alt',
 | 
			
		||||
					text: this.$ts.jumpToSpecifiedDate,
 | 
			
		||||
					handler: this.timetravel
 | 
			
		||||
				}]
 | 
			
		||||
			})),
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	async created() {
 | 
			
		||||
		this.channel = await os.api('channels/show', { channelId: this.channelId });
 | 
			
		||||
 | 
			
		||||
		const prepend = note => {
 | 
			
		||||
			(this.$refs.tl as any).prepend(note);
 | 
			
		||||
 | 
			
		||||
			this.$emit('note');
 | 
			
		||||
 | 
			
		||||
			sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		this.connection = markRaw(stream.useChannel('channel', {
 | 
			
		||||
			channelId: this.channelId
 | 
			
		||||
		}));
 | 
			
		||||
		this.connection.on('note', prepend);
 | 
			
		||||
		this.connection.on('typers', typers => {
 | 
			
		||||
			this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		this.pagination = {
 | 
			
		||||
			endpoint: 'channels/timeline',
 | 
			
		||||
			reversed: true,
 | 
			
		||||
			limit: 10,
 | 
			
		||||
			params: init => ({
 | 
			
		||||
				channelId: this.channelId,
 | 
			
		||||
				untilDate: this.date?.getTime(),
 | 
			
		||||
				...this.baseQuery
 | 
			
		||||
			})
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.body.focus();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		goTop() {
 | 
			
		||||
			const container = getScrollContainer(this.$refs.body);
 | 
			
		||||
			container.scrollTop = 0;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		queueUpdated(q) {
 | 
			
		||||
			if (this.$refs.body.offsetWidth !== 0) {
 | 
			
		||||
				const rect = this.$refs.body.getBoundingClientRect();
 | 
			
		||||
				this.width = this.$refs.body.offsetWidth;
 | 
			
		||||
				this.top = rect.top;
 | 
			
		||||
				this.bottom = this.$refs.body.offsetHeight;
 | 
			
		||||
			}
 | 
			
		||||
			this.queue = q;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async inChannelSearch() {
 | 
			
		||||
			const { canceled, result: query } = await os.inputText({
 | 
			
		||||
				title: this.$ts.inChannelSearch,
 | 
			
		||||
			});
 | 
			
		||||
			if (canceled || query == null || query === '') return;
 | 
			
		||||
			router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.channelId}`);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async toggleChannelFollow() {
 | 
			
		||||
			if (this.channel.isFollowing) {
 | 
			
		||||
				await os.apiWithDialog('channels/unfollow', {
 | 
			
		||||
					channelId: this.channel.id
 | 
			
		||||
				});
 | 
			
		||||
				this.channel.isFollowing = false;
 | 
			
		||||
			} else {
 | 
			
		||||
				await os.apiWithDialog('channels/follow', {
 | 
			
		||||
					channelId: this.channel.id
 | 
			
		||||
				});
 | 
			
		||||
				this.channel.isFollowing = true;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		openChannelMenu(ev) {
 | 
			
		||||
			os.popupMenu([{
 | 
			
		||||
				text: this.$ts.copyUrl,
 | 
			
		||||
				icon: 'fas fa-link',
 | 
			
		||||
				action: () => {
 | 
			
		||||
					copyToClipboard(`${url}/channels/${this.currentChannel.id}`);
 | 
			
		||||
				}
 | 
			
		||||
			}], ev.currentTarget || ev.target);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		timetravel(date?: Date) {
 | 
			
		||||
			this.date = date;
 | 
			
		||||
			this.$refs.tl.reload();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.hhizbblb {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
	flex: 1;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
 | 
			
		||||
	> .info {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .top {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .bottom {
 | 
			
		||||
		padding: 0 16px 16px 16px;
 | 
			
		||||
		position: relative;
 | 
			
		||||
 | 
			
		||||
		> .typers {
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			bottom: 100%;
 | 
			
		||||
			padding: 0 8px 0 8px;
 | 
			
		||||
			font-size: 0.9em;
 | 
			
		||||
			background: var(--panel);
 | 
			
		||||
			border-radius: 0 8px 0 0;
 | 
			
		||||
			color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
			> .users {
 | 
			
		||||
				> .user + .user:before {
 | 
			
		||||
					content: ", ";
 | 
			
		||||
					font-weight: normal;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .user:last-of-type:after {
 | 
			
		||||
					content: " ";
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tl {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		padding: 16px 0;
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
 | 
			
		||||
		> .new {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
 | 
			
		||||
			> button {
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 16px auto;
 | 
			
		||||
				padding: 8px 16px;
 | 
			
		||||
				border-radius: 32px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,222 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="dbiokgaf">
 | 
			
		||||
	<div v-if="date" class="info">
 | 
			
		||||
		<MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="top">
 | 
			
		||||
		<XPostForm/>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div ref="body" class="tl">
 | 
			
		||||
		<div v-if="queue > 0" class="new" :style="{ width: width + 'px', top: top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
 | 
			
		||||
		<XNotes ref="tl" class="tl" :pagination="pagination" @queue="queueUpdated"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { computed, defineComponent, markRaw } from 'vue';
 | 
			
		||||
import XNotes from '../notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import * as sound from '@/scripts/sound';
 | 
			
		||||
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
 | 
			
		||||
import follow from '@/directives/follow-append';
 | 
			
		||||
import XPostForm from '../post-form.vue';
 | 
			
		||||
import MkInfo from '@/components/ui/info.vue';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotes,
 | 
			
		||||
		XPostForm,
 | 
			
		||||
		MkInfo,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	directives: {
 | 
			
		||||
		follow
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		src: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			connection: null,
 | 
			
		||||
			connection2: null,
 | 
			
		||||
			pagination: null,
 | 
			
		||||
			baseQuery: {
 | 
			
		||||
				includeMyRenotes: this.$store.state.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.showLocalRenotes
 | 
			
		||||
			},
 | 
			
		||||
			query: {},
 | 
			
		||||
			queue: 0,
 | 
			
		||||
			width: 0,
 | 
			
		||||
			top: 0,
 | 
			
		||||
			bottom: 0,
 | 
			
		||||
			typers: [],
 | 
			
		||||
			date: null,
 | 
			
		||||
			[symbols.PAGE_INFO]: computed(() => ({
 | 
			
		||||
				title: this.$ts.timeline,
 | 
			
		||||
				icon: 'fas fa-home',
 | 
			
		||||
				actions: [{
 | 
			
		||||
					icon: 'fas fa-calendar-alt',
 | 
			
		||||
					text: this.$ts.jumpToSpecifiedDate,
 | 
			
		||||
					handler: this.timetravel
 | 
			
		||||
				}]
 | 
			
		||||
			})),
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		const prepend = note => {
 | 
			
		||||
			(this.$refs.tl as any).prepend(note);
 | 
			
		||||
 | 
			
		||||
			this.$emit('note');
 | 
			
		||||
 | 
			
		||||
			sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const onChangeFollowing = () => {
 | 
			
		||||
			if (!this.$refs.tl.backed) {
 | 
			
		||||
				this.$refs.tl.reload();
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		let endpoint;
 | 
			
		||||
 | 
			
		||||
		if (this.src == 'home') {
 | 
			
		||||
			endpoint = 'notes/timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('homeTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
 | 
			
		||||
			this.connection2 = markRaw(stream.useChannel('main'));
 | 
			
		||||
			this.connection2.on('follow', onChangeFollowing);
 | 
			
		||||
			this.connection2.on('unfollow', onChangeFollowing);
 | 
			
		||||
		} else if (this.src == 'local') {
 | 
			
		||||
			endpoint = 'notes/local-timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('localTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'social') {
 | 
			
		||||
			endpoint = 'notes/hybrid-timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('hybridTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		} else if (this.src == 'global') {
 | 
			
		||||
			endpoint = 'notes/global-timeline';
 | 
			
		||||
			this.connection = markRaw(stream.useChannel('globalTimeline'));
 | 
			
		||||
			this.connection.on('note', prepend);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.pagination = {
 | 
			
		||||
			endpoint: endpoint,
 | 
			
		||||
			limit: 10,
 | 
			
		||||
			params: init => ({
 | 
			
		||||
				untilDate: this.date?.getTime(),
 | 
			
		||||
				...this.baseQuery, ...this.query
 | 
			
		||||
			})
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
		if (this.connection2) this.connection2.dispose();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		focus() {
 | 
			
		||||
			this.$refs.body.focus();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		goTop() {
 | 
			
		||||
			const container = getScrollContainer(this.$refs.body);
 | 
			
		||||
			container.scrollTop = 0;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		queueUpdated(q) {
 | 
			
		||||
			if (this.$refs.body.offsetWidth !== 0) {
 | 
			
		||||
				const rect = this.$refs.body.getBoundingClientRect();
 | 
			
		||||
				this.width = this.$refs.body.offsetWidth;
 | 
			
		||||
				this.top = rect.top;
 | 
			
		||||
				this.bottom = this.$refs.body.offsetHeight;
 | 
			
		||||
			}
 | 
			
		||||
			this.queue = q;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		timetravel(date?: Date) {
 | 
			
		||||
			this.date = date;
 | 
			
		||||
			this.$refs.tl.reload();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.dbiokgaf {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
	flex: 1;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
 | 
			
		||||
	> .info {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .top {
 | 
			
		||||
		padding: 16px 16px 0 16px;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .bottom {
 | 
			
		||||
		padding: 0 16px 16px 16px;
 | 
			
		||||
		position: relative;
 | 
			
		||||
 | 
			
		||||
		> .typers {
 | 
			
		||||
			position: absolute;
 | 
			
		||||
			bottom: 100%;
 | 
			
		||||
			padding: 0 8px 0 8px;
 | 
			
		||||
			font-size: 0.9em;
 | 
			
		||||
			background: var(--panel);
 | 
			
		||||
			border-radius: 0 8px 0 0;
 | 
			
		||||
			color: var(--fgTransparentWeak);
 | 
			
		||||
 | 
			
		||||
			> .users {
 | 
			
		||||
				> .user + .user:before {
 | 
			
		||||
					content: ", ";
 | 
			
		||||
					font-weight: normal;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .user:last-of-type:after {
 | 
			
		||||
					content: " ";
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .tl {
 | 
			
		||||
		position: relative;
 | 
			
		||||
		padding: 16px 0;
 | 
			
		||||
		flex: 1;
 | 
			
		||||
		min-width: 0;
 | 
			
		||||
		overflow: auto;
 | 
			
		||||
 | 
			
		||||
		> .new {
 | 
			
		||||
			position: fixed;
 | 
			
		||||
			z-index: 1000;
 | 
			
		||||
 | 
			
		||||
			> button {
 | 
			
		||||
				display: block;
 | 
			
		||||
				margin: 16px auto;
 | 
			
		||||
				padding: 8px 16px;
 | 
			
		||||
				border-radius: 32px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,770 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="pxiwixjf"
 | 
			
		||||
	@dragover.stop="onDragover"
 | 
			
		||||
	@dragenter="onDragenter"
 | 
			
		||||
	@dragleave="onDragleave"
 | 
			
		||||
	@drop.stop="onDrop"
 | 
			
		||||
>
 | 
			
		||||
	<div class="form">
 | 
			
		||||
		<div v-if="quoteId" class="with-quote"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div>
 | 
			
		||||
		<div v-if="visibility === 'specified'" class="to-specified">
 | 
			
		||||
			<span style="margin-right: 8px;">{{ $ts.recipient }}</span>
 | 
			
		||||
			<div class="visibleUsers">
 | 
			
		||||
				<span v-for="u in visibleUsers" :key="u.id">
 | 
			
		||||
					<MkAcct :user="u"/>
 | 
			
		||||
					<button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button>
 | 
			
		||||
				</span>
 | 
			
		||||
				<button class="_buttonPrimary" @click="addVisibleUser"><i class="fas fa-plus fa-fw"></i></button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<input v-show="useCw" ref="cw" v-model="cw" class="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
 | 
			
		||||
		<textarea ref="text" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
 | 
			
		||||
		<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
 | 
			
		||||
		<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
 | 
			
		||||
		<footer>
 | 
			
		||||
			<div class="left">
 | 
			
		||||
				<button v-tooltip="$ts.attachFile" class="_button" @click="chooseFileFrom"><i class="fas fa-photo-video"></i></button>
 | 
			
		||||
				<button v-tooltip="$ts.poll" class="_button" :class="{ active: poll }" @click="togglePoll"><i class="fas fa-poll-h"></i></button>
 | 
			
		||||
				<button v-tooltip="$ts.useCw" class="_button" :class="{ active: useCw }" @click="useCw = !useCw"><i class="fas fa-eye-slash"></i></button>
 | 
			
		||||
				<button v-tooltip="$ts.mention" class="_button" @click="insertMention"><i class="fas fa-at"></i></button>
 | 
			
		||||
				<button v-tooltip="$ts.emoji" class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
 | 
			
		||||
				<button v-if="postFormActions.length > 0" v-tooltip="$ts.plugin" class="_button" @click="showActions"><i class="fas fa-plug"></i></button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="right">
 | 
			
		||||
				<span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
 | 
			
		||||
				<span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span>
 | 
			
		||||
				<button ref="visibilityButton" v-tooltip="$ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
 | 
			
		||||
					<span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
 | 
			
		||||
					<span v-if="visibility === 'home'"><i class="fas fa-home"></i></span>
 | 
			
		||||
					<span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span>
 | 
			
		||||
					<span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span>
 | 
			
		||||
				</button>
 | 
			
		||||
				<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</footer>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import insertTextAtCursor from 'insert-text-at-cursor';
 | 
			
		||||
import { length } from 'stringz';
 | 
			
		||||
import { toASCII } from 'punycode/';
 | 
			
		||||
import * as mfm from 'mfm-js';
 | 
			
		||||
import { host, url } from '@/config';
 | 
			
		||||
import { erase, unique } from '@/scripts/array';
 | 
			
		||||
import { extractMentions } from '@/scripts/extract-mentions';
 | 
			
		||||
import * as Acct from 'misskey-js/built/acct';
 | 
			
		||||
import { formatTimeString } from '@/scripts/format-time-string';
 | 
			
		||||
import { Autocomplete } from '@/scripts/autocomplete';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import { selectFiles } from '@/scripts/select-file';
 | 
			
		||||
import { notePostInterruptors, postFormActions } from '@/store';
 | 
			
		||||
import { throttle } from 'throttle-debounce';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')),
 | 
			
		||||
		XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue'))
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		reply: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		renote: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		channel: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		mention: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		specified: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		initialText: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		initialNote: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: false
 | 
			
		||||
		},
 | 
			
		||||
		share: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		autofocus: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	emits: ['posted', 'cancel', 'esc'],
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			posting: false,
 | 
			
		||||
			text: '',
 | 
			
		||||
			files: [],
 | 
			
		||||
			poll: null,
 | 
			
		||||
			useCw: false,
 | 
			
		||||
			cw: null,
 | 
			
		||||
			localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
 | 
			
		||||
			visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility,
 | 
			
		||||
			visibleUsers: [],
 | 
			
		||||
			autocomplete: null,
 | 
			
		||||
			draghover: false,
 | 
			
		||||
			quoteId: null,
 | 
			
		||||
			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
 | 
			
		||||
			imeText: '',
 | 
			
		||||
			typing: throttle(3000, () => {
 | 
			
		||||
				if (this.channel) {
 | 
			
		||||
					stream.send('typingOnChannel', { channel: this.channel });
 | 
			
		||||
				}
 | 
			
		||||
			}),
 | 
			
		||||
			postFormActions,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		draftKey(): string {
 | 
			
		||||
			let key = this.channel ? `channel:${this.channel}` : '';
 | 
			
		||||
 | 
			
		||||
			if (this.renote) {
 | 
			
		||||
				key += `renote:${this.renote.id}`;
 | 
			
		||||
			} else if (this.reply) {
 | 
			
		||||
				key += `reply:${this.reply.id}`;
 | 
			
		||||
			} else {
 | 
			
		||||
				key += 'note';
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return key;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		placeholder(): string {
 | 
			
		||||
			if (this.renote) {
 | 
			
		||||
				return this.$ts._postForm.quotePlaceholder;
 | 
			
		||||
			} else if (this.reply) {
 | 
			
		||||
				return this.$ts._postForm.replyPlaceholder;
 | 
			
		||||
			} else if (this.channel) {
 | 
			
		||||
				return this.$ts._postForm.channelPlaceholder;
 | 
			
		||||
			} else {
 | 
			
		||||
				const xs = [
 | 
			
		||||
					this.$ts._postForm._placeholders.a,
 | 
			
		||||
					this.$ts._postForm._placeholders.b,
 | 
			
		||||
					this.$ts._postForm._placeholders.c,
 | 
			
		||||
					this.$ts._postForm._placeholders.d,
 | 
			
		||||
					this.$ts._postForm._placeholders.e,
 | 
			
		||||
					this.$ts._postForm._placeholders.f
 | 
			
		||||
				];
 | 
			
		||||
				return xs[Math.floor(Math.random() * xs.length)];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		submitText(): string {
 | 
			
		||||
			return this.renote
 | 
			
		||||
				? this.$ts.quote
 | 
			
		||||
				: this.reply
 | 
			
		||||
					? this.$ts.reply
 | 
			
		||||
					: this.$ts.note;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		textLength(): number {
 | 
			
		||||
			return length((this.text + this.imeText).trim());
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		canPost(): boolean {
 | 
			
		||||
			return !this.posting &&
 | 
			
		||||
				(1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
 | 
			
		||||
				(this.textLength <= this.max) &&
 | 
			
		||||
				(!this.poll || this.poll.choices.length >= 2);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		max(): number {
 | 
			
		||||
			return this.$instance ? this.$instance.maxNoteTextLength : 1000;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (this.initialText) {
 | 
			
		||||
			this.text = this.initialText;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.mention) {
 | 
			
		||||
			this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
 | 
			
		||||
			this.text += ' ';
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.reply && (this.reply.user.username != this.$i.username || (this.reply.user.host != null && this.reply.user.host != host))) {
 | 
			
		||||
			this.text = `@${this.reply.user.username}${this.reply.user.host != null ? '@' + toASCII(this.reply.user.host) : ''} `;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.reply && this.reply.text != null) {
 | 
			
		||||
			const ast = mfm.parse(this.reply.text);
 | 
			
		||||
 | 
			
		||||
			for (const x of extractMentions(ast)) {
 | 
			
		||||
				const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
 | 
			
		||||
 | 
			
		||||
				// 自分は除外
 | 
			
		||||
				if (this.$i.username == x.username && x.host == null) continue;
 | 
			
		||||
				if (this.$i.username == x.username && x.host == host) continue;
 | 
			
		||||
 | 
			
		||||
				// 重複は除外
 | 
			
		||||
				if (this.text.indexOf(`${mention} `) != -1) continue;
 | 
			
		||||
 | 
			
		||||
				this.text += `${mention} `;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.channel) {
 | 
			
		||||
			this.visibility = 'public';
 | 
			
		||||
			this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 公開以外へのリプライ時は元の公開範囲を引き継ぐ
 | 
			
		||||
		if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
 | 
			
		||||
			this.visibility = this.reply.visibility;
 | 
			
		||||
			if (this.reply.visibility === 'specified') {
 | 
			
		||||
				os.api('users/show', {
 | 
			
		||||
					userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
 | 
			
		||||
				}).then(users => {
 | 
			
		||||
					this.visibleUsers.push(...users);
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				if (this.reply.userId !== this.$i.id) {
 | 
			
		||||
					os.api('users/show', { userId: this.reply.userId }).then(user => {
 | 
			
		||||
						this.visibleUsers.push(user);
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.specified) {
 | 
			
		||||
			this.visibility = 'specified';
 | 
			
		||||
			this.visibleUsers.push(this.specified);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// keep cw when reply
 | 
			
		||||
		if (this.$store.state.keepCw && this.reply && this.reply.cw) {
 | 
			
		||||
			this.useCw = true;
 | 
			
		||||
			this.cw = this.reply.cw;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (this.autofocus) {
 | 
			
		||||
			this.focus();
 | 
			
		||||
 | 
			
		||||
			this.$nextTick(() => {
 | 
			
		||||
				this.focus();
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// TODO: detach when unmount
 | 
			
		||||
		new Autocomplete(this.$refs.text, this, { model: 'text' });
 | 
			
		||||
		new Autocomplete(this.$refs.cw, this, { model: 'cw' });
 | 
			
		||||
 | 
			
		||||
		this.$nextTick(() => {
 | 
			
		||||
			// 書きかけの投稿を復元
 | 
			
		||||
			if (!this.share && !this.mention && !this.specified) {
 | 
			
		||||
				const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
 | 
			
		||||
				if (draft) {
 | 
			
		||||
					this.text = draft.data.text;
 | 
			
		||||
					this.useCw = draft.data.useCw;
 | 
			
		||||
					this.cw = draft.data.cw;
 | 
			
		||||
					this.visibility = draft.data.visibility;
 | 
			
		||||
					this.localOnly = draft.data.localOnly;
 | 
			
		||||
					this.files = (draft.data.files || []).filter(e => e);
 | 
			
		||||
					if (draft.data.poll) {
 | 
			
		||||
						this.poll = draft.data.poll;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// 削除して編集
 | 
			
		||||
			if (this.initialNote) {
 | 
			
		||||
				const init = this.initialNote;
 | 
			
		||||
				this.text = init.text ? init.text : '';
 | 
			
		||||
				this.files = init.files;
 | 
			
		||||
				this.cw = init.cw;
 | 
			
		||||
				this.useCw = init.cw != null;
 | 
			
		||||
				if (init.poll) {
 | 
			
		||||
					this.poll = init.poll;
 | 
			
		||||
				}
 | 
			
		||||
				this.visibility = init.visibility;
 | 
			
		||||
				this.localOnly = init.localOnly;
 | 
			
		||||
				this.quoteId = init.renote ? init.renote.id : null;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			this.$nextTick(() => this.watch());
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		watch() {
 | 
			
		||||
			this.$watch('text', () => this.saveDraft());
 | 
			
		||||
			this.$watch('useCw', () => this.saveDraft());
 | 
			
		||||
			this.$watch('cw', () => this.saveDraft());
 | 
			
		||||
			this.$watch('poll', () => this.saveDraft());
 | 
			
		||||
			this.$watch('files', () => this.saveDraft(), { deep: true });
 | 
			
		||||
			this.$watch('visibility', () => this.saveDraft());
 | 
			
		||||
			this.$watch('localOnly', () => this.saveDraft());
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		togglePoll() {
 | 
			
		||||
			if (this.poll) {
 | 
			
		||||
				this.poll = null;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.poll = {
 | 
			
		||||
					choices: ['', ''],
 | 
			
		||||
					multiple: false,
 | 
			
		||||
					expiresAt: null,
 | 
			
		||||
					expiredAfter: null,
 | 
			
		||||
				};
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		addTag(tag: string) {
 | 
			
		||||
			insertTextAtCursor(this.$refs.text, ` #${tag} `);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focus() {
 | 
			
		||||
			(this.$refs.text as any).focus();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		chooseFileFrom(ev) {
 | 
			
		||||
			selectFiles(ev.currentTarget || ev.target, this.$ts.attachFile).then(files => {
 | 
			
		||||
				for (const file of files) {
 | 
			
		||||
					this.files.push(file);
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		detachFile(id) {
 | 
			
		||||
			this.files = this.files.filter(x => x.id != id);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateFiles(files) {
 | 
			
		||||
			this.files = files;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateFileSensitive(file, sensitive) {
 | 
			
		||||
			this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateFileName(file, name) {
 | 
			
		||||
			this.files[this.files.findIndex(x => x.id === file.id)].name = name;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		upload(file: File, name?: string) {
 | 
			
		||||
			os.upload(file, this.$store.state.uploadFolder, name).then(res => {
 | 
			
		||||
				this.files.push(res);
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onPollUpdate(poll) {
 | 
			
		||||
			this.poll = poll;
 | 
			
		||||
			this.saveDraft();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		setVisibility() {
 | 
			
		||||
			if (this.channel) {
 | 
			
		||||
				// TODO: information dialog
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			os.popup(import('@/components/visibility-picker.vue'), {
 | 
			
		||||
				currentVisibility: this.visibility,
 | 
			
		||||
				currentLocalOnly: this.localOnly,
 | 
			
		||||
				src: this.$refs.visibilityButton
 | 
			
		||||
			}, {
 | 
			
		||||
				changeVisibility: visibility => {
 | 
			
		||||
					this.visibility = visibility;
 | 
			
		||||
					if (this.$store.state.rememberNoteVisibility) {
 | 
			
		||||
						this.$store.set('visibility', visibility);
 | 
			
		||||
					}
 | 
			
		||||
				},
 | 
			
		||||
				changeLocalOnly: localOnly => {
 | 
			
		||||
					this.localOnly = localOnly;
 | 
			
		||||
					if (this.$store.state.rememberNoteVisibility) {
 | 
			
		||||
						this.$store.set('localOnly', localOnly);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}, 'closed');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		addVisibleUser() {
 | 
			
		||||
			os.selectUser().then(user => {
 | 
			
		||||
				this.visibleUsers.push(user);
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeVisibleUser(user) {
 | 
			
		||||
			this.visibleUsers = erase(user, this.visibleUsers);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		clear() {
 | 
			
		||||
			this.text = '';
 | 
			
		||||
			this.files = [];
 | 
			
		||||
			this.poll = null;
 | 
			
		||||
			this.quoteId = null;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onKeydown(e: KeyboardEvent) {
 | 
			
		||||
			if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
 | 
			
		||||
			if (e.which === 27) this.$emit('esc');
 | 
			
		||||
			this.typing();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onCompositionUpdate(e: CompositionEvent) {
 | 
			
		||||
			this.imeText = e.data;
 | 
			
		||||
			this.typing();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onCompositionEnd(e: CompositionEvent) {
 | 
			
		||||
			this.imeText = '';
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async onPaste(e: ClipboardEvent) {
 | 
			
		||||
			for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
 | 
			
		||||
				if (item.kind == 'file') {
 | 
			
		||||
					const file = item.getAsFile();
 | 
			
		||||
					const lio = file.name.lastIndexOf('.');
 | 
			
		||||
					const ext = lio >= 0 ? file.name.slice(lio) : '';
 | 
			
		||||
					const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
 | 
			
		||||
					this.upload(file, formatted);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const paste = e.clipboardData.getData('text');
 | 
			
		||||
 | 
			
		||||
			if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
 | 
			
		||||
				os.confirm({
 | 
			
		||||
					type: 'info',
 | 
			
		||||
					text: this.$ts.quoteQuestion,
 | 
			
		||||
				}).then(({ canceled }) => {
 | 
			
		||||
					if (canceled) {
 | 
			
		||||
						insertTextAtCursor(this.$refs.text, paste);
 | 
			
		||||
						return;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragover(e) {
 | 
			
		||||
			if (!e.dataTransfer.items[0]) return;
 | 
			
		||||
			const isFile = e.dataTransfer.items[0].kind == 'file';
 | 
			
		||||
			const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
 | 
			
		||||
			if (isFile || isDriveFile) {
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
				this.draghover = true;
 | 
			
		||||
				e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragenter(e) {
 | 
			
		||||
			this.draghover = true;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDragleave(e) {
 | 
			
		||||
			this.draghover = false;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onDrop(e): void {
 | 
			
		||||
			this.draghover = false;
 | 
			
		||||
 | 
			
		||||
			// ファイルだったら
 | 
			
		||||
			if (e.dataTransfer.files.length > 0) {
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
				for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			//#region ドライブのファイル
 | 
			
		||||
			const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
 | 
			
		||||
			if (driveFile != null && driveFile != '') {
 | 
			
		||||
				const file = JSON.parse(driveFile);
 | 
			
		||||
				this.files.push(file);
 | 
			
		||||
				e.preventDefault();
 | 
			
		||||
			}
 | 
			
		||||
			//#endregion
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		saveDraft() {
 | 
			
		||||
			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
 | 
			
		||||
 | 
			
		||||
			data[this.draftKey] = {
 | 
			
		||||
				updatedAt: new Date(),
 | 
			
		||||
				data: {
 | 
			
		||||
					text: this.text,
 | 
			
		||||
					useCw: this.useCw,
 | 
			
		||||
					cw: this.cw,
 | 
			
		||||
					visibility: this.visibility,
 | 
			
		||||
					localOnly: this.localOnly,
 | 
			
		||||
					files: this.files,
 | 
			
		||||
					poll: this.poll
 | 
			
		||||
				}
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			localStorage.setItem('drafts', JSON.stringify(data));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		deleteDraft() {
 | 
			
		||||
			const data = JSON.parse(localStorage.getItem('drafts') || '{}');
 | 
			
		||||
 | 
			
		||||
			delete data[this.draftKey];
 | 
			
		||||
 | 
			
		||||
			localStorage.setItem('drafts', JSON.stringify(data));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async post() {
 | 
			
		||||
			let data = {
 | 
			
		||||
				text: this.text == '' ? undefined : this.text,
 | 
			
		||||
				fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
 | 
			
		||||
				replyId: this.reply ? this.reply.id : undefined,
 | 
			
		||||
				renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
 | 
			
		||||
				channelId: this.channel ? this.channel : undefined,
 | 
			
		||||
				poll: this.poll,
 | 
			
		||||
				cw: this.useCw ? this.cw || '' : undefined,
 | 
			
		||||
				localOnly: this.localOnly,
 | 
			
		||||
				visibility: this.visibility,
 | 
			
		||||
				visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			// plugin
 | 
			
		||||
			if (notePostInterruptors.length > 0) {
 | 
			
		||||
				for (const interruptor of notePostInterruptors) {
 | 
			
		||||
					data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			this.posting = true;
 | 
			
		||||
			os.api('notes/create', data).then(() => {
 | 
			
		||||
				this.clear();
 | 
			
		||||
				this.$nextTick(() => {
 | 
			
		||||
					this.deleteDraft();
 | 
			
		||||
					this.$emit('posted');
 | 
			
		||||
					if (this.text && this.text != '') {
 | 
			
		||||
						const hashtags = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
 | 
			
		||||
						const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
 | 
			
		||||
						localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
 | 
			
		||||
					}
 | 
			
		||||
					this.posting = false;
 | 
			
		||||
				});
 | 
			
		||||
			}).catch(err => {
 | 
			
		||||
				this.posting = false;
 | 
			
		||||
				os.alert({
 | 
			
		||||
					type: 'error',
 | 
			
		||||
					text: err.message + '\n' + (err as any).id,
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		cancel() {
 | 
			
		||||
			this.$emit('cancel');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		insertMention() {
 | 
			
		||||
			os.selectUser().then(user => {
 | 
			
		||||
				insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' ');
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		async insertEmoji(ev) {
 | 
			
		||||
			os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showActions(ev) {
 | 
			
		||||
			os.popupMenu(postFormActions.map(action => ({
 | 
			
		||||
				text: action.title,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					action.handler({
 | 
			
		||||
						text: this.text
 | 
			
		||||
					}, (key, value) => {
 | 
			
		||||
						if (key === 'text') { this.text = value; }
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			})), ev.currentTarget || ev.target);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.pxiwixjf {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	border: solid 0.5px var(--divider);
 | 
			
		||||
	border-radius: 8px;
 | 
			
		||||
 | 
			
		||||
	> .form {
 | 
			
		||||
		> .preview {
 | 
			
		||||
			padding: 16px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .with-quote {
 | 
			
		||||
			margin: 0 0 8px 0;
 | 
			
		||||
			color: var(--accent);
 | 
			
		||||
 | 
			
		||||
			> button {
 | 
			
		||||
				padding: 4px 8px;
 | 
			
		||||
				color: var(--accentAlpha04);
 | 
			
		||||
 | 
			
		||||
				&:hover {
 | 
			
		||||
					color: var(--accentAlpha06);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				&:active {
 | 
			
		||||
					color: var(--accentDarken30);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .to-specified {
 | 
			
		||||
			padding: 6px 24px;
 | 
			
		||||
			margin-bottom: 8px;
 | 
			
		||||
			overflow: auto;
 | 
			
		||||
			white-space: nowrap;
 | 
			
		||||
 | 
			
		||||
			> .visibleUsers {
 | 
			
		||||
				display: inline;
 | 
			
		||||
				top: -1px;
 | 
			
		||||
				font-size: 14px;
 | 
			
		||||
 | 
			
		||||
				> button {
 | 
			
		||||
					padding: 4px;
 | 
			
		||||
					border-radius: 8px;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> span {
 | 
			
		||||
					margin-right: 14px;
 | 
			
		||||
					padding: 8px 0 8px 8px;
 | 
			
		||||
					border-radius: 8px;
 | 
			
		||||
					background: var(--X4);
 | 
			
		||||
 | 
			
		||||
					> button {
 | 
			
		||||
						padding: 4px 8px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .cw,
 | 
			
		||||
		> .text {
 | 
			
		||||
			display: block;
 | 
			
		||||
			box-sizing: border-box;
 | 
			
		||||
			padding: 16px;
 | 
			
		||||
			margin: 0;
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			font-size: 16px;
 | 
			
		||||
			border: none;
 | 
			
		||||
			border-radius: 0;
 | 
			
		||||
			background: transparent;
 | 
			
		||||
			color: var(--fg);
 | 
			
		||||
			font-family: inherit;
 | 
			
		||||
 | 
			
		||||
			&:focus {
 | 
			
		||||
				outline: none;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			&:disabled {
 | 
			
		||||
				opacity: 0.5;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .cw {
 | 
			
		||||
			z-index: 1;
 | 
			
		||||
			padding-bottom: 8px;
 | 
			
		||||
			border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .text {
 | 
			
		||||
			max-width: 100%;
 | 
			
		||||
			min-width: 100%;
 | 
			
		||||
			min-height: 60px;
 | 
			
		||||
 | 
			
		||||
			&.withCw {
 | 
			
		||||
				padding-top: 8px;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> footer {
 | 
			
		||||
			$height: 44px;
 | 
			
		||||
			display: flex;
 | 
			
		||||
			padding: 0 8px 8px 8px;
 | 
			
		||||
			line-height: $height;
 | 
			
		||||
 | 
			
		||||
			> .left {
 | 
			
		||||
				> button {
 | 
			
		||||
					display: inline-block;
 | 
			
		||||
					padding: 0;
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					font-size: 16px;
 | 
			
		||||
					width: $height;
 | 
			
		||||
					height: $height;
 | 
			
		||||
					border-radius: 6px;
 | 
			
		||||
 | 
			
		||||
					&:hover {
 | 
			
		||||
						background: var(--X5);
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					&.active {
 | 
			
		||||
						color: var(--accent);
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			> .right {
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
 | 
			
		||||
				> .text-count {
 | 
			
		||||
					opacity: 0.7;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .visibility {
 | 
			
		||||
					width: $height;
 | 
			
		||||
					margin: 0 8px;
 | 
			
		||||
 | 
			
		||||
					& + .localOnly {
 | 
			
		||||
						margin-left: 0 !important;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				
 | 
			
		||||
				> .local-only {
 | 
			
		||||
					margin: 0 0 0 12px;
 | 
			
		||||
					opacity: 0.7;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				> .submit {
 | 
			
		||||
					margin: 0;
 | 
			
		||||
					padding: 0 12px;
 | 
			
		||||
					line-height: 34px;
 | 
			
		||||
					font-weight: bold;
 | 
			
		||||
					border-radius: 4px;
 | 
			
		||||
 | 
			
		||||
					&:disabled {
 | 
			
		||||
						opacity: 0.7;
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					> i {
 | 
			
		||||
						margin-left: 6px;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,157 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div v-if="component" class="mrajymqm _narrow_">
 | 
			
		||||
	<header class="header" @contextmenu.prevent.stop="onContextmenu">
 | 
			
		||||
		<MkHeader class="title" :info="pageInfo" :center="false"/>
 | 
			
		||||
	</header>
 | 
			
		||||
	<component :is="component" v-bind="props" :ref="changePage" class="body"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
 | 
			
		||||
import { resolve } from '@/router';
 | 
			
		||||
import { url } from '@/config';
 | 
			
		||||
import * as symbols from '@/symbols';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	provide() {
 | 
			
		||||
		return {
 | 
			
		||||
			navHook: (path) => {
 | 
			
		||||
				this.navigate(path);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			path: null,
 | 
			
		||||
			component: null,
 | 
			
		||||
			props: {},
 | 
			
		||||
			pageInfo: null,
 | 
			
		||||
			history: [],
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		url(): string {
 | 
			
		||||
			return url + this.path;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		changePage(page) {
 | 
			
		||||
			if (page == null) return;
 | 
			
		||||
			if (page[symbols.PAGE_INFO]) {
 | 
			
		||||
				this.pageInfo = page[symbols.PAGE_INFO];
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		navigate(path, record = true) {
 | 
			
		||||
			if (record && this.path) this.history.push(this.path);
 | 
			
		||||
			this.path = path;
 | 
			
		||||
			const { component, props } = resolve(path);
 | 
			
		||||
			this.component = component;
 | 
			
		||||
			this.props = props;
 | 
			
		||||
			this.$emit('open');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		back() {
 | 
			
		||||
			this.navigate(this.history.pop(), false);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		close() {
 | 
			
		||||
			this.path = null;
 | 
			
		||||
			this.component = null;
 | 
			
		||||
			this.props = {};
 | 
			
		||||
			this.$emit('close');
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onContextmenu(e) {
 | 
			
		||||
			os.contextMenu([{
 | 
			
		||||
				type: 'label',
 | 
			
		||||
				text: this.path,
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-expand-alt',
 | 
			
		||||
				text: this.$ts.showInPage,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.$router.push(this.path);
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-window-maximize',
 | 
			
		||||
				text: this.$ts.openInWindow,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					os.pageWindow(this.path);
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, null, {
 | 
			
		||||
				icon: 'fas fa-external-link-alt',
 | 
			
		||||
				text: this.$ts.openInNewTab,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					window.open(this.url, '_blank');
 | 
			
		||||
					this.close();
 | 
			
		||||
				}
 | 
			
		||||
			}, {
 | 
			
		||||
				icon: 'fas fa-link',
 | 
			
		||||
				text: this.$ts.copyLink,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					copyToClipboard(this.url);
 | 
			
		||||
				}
 | 
			
		||||
			}], e);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.mrajymqm {
 | 
			
		||||
	$header-height: 54px; // TODO: どこかに集約したい
 | 
			
		||||
 | 
			
		||||
	--root-margin: 16px;
 | 
			
		||||
	--margin: var(--marginHalf);
 | 
			
		||||
 | 
			
		||||
	height: 100%;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
	> .header {
 | 
			
		||||
		display: flex;
 | 
			
		||||
		position: sticky;
 | 
			
		||||
		z-index: 1000;
 | 
			
		||||
		top: 0;
 | 
			
		||||
		height: $header-height;
 | 
			
		||||
		width: 100%;
 | 
			
		||||
		font-weight: bold;
 | 
			
		||||
		//background-color: var(--panel);
 | 
			
		||||
		-webkit-backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		backdrop-filter: var(--blur, blur(32px));
 | 
			
		||||
		background-color: var(--header);
 | 
			
		||||
		border-bottom: solid 0.5px var(--divider);
 | 
			
		||||
		box-sizing: border-box;
 | 
			
		||||
 | 
			
		||||
		> ._button {
 | 
			
		||||
			height: $header-height;
 | 
			
		||||
			width: $header-height;
 | 
			
		||||
 | 
			
		||||
			&:hover {
 | 
			
		||||
				color: var(--fgHighlighted);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .title {
 | 
			
		||||
			flex: 1;
 | 
			
		||||
			position: relative;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	> .body {
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
import { markRaw } from 'vue';
 | 
			
		||||
import { Storage } from '../../pizzax';
 | 
			
		||||
 | 
			
		||||
export const store = markRaw(new Storage('chatUi', {
 | 
			
		||||
	widgets: {
 | 
			
		||||
		where: 'account',
 | 
			
		||||
		default: [] as {
 | 
			
		||||
			name: string;
 | 
			
		||||
			id: string;
 | 
			
		||||
			data: Record<string, any>;
 | 
			
		||||
		}[]
 | 
			
		||||
	},
 | 
			
		||||
	tl: {
 | 
			
		||||
		where: 'deviceAccount',
 | 
			
		||||
		default: 'home'
 | 
			
		||||
	},
 | 
			
		||||
}));
 | 
			
		||||
@@ -1,62 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="wrmlmaau">
 | 
			
		||||
	<div class="body">
 | 
			
		||||
		<span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
 | 
			
		||||
		<span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
 | 
			
		||||
		<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
 | 
			
		||||
		<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
 | 
			
		||||
		<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
 | 
			
		||||
	</div>
 | 
			
		||||
	<details v-if="note.files.length > 0">
 | 
			
		||||
		<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
 | 
			
		||||
		<XMediaList :media-list="note.files"/>
 | 
			
		||||
	</details>
 | 
			
		||||
	<details v-if="note.poll">
 | 
			
		||||
		<summary>{{ $ts.poll }}</summary>
 | 
			
		||||
		<XPoll :note="note"/>
 | 
			
		||||
	</details>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import XPoll from '@/components/poll.vue';
 | 
			
		||||
import XMediaList from '@/components/media-list.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XPoll,
 | 
			
		||||
		XMediaList,
 | 
			
		||||
	},
 | 
			
		||||
	props: {
 | 
			
		||||
		note: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.wrmlmaau {
 | 
			
		||||
	overflow-wrap: break-word;
 | 
			
		||||
 | 
			
		||||
	> .body {
 | 
			
		||||
		> .reply {
 | 
			
		||||
			margin-right: 6px;
 | 
			
		||||
			color: var(--accent);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		> .rp {
 | 
			
		||||
			margin-left: 4px;
 | 
			
		||||
			font-style: oblique;
 | 
			
		||||
			color: var(--renote);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,62 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="qydbhufi">
 | 
			
		||||
	<XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
 | 
			
		||||
 | 
			
		||||
	<button v-if="edit" class="_textButton" style="font-size: 0.9em;" @click="edit = false">{{ $ts.editWidgetsExit }}</button>
 | 
			
		||||
	<button v-else class="_textButton" style="font-size: 0.9em;" @click="edit = true">{{ $ts.editWidgets }}</button>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, defineAsyncComponent } from 'vue';
 | 
			
		||||
import XWidgets from '@/components/widgets.vue';
 | 
			
		||||
import { store } from './store';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		XWidgets,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			edit: false,
 | 
			
		||||
			widgets: store.reactiveState.widgets
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		addWidget(widget) {
 | 
			
		||||
			store.set('widgets', [widget, ...store.state.widgets]);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		removeWidget(widget) {
 | 
			
		||||
			store.set('widgets', store.state.widgets.filter(w => w.id != widget.id));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidget({ id, data }) {
 | 
			
		||||
			// TODO: throttleしたい
 | 
			
		||||
			store.set('widgets', store.state.widgets.map(w => w.id === id ? {
 | 
			
		||||
				...w,
 | 
			
		||||
				data: data
 | 
			
		||||
			} : w));
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		updateWidgets(widgets) {
 | 
			
		||||
			store.set('widgets', widgets);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.qydbhufi {
 | 
			
		||||
	height: 100%;
 | 
			
		||||
	box-sizing: border-box;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
	padding: var(--margin);
 | 
			
		||||
 | 
			
		||||
	::v-deep(._panel) {
 | 
			
		||||
		box-shadow: none;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { computed, defineComponent } from 'vue';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import XNotes from '@/components/notes.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
@@ -34,9 +34,9 @@ export default defineComponent({
 | 
			
		||||
			pagination: {
 | 
			
		||||
				endpoint: 'notes/mentions',
 | 
			
		||||
				limit: 10,
 | 
			
		||||
				params: () => ({
 | 
			
		||||
				params: computed(() => ({
 | 
			
		||||
					visibility: 'specified'
 | 
			
		||||
				})
 | 
			
		||||
				})),
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -1,82 +1,89 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader" :naked="props.transparent">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
 | 
			
		||||
	<template #header><i class="fas fa-chart-bar"></i>{{ $ts._widgets.activity }}</template>
 | 
			
		||||
	<template #func><button class="_button" @click="toggleView()"><i class="fas fa-sort"></i></button></template>
 | 
			
		||||
 | 
			
		||||
	<div>
 | 
			
		||||
		<MkLoading v-if="fetching"/>
 | 
			
		||||
		<template v-else>
 | 
			
		||||
			<XCalendar v-show="props.view === 0" :data="[].concat(activity)"/>
 | 
			
		||||
			<XChart v-show="props.view === 1" :data="[].concat(activity)"/>
 | 
			
		||||
			<XCalendar v-show="widgetProps.view === 0" :data="[].concat(activity)"/>
 | 
			
		||||
			<XChart v-show="widgetProps.view === 1" :data="[].concat(activity)"/>
 | 
			
		||||
		</template>
 | 
			
		||||
	</div>
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import XCalendar from './activity.calendar.vue';
 | 
			
		||||
import XChart from './activity.chart.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'activity',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		view: {
 | 
			
		||||
			type: 'number',
 | 
			
		||||
			default: 0,
 | 
			
		||||
			hidden: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'activity';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
	view: {
 | 
			
		||||
		type: 'number' as const,
 | 
			
		||||
		default: 0,
 | 
			
		||||
		hidden: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure, save } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const activity = ref(null);
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
 | 
			
		||||
const toggleView = () => {
 | 
			
		||||
	if (widgetProps.view === 1) {
 | 
			
		||||
		widgetProps.view = 0;
 | 
			
		||||
	} else {
 | 
			
		||||
		widgetProps.view++;
 | 
			
		||||
	}
 | 
			
		||||
	save();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
os.api('charts/user/notes', {
 | 
			
		||||
	userId: $i.id,
 | 
			
		||||
	span: 'day',
 | 
			
		||||
	limit: 7 * 21,
 | 
			
		||||
}).then(res => {
 | 
			
		||||
	activity.value = res.diffs.normal.map((_, i) => ({
 | 
			
		||||
		total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i],
 | 
			
		||||
		notes: res.diffs.normal[i],
 | 
			
		||||
		replies: res.diffs.reply[i],
 | 
			
		||||
		renotes: res.diffs.renote[i]
 | 
			
		||||
	}));
 | 
			
		||||
	fetching.value = false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
		XCalendar,
 | 
			
		||||
		XChart,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			activity: null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		os.api('charts/user/notes', {
 | 
			
		||||
			userId: this.$i.id,
 | 
			
		||||
			span: 'day',
 | 
			
		||||
			limit: 7 * 21
 | 
			
		||||
		}).then(activity => {
 | 
			
		||||
			this.activity = activity.diffs.normal.map((_, i) => ({
 | 
			
		||||
				total: activity.diffs.normal[i] + activity.diffs.reply[i] + activity.diffs.renote[i],
 | 
			
		||||
				notes: activity.diffs.normal[i],
 | 
			
		||||
				replies: activity.diffs.reply[i],
 | 
			
		||||
				renotes: activity.diffs.renote[i]
 | 
			
		||||
			}));
 | 
			
		||||
			this.fetching = false;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		toggleView() {
 | 
			
		||||
			if (this.props.view === 1) {
 | 
			
		||||
				this.props.view = 0;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.props.view++;
 | 
			
		||||
			}
 | 
			
		||||
			this.save();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,51 +1,65 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :naked="props.transparent" :show-header="false">
 | 
			
		||||
<MkContainer :naked="widgetProps.transparent" :show-header="false">
 | 
			
		||||
	<iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, markRaw } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'ai',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'ai';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const live2d = ref<HTMLIFrameElement>();
 | 
			
		||||
 | 
			
		||||
const touched = () => {
 | 
			
		||||
	//if (this.live2d) this.live2d.changeExpression('gurugurume');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	const onMousemove = (ev: MouseEvent) => {
 | 
			
		||||
		const iframeRect = live2d.value.getBoundingClientRect();
 | 
			
		||||
		live2d.value.contentWindow.postMessage({
 | 
			
		||||
			type: 'moveCursor',
 | 
			
		||||
			body: {
 | 
			
		||||
				x: ev.clientX - iframeRect.left,
 | 
			
		||||
				y: ev.clientY - iframeRect.top,
 | 
			
		||||
			}
 | 
			
		||||
		}, '*');
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	window.addEventListener('mousemove', onMousemove, { passive: true });
 | 
			
		||||
	onUnmounted(() => {
 | 
			
		||||
		window.removeEventListener('mousemove', onMousemove);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		window.addEventListener('mousemove', ev => {
 | 
			
		||||
			const iframeRect = this.$refs.live2d.getBoundingClientRect();
 | 
			
		||||
			this.$refs.live2d.contentWindow.postMessage({
 | 
			
		||||
				type: 'moveCursor',
 | 
			
		||||
				body: {
 | 
			
		||||
					x: ev.clientX - iframeRect.left,
 | 
			
		||||
					y: ev.clientY - iframeRect.top,
 | 
			
		||||
				}
 | 
			
		||||
			}, '*');
 | 
			
		||||
		}, { passive: true });
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		touched() {
 | 
			
		||||
			//if (this.live2d) this.live2d.changeExpression('gurugurume');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader">
 | 
			
		||||
	<template #header><i class="fas fa-terminal"></i>{{ $ts._widgets.aiscript }}</template>
 | 
			
		||||
 | 
			
		||||
	<div class="uylguesu _monospace">
 | 
			
		||||
		<textarea v-model="props.script" placeholder="(1 + 1)"></textarea>
 | 
			
		||||
		<textarea v-model="widgetProps.script" placeholder="(1 + 1)"></textarea>
 | 
			
		||||
		<button class="_buttonPrimary" @click="run">RUN</button>
 | 
			
		||||
		<div class="logs">
 | 
			
		||||
			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
 | 
			
		||||
@@ -12,97 +12,109 @@
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import { AiScript, parse, utils } from '@syuilo/aiscript';
 | 
			
		||||
import { createAiScriptEnv } from '@/scripts/aiscript/api';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'aiscript',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		script: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			multiline: true,
 | 
			
		||||
			default: '(1 + 1)',
 | 
			
		||||
			hidden: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
});
 | 
			
		||||
const name = 'aiscript';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			logs: [],
 | 
			
		||||
		};
 | 
			
		||||
	script: {
 | 
			
		||||
		type: 'string' as const,
 | 
			
		||||
		multiline: true,
 | 
			
		||||
		default: '(1 + 1)',
 | 
			
		||||
		hidden: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		async run() {
 | 
			
		||||
			this.logs = [];
 | 
			
		||||
			const aiscript = new AiScript(createAiScriptEnv({
 | 
			
		||||
				storageKey: 'widget',
 | 
			
		||||
				token: this.$i?.token,
 | 
			
		||||
			}), {
 | 
			
		||||
				in: (q) => {
 | 
			
		||||
					return new Promise(ok => {
 | 
			
		||||
						os.inputText({
 | 
			
		||||
							title: q,
 | 
			
		||||
						}).then(({ canceled, result: a }) => {
 | 
			
		||||
							ok(a);
 | 
			
		||||
						});
 | 
			
		||||
					});
 | 
			
		||||
				},
 | 
			
		||||
				out: (value) => {
 | 
			
		||||
					this.logs.push({
 | 
			
		||||
						id: Math.random(),
 | 
			
		||||
						text: value.type === 'str' ? value.value : utils.valToString(value),
 | 
			
		||||
						print: true
 | 
			
		||||
					});
 | 
			
		||||
				},
 | 
			
		||||
				log: (type, params) => {
 | 
			
		||||
					switch (type) {
 | 
			
		||||
						case 'end': this.logs.push({
 | 
			
		||||
							id: Math.random(),
 | 
			
		||||
							text: utils.valToString(params.val, true),
 | 
			
		||||
							print: false
 | 
			
		||||
						}); break;
 | 
			
		||||
						default: break;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const logs = ref<{
 | 
			
		||||
	id: string;
 | 
			
		||||
	text: string;
 | 
			
		||||
	print: boolean;
 | 
			
		||||
}[]>([]);
 | 
			
		||||
 | 
			
		||||
const run = async () => {
 | 
			
		||||
	logs.value = [];
 | 
			
		||||
	const aiscript = new AiScript(createAiScriptEnv({
 | 
			
		||||
		storageKey: 'widget',
 | 
			
		||||
		token: $i?.token,
 | 
			
		||||
	}), {
 | 
			
		||||
		in: (q) => {
 | 
			
		||||
			return new Promise(ok => {
 | 
			
		||||
				os.inputText({
 | 
			
		||||
					title: q,
 | 
			
		||||
				}).then(({ canceled, result: a }) => {
 | 
			
		||||
					ok(a);
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			let ast;
 | 
			
		||||
			try {
 | 
			
		||||
				ast = parse(this.props.script);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				os.alert({
 | 
			
		||||
					type: 'error',
 | 
			
		||||
					text: 'Syntax error :('
 | 
			
		||||
				});
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
			try {
 | 
			
		||||
				await aiscript.exec(ast);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				os.alert({
 | 
			
		||||
					type: 'error',
 | 
			
		||||
					text: e
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		out: (value) => {
 | 
			
		||||
			logs.value.push({
 | 
			
		||||
				id: Math.random().toString(),
 | 
			
		||||
				text: value.type === 'str' ? value.value : utils.valToString(value),
 | 
			
		||||
				print: true,
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
		log: (type, params) => {
 | 
			
		||||
			switch (type) {
 | 
			
		||||
				case 'end': logs.value.push({
 | 
			
		||||
					id: Math.random().toString(),
 | 
			
		||||
					text: utils.valToString(params.val, true),
 | 
			
		||||
					print: false,
 | 
			
		||||
				}); break;
 | 
			
		||||
				default: break;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	let ast;
 | 
			
		||||
	try {
 | 
			
		||||
		ast = parse(widgetProps.script);
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'error',
 | 
			
		||||
			text: 'Syntax error :(',
 | 
			
		||||
		});
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	try {
 | 
			
		||||
		await aiscript.exec(ast);
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'error',
 | 
			
		||||
			text: e,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,90 +1,99 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="mkw-button">
 | 
			
		||||
	<MkButton :primary="props.colored" full @click="run">
 | 
			
		||||
		{{ props.label }}
 | 
			
		||||
	<MkButton :primary="widgetProps.colored" full @click="run">
 | 
			
		||||
		{{ widgetProps.label }}
 | 
			
		||||
	</MkButton>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { AiScript, parse, utils } from '@syuilo/aiscript';
 | 
			
		||||
import { createAiScriptEnv } from '@/scripts/aiscript/api';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
import MkButton from '@/components/ui/button.vue';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'button',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		label: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			default: 'BUTTON',
 | 
			
		||||
		},
 | 
			
		||||
		colored: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		script: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			multiline: true,
 | 
			
		||||
			default: 'Mk:dialog("hello" "world")',
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
});
 | 
			
		||||
const name = 'button';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkButton
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	label: {
 | 
			
		||||
		type: 'string' as const,
 | 
			
		||||
		default: 'BUTTON',
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	colored: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		async run() {
 | 
			
		||||
			const aiscript = new AiScript(createAiScriptEnv({
 | 
			
		||||
				storageKey: 'widget',
 | 
			
		||||
				token: this.$i?.token,
 | 
			
		||||
			}), {
 | 
			
		||||
				in: (q) => {
 | 
			
		||||
					return new Promise(ok => {
 | 
			
		||||
						os.inputText({
 | 
			
		||||
							title: q,
 | 
			
		||||
						}).then(({ canceled, result: a }) => {
 | 
			
		||||
							ok(a);
 | 
			
		||||
						});
 | 
			
		||||
					});
 | 
			
		||||
				},
 | 
			
		||||
				out: (value) => {
 | 
			
		||||
					// nop
 | 
			
		||||
				},
 | 
			
		||||
				log: (type, params) => {
 | 
			
		||||
					// nop
 | 
			
		||||
				}
 | 
			
		||||
	script: {
 | 
			
		||||
		type: 'string' as const,
 | 
			
		||||
		multiline: true,
 | 
			
		||||
		default: 'Mk:dialog("hello" "world")',
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const run = async () => {
 | 
			
		||||
	const aiscript = new AiScript(createAiScriptEnv({
 | 
			
		||||
		storageKey: 'widget',
 | 
			
		||||
		token: $i?.token,
 | 
			
		||||
	}), {
 | 
			
		||||
		in: (q) => {
 | 
			
		||||
			return new Promise(ok => {
 | 
			
		||||
				os.inputText({
 | 
			
		||||
					title: q,
 | 
			
		||||
				}).then(({ canceled, result: a }) => {
 | 
			
		||||
					ok(a);
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			let ast;
 | 
			
		||||
			try {
 | 
			
		||||
				ast = parse(this.props.script);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				os.alert({
 | 
			
		||||
					type: 'error',
 | 
			
		||||
					text: 'Syntax error :('
 | 
			
		||||
				});
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
			try {
 | 
			
		||||
				await aiscript.exec(ast);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				os.alert({
 | 
			
		||||
					type: 'error',
 | 
			
		||||
					text: e
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		out: (value) => {
 | 
			
		||||
			// nop
 | 
			
		||||
		},
 | 
			
		||||
		log: (type, params) => {
 | 
			
		||||
			// nop
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	let ast;
 | 
			
		||||
	try {
 | 
			
		||||
		ast = parse(widgetProps.script);
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'error',
 | 
			
		||||
			text: 'Syntax error :(',
 | 
			
		||||
		});
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	try {
 | 
			
		||||
		await aiscript.exec(ast);
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'error',
 | 
			
		||||
			text: e,
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="mkw-calendar" :class="{ _panel: !props.transparent }">
 | 
			
		||||
<div class="mkw-calendar" :class="{ _panel: !widgetProps.transparent }">
 | 
			
		||||
	<div class="calendar" :class="{ isHoliday }">
 | 
			
		||||
		<p class="month-and-year">
 | 
			
		||||
			<span class="year">{{ $t('yearX', { year }) }}</span>
 | 
			
		||||
@@ -32,77 +32,87 @@
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onUnmounted, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'calendar',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'calendar';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const year = ref(0);
 | 
			
		||||
const month = ref(0);
 | 
			
		||||
const day = ref(0);
 | 
			
		||||
const weekDay = ref('');
 | 
			
		||||
const yearP = ref(0);
 | 
			
		||||
const monthP = ref(0);
 | 
			
		||||
const dayP = ref(0);
 | 
			
		||||
const isHoliday = ref(false);
 | 
			
		||||
const tick = () => {
 | 
			
		||||
	const now = new Date();
 | 
			
		||||
	const nd = now.getDate();
 | 
			
		||||
	const nm = now.getMonth();
 | 
			
		||||
	const ny = now.getFullYear();
 | 
			
		||||
 | 
			
		||||
	year.value = ny;
 | 
			
		||||
	month.value = nm + 1;
 | 
			
		||||
	day.value = nd;
 | 
			
		||||
	weekDay.value = [
 | 
			
		||||
		i18n.locale._weekday.sunday,
 | 
			
		||||
		i18n.locale._weekday.monday,
 | 
			
		||||
		i18n.locale._weekday.tuesday,
 | 
			
		||||
		i18n.locale._weekday.wednesday,
 | 
			
		||||
		i18n.locale._weekday.thursday,
 | 
			
		||||
		i18n.locale._weekday.friday,
 | 
			
		||||
		i18n.locale._weekday.saturday
 | 
			
		||||
	][now.getDay()];
 | 
			
		||||
 | 
			
		||||
	const dayNumer   = now.getTime() - new Date(ny, nm, nd).getTime();
 | 
			
		||||
	const dayDenom   = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
 | 
			
		||||
	const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
 | 
			
		||||
	const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
 | 
			
		||||
	const yearNumer  = now.getTime() - new Date(ny, 0, 1).getTime();
 | 
			
		||||
	const yearDenom  = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
 | 
			
		||||
 | 
			
		||||
	dayP.value   = dayNumer   / dayDenom   * 100;
 | 
			
		||||
	monthP.value = monthNumer / monthDenom * 100;
 | 
			
		||||
	yearP.value  = yearNumer  / yearDenom  * 100;
 | 
			
		||||
 | 
			
		||||
	isHoliday.value = now.getDay() === 0 || now.getDay() === 6;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
tick();
 | 
			
		||||
 | 
			
		||||
const intervalId = setInterval(tick, 1000);
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
	clearInterval(intervalId);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			now: new Date(),
 | 
			
		||||
			year: null,
 | 
			
		||||
			month: null,
 | 
			
		||||
			day: null,
 | 
			
		||||
			weekDay: null,
 | 
			
		||||
			yearP: null,
 | 
			
		||||
			dayP: null,
 | 
			
		||||
			monthP: null,
 | 
			
		||||
			isHoliday: null,
 | 
			
		||||
			clock: null
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		this.tick();
 | 
			
		||||
		this.clock = setInterval(this.tick, 1000);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		tick() {
 | 
			
		||||
			const now = new Date();
 | 
			
		||||
			const nd = now.getDate();
 | 
			
		||||
			const nm = now.getMonth();
 | 
			
		||||
			const ny = now.getFullYear();
 | 
			
		||||
 | 
			
		||||
			this.year = ny;
 | 
			
		||||
			this.month = nm + 1;
 | 
			
		||||
			this.day = nd;
 | 
			
		||||
			this.weekDay = [
 | 
			
		||||
				this.$ts._weekday.sunday,
 | 
			
		||||
				this.$ts._weekday.monday,
 | 
			
		||||
				this.$ts._weekday.tuesday,
 | 
			
		||||
				this.$ts._weekday.wednesday,
 | 
			
		||||
				this.$ts._weekday.thursday,
 | 
			
		||||
				this.$ts._weekday.friday,
 | 
			
		||||
				this.$ts._weekday.saturday
 | 
			
		||||
			][now.getDay()];
 | 
			
		||||
 | 
			
		||||
			const dayNumer   = now.getTime() - new Date(ny, nm, nd).getTime();
 | 
			
		||||
			const dayDenom   = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
 | 
			
		||||
			const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
 | 
			
		||||
			const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
 | 
			
		||||
			const yearNumer  = now.getTime() - new Date(ny, 0, 1).getTime();
 | 
			
		||||
			const yearDenom  = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
 | 
			
		||||
 | 
			
		||||
			this.dayP   = dayNumer   / dayDenom   * 100;
 | 
			
		||||
			this.monthP = monthNumer / monthDenom * 100;
 | 
			
		||||
			this.yearP  = yearNumer  / yearDenom  * 100;
 | 
			
		||||
 | 
			
		||||
			this.isHoliday = now.getDay() === 0 || now.getDay() === 6;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,45 +1,56 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :naked="props.transparent" :show-header="false">
 | 
			
		||||
<MkContainer :naked="widgetProps.transparent" :show-header="false">
 | 
			
		||||
	<div class="vubelbmv">
 | 
			
		||||
		<MkAnalogClock class="clock" :thickness="props.thickness"/>
 | 
			
		||||
		<MkAnalogClock class="clock" :thickness="widgetProps.thickness"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import MkAnalogClock from '@/components/analog-clock.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'clock',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		thickness: {
 | 
			
		||||
			type: 'radio',
 | 
			
		||||
			default: 0.1,
 | 
			
		||||
			options: [{
 | 
			
		||||
				value: 0.1, label: 'thin'
 | 
			
		||||
			}, {
 | 
			
		||||
				value: 0.2, label: 'medium'
 | 
			
		||||
			}, {
 | 
			
		||||
				value: 0.3, label: 'thick'
 | 
			
		||||
			}]
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
});
 | 
			
		||||
const name = 'clock';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
		MkAnalogClock
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	thickness: {
 | 
			
		||||
		type: 'radio' as const,
 | 
			
		||||
		default: 0.1,
 | 
			
		||||
		options: [{
 | 
			
		||||
			value: 0.1, label: 'thin'
 | 
			
		||||
		}, {
 | 
			
		||||
			value: 0.2, label: 'medium'
 | 
			
		||||
		}, {
 | 
			
		||||
			value: 0.3, label: 'thick'
 | 
			
		||||
		}],
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,75 +0,0 @@
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import { throttle } from 'throttle-debounce';
 | 
			
		||||
import { Form } from '@/scripts/form';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export default function <T extends Form>(data: {
 | 
			
		||||
	name: string;
 | 
			
		||||
	props?: () => T;
 | 
			
		||||
}) {
 | 
			
		||||
	return defineComponent({
 | 
			
		||||
		props: {
 | 
			
		||||
			widget: {
 | 
			
		||||
				type: Object,
 | 
			
		||||
				required: false
 | 
			
		||||
			},
 | 
			
		||||
			settingCallback: {
 | 
			
		||||
				required: false
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		emits: ['updateProps'],
 | 
			
		||||
 | 
			
		||||
		data() {
 | 
			
		||||
			return {
 | 
			
		||||
				props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {},
 | 
			
		||||
				save: throttle(3000, () => {
 | 
			
		||||
					this.$emit('updateProps', this.props);
 | 
			
		||||
				}),
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		computed: {
 | 
			
		||||
			id(): string {
 | 
			
		||||
				return this.widget ? this.widget.id : null;
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		created() {
 | 
			
		||||
			this.mergeProps();
 | 
			
		||||
 | 
			
		||||
			this.$watch('props', () => {
 | 
			
		||||
				this.mergeProps();
 | 
			
		||||
			}, { deep: true });
 | 
			
		||||
 | 
			
		||||
			if (this.settingCallback) this.settingCallback(this.setting);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		methods: {
 | 
			
		||||
			mergeProps() {
 | 
			
		||||
				if (data.props) {
 | 
			
		||||
					const defaultProps = data.props();
 | 
			
		||||
					for (const prop of Object.keys(defaultProps)) {
 | 
			
		||||
						if (this.props.hasOwnProperty(prop)) continue;
 | 
			
		||||
						this.props[prop] = defaultProps[prop].default;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			},
 | 
			
		||||
 | 
			
		||||
			async setting() {
 | 
			
		||||
				const form = data.props();
 | 
			
		||||
				for (const item of Object.keys(form)) {
 | 
			
		||||
					form[item].default = this.props[item];
 | 
			
		||||
				}
 | 
			
		||||
				const { canceled, result } = await os.form(data.name, form);
 | 
			
		||||
				if (canceled) return;
 | 
			
		||||
 | 
			
		||||
				for (const key of Object.keys(result)) {
 | 
			
		||||
					this.props[key] = result[key];
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				this.save();
 | 
			
		||||
			},
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
@@ -1,73 +1,84 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="mkw-digitalClock _monospace" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }">
 | 
			
		||||
<div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
 | 
			
		||||
	<span>
 | 
			
		||||
		<span v-text="hh"></span>
 | 
			
		||||
		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 | 
			
		||||
		<span v-text="mm"></span>
 | 
			
		||||
		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 | 
			
		||||
		<span v-text="ss"></span>
 | 
			
		||||
		<span v-if="props.showMs" :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 | 
			
		||||
		<span v-if="props.showMs" v-text="ms"></span>
 | 
			
		||||
		<span v-if="widgetProps.showMs" :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
 | 
			
		||||
		<span v-if="widgetProps.showMs" v-text="ms"></span>
 | 
			
		||||
	</span>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onUnmounted, ref, watch } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'digitalClock',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		fontSize: {
 | 
			
		||||
			type: 'number',
 | 
			
		||||
			default: 1.5,
 | 
			
		||||
			step: 0.1,
 | 
			
		||||
		},
 | 
			
		||||
		showMs: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'digitalClock';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
	fontSize: {
 | 
			
		||||
		type: 'number' as const,
 | 
			
		||||
		default: 1.5,
 | 
			
		||||
		step: 0.1,
 | 
			
		||||
	},
 | 
			
		||||
	showMs: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
let intervalId;
 | 
			
		||||
const hh = ref('');
 | 
			
		||||
const mm = ref('');
 | 
			
		||||
const ss = ref('');
 | 
			
		||||
const ms = ref('');
 | 
			
		||||
const showColon = ref(true);
 | 
			
		||||
const tick = () => {
 | 
			
		||||
	const now = new Date();
 | 
			
		||||
	hh.value = now.getHours().toString().padStart(2, '0');
 | 
			
		||||
	mm.value = now.getMinutes().toString().padStart(2, '0');
 | 
			
		||||
	ss.value = now.getSeconds().toString().padStart(2, '0');
 | 
			
		||||
	ms.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
 | 
			
		||||
	showColon.value = now.getSeconds() % 2 === 0;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
tick();
 | 
			
		||||
 | 
			
		||||
watch(() => widgetProps.showMs, () => {
 | 
			
		||||
	if (intervalId) clearInterval(intervalId);
 | 
			
		||||
	intervalId = setInterval(tick, widgetProps.showMs ? 10 : 1000);
 | 
			
		||||
}, { immediate: true });
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
	clearInterval(intervalId);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			clock: null,
 | 
			
		||||
			hh: null,
 | 
			
		||||
			mm: null,
 | 
			
		||||
			ss: null,
 | 
			
		||||
			ms: null,
 | 
			
		||||
			showColon: true,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		this.tick();
 | 
			
		||||
		this.$watch(() => this.props.showMs, () => {
 | 
			
		||||
			if (this.clock) clearInterval(this.clock);
 | 
			
		||||
			this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000);
 | 
			
		||||
		}, { immediate: true });
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		tick() {
 | 
			
		||||
			const now = new Date();
 | 
			
		||||
			this.hh = now.getHours().toString().padStart(2, '0');
 | 
			
		||||
			this.mm = now.getMinutes().toString().padStart(2, '0');
 | 
			
		||||
			this.ss = now.getSeconds().toString().padStart(2, '0');
 | 
			
		||||
			this.ms = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
 | 
			
		||||
			this.showColon = now.getSeconds() % 2 === 0;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader" :foldable="foldable" :scrollable="scrollable">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable">
 | 
			
		||||
	<template #header><i class="fas fa-globe"></i>{{ $ts._widgets.federation }}</template>
 | 
			
		||||
 | 
			
		||||
	<div class="wbrkwalb">
 | 
			
		||||
@@ -18,66 +18,64 @@
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import MkMiniChart from '@/components/mini-chart.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'federation',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'federation';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const instances = ref([]);
 | 
			
		||||
const charts = ref([]);
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
 | 
			
		||||
const fetch = async () => {
 | 
			
		||||
	const instances = await os.api('federation/instances', {
 | 
			
		||||
		sort: '+lastCommunicatedAt',
 | 
			
		||||
		limit: 5
 | 
			
		||||
	});
 | 
			
		||||
	const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
 | 
			
		||||
	instances.value = instances;
 | 
			
		||||
	charts.value = charts;
 | 
			
		||||
	fetching.value = false;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	fetch();
 | 
			
		||||
	const intervalId = setInterval(fetch, 1000 * 60);
 | 
			
		||||
	onUnmounted(() => {
 | 
			
		||||
		clearInterval(intervalId);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer, MkMiniChart
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	props: {
 | 
			
		||||
		foldable: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		scrollable: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			instances: [],
 | 
			
		||||
			charts: [],
 | 
			
		||||
			fetching: true,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.fetch();
 | 
			
		||||
		this.clock = setInterval(this.fetch, 1000 * 60);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		async fetch() {
 | 
			
		||||
			const instances = await os.api('federation/instances', {
 | 
			
		||||
				sort: '+lastCommunicatedAt',
 | 
			
		||||
				limit: 5
 | 
			
		||||
			});
 | 
			
		||||
			const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
 | 
			
		||||
			this.instances = instances;
 | 
			
		||||
			this.charts = charts;
 | 
			
		||||
			this.fetching = false;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,134 +1,146 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="mkw-jobQueue _monospace" :class="{ _panel: !props.transparent }">
 | 
			
		||||
<div class="mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }">
 | 
			
		||||
	<div class="inbox">
 | 
			
		||||
		<div class="label">Inbox queue<i v-if="inbox.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
 | 
			
		||||
		<div class="label">Inbox queue<i v-if="current.inbox.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
 | 
			
		||||
		<div class="values">
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Process</div>
 | 
			
		||||
				<div :class="{ inc: inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(inbox.activeSincePrevTick) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(current.inbox.activeSincePrevTick) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Active</div>
 | 
			
		||||
				<div :class="{ inc: inbox.active > prev.inbox.active, dec: inbox.active < prev.inbox.active }">{{ number(inbox.active) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }">{{ number(current.inbox.active) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Delayed</div>
 | 
			
		||||
				<div :class="{ inc: inbox.delayed > prev.inbox.delayed, dec: inbox.delayed < prev.inbox.delayed }">{{ number(inbox.delayed) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }">{{ number(current.inbox.delayed) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Waiting</div>
 | 
			
		||||
				<div :class="{ inc: inbox.waiting > prev.inbox.waiting, dec: inbox.waiting < prev.inbox.waiting }">{{ number(inbox.waiting) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }">{{ number(current.inbox.waiting) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="deliver">
 | 
			
		||||
		<div class="label">Deliver queue<i v-if="deliver.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
 | 
			
		||||
		<div class="label">Deliver queue<i v-if="current.deliver.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
 | 
			
		||||
		<div class="values">
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Process</div>
 | 
			
		||||
				<div :class="{ inc: deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(deliver.activeSincePrevTick) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(current.deliver.activeSincePrevTick) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Active</div>
 | 
			
		||||
				<div :class="{ inc: deliver.active > prev.deliver.active, dec: deliver.active < prev.deliver.active }">{{ number(deliver.active) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }">{{ number(current.deliver.active) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Delayed</div>
 | 
			
		||||
				<div :class="{ inc: deliver.delayed > prev.deliver.delayed, dec: deliver.delayed < prev.deliver.delayed }">{{ number(deliver.delayed) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }">{{ number(current.deliver.delayed) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<div>Waiting</div>
 | 
			
		||||
				<div :class="{ inc: deliver.waiting > prev.deliver.waiting, dec: deliver.waiting < prev.deliver.waiting }">{{ number(deliver.waiting) }}</div>
 | 
			
		||||
				<div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }">{{ number(current.deliver.waiting) }}</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, markRaw } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import number from '@/filters/number';
 | 
			
		||||
import * as sound from '@/scripts/sound';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'jobQueue',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		sound: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'jobQueue';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
	sound: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const connection = stream.useChannel('queueStats');
 | 
			
		||||
const current = reactive({
 | 
			
		||||
	inbox: {
 | 
			
		||||
		activeSincePrevTick: 0,
 | 
			
		||||
		active: 0,
 | 
			
		||||
		waiting: 0,
 | 
			
		||||
		delayed: 0,
 | 
			
		||||
	},
 | 
			
		||||
	deliver: {
 | 
			
		||||
		activeSincePrevTick: 0,
 | 
			
		||||
		active: 0,
 | 
			
		||||
		waiting: 0,
 | 
			
		||||
		delayed: 0,
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
const prev = reactive({} as typeof current);
 | 
			
		||||
const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1);
 | 
			
		||||
 | 
			
		||||
for (const domain of ['inbox', 'deliver']) {
 | 
			
		||||
	prev[domain] = JSON.parse(JSON.stringify(current[domain]));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const onStats = (stats) => {
 | 
			
		||||
	for (const domain of ['inbox', 'deliver']) {
 | 
			
		||||
		prev[domain] = JSON.parse(JSON.stringify(current[domain]));
 | 
			
		||||
		current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
 | 
			
		||||
		current[domain].active = stats[domain].active;
 | 
			
		||||
		current[domain].waiting = stats[domain].waiting;
 | 
			
		||||
		current[domain].delayed = stats[domain].delayed;
 | 
			
		||||
 | 
			
		||||
		if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) {
 | 
			
		||||
			jammedSound.play();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const onStatsLog = (statsLog) => {
 | 
			
		||||
	for (const stats of [...statsLog].reverse()) {
 | 
			
		||||
		onStats(stats);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
connection.on('stats', onStats);
 | 
			
		||||
connection.on('statsLog', onStatsLog);
 | 
			
		||||
 | 
			
		||||
connection.send('requestLog', {
 | 
			
		||||
	id: Math.random().toString().substr(2, 8),
 | 
			
		||||
	length: 1,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			connection: markRaw(stream.useChannel('queueStats')),
 | 
			
		||||
			inbox: {
 | 
			
		||||
				activeSincePrevTick: 0,
 | 
			
		||||
				active: 0,
 | 
			
		||||
				waiting: 0,
 | 
			
		||||
				delayed: 0,
 | 
			
		||||
			},
 | 
			
		||||
			deliver: {
 | 
			
		||||
				activeSincePrevTick: 0,
 | 
			
		||||
				active: 0,
 | 
			
		||||
				waiting: 0,
 | 
			
		||||
				delayed: 0,
 | 
			
		||||
			},
 | 
			
		||||
			prev: {},
 | 
			
		||||
			sound: sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1)
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		for (const domain of ['inbox', 'deliver']) {
 | 
			
		||||
			this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
 | 
			
		||||
		}
 | 
			
		||||
	
 | 
			
		||||
		this.connection.on('stats', this.onStats);
 | 
			
		||||
		this.connection.on('statsLog', this.onStatsLog);
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
	connection.off('stats', onStats);
 | 
			
		||||
	connection.off('statsLog', onStatsLog);
 | 
			
		||||
	connection.dispose();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
		this.connection.send('requestLog', {
 | 
			
		||||
			id: Math.random().toString().substr(2, 8),
 | 
			
		||||
			length: 1
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.off('stats', this.onStats);
 | 
			
		||||
		this.connection.off('statsLog', this.onStatsLog);
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		onStats(stats) {
 | 
			
		||||
			for (const domain of ['inbox', 'deliver']) {
 | 
			
		||||
				this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
 | 
			
		||||
				this[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
 | 
			
		||||
				this[domain].active = stats[domain].active;
 | 
			
		||||
				this[domain].waiting = stats[domain].waiting;
 | 
			
		||||
				this[domain].delayed = stats[domain].delayed;
 | 
			
		||||
 | 
			
		||||
				if (this[domain].waiting > 0 && this.props.sound && this.sound.paused) {
 | 
			
		||||
					this.sound.play();
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onStatsLog(statsLog) {
 | 
			
		||||
			for (const stats of [...statsLog].reverse()) {
 | 
			
		||||
				this.onStats(stats);
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		number
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader">
 | 
			
		||||
	<template #header><i class="fas fa-sticky-note"></i>{{ $ts._widgets.memo }}</template>
 | 
			
		||||
 | 
			
		||||
	<div class="otgbylcu">
 | 
			
		||||
@@ -9,56 +9,60 @@
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import { defaultStore } from '@/store';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'memo',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'memo';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const text = ref<string | null>(defaultStore.state.memo);
 | 
			
		||||
const changed = ref(false);
 | 
			
		||||
let timeoutId;
 | 
			
		||||
 | 
			
		||||
const saveMemo = () => {
 | 
			
		||||
	defaultStore.set('memo', text.value);
 | 
			
		||||
	changed.value = false;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const onChange = () => {
 | 
			
		||||
	changed.value = true;
 | 
			
		||||
	clearTimeout(timeoutId);
 | 
			
		||||
	timeoutId = setTimeout(saveMemo, 1000);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
watch(() => defaultStore.reactiveState.memo, newText => {
 | 
			
		||||
	text.value = newText.value;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			text: null,
 | 
			
		||||
			changed: false,
 | 
			
		||||
			timeoutId: null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	created() {
 | 
			
		||||
		this.text = this.$store.state.memo;
 | 
			
		||||
 | 
			
		||||
		this.$watch(() => this.$store.reactiveState.memo, text => {
 | 
			
		||||
			this.text = text;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		onChange() {
 | 
			
		||||
			this.changed = true;
 | 
			
		||||
			clearTimeout(this.timeoutId);
 | 
			
		||||
			this.timeoutId = setTimeout(this.saveMemo, 1000);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		saveMemo() {
 | 
			
		||||
			this.$store.set('memo', this.text);
 | 
			
		||||
			this.changed = false;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,65 +1,68 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true">
 | 
			
		||||
<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true">
 | 
			
		||||
	<template #header><i class="fas fa-bell"></i>{{ $ts.notifications }}</template>
 | 
			
		||||
	<template #func><button class="_button" @click="configure()"><i class="fas fa-cog"></i></button></template>
 | 
			
		||||
	<template #func><button class="_button" @click="configureNotification()"><i class="fas fa-cog"></i></button></template>
 | 
			
		||||
 | 
			
		||||
	<div>
 | 
			
		||||
		<XNotifications :include-types="props.includingTypes"/>
 | 
			
		||||
		<XNotifications :include-types="widgetProps.includingTypes"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import XNotifications from '@/components/notifications.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'notifications',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		height: {
 | 
			
		||||
			type: 'number',
 | 
			
		||||
			default: 300,
 | 
			
		||||
		},
 | 
			
		||||
		includingTypes: {
 | 
			
		||||
			type: 'array',
 | 
			
		||||
			hidden: true,
 | 
			
		||||
			default: null,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
});
 | 
			
		||||
const name = 'notifications';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
		XNotifications,
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
		};
 | 
			
		||||
	height: {
 | 
			
		||||
		type: 'number' as const,
 | 
			
		||||
		default: 300,
 | 
			
		||||
	},
 | 
			
		||||
	includingTypes: {
 | 
			
		||||
		type: 'array' as const,
 | 
			
		||||
		hidden: true,
 | 
			
		||||
		default: null,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		configure() {
 | 
			
		||||
			os.popup(import('@/components/notification-setting-window.vue'), {
 | 
			
		||||
				includingTypes: this.props.includingTypes,
 | 
			
		||||
			}, {
 | 
			
		||||
				done: async (res) => {
 | 
			
		||||
					const { includingTypes } = res;
 | 
			
		||||
					this.props.includingTypes = includingTypes;
 | 
			
		||||
					this.save();
 | 
			
		||||
				}
 | 
			
		||||
			}, 'closed');
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure, save } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const configureNotification = () => {
 | 
			
		||||
	os.popup(import('@/components/notification-setting-window.vue'), {
 | 
			
		||||
		includingTypes: widgetProps.includingTypes,
 | 
			
		||||
	}, {
 | 
			
		||||
		done: async (res) => {
 | 
			
		||||
			const { includingTypes } = res;
 | 
			
		||||
			widgetProps.includingTypes = includingTypes;
 | 
			
		||||
			save();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	}, 'closed');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,48 +1,60 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="mkw-onlineUsers" :class="{ _panel: !props.transparent, pad: !props.transparent }">
 | 
			
		||||
<div class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }">
 | 
			
		||||
	<I18n v-if="onlineUsersCount" :src="$ts.onlineUsersCount" text-tag="span" class="text">
 | 
			
		||||
		<template #n><b>{{ onlineUsersCount }}</b></template>
 | 
			
		||||
	</I18n>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'onlineUsers',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'onlineUsers';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const onlineUsersCount = ref(0);
 | 
			
		||||
 | 
			
		||||
const tick = () => {
 | 
			
		||||
	os.api('get-online-users-count').then(res => {
 | 
			
		||||
		onlineUsersCount.value = res.count;
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	tick();
 | 
			
		||||
	const intervalId = setInterval(tick, 1000 * 15);
 | 
			
		||||
	onUnmounted(() => {
 | 
			
		||||
		clearInterval(intervalId);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			onlineUsersCount: null,
 | 
			
		||||
			clock: null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		this.tick();
 | 
			
		||||
		this.clock = setInterval(this.tick, 1000 * 15);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		tick() {
 | 
			
		||||
			os.api('get-online-users-count').then(res => {
 | 
			
		||||
				this.onlineUsersCount = res.count;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent ? true : null">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null">
 | 
			
		||||
	<template #header><i class="fas fa-camera"></i>{{ $ts._widgets.photos }}</template>
 | 
			
		||||
 | 
			
		||||
	<div class="">
 | 
			
		||||
@@ -14,70 +14,77 @@
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, markRaw } from 'vue';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import { defaultStore } from '@/store';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'photos',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'photos';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const connection = stream.useChannel('main');
 | 
			
		||||
const images = ref([]);
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
 | 
			
		||||
const onDriveFileCreated = (file) => {
 | 
			
		||||
	if (/^image\/.+$/.test(file.type)) {
 | 
			
		||||
		images.value.unshift(file);
 | 
			
		||||
		if (images.value.length > 9) images.value.pop();
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const thumbnail = (image: any): string => {
 | 
			
		||||
	return defaultStore.state.disableShowingAnimatedImages
 | 
			
		||||
		? getStaticImageUrl(image.thumbnailUrl)
 | 
			
		||||
		: image.thumbnailUrl;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
os.api('drive/stream', {
 | 
			
		||||
	type: 'image/*',
 | 
			
		||||
	limit: 9
 | 
			
		||||
}).then(res => {
 | 
			
		||||
	images.value = res;
 | 
			
		||||
	fetching.value = false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			images: [],
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			connection: null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.connection = markRaw(stream.useChannel('main'));
 | 
			
		||||
connection.on('driveFileCreated', onDriveFileCreated);
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
	connection.dispose();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
		this.connection.on('driveFileCreated', this.onDriveFileCreated);
 | 
			
		||||
 | 
			
		||||
		os.api('drive/stream', {
 | 
			
		||||
			type: 'image/*',
 | 
			
		||||
			limit: 9
 | 
			
		||||
		}).then(images => {
 | 
			
		||||
			this.images = images;
 | 
			
		||||
			this.fetching = false;
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		onDriveFileCreated(file) {
 | 
			
		||||
			if (/^image\/.+$/.test(file.type)) {
 | 
			
		||||
				this.images.unshift(file);
 | 
			
		||||
				if (this.images.length > 9) this.images.pop();
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		thumbnail(image: any): string {
 | 
			
		||||
			return this.$store.state.disableShowingAnimatedImages
 | 
			
		||||
				? getStaticImageUrl(image.thumbnailUrl)
 | 
			
		||||
				: image.thumbnailUrl;
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,22 +2,34 @@
 | 
			
		||||
<XPostForm class="_panel" :fixed="true" :autofocus="false"/>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import XPostForm from '@/components/post-form.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'postForm',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
	})
 | 
			
		||||
});
 | 
			
		||||
const name = 'postForm';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	components: {
 | 
			
		||||
		XPostForm,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader">
 | 
			
		||||
	<template #header><i class="fas fa-rss-square"></i>RSS</template>
 | 
			
		||||
	<template #func><button class="_button" @click="setting"><i class="fas fa-cog"></i></button></template>
 | 
			
		||||
	<template #func><button class="_button" @click="configure"><i class="fas fa-cog"></i></button></template>
 | 
			
		||||
 | 
			
		||||
	<div class="ekmkgxbj">
 | 
			
		||||
		<MkLoading v-if="fetching"/>
 | 
			
		||||
@@ -12,57 +12,66 @@
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'rss',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		url: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'rss';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	url: {
 | 
			
		||||
		type: 'string' as const,
 | 
			
		||||
		default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const items = ref([]);
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
 | 
			
		||||
const tick = () => {
 | 
			
		||||
	fetch(`https://api.rss2json.com/v1/api.json?rss_url=${widgetProps.url}`, {}).then(res => {
 | 
			
		||||
		res.json().then(feed => {
 | 
			
		||||
			items.value = feed.items;
 | 
			
		||||
			fetching.value = false;
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
watch(() => widgetProps.url, tick);
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	tick();
 | 
			
		||||
	const intervalId = setInterval(tick, 60000);
 | 
			
		||||
	onUnmounted(() => {
 | 
			
		||||
		clearInterval(intervalId);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			items: [],
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			clock: null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.fetch();
 | 
			
		||||
		this.clock = setInterval(this.fetch, 60000);
 | 
			
		||||
		this.$watch(() => this.props.url, this.fetch);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		fetch() {
 | 
			
		||||
			fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, {
 | 
			
		||||
			}).then(res => {
 | 
			
		||||
				res.json().then(feed => {
 | 
			
		||||
					this.items = feed.items;
 | 
			
		||||
					this.fetching = false;
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,22 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader" :naked="props.transparent">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
 | 
			
		||||
	<template #header><i class="fas fa-server"></i>{{ $ts._widgets.serverMetric }}</template>
 | 
			
		||||
	<template #func><button class="_button" @click="toggleView()"><i class="fas fa-sort"></i></button></template>
 | 
			
		||||
 | 
			
		||||
	<div v-if="meta" class="mkw-serverMetric">
 | 
			
		||||
		<XCpuMemory v-if="props.view === 0" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XNet v-if="props.view === 1" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XCpu v-if="props.view === 2" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XMemory v-if="props.view === 3" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XDisk v-if="props.view === 4" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XCpuMemory v-if="widgetProps.view === 0" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XNet v-else-if="widgetProps.view === 1" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XCpu v-else-if="widgetProps.view === 2" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XMemory v-else-if="widgetProps.view === 3" :connection="connection" :meta="meta"/>
 | 
			
		||||
		<XDisk v-else-if="widgetProps.view === 4" :connection="connection" :meta="meta"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent, markRaw } from 'vue';
 | 
			
		||||
import define from '../define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import XCpuMemory from './cpu-mem.vue';
 | 
			
		||||
import XNet from './net.vue';
 | 
			
		||||
@@ -25,59 +26,61 @@ import XDisk from './disk.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { stream } from '@/stream';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'serverMetric',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		transparent: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
		},
 | 
			
		||||
		view: {
 | 
			
		||||
			type: 'number',
 | 
			
		||||
			default: 0,
 | 
			
		||||
			hidden: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'serverMetric';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	transparent: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: false,
 | 
			
		||||
	},
 | 
			
		||||
	view: {
 | 
			
		||||
		type: 'number' as const,
 | 
			
		||||
		default: 0,
 | 
			
		||||
		hidden: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure, save } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const meta = ref(null);
 | 
			
		||||
 | 
			
		||||
os.api('server-info', {}).then(res => {
 | 
			
		||||
	meta.value = res;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
		XCpuMemory,
 | 
			
		||||
		XNet,
 | 
			
		||||
		XCpu,
 | 
			
		||||
		XMemory,
 | 
			
		||||
		XDisk,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			meta: null,
 | 
			
		||||
			connection: null,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	created() {
 | 
			
		||||
		os.api('server-info', {}).then(res => {
 | 
			
		||||
			this.meta = res;
 | 
			
		||||
		});
 | 
			
		||||
		this.connection = markRaw(stream.useChannel('serverStats'));
 | 
			
		||||
	},
 | 
			
		||||
	unmounted() {
 | 
			
		||||
		this.connection.dispose();
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		toggleView() {
 | 
			
		||||
			if (this.props.view == 4) {
 | 
			
		||||
				this.props.view = 0;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.props.view++;
 | 
			
		||||
			}
 | 
			
		||||
			this.save();
 | 
			
		||||
		},
 | 
			
		||||
const toggleView = () => {
 | 
			
		||||
	if (widgetProps.view == 4) {
 | 
			
		||||
		widgetProps.view = 0;
 | 
			
		||||
	} else {
 | 
			
		||||
		widgetProps.view++;
 | 
			
		||||
	}
 | 
			
		||||
	save();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const connection = stream.useChannel('serverStats');
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
	connection.dispose();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,126 +1,116 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div class="kvausudm _panel">
 | 
			
		||||
<div class="kvausudm _panel" :style="{ height: widgetProps.height + 'px' }">
 | 
			
		||||
	<div @click="choose">
 | 
			
		||||
		<p v-if="props.folderId == null">
 | 
			
		||||
			<template v-if="isCustomizeMode">{{ $t('folder-customize-mode') }}</template>
 | 
			
		||||
			<template v-else>{{ $ts.folder }}</template>
 | 
			
		||||
		<p v-if="widgetProps.folderId == null">
 | 
			
		||||
			{{ $ts.folder }}
 | 
			
		||||
		</p>
 | 
			
		||||
		<p v-if="props.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
 | 
			
		||||
		<p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
 | 
			
		||||
		<div ref="slideA" class="slide a"></div>
 | 
			
		||||
		<div ref="slideB" class="slide b"></div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'slideshow',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		height: {
 | 
			
		||||
			type: 'number',
 | 
			
		||||
			default: 300,
 | 
			
		||||
		},
 | 
			
		||||
		folderId: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			default: null,
 | 
			
		||||
			hidden: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'slideshow';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	height: {
 | 
			
		||||
		type: 'number' as const,
 | 
			
		||||
		default: 300,
 | 
			
		||||
	},
 | 
			
		||||
	folderId: {
 | 
			
		||||
		type: 'string' as const,
 | 
			
		||||
		default: null,
 | 
			
		||||
		hidden: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure, save } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const images = ref([]);
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
const slideA = ref<HTMLElement>();
 | 
			
		||||
const slideB = ref<HTMLElement>();
 | 
			
		||||
 | 
			
		||||
const change = () => {
 | 
			
		||||
	if (images.value.length == 0) return;
 | 
			
		||||
 | 
			
		||||
	const index = Math.floor(Math.random() * images.value.length);
 | 
			
		||||
	const img = `url(${ images.value[index].url })`;
 | 
			
		||||
 | 
			
		||||
	slideB.value.style.backgroundImage = img;
 | 
			
		||||
 | 
			
		||||
	slideB.value.classList.add('anime');
 | 
			
		||||
	setTimeout(() => {
 | 
			
		||||
		// 既にこのウィジェットがunmountされていたら要素がない
 | 
			
		||||
		if (slideA.value == null) return;
 | 
			
		||||
 | 
			
		||||
		slideA.value.style.backgroundImage = img;
 | 
			
		||||
 | 
			
		||||
		slideB.value.classList.remove('anime');
 | 
			
		||||
	}, 1000);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const fetch = () => {
 | 
			
		||||
	fetching.value = true;
 | 
			
		||||
 | 
			
		||||
	os.api('drive/files', {
 | 
			
		||||
		folderId: widgetProps.folderId,
 | 
			
		||||
		type: 'image/*',
 | 
			
		||||
		limit: 100
 | 
			
		||||
	}).then(res => {
 | 
			
		||||
		images.value = res;
 | 
			
		||||
		fetching.value = false;
 | 
			
		||||
		slideA.value.style.backgroundImage = '';
 | 
			
		||||
		slideB.value.style.backgroundImage = '';
 | 
			
		||||
		change();
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const choose = () => {
 | 
			
		||||
	os.selectDriveFolder(false).then(folder => {
 | 
			
		||||
		if (folder == null) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		widgetProps.folderId = folder.id;
 | 
			
		||||
		save();
 | 
			
		||||
		fetch();
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	if (widgetProps.folderId != null) {
 | 
			
		||||
		fetch();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const intervalId = setInterval(change, 10000);
 | 
			
		||||
	onUnmounted(() => {
 | 
			
		||||
		clearInterval(intervalId);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			images: [],
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			clock: null
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.$nextTick(() => {
 | 
			
		||||
			this.applySize();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		if (this.props.folderId != null) {
 | 
			
		||||
			this.fetch();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.clock = setInterval(this.change, 10000);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		applySize() {
 | 
			
		||||
			let h;
 | 
			
		||||
 | 
			
		||||
			if (this.props.size == 1) {
 | 
			
		||||
				h = 250;
 | 
			
		||||
			} else {
 | 
			
		||||
				h = 170;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			this.$el.style.height = `${h}px`;
 | 
			
		||||
		},
 | 
			
		||||
		resize() {
 | 
			
		||||
			if (this.props.size == 1) {
 | 
			
		||||
				this.props.size = 0;
 | 
			
		||||
			} else {
 | 
			
		||||
				this.props.size++;
 | 
			
		||||
			}
 | 
			
		||||
			this.save();
 | 
			
		||||
 | 
			
		||||
			this.applySize();
 | 
			
		||||
		},
 | 
			
		||||
		change() {
 | 
			
		||||
			if (this.images.length == 0) return;
 | 
			
		||||
 | 
			
		||||
			const index = Math.floor(Math.random() * this.images.length);
 | 
			
		||||
			const img = `url(${ this.images[index].url })`;
 | 
			
		||||
 | 
			
		||||
			(this.$refs.slideB as any).style.backgroundImage = img;
 | 
			
		||||
 | 
			
		||||
			this.$refs.slideB.classList.add('anime');
 | 
			
		||||
			setTimeout(() => {
 | 
			
		||||
				// 既にこのウィジェットがunmountされていたら要素がない
 | 
			
		||||
				if ((this.$refs.slideA as any) == null) return;
 | 
			
		||||
 | 
			
		||||
				(this.$refs.slideA as any).style.backgroundImage = img;
 | 
			
		||||
 | 
			
		||||
				this.$refs.slideB.classList.remove('anime');
 | 
			
		||||
			}, 1000);
 | 
			
		||||
		},
 | 
			
		||||
		fetch() {
 | 
			
		||||
			this.fetching = true;
 | 
			
		||||
 | 
			
		||||
			os.api('drive/files', {
 | 
			
		||||
				folderId: this.props.folderId,
 | 
			
		||||
				type: 'image/*',
 | 
			
		||||
				limit: 100
 | 
			
		||||
			}).then(images => {
 | 
			
		||||
				this.images = images;
 | 
			
		||||
				this.fetching = false;
 | 
			
		||||
				(this.$refs.slideA as any).style.backgroundImage = '';
 | 
			
		||||
				(this.$refs.slideB as any).style.backgroundImage = '';
 | 
			
		||||
				this.change();
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
		choose() {
 | 
			
		||||
			os.selectDriveFolder(false).then(folder => {
 | 
			
		||||
				if (folder == null) {
 | 
			
		||||
					return;
 | 
			
		||||
				}
 | 
			
		||||
				this.props.folderId = folder.id;
 | 
			
		||||
				this.save();
 | 
			
		||||
				this.fetch();
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,116 +1,129 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true">
 | 
			
		||||
	<template #header>
 | 
			
		||||
		<button class="_button" @click="choose">
 | 
			
		||||
			<i v-if="props.src === 'home'" class="fas fa-home"></i>
 | 
			
		||||
			<i v-else-if="props.src === 'local'" class="fas fa-comments"></i>
 | 
			
		||||
			<i v-else-if="props.src === 'social'" class="fas fa-share-alt"></i>
 | 
			
		||||
			<i v-else-if="props.src === 'global'" class="fas fa-globe"></i>
 | 
			
		||||
			<i v-else-if="props.src === 'list'" class="fas fa-list-ul"></i>
 | 
			
		||||
			<i v-else-if="props.src === 'antenna'" class="fas fa-satellite"></i>
 | 
			
		||||
			<span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
 | 
			
		||||
			<i v-if="widgetProps.src === 'home'" class="fas fa-home"></i>
 | 
			
		||||
			<i v-else-if="widgetProps.src === 'local'" class="fas fa-comments"></i>
 | 
			
		||||
			<i v-else-if="widgetProps.src === 'social'" class="fas fa-share-alt"></i>
 | 
			
		||||
			<i v-else-if="widgetProps.src === 'global'" class="fas fa-globe"></i>
 | 
			
		||||
			<i v-else-if="widgetProps.src === 'list'" class="fas fa-list-ul"></i>
 | 
			
		||||
			<i v-else-if="widgetProps.src === 'antenna'" class="fas fa-satellite"></i>
 | 
			
		||||
			<span style="margin-left: 8px;">{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : $t('_timelines.' + widgetProps.src) }}</span>
 | 
			
		||||
			<i :class="menuOpened ? 'fas fa-angle-up' : 'fas fa-angle-down'" style="margin-left: 8px;"></i>
 | 
			
		||||
		</button>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
	<div>
 | 
			
		||||
		<XTimeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/>
 | 
			
		||||
		<XTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
 | 
			
		||||
	</div>
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import XTimeline from '@/components/timeline.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'timeline',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
		height: {
 | 
			
		||||
			type: 'number',
 | 
			
		||||
			default: 300,
 | 
			
		||||
		},
 | 
			
		||||
		src: {
 | 
			
		||||
			type: 'string',
 | 
			
		||||
			default: 'home',
 | 
			
		||||
			hidden: true,
 | 
			
		||||
		},
 | 
			
		||||
		list: {
 | 
			
		||||
			type: 'object',
 | 
			
		||||
			default: null,
 | 
			
		||||
			hidden: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
});
 | 
			
		||||
const name = 'timeline';
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer,
 | 
			
		||||
		XTimeline,
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			menuOpened: false,
 | 
			
		||||
		};
 | 
			
		||||
	height: {
 | 
			
		||||
		type: 'number' as const,
 | 
			
		||||
		default: 300,
 | 
			
		||||
	},
 | 
			
		||||
	src: {
 | 
			
		||||
		type: 'string' as const,
 | 
			
		||||
		default: 'home',
 | 
			
		||||
		hidden: true,
 | 
			
		||||
	},
 | 
			
		||||
	antenna: {
 | 
			
		||||
		type: 'object' as const,
 | 
			
		||||
		default: null,
 | 
			
		||||
		hidden: true,
 | 
			
		||||
	},
 | 
			
		||||
	list: {
 | 
			
		||||
		type: 'object' as const,
 | 
			
		||||
		default: null,
 | 
			
		||||
		hidden: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		async choose(ev) {
 | 
			
		||||
			this.menuOpened = true;
 | 
			
		||||
			const [antennas, lists] = await Promise.all([
 | 
			
		||||
				os.api('antennas/list'),
 | 
			
		||||
				os.api('users/lists/list')
 | 
			
		||||
			]);
 | 
			
		||||
			const antennaItems = antennas.map(antenna => ({
 | 
			
		||||
				text: antenna.name,
 | 
			
		||||
				icon: 'fas fa-satellite',
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.props.antenna = antenna;
 | 
			
		||||
					this.setSrc('antenna');
 | 
			
		||||
				}
 | 
			
		||||
			}));
 | 
			
		||||
			const listItems = lists.map(list => ({
 | 
			
		||||
				text: list.name,
 | 
			
		||||
				icon: 'fas fa-list-ul',
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.props.list = list;
 | 
			
		||||
					this.setSrc('list');
 | 
			
		||||
				}
 | 
			
		||||
			}));
 | 
			
		||||
			os.popupMenu([{
 | 
			
		||||
				text: this.$ts._timelines.home,
 | 
			
		||||
				icon: 'fas fa-home',
 | 
			
		||||
				action: () => { this.setSrc('home') }
 | 
			
		||||
			}, {
 | 
			
		||||
				text: this.$ts._timelines.local,
 | 
			
		||||
				icon: 'fas fa-comments',
 | 
			
		||||
				action: () => { this.setSrc('local') }
 | 
			
		||||
			}, {
 | 
			
		||||
				text: this.$ts._timelines.social,
 | 
			
		||||
				icon: 'fas fa-share-alt',
 | 
			
		||||
				action: () => { this.setSrc('social') }
 | 
			
		||||
			}, {
 | 
			
		||||
				text: this.$ts._timelines.global,
 | 
			
		||||
				icon: 'fas fa-globe',
 | 
			
		||||
				action: () => { this.setSrc('global') }
 | 
			
		||||
			}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget || ev.target).then(() => {
 | 
			
		||||
				this.menuOpened = false;
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
		setSrc(src) {
 | 
			
		||||
			this.props.src = src;
 | 
			
		||||
			this.save();
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure, save } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const menuOpened = ref(false);
 | 
			
		||||
 | 
			
		||||
const setSrc = (src) => {
 | 
			
		||||
	widgetProps.src = src;
 | 
			
		||||
	save();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const choose = async (ev) => {
 | 
			
		||||
	menuOpened.value = true;
 | 
			
		||||
	const [antennas, lists] = await Promise.all([
 | 
			
		||||
		os.api('antennas/list'),
 | 
			
		||||
		os.api('users/lists/list')
 | 
			
		||||
	]);
 | 
			
		||||
	const antennaItems = antennas.map(antenna => ({
 | 
			
		||||
		text: antenna.name,
 | 
			
		||||
		icon: 'fas fa-satellite',
 | 
			
		||||
		action: () => {
 | 
			
		||||
			widgetProps.antenna = antenna;
 | 
			
		||||
			setSrc('antenna');
 | 
			
		||||
		}
 | 
			
		||||
	}));
 | 
			
		||||
	const listItems = lists.map(list => ({
 | 
			
		||||
		text: list.name,
 | 
			
		||||
		icon: 'fas fa-list-ul',
 | 
			
		||||
		action: () => {
 | 
			
		||||
			widgetProps.list = list;
 | 
			
		||||
			setSrc('list');
 | 
			
		||||
		}
 | 
			
		||||
	}));
 | 
			
		||||
	os.popupMenu([{
 | 
			
		||||
		text: i18n.locale._timelines.home,
 | 
			
		||||
		icon: 'fas fa-home',
 | 
			
		||||
		action: () => { setSrc('home') }
 | 
			
		||||
	}, {
 | 
			
		||||
		text: i18n.locale._timelines.local,
 | 
			
		||||
		icon: 'fas fa-comments',
 | 
			
		||||
		action: () => { setSrc('local') }
 | 
			
		||||
	}, {
 | 
			
		||||
		text: i18n.locale._timelines.social,
 | 
			
		||||
		icon: 'fas fa-share-alt',
 | 
			
		||||
		action: () => { setSrc('social') }
 | 
			
		||||
	}, {
 | 
			
		||||
		text: i18n.locale._timelines.global,
 | 
			
		||||
		icon: 'fas fa-globe',
 | 
			
		||||
		action: () => { setSrc('global') }
 | 
			
		||||
	}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget || ev.target).then(() => {
 | 
			
		||||
		menuOpened.value = false;
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
<MkContainer :show-header="props.showHeader">
 | 
			
		||||
<MkContainer :show-header="widgetProps.showHeader">
 | 
			
		||||
	<template #header><i class="fas fa-hashtag"></i>{{ $ts._widgets.trends }}</template>
 | 
			
		||||
 | 
			
		||||
	<div class="wbrkwala">
 | 
			
		||||
@@ -17,49 +17,59 @@
 | 
			
		||||
</MkContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import { defineComponent } from 'vue';
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, ref } from 'vue';
 | 
			
		||||
import { GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
 | 
			
		||||
import MkContainer from '@/components/ui/container.vue';
 | 
			
		||||
import define from './define';
 | 
			
		||||
import MkMiniChart from '@/components/mini-chart.vue';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const widget = define({
 | 
			
		||||
	name: 'hashtags',
 | 
			
		||||
	props: () => ({
 | 
			
		||||
		showHeader: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: true,
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
const name = 'hashtags';
 | 
			
		||||
 | 
			
		||||
const widgetPropsDef = {
 | 
			
		||||
	showHeader: {
 | 
			
		||||
		type: 'boolean' as const,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
 | 
			
		||||
 | 
			
		||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
 | 
			
		||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
 | 
			
		||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
 | 
			
		||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
 | 
			
		||||
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
 | 
			
		||||
 | 
			
		||||
const { widgetProps, configure } = useWidgetPropsManager(name,
 | 
			
		||||
	widgetPropsDef,
 | 
			
		||||
	props,
 | 
			
		||||
	emit,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const stats = ref([]);
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
 | 
			
		||||
const fetch = () => {
 | 
			
		||||
	os.api('hashtags/trend').then(stats => {
 | 
			
		||||
		stats.value = stats;
 | 
			
		||||
		fetching.value = false;
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	fetch();
 | 
			
		||||
	const intervalId = setInterval(fetch, 1000 * 60);
 | 
			
		||||
	onUnmounted(() => {
 | 
			
		||||
		clearInterval(intervalId);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default defineComponent({
 | 
			
		||||
	components: {
 | 
			
		||||
		MkContainer, MkMiniChart
 | 
			
		||||
	},
 | 
			
		||||
	extends: widget,
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			stats: [],
 | 
			
		||||
			fetching: true,
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.fetch();
 | 
			
		||||
		this.clock = setInterval(this.fetch, 1000 * 60);
 | 
			
		||||
	},
 | 
			
		||||
	beforeUnmount() {
 | 
			
		||||
		clearInterval(this.clock);
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		fetch() {
 | 
			
		||||
			os.api('hashtags/trend').then(stats => {
 | 
			
		||||
				this.stats = stats;
 | 
			
		||||
				this.fetching = false;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
defineExpose<WidgetComponentExpose>({
 | 
			
		||||
	name,
 | 
			
		||||
	configure,
 | 
			
		||||
	id: props.widget ? props.widget.id : null,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										71
									
								
								packages/client/src/widgets/widget.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								packages/client/src/widgets/widget.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
import { reactive, watch } from 'vue';
 | 
			
		||||
import { throttle } from 'throttle-debounce';
 | 
			
		||||
import { Form, GetFormResultType } from '@/scripts/form';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
export type Widget<P extends Record<string, unknown>> = {
 | 
			
		||||
	id: string;
 | 
			
		||||
	data: Partial<P>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type WidgetComponentProps<P extends Record<string, unknown>> = {
 | 
			
		||||
	widget?: Widget<P>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type WidgetComponentEmits<P extends Record<string, unknown>> = {
 | 
			
		||||
	(e: 'updateProps', props: P);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type WidgetComponentExpose = {
 | 
			
		||||
	name: string;
 | 
			
		||||
	id: string | null;
 | 
			
		||||
	configure: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useWidgetPropsManager = <F extends Form & Record<string, { default: any; }>>(
 | 
			
		||||
	name: string,
 | 
			
		||||
	propsDef: F,
 | 
			
		||||
	props: Readonly<WidgetComponentProps<GetFormResultType<F>>>,
 | 
			
		||||
	emit: WidgetComponentEmits<GetFormResultType<F>>,
 | 
			
		||||
): {
 | 
			
		||||
	widgetProps: GetFormResultType<F>;
 | 
			
		||||
	save: () => void;
 | 
			
		||||
	configure: () => void;
 | 
			
		||||
} => {
 | 
			
		||||
	const widgetProps = reactive(props.widget ? JSON.parse(JSON.stringify(props.widget.data)) : {});
 | 
			
		||||
 | 
			
		||||
	const mergeProps = () => {
 | 
			
		||||
		for (const prop of Object.keys(propsDef)) {
 | 
			
		||||
			if (widgetProps.hasOwnProperty(prop)) continue;
 | 
			
		||||
			widgetProps[prop] = propsDef[prop].default;
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
	watch(widgetProps, () => {
 | 
			
		||||
		mergeProps();
 | 
			
		||||
	}, { deep: true, immediate: true, });
 | 
			
		||||
 | 
			
		||||
	const save = throttle(3000, () => {
 | 
			
		||||
		emit('updateProps', widgetProps)
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const configure = async () => {
 | 
			
		||||
		const form = JSON.parse(JSON.stringify(propsDef));
 | 
			
		||||
		for (const item of Object.keys(form)) {
 | 
			
		||||
			form[item].default = widgetProps[item];
 | 
			
		||||
		}
 | 
			
		||||
		const { canceled, result } = await os.form(name, form);
 | 
			
		||||
		if (canceled) return;
 | 
			
		||||
 | 
			
		||||
		for (const key of Object.keys(result)) {
 | 
			
		||||
			widgetProps[key] = result[key];
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		save();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		widgetProps,
 | 
			
		||||
		save,
 | 
			
		||||
		configure,
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
		Reference in New Issue
	
	Block a user