Merge branch 'develop' into pizzax-indexeddb

This commit is contained in:
tamaina 2022-01-11 16:03:25 +09:00
commit 4219b4dd62
77 changed files with 2610 additions and 6776 deletions

View file

@ -12,6 +12,7 @@
### Changes
- Room機能が削除されました
- 後日別リポジトリとして復活予定です
- Chat UIが削除されました
### Improvements

View file

@ -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() {
function send() {
os.apiWithDialog('users/report-abuse', {
userId: this.user.id,
comment: this.comment,
}, undefined, res => {
userId: props.user.id,
comment: comment.value,
}, undefined).then(res => {
os.alert({
type: 'success',
text: this.$ts.abuseReported
text: i18n.locale.abuseReported
});
this.$refs.window.close();
window.value?.close();
emit('closed');
});
}
},
});
}
</script>
<style lang="scss" scoped>

View file

@ -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 = [];
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;
}
},
});
mounted() {
function tick() {
now.value = new Date();
}
onMounted(() => {
const update = () => {
if (this.enabled) {
this.tick();
if (enabled.value) {
tick();
setTimeout(update, 1000);
}
};
update();
},
});
beforeUnmount() {
this.enabled = false;
},
methods: {
tick() {
this.now = new Date();
}
}
onBeforeUnmount(() => {
enabled.value = false;
});
</script>

View file

@ -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,
},
y: {
type: Number,
required: true,
},
},
emits: ['done', 'closed'],
data() {
return {
getStaticImageUrl,
fetching: true,
users: [],
hashtags: [],
emojis: [],
items: [],
mfmTags: [],
select: -1,
zIndex: os.claimZIndex('high'),
}
},
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);
}
this.$nextTick(() => {
this.exec();
this.$watch('q', () => {
this.$nextTick(() => {
this.exec();
});
});
});
},
beforeUnmount() {
this.textarea.removeEventListener('keydown', this.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');
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');
function complete(type: string, value: any) {
emit('done', { type, value });
emit('closed');
if (type === 'emoji') {
let recents = this.$store.state.recentlyUsedEmojis;
let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((e: any) => e !== value);
recents.unshift(value);
this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
defaultStore.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';
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 {
this.$el.style.left = this.x + 'px';
rootEl.value.style.left = `${props.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';
if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
rootEl.value.style.marginTop = '0';
} else {
this.$el.style.top = this.y + 'px';
this.$el.style.marginTop = 'calc(1em + 8px)';
rootEl.value.style.top = props.y + 'px';
rootEl.value.style.marginTop = 'calc(1em + 8px)';
}
},
}
exec() {
this.select = -1;
if (this.$refs.suggests) {
for (const el of Array.from(this.items)) {
function exec() {
select.value = -1;
if (suggests.value) {
for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected');
}
}
if (this.type === 'user') {
if (this.q == null) {
this.users = [];
this.fetching = false;
if (props.type === 'user') {
if (!props.q) {
users.value = [];
fetching.value = false;
return;
}
const cacheKey = `autocomplete:user:${this.q}`;
const cacheKey = `autocomplete:user:${props.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const users = JSON.parse(cache);
this.users = users;
this.fetching = false;
users.value = users;
fetching.value = false;
} else {
os.api('users/search-by-username-and-host', {
username: this.q,
username: props.q,
limit: 10,
detail: false
}).then(users => {
this.users = users;
this.fetching = false;
}).then(searchedUsers => {
users.value = searchedUsers as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(users));
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
});
}
} else if (this.type === 'hashtag') {
if (this.q == null || this.q == '') {
this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
this.fetching = false;
} 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:${this.q}`;
const cacheKey = `autocomplete:hashtag:${props.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const hashtags = JSON.parse(cache);
this.hashtags = hashtags;
this.fetching = false;
hashtags.value = hashtags;
fetching.value = false;
} else {
os.api('hashtags/search', {
query: this.q,
query: props.q,
limit: 30
}).then(hashtags => {
this.hashtags = hashtags;
this.fetching = false;
}).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
fetching.value = false;
//
sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
});
}
}
} else if (this.type === 'emoji') {
if (this.q == null || this.q == '') {
} else if (props.type === 'emoji') {
if (!props.q || props.q == '') {
// 使
this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null);
emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x) as EmojiDef[];
return;
}
const matched = [];
const matched: EmojiDef[] = [];
const max = 30;
emojiDb.some(x => {
if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(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(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);
if (x.name.startsWith(props.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;
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;
}
this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q));
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q || ''));
}
},
}
onMousedown(e) {
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
},
function onMousedown(e: Event) {
if (!contains(rootEl.value, e.target) && (rootEl.value != e.target)) props.close();
}
onKeydown(e) {
function onKeydown(e: KeyboardEvent) {
const cancel = () => {
e.preventDefault();
e.stopPropagation();
};
switch (e.which) {
case 10: // [ENTER]
case 13: // [ENTER]
if (this.select !== -1) {
switch (e.key) {
case 'Enter':
if (select.value !== -1) {
cancel();
(this.items[this.select] as any).click();
(items.value[select.value] as any).click();
} else {
this.close();
props.close();
}
break;
case 27: // [ESC]
case 'Escape':
cancel();
this.close();
props.close();
break;
case 38: // []
if (this.select !== -1) {
case 'ArrowUp':
if (select.value !== -1) {
cancel();
this.selectPrev();
selectPrev();
} else {
this.close();
props.close();
}
break;
case 9: // [TAB]
case 40: // []
case 'Tab':
case 'ArrowDown':
cancel();
this.selectNext();
selectNext();
break;
default:
e.stopPropagation();
this.textarea.focus();
props.textarea.focus();
}
},
}
selectNext() {
if (++this.select >= this.items.length) this.select = 0;
if (this.items.length === 0) this.select = -1;
this.applySelect();
},
function selectNext() {
if (++select.value >= items.value.length) select.value = 0;
if (items.value.length === 0) select.value = -1;
applySelect();
}
selectPrev() {
if (--this.select < 0) this.select = this.items.length - 1;
this.applySelect();
},
function selectPrev() {
if (--select.value < 0) select.value = items.value.length - 1;
applySelect();
}
applySelect() {
for (const el of Array.from(this.items)) {
function applySelect() {
for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected');
}
if (this.select !== -1) {
this.items[this.select].setAttribute('data-selected', 'true');
(this.items[this.select] as any).focus();
if (select.value !== -1) {
items.value[select.value].setAttribute('data-selected', 'true');
(items.value[select.value] as any).focus();
}
},
}
chooseUser() {
this.close();
function chooseUser() {
props.close();
os.selectUser().then(user => {
this.complete('user', user);
this.textarea.focus();
complete('user', user);
props.textarea.focus();
});
},
}
acct
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();
});
});
});
});
onBeforeUnmount(() => {
props.textarea.removeEventListener('keydown', onKeydown);
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.removeEventListener('mousedown', onMousedown);
}
});
</script>

View file

@ -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) {
const available = ref(false);
const captchaEl = ref<HTMLDivElement | undefined>();
const variable = computed(() => {
switch (props.provider) {
case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha';
}
},
loaded(): boolean {
return !!window[this.variable];
},
src(): string {
});
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>)[this.provider];
} as Record<CaptchaProvider, string>)[props.provider];
return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
},
captcha(): Captcha {
return window[this.variable] || {} as unknown as Captcha;
},
},
});
created() {
if (this.loaded) {
this.available = true;
} else {
(document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
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: this.provider,
src: this.src,
id: props.provider,
src: src.value,
})))
.addEventListener('load', () => this.available = true);
}
},
.addEventListener('load', () => available.value = true);
}
mounted() {
if (this.available) {
this.requestRender();
} else {
this.$watch('available', this.requestRender);
}
},
function reset() {
if (captcha.value?.reset) captcha.value.reset();
}
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,
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(this.requestRender.bind(this), 1);
setTimeout(requestRender, 1);
}
}
function callback(response?: string) {
emit('update:modelValue', typeof response == 'string' ? response : null);
}
onMounted(() => {
if (available.value) {
requestRender();
} else {
watch(available, requestRender);
}
},
callback(response?: string) {
this.$emit('update:modelValue', typeof response == 'string' ? response : null);
},
},
});
onBeforeUnmount(() => {
reset();
});
defineExpose({
reset,
});
</script>

View file

@ -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,
},
},
const props = withDefaults(defineProps<{
channel: Record<string, any>;
full?: boolean;
}>(), {
full: false,
});
data() {
return {
isFollowing: this.channel.isFollowing,
wait: false,
};
},
const isFollowing = ref<boolean>(props.channel.isFollowing);
const wait = ref(false);
methods: {
async onClick() {
this.wait = true;
async function onClick() {
wait.value = true;
try {
if (this.isFollowing) {
if (isFollowing.value) {
await os.api('channels/unfollow', {
channelId: this.channel.id
channelId: props.channel.id
});
this.isFollowing = false;
isFollowing.value = false;
} else {
await os.api('channels/follow', {
channelId: this.channel.id
channelId: props.channel.id
});
this.isFollowing = true;
isFollowing.value = true;
}
} catch (e) {
console.error(e);
} finally {
this.wait = false;
wait.value = false;
}
}
}
});
}
</script>
<style lang="scss" scoped>

View file

@ -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})` };
const bannerStyle = computed(() => {
if (props.channel.bannerUrl) {
return { backgroundImage: `url(${props.channel.bannerUrl})` };
} else {
return { backgroundColor: '#4c5e6d' };
}
}
},
});
</script>

View file

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

View file

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

View file

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

View file

@ -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' : ''),
return () => h(
defaultStore.state.animation ? TransitionGroup : 'div',
defaultStore.state.animation ? {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
name: 'list',
tag: 'div',
'data-direction': this.direction,
'data-reversed': this.reversed ? 'true' : 'false',
'data-direction': props.direction,
'data-reversed': props.reversed ? 'true' : 'false',
} : {
class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
}, {
default: renderChildren
});
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
},
{ default: renderChildren });
}
});
</script>

View file

@ -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 :
props.input ? inputValue.value :
props.select ? selectedValue.value :
true;
this.done(false, result);
},
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
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
this.ok();
}
}
ok();
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown);
});
</script>

View file

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

View file

@ -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">
<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>
<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>
</div>
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes">
<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 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>
</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;
}>();
mixins: [
paging({
before: (self) => {
self.$emit('before');
},
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
after: (self, e) => {
self.$emit('after', e);
}
}),
],
const updated = (oldValue, newValue) => {
pagingComponent.value?.updateItem(oldValue.id, () => newValue);
};
props: {
pagination: {
required: true
defineExpose({
prepend: (note) => {
pagingComponent.value?.prepend(note);
},
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 {

View file

@ -1,117 +1,53 @@
<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',
const pagination: Paging = {
endpoint: 'i/notifications' as const,
limit: 10,
params: () => ({
includeTypes: this.allIncludeTypes || undefined,
unreadOnly: this.unreadOnly,
})
},
};
},
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);
},
beforeUnmount() {
this.connection.dispose();
},
methods: {
onNotification(notification) {
const isMuted = !this.allIncludeTypes.includes(notification.type);
const onNotification = (notification) => {
const isMuted = !allIncludeTypes.value.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') {
stream.send('readNotification', {
id: notification.id
@ -119,41 +55,30 @@ export default defineComponent({
}
if (!isMuted) {
this.prepend({
pagingComponent.value.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
};
},
}
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);
}

View file

@ -1,129 +1,97 @@
<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'));
const tlComponent = ref<InstanceType<typeof XNotes>>();
const prepend = note => {
tlComponent.value.prepend(note);
emit('note');
if (props.sound) {
sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note');
}
},
};
emits: ['note', 'queue', 'before', 'after'],
const onUserAdded = () => {
tlComponent.value.reload();
};
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 onUserRemoved = () => {
tlComponent.value.reload();
};
created() {
const prepend = note => {
(this.$refs.tl as any).prepend(note);
this.$emit('note');
if (this.sound) {
sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
const onChangeFollowing = () => {
if (!tlComponent.value.backed) {
tlComponent.value.reload();
}
};
};
const onUserAdded = () => {
(this.$refs.tl as any).reload();
};
let endpoint;
let query;
let connection;
let connection2;
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') {
if (props.src === 'antenna') {
endpoint = 'antennas/notes';
this.query = {
antennaId: this.antenna
query = {
antennaId: props.antenna
};
this.connection = markRaw(stream.useChannel('antenna', {
antennaId: this.antenna
}));
this.connection.on('note', prepend);
} else if (this.src == 'home') {
connection = stream.useChannel('antenna', {
antennaId: props.antenna
});
connection.on('note', prepend);
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
this.connection = markRaw(stream.useChannel('homeTimeline'));
this.connection.on('note', prepend);
connection = stream.useChannel('homeTimeline');
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') {
connection2 = stream.useChannel('main');
connection2.on('follow', onChangeFollowing);
connection2.on('unfollow', onChangeFollowing);
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
this.connection = markRaw(stream.useChannel('localTimeline'));
this.connection.on('note', prepend);
} else if (this.src == 'social') {
connection = stream.useChannel('localTimeline');
connection.on('note', prepend);
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
this.connection = markRaw(stream.useChannel('hybridTimeline'));
this.connection.on('note', prepend);
} else if (this.src == 'global') {
connection = stream.useChannel('hybridTimeline');
connection.on('note', prepend);
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
this.connection = markRaw(stream.useChannel('globalTimeline'));
this.connection.on('note', prepend);
} else if (this.src == 'mentions') {
connection = stream.useChannel('globalTimeline');
connection.on('note', prepend);
} else if (props.src === 'mentions') {
endpoint = 'notes/mentions';
this.connection = markRaw(stream.useChannel('main'));
this.connection.on('mention', prepend);
} else if (this.src == 'directs') {
connection = stream.useChannel('main');
connection.on('mention', prepend);
} else if (props.src === 'directs') {
endpoint = 'notes/mentions';
this.query = {
query = {
visibility: 'specified'
};
const onNote = note => {
@ -131,54 +99,45 @@ export default defineComponent({
prepend(note);
}
};
this.connection = markRaw(stream.useChannel('main'));
this.connection.on('mention', onNote);
} else if (this.src == 'list') {
connection = stream.useChannel('main');
connection.on('mention', onNote);
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
this.query = {
listId: this.list
query = {
listId: props.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') {
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';
this.query = {
channelId: this.channel
query = {
channelId: props.channel
};
this.connection = markRaw(stream.useChannel('channel', {
channelId: this.channel
}));
this.connection.on('note', prepend);
}
connection = stream.useChannel('channel', {
channelId: props.channel
});
connection.on('note', prepend);
}
this.pagination = {
const pagination = {
endpoint: endpoint,
limit: 10,
params: init => ({
untilDate: this.date?.getTime(),
...this.baseQuery, ...this.query
})
};
},
params: query,
};
beforeUnmount() {
this.connection.dispose();
if (this.connection2) this.connection2.dispose();
},
onUnmounted(() => {
connection.dispose();
if (connection2) connection2.dispose();
});
methods: {
focus() {
this.$refs.tl.focus();
},
timetravel(date?: Date) {
/* TODO
const timetravel = (date?: Date) => {
this.date = date;
this.$refs.tl.reload();
}
}
});
};
*/
</script>

View file

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

View file

@ -1,91 +1,39 @@
<template>
<MkError v-if="error" @retry="init()"/>
<div v-else class="efvhhmdq _isolated">
<div v-if="empty" class="no-users">
<p>{{ $ts.noUsers }}</p>
<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>
<div class="users">
</template>
<template #default="{ items: users }">
<div class="efvhhmdq">
<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>
</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;
}
}
}
</style>

View file

@ -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);
return {
widgetRefs,
configWidget,
widgetAdderSelected,
widgetDefs,
addWidget,
removeWidget,
updateWidget,
widgets_,
};
},
updateWidget(id, data) {
this.$emit('updateWidget', { id, data });
},
}
});
</script>

View file

@ -175,7 +175,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));

View file

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

View file

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

View file

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

View file

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

View file

@ -69,9 +69,9 @@ export default defineComponent({
pagination: {
endpoint: 'channels/timeline',
limit: 10,
params: () => ({
params: computed(() => ({
channelId: this.channelId,
})
}))
},
};
},

View file

@ -52,9 +52,9 @@ export default defineComponent({
pagination: {
endpoint: 'clips/notes',
limit: 10,
params: () => ({
params: computed(() => ({
clipId: this.clipId,
})
}))
},
};
},

View file

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

View file

@ -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 } :
{})
})
}))
},
}
},

View file

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

View file

@ -108,9 +108,9 @@ export default defineComponent({
otherPostsPagination: {
endpoint: 'users/pages',
limit: 6,
params: () => ({
params: computed(() => ({
userId: this.page.user.id
})
})),
},
};
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
const pagination = {
endpoint: 'users/notes' as const,
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();
},
},
});
params: computed(() => ({
userId: props.user.id,
includeReplies: include.value === 'replies',
withFiles: include.value === 'files',
})),
};
</script>
<style lang="scss" scoped>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,465 +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 importAsyncComponentWrapperref
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);
}
}];
}
},
async created() {
if (window.innerWidth < 1024) {
localStorage.setItem('ui', 'default');
location.reload();
}
await store.ready;
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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'
})
})),
},
}
},

View file

@ -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: () => ({
const name = 'activity';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
view: {
type: 'number',
type: 'number' as const,
default: 0,
hidden: true,
},
})
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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>

View file

@ -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: () => ({
const name = 'ai';
const widgetPropsDef = {
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
})
});
};
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 WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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,
}
}, '*');
}, { passive: true });
},
methods: {
touched() {
//if (this.live2d) this.live2d.changeExpression('gurugurume');
}
}
};
window.addEventListener('mousemove', onMousemove, { passive: true });
onUnmounted(() => {
window.removeEventListener('mousemove', onMousemove);
});
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -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,48 +12,56 @@
</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: () => ({
const name = 'aiscript';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
script: {
type: 'string',
type: 'string' as const,
multiline: true,
default: '(1 + 1)',
hidden: true,
},
})
});
};
export default defineComponent({
components: {
MkContainer
},
extends: widget,
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
data() {
return {
logs: [],
};
},
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
methods: {
async run() {
this.logs = [];
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: this.$i?.token,
token: $i?.token,
}), {
in: (q) => {
return new Promise(ok => {
@ -65,18 +73,18 @@ export default defineComponent({
});
},
out: (value) => {
this.logs.push({
id: Math.random(),
logs.value.push({
id: Math.random().toString(),
text: value.type === 'str' ? value.value : utils.valToString(value),
print: true
print: true,
});
},
log: (type, params) => {
switch (type) {
case 'end': this.logs.push({
id: Math.random(),
case 'end': logs.value.push({
id: Math.random().toString(),
text: utils.valToString(params.val, true),
print: false
print: false,
}); break;
default: break;
}
@ -85,11 +93,11 @@ export default defineComponent({
let ast;
try {
ast = parse(this.props.script);
ast = parse(widgetProps.script);
} catch (e) {
os.alert({
type: 'error',
text: 'Syntax error :('
text: 'Syntax error :(',
});
return;
}
@ -98,11 +106,15 @@ export default defineComponent({
} catch (e) {
os.alert({
type: 'error',
text: e
text: e,
});
}
},
}
};
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -1,52 +1,57 @@
<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: () => ({
const name = 'button';
const widgetPropsDef = {
label: {
type: 'string',
type: 'string' as const,
default: 'BUTTON',
},
colored: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
script: {
type: 'string',
type: 'string' as const,
multiline: true,
default: 'Mk:dialog("hello" "world")',
},
})
});
};
export default defineComponent({
components: {
MkButton
},
extends: widget,
data() {
return {
};
},
methods: {
async run() {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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: this.$i?.token,
token: $i?.token,
}), {
in: (q) => {
return new Promise(ok => {
@ -67,11 +72,11 @@ export default defineComponent({
let ast;
try {
ast = parse(this.props.script);
ast = parse(widgetProps.script);
} catch (e) {
os.alert({
type: 'error',
text: 'Syntax error :('
text: 'Syntax error :(',
});
return;
}
@ -80,11 +85,15 @@ export default defineComponent({
} catch (e) {
os.alert({
type: 'error',
text: e
text: e,
});
}
},
}
};
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -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,61 +32,60 @@
</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: () => ({
const name = 'calendar';
const widgetPropsDef = {
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
})
});
};
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() {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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();
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
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();
@ -96,13 +95,24 @@ export default defineComponent({
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;
dayP.value = dayNumer / dayDenom * 100;
monthP.value = monthNumer / monthDenom * 100;
yearP.value = yearNumer / yearDenom * 100;
this.isHoliday = now.getDay() === 0 || now.getDay() === 6;
}
}
isHoliday.value = now.getDay() === 0 || now.getDay() === 6;
};
tick();
const intervalId = setInterval(tick, 1000);
onUnmounted(() => {
clearInterval(intervalId);
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -1,27 +1,27 @@
<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: () => ({
const name = 'clock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
thickness: {
type: 'radio',
type: 'radio' as const,
default: 0.1,
options: [{
value: 0.1, label: 'thin'
@ -29,17 +29,28 @@ const widget = define({
value: 0.2, label: 'medium'
}, {
value: 0.3, label: 'thick'
}]
}
})
});
export default defineComponent({
components: {
MkContainer,
MkAnalogClock
}],
},
extends: widget,
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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>

View file

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

View file

@ -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: () => ({
const name = 'digitalClock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
fontSize: {
type: 'number',
type: 'number' as const,
default: 1.5,
step: 0.1,
},
showMs: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
})
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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>

View file

@ -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: () => ({
const name = 'federation';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
})
});
};
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() {
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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' })));
this.instances = instances;
this.charts = charts;
this.fetching = false;
}
}
instances.value = instances;
charts.value = charts;
fetching.value = false;
};
onMounted(() => {
fetch();
const intervalId = setInterval(fetch, 1000 * 60);
onUnmounted(() => {
clearInterval(intervalId);
});
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -1,77 +1,88 @@
<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: () => ({
const name = 'jobQueue';
const widgetPropsDef = {
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
sound: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
})
});
};
export default defineComponent({
extends: widget,
data() {
return {
connection: markRaw(stream.useChannel('queueStats')),
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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,
@ -84,51 +95,52 @@ export default defineComponent({
waiting: 0,
delayed: 0,
},
prev: {},
sound: sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1)
};
},
created() {
});
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']) {
this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
}
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;
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);
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();
if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) {
jammedSound.play();
}
}
},
};
onStatsLog(statsLog) {
const onStatsLog = (statsLog) => {
for (const stats of [...statsLog].reverse()) {
this.onStats(stats);
onStats(stats);
}
},
};
number
}
connection.on('stats', onStats);
connection.on('statsLog', onStatsLog);
connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 1,
});
onUnmounted(() => {
connection.off('stats', onStats);
connection.off('statsLog', onStatsLog);
connection.dispose();
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -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: () => ({
const name = 'memo';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
})
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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>

View file

@ -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: () => ({
const name = 'notifications';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
height: {
type: 'number',
type: 'number' as const,
default: 300,
},
includingTypes: {
type: 'array',
type: 'array' as const,
hidden: true,
default: null,
},
})
});
};
export default defineComponent({
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
components: {
MkContainer,
XNotifications,
},
extends: widget,
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
data() {
return {
};
},
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
methods: {
configure() {
const configureNotification = () => {
os.popup(import('@/components/notification-setting-window.vue'), {
includingTypes: this.props.includingTypes,
includingTypes: widgetProps.includingTypes,
}, {
done: async (res) => {
const { includingTypes } = res;
this.props.includingTypes = includingTypes;
this.save();
widgetProps.includingTypes = includingTypes;
save();
}
}, 'closed');
}
}
};
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -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: () => ({
const name = 'onlineUsers';
const widgetPropsDef = {
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
})
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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>

View file

@ -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: () => ({
const name = 'photos';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
})
});
};
export default defineComponent({
components: {
MkContainer,
},
extends: widget,
data() {
return {
images: [],
fetching: true,
connection: null,
};
},
mounted() {
this.connection = markRaw(stream.useChannel('main'));
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
this.connection.on('driveFileCreated', this.onDriveFileCreated);
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
os.api('drive/stream', {
type: 'image/*',
limit: 9
}).then(images => {
this.images = images;
this.fetching = false;
});
},
beforeUnmount() {
this.connection.dispose();
},
methods: {
onDriveFileCreated(file) {
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)) {
this.images.unshift(file);
if (this.images.length > 9) this.images.pop();
images.value.unshift(file);
if (images.value.length > 9) images.value.pop();
}
},
};
thumbnail(image: any): string {
return this.$store.state.disableShowingAnimatedImages
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;
});
connection.on('driveFileCreated', onDriveFileCreated);
onUnmounted(() => {
connection.dispose();
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -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>;
// vueimporttype
//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>

View file

@ -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: () => ({
const name = 'rss';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
url: {
type: 'string',
type: 'string' as const,
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
},
})
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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>

View file

@ -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: () => ({
const name = 'serverMetric';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
transparent: {
type: 'boolean',
type: 'boolean' as const,
default: false,
},
view: {
type: 'number',
type: 'number' as const,
default: 0,
hidden: true,
},
})
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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;
const toggleView = () => {
if (widgetProps.view == 4) {
widgetProps.view = 0;
} else {
this.props.view++;
}
this.save();
},
widgetProps.view++;
}
save();
};
const connection = stream.useChannel('serverStats');
onUnmounted(() => {
connection.dispose();
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -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: () => ({
const name = 'slideshow';
const widgetPropsDef = {
height: {
type: 'number',
type: 'number' as const,
default: 300,
},
folderId: {
type: 'string',
type: 'string' as const,
default: null,
hidden: true,
},
})
});
};
export default defineComponent({
extends: widget,
data() {
return {
images: [],
fetching: true,
clock: null
};
},
mounted() {
this.$nextTick(() => {
this.applySize();
});
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
if (this.props.folderId != null) {
this.fetch();
}
// vueimporttype
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
const emit = defineEmits<{ (e: 'updateProps', props: WidgetProps); }>();
this.clock = setInterval(this.change, 10000);
},
beforeUnmount() {
clearInterval(this.clock);
},
methods: {
applySize() {
let h;
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
if (this.props.size == 1) {
h = 250;
} else {
h = 170;
}
const images = ref([]);
const fetching = ref(true);
const slideA = ref<HTMLElement>();
const slideB = ref<HTMLElement>();
this.$el.style.height = `${h}px`;
},
resize() {
if (this.props.size == 1) {
this.props.size = 0;
} else {
this.props.size++;
}
this.save();
const change = () => {
if (images.value.length == 0) return;
this.applySize();
},
change() {
if (this.images.length == 0) return;
const index = Math.floor(Math.random() * images.value.length);
const img = `url(${ images.value[index].url })`;
const index = Math.floor(Math.random() * this.images.length);
const img = `url(${ this.images[index].url })`;
slideB.value.style.backgroundImage = img;
(this.$refs.slideB as any).style.backgroundImage = img;
this.$refs.slideB.classList.add('anime');
slideB.value.classList.add('anime');
setTimeout(() => {
// unmount
if ((this.$refs.slideA as any) == null) return;
if (slideA.value == null) return;
(this.$refs.slideA as any).style.backgroundImage = img;
slideA.value.style.backgroundImage = img;
this.$refs.slideB.classList.remove('anime');
slideB.value.classList.remove('anime');
}, 1000);
},
fetch() {
this.fetching = true;
};
const fetch = () => {
fetching.value = true;
os.api('drive/files', {
folderId: this.props.folderId,
folderId: widgetProps.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();
}).then(res => {
images.value = res;
fetching.value = false;
slideA.value.style.backgroundImage = '';
slideB.value.style.backgroundImage = '';
change();
});
},
choose() {
};
const choose = () => {
os.selectDriveFolder(false).then(folder => {
if (folder == null) {
return;
}
this.props.folderId = folder.id;
this.save();
this.fetch();
widgetProps.folderId = folder.id;
save();
fetch();
});
};
onMounted(() => {
if (widgetProps.folderId != null) {
fetch();
}
}
const intervalId = setInterval(change, 10000);
onUnmounted(() => {
clearInterval(intervalId);
});
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -1,71 +1,85 @@
<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: () => ({
const name = 'timeline';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
height: {
type: 'number',
type: 'number' as const,
default: 300,
},
src: {
type: 'string',
type: 'string' as const,
default: 'home',
hidden: true,
},
list: {
type: 'object',
antenna: {
type: 'object' as const,
default: null,
hidden: true,
},
})
});
export default defineComponent({
components: {
MkContainer,
XTimeline,
list: {
type: 'object' as const,
default: null,
hidden: true,
},
extends: widget,
};
data() {
return {
menuOpened: false,
};
},
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
methods: {
async choose(ev) {
this.menuOpened = true;
// vueimporttype
//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')
@ -74,43 +88,42 @@ export default defineComponent({
text: antenna.name,
icon: 'fas fa-satellite',
action: () => {
this.props.antenna = antenna;
this.setSrc('antenna');
widgetProps.antenna = antenna;
setSrc('antenna');
}
}));
const listItems = lists.map(list => ({
text: list.name,
icon: 'fas fa-list-ul',
action: () => {
this.props.list = list;
this.setSrc('list');
widgetProps.list = list;
setSrc('list');
}
}));
os.popupMenu([{
text: this.$ts._timelines.home,
text: i18n.locale._timelines.home,
icon: 'fas fa-home',
action: () => { this.setSrc('home') }
action: () => { setSrc('home') }
}, {
text: this.$ts._timelines.local,
text: i18n.locale._timelines.local,
icon: 'fas fa-comments',
action: () => { this.setSrc('local') }
action: () => { setSrc('local') }
}, {
text: this.$ts._timelines.social,
text: i18n.locale._timelines.social,
icon: 'fas fa-share-alt',
action: () => { this.setSrc('social') }
action: () => { setSrc('social') }
}, {
text: this.$ts._timelines.global,
text: i18n.locale._timelines.global,
icon: 'fas fa-globe',
action: () => { this.setSrc('global') }
action: () => { setSrc('global') }
}, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget || ev.target).then(() => {
this.menuOpened = false;
menuOpened.value = false;
});
},
};
setSrc(src) {
this.props.src = src;
this.save();
},
}
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View file

@ -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: () => ({
const name = 'hashtags';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
type: 'boolean' as const,
default: true,
},
})
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
// vueimporttype
//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>

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