summaryrefslogtreecommitdiff
path: root/packages/client/src/pages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-07-07 21:23:03 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-07-07 21:23:03 +0900
commit84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b (patch)
treea182502a5192992d873e7a7fcbf01662bb0dfca2 /packages/client/src/pages
parentMerge pull request #8821 from misskey-dev/develop (diff)
parent12.112.1 (diff)
downloadmisskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.gz
misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.bz2
misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.zip
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages')
-rw-r--r--packages/client/src/pages/_error_.vue14
-rw-r--r--packages/client/src/pages/about-misskey.vue130
-rw-r--r--packages/client/src/pages/about.emojis.vue (renamed from packages/client/src/pages/emojis.category.vue)1
-rw-r--r--packages/client/src/pages/about.federation.vue106
-rw-r--r--packages/client/src/pages/about.vue172
-rw-r--r--packages/client/src/pages/admin-file.vue160
-rw-r--r--packages/client/src/pages/admin/_header_.vue292
-rw-r--r--packages/client/src/pages/admin/abuses.vue83
-rw-r--r--packages/client/src/pages/admin/ads.vue102
-rw-r--r--packages/client/src/pages/admin/announcements.vue80
-rw-r--r--packages/client/src/pages/admin/bot-protection.vue12
-rw-r--r--packages/client/src/pages/admin/database.vue22
-rw-r--r--packages/client/src/pages/admin/email-settings.vue120
-rw-r--r--packages/client/src/pages/admin/emojis.vue181
-rw-r--r--packages/client/src/pages/admin/file-dialog.vue103
-rw-r--r--packages/client/src/pages/admin/files.vue177
-rw-r--r--packages/client/src/pages/admin/index.vue66
-rw-r--r--packages/client/src/pages/admin/instance-block.vue37
-rw-r--r--packages/client/src/pages/admin/integrations.vue26
-rw-r--r--packages/client/src/pages/admin/object-storage.vue137
-rw-r--r--packages/client/src/pages/admin/other-settings.vue40
-rw-r--r--packages/client/src/pages/admin/overview.federation.vue100
-rw-r--r--packages/client/src/pages/admin/overview.pie.vue108
-rw-r--r--packages/client/src/pages/admin/overview.queue-chart.vue211
-rw-r--r--packages/client/src/pages/admin/overview.user.vue76
-rw-r--r--packages/client/src/pages/admin/overview.vue665
-rw-r--r--packages/client/src/pages/admin/proxy-account.vue22
-rw-r--r--packages/client/src/pages/admin/queue.chart.chart.vue181
-rw-r--r--packages/client/src/pages/admin/queue.chart.vue142
-rw-r--r--packages/client/src/pages/admin/queue.vue67
-rw-r--r--packages/client/src/pages/admin/relays.vue62
-rw-r--r--packages/client/src/pages/admin/security.vue153
-rw-r--r--packages/client/src/pages/admin/settings.vue264
-rw-r--r--packages/client/src/pages/admin/users.vue209
-rw-r--r--packages/client/src/pages/announcements.vue85
-rw-r--r--packages/client/src/pages/antenna-timeline.vue156
-rw-r--r--packages/client/src/pages/api-console.vue65
-rw-r--r--packages/client/src/pages/auth.vue18
-rw-r--r--packages/client/src/pages/channel-editor.vue195
-rw-r--r--packages/client/src/pages/channel.vue144
-rw-r--r--packages/client/src/pages/channels.vue145
-rw-r--r--packages/client/src/pages/clip.vue204
-rw-r--r--packages/client/src/pages/drive.vue19
-rw-r--r--packages/client/src/pages/emojis.vue56
-rw-r--r--packages/client/src/pages/explore.featured.vue30
-rw-r--r--packages/client/src/pages/explore.users.vue143
-rw-r--r--packages/client/src/pages/explore.vue303
-rw-r--r--packages/client/src/pages/favorites.vue44
-rw-r--r--packages/client/src/pages/featured.vue25
-rw-r--r--packages/client/src/pages/federation.vue236
-rw-r--r--packages/client/src/pages/follow-requests.vue74
-rw-r--r--packages/client/src/pages/follow.vue15
-rw-r--r--packages/client/src/pages/gallery/edit.vue213
-rw-r--r--packages/client/src/pages/gallery/index.vue211
-rw-r--r--packages/client/src/pages/gallery/post.vue272
-rw-r--r--packages/client/src/pages/instance-info.vue246
-rw-r--r--packages/client/src/pages/mentions.vue24
-rw-r--r--packages/client/src/pages/messages.vue27
-rw-r--r--packages/client/src/pages/messaging/index.vue271
-rw-r--r--packages/client/src/pages/messaging/messaging-room.form.vue420
-rw-r--r--packages/client/src/pages/messaging/messaging-room.message.vue52
-rw-r--r--packages/client/src/pages/messaging/messaging-room.vue567
-rw-r--r--packages/client/src/pages/mfm-cheat-sheet.vue536
-rw-r--r--packages/client/src/pages/miauth.vue26
-rw-r--r--packages/client/src/pages/my-antennas/create.vue23
-rw-r--r--packages/client/src/pages/my-antennas/edit.vue22
-rw-r--r--packages/client/src/pages/my-antennas/editor.vue1
-rw-r--r--packages/client/src/pages/my-antennas/index.vue22
-rw-r--r--packages/client/src/pages/my-clips/index.vue28
-rw-r--r--packages/client/src/pages/my-groups/group.vue178
-rw-r--r--packages/client/src/pages/my-groups/index.vue147
-rw-r--r--packages/client/src/pages/my-lists/index.vue28
-rw-r--r--packages/client/src/pages/my-lists/list.vue161
-rw-r--r--packages/client/src/pages/not-found.vue15
-rw-r--r--packages/client/src/pages/note.vue251
-rw-r--r--packages/client/src/pages/notifications.vue108
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.button.vue48
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue34
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.counter.vue30
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.if.vue61
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.image.vue64
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.note.vue57
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue30
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.post.vue34
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue49
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.section.vue83
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.switch.vue30
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue30
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.text.vue30
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue30
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue30
-rw-r--r--packages/client/src/pages/page-editor/page-editor.vue752
-rw-r--r--packages/client/src/pages/page.vue287
-rw-r--r--packages/client/src/pages/pages.vue145
-rw-r--r--packages/client/src/pages/preview.vue17
-rw-r--r--packages/client/src/pages/reset-password.vue42
-rw-r--r--packages/client/src/pages/scratchpad.vue26
-rw-r--r--packages/client/src/pages/search.vue28
-rw-r--r--packages/client/src/pages/settings/2fa.vue2
-rw-r--r--packages/client/src/pages/settings/account-info.vue18
-rw-r--r--packages/client/src/pages/settings/accounts.vue25
-rw-r--r--packages/client/src/pages/settings/api.vue19
-rw-r--r--packages/client/src/pages/settings/apps.vue23
-rw-r--r--packages/client/src/pages/settings/custom-css.vue17
-rw-r--r--packages/client/src/pages/settings/deck.vue49
-rw-r--r--packages/client/src/pages/settings/delete-account.vue20
-rw-r--r--packages/client/src/pages/settings/drive.vue47
-rw-r--r--packages/client/src/pages/settings/email.vue23
-rw-r--r--packages/client/src/pages/settings/general.vue34
-rw-r--r--packages/client/src/pages/settings/import-export.vue77
-rw-r--r--packages/client/src/pages/settings/index.vue110
-rw-r--r--packages/client/src/pages/settings/instance-mute.vue16
-rw-r--r--packages/client/src/pages/settings/integration.vue21
-rw-r--r--packages/client/src/pages/settings/menu.vue25
-rw-r--r--packages/client/src/pages/settings/mute-block.vue19
-rw-r--r--packages/client/src/pages/settings/notifications.vue21
-rw-r--r--packages/client/src/pages/settings/other.vue19
-rw-r--r--packages/client/src/pages/settings/plugin.install.vue35
-rw-r--r--packages/client/src/pages/settings/plugin.vue19
-rw-r--r--packages/client/src/pages/settings/privacy.vue26
-rw-r--r--packages/client/src/pages/settings/profile.vue21
-rw-r--r--packages/client/src/pages/settings/reaction.vue25
-rw-r--r--packages/client/src/pages/settings/security.vue34
-rw-r--r--packages/client/src/pages/settings/sounds.vue39
-rw-r--r--packages/client/src/pages/settings/statusbars.statusbar.vue136
-rw-r--r--packages/client/src/pages/settings/statusbars.vue54
-rw-r--r--packages/client/src/pages/settings/theme.install.vue23
-rw-r--r--packages/client/src/pages/settings/theme.manage.vue17
-rw-r--r--packages/client/src/pages/settings/theme.vue79
-rw-r--r--packages/client/src/pages/settings/webhook.edit.vue21
-rw-r--r--packages/client/src/pages/settings/webhook.new.vue15
-rw-r--r--packages/client/src/pages/settings/webhook.vue17
-rw-r--r--packages/client/src/pages/settings/word-mute.vue19
-rw-r--r--packages/client/src/pages/share.vue240
-rw-r--r--packages/client/src/pages/signup-complete.vue14
-rw-r--r--packages/client/src/pages/tag.vue17
-rw-r--r--packages/client/src/pages/theme-editor.vue152
-rw-r--r--packages/client/src/pages/timeline.vue136
-rw-r--r--packages/client/src/pages/user-info.vue627
-rw-r--r--packages/client/src/pages/user-list-timeline.vue149
-rw-r--r--packages/client/src/pages/user/follow-list.vue2
-rw-r--r--packages/client/src/pages/user/followers.vue61
-rw-r--r--packages/client/src/pages/user/following.vue61
-rw-r--r--packages/client/src/pages/user/gallery.vue40
-rw-r--r--packages/client/src/pages/user/home.vue478
-rw-r--r--packages/client/src/pages/user/index.activity.vue6
-rw-r--r--packages/client/src/pages/user/index.photos.vue2
-rw-r--r--packages/client/src/pages/user/index.vue647
-rw-r--r--packages/client/src/pages/welcome.entrance.a.vue277
-rw-r--r--packages/client/src/pages/welcome.entrance.b.vue1
-rw-r--r--packages/client/src/pages/welcome.entrance.c.vue1
-rw-r--r--packages/client/src/pages/welcome.setup.vue2
-rw-r--r--packages/client/src/pages/welcome.vue16
153 files changed, 8662 insertions, 7496 deletions
diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue
index 4cfe2e255c..6ac1f4297a 100644
--- a/packages/client/src/pages/_error_.vue
+++ b/packages/client/src/pages/_error_.vue
@@ -21,11 +21,11 @@
import { } from 'vue';
import * as misskey from 'misskey-js';
import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
import { version } from '@/config';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const props = withDefaults(defineProps<{
error?: Error;
@@ -52,11 +52,13 @@ function reload() {
unisonReload();
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.error,
- icon: 'fas fa-exclamation-triangle',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.error,
+ icon: 'fas fa-exclamation-triangle',
});
</script>
diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue
index 691bc4f07b..a80041b5ce 100644
--- a/packages/client/src/pages/about-misskey.vue
+++ b/packages/client/src/pages/about-misskey.vue
@@ -1,62 +1,65 @@
<template>
-<div style="overflow: clip;">
- <MkSpacer :content-max="600" :margin-min="20">
- <div class="_formRoot znqjceqz">
- <div id="debug"></div>
- <div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
- <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
- <div class="misskey">Misskey</div>
- <div class="version">v{{ version }}</div>
- <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
- </div>
- <div class="_formBlock" style="text-align: center;">
- {{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
- </div>
- <div class="_formBlock" style="text-align: center;">
- <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
- </div>
- <FormSection>
- <div class="_formLinks">
- <FormLink to="https://github.com/misskey-dev/misskey" external>
- <template #icon><i class="fas fa-code"></i></template>
- {{ i18n.ts._aboutMisskey.source }}
- <template #suffix>GitHub</template>
- </FormLink>
- <FormLink to="https://crowdin.com/project/misskey" external>
- <template #icon><i class="fas fa-language"></i></template>
- {{ i18n.ts._aboutMisskey.translation }}
- <template #suffix>Crowdin</template>
- </FormLink>
- <FormLink to="https://www.patreon.com/syuilo" external>
- <template #icon><i class="fas fa-hand-holding-medical"></i></template>
- {{ i18n.ts._aboutMisskey.donate }}
- <template #suffix>Patreon</template>
- </FormLink>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <div style="overflow: hidden; overflow: clip;">
+ <MkSpacer :content-max="600" :margin-min="20">
+ <div class="_formRoot znqjceqz">
+ <div id="debug"></div>
+ <div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
+ <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
+ <div class="misskey">Misskey</div>
+ <div class="version">v{{ version }}</div>
+ <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
+ </div>
+ <div class="_formBlock" style="text-align: center;">
+ {{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
</div>
- </FormSection>
- <FormSection>
- <template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
- <div class="_formLinks">
- <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
- <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
- <FormLink to="https://github.com/mei23" external>@mei23</FormLink>
- <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
- <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
- <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
- <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
- <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
- <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+ <div class="_formBlock" style="text-align: center;">
+ <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
</div>
- <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
- </FormSection>
- <FormSection>
- <template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
- <div v-for="patron in patrons" :key="patron">{{ patron }}</div>
- <template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
- </FormSection>
- </div>
- </MkSpacer>
-</div>
+ <FormSection>
+ <div class="_formLinks">
+ <FormLink to="https://github.com/misskey-dev/misskey" external>
+ <template #icon><i class="fas fa-code"></i></template>
+ {{ i18n.ts._aboutMisskey.source }}
+ <template #suffix>GitHub</template>
+ </FormLink>
+ <FormLink to="https://crowdin.com/project/misskey" external>
+ <template #icon><i class="fas fa-language"></i></template>
+ {{ i18n.ts._aboutMisskey.translation }}
+ <template #suffix>Crowdin</template>
+ </FormLink>
+ <FormLink to="https://www.patreon.com/syuilo" external>
+ <template #icon><i class="fas fa-hand-holding-medical"></i></template>
+ {{ i18n.ts._aboutMisskey.donate }}
+ <template #suffix>Patreon</template>
+ </FormLink>
+ </div>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
+ <div class="_formLinks">
+ <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
+ <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
+ <FormLink to="https://github.com/mei23" external>@mei23</FormLink>
+ <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
+ <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
+ <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
+ <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
+ <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
+ <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+ </div>
+ <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
+ </FormSection>
+ <FormSection>
+ <template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
+ <div v-for="patron in patrons" :key="patron">{{ patron }}</div>
+ <template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
+ </FormSection>
+ </div>
+ </MkSpacer>
+ </div>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -67,10 +70,10 @@ import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/ui/button.vue';
import MkLink from '@/components/link.vue';
import { physics } from '@/scripts/physics';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
const patrons = [
'まっちゃとーにゅ',
@@ -194,12 +197,13 @@ onBeforeUnmount(() => {
}
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.aboutMisskey,
- icon: null,
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.aboutMisskey,
+ icon: null,
});
</script>
diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/about.emojis.vue
index c47870f4d4..6d915c5843 100644
--- a/packages/client/src/pages/emojis.category.vue
+++ b/packages/client/src/pages/about.emojis.vue
@@ -36,7 +36,6 @@ import MkSelect from '@/components/form/select.vue';
import MkFolder from '@/components/ui/folder.vue';
import MkTab from '@/components/tab.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { emojiCategories, emojiTags } from '@/instance';
import XEmoji from './emojis.emoji.vue';
diff --git a/packages/client/src/pages/about.federation.vue b/packages/client/src/pages/about.federation.vue
new file mode 100644
index 0000000000..00ca44eec8
--- /dev/null
+++ b/packages/client/src/pages/about.federation.vue
@@ -0,0 +1,106 @@
+<template>
+<div class="taeiyria">
+ <div class="query">
+ <MkInput v-model="host" :debounce="true" class="">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ <FormSplit style="margin-top: var(--margin);">
+ <MkSelect v-model="state">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="federating">{{ $ts.federating }}</option>
+ <option value="subscribing">{{ $ts.subscribing }}</option>
+ <option value="publishing">{{ $ts.publishing }}</option>
+ <option value="suspended">{{ $ts.suspended }}</option>
+ <option value="blocked">{{ $ts.blocked }}</option>
+ <option value="notResponding">{{ $ts.notResponding }}</option>
+ </MkSelect>
+ <MkSelect v-model="sort">
+ <template #label>{{ $ts.sort }}</template>
+ <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
+ <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
+ <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
+ <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
+ <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
+ <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
+ <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
+ <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
+ </MkSelect>
+ </FormSplit>
+ </div>
+
+ <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
+ <div class="dqokceoi">
+ <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`">
+ <MkInstanceCardMini :instance="instance"/>
+ </MkA>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkInstanceCardMini from '@/components/instance-card-mini.vue';
+import FormSplit from '@/components/form/split.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+let host = $ref('');
+let state = $ref('federating');
+let sort = $ref('+pubSub');
+const pagination = {
+ endpoint: 'federation/instances' as const,
+ limit: 10,
+ offsetMode: true,
+ params: computed(() => ({
+ sort: sort,
+ host: host !== '' ? host : null,
+ ...(
+ state === 'federating' ? { federating: true } :
+ state === 'subscribing' ? { subscribing: true } :
+ state === 'publishing' ? { publishing: true } :
+ state === 'suspended' ? { suspended: true } :
+ state === 'blocked' ? { blocked: true } :
+ state === 'notResponding' ? { notResponding: true } :
+ {}),
+ })),
+};
+
+function getStatus(instance) {
+ if (instance.isSuspended) return 'Suspended';
+ if (instance.isBlocked) return 'Blocked';
+ if (instance.isNotResponding) return 'Error';
+ return 'Alive';
+}
+</script>
+
+<style lang="scss" scoped>
+.taeiyria {
+ > .query {
+ background: var(--bg);
+ margin-bottom: 16px;
+ }
+}
+
+.dqokceoi {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ grid-gap: 12px;
+
+ > .instance:hover {
+ text-decoration: none;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
index 6cc2e387ec..6241bbbdda 100644
--- a/packages/client/src/pages/about.vue
+++ b/packages/client/src/pages/about.vue
@@ -1,78 +1,89 @@
<template>
-<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
- <div class="_formRoot">
- <div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
- <div class="content">
- <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
- <div class="name">
- <b>{{ $instance.name || host }}</b>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
+ <div class="_formRoot">
+ <div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+ <div class="content">
+ <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+ <div class="name">
+ <b>{{ $instance.name || host }}</b>
+ </div>
</div>
</div>
- </div>
-
- <MkKeyValue class="_formBlock">
- <template #key>{{ $ts.description }}</template>
- <template #value>{{ $instance.description }}</template>
- </MkKeyValue>
- <FormSection>
- <MkKeyValue class="_formBlock" :copy="version">
- <template #key>Misskey</template>
- <template #value>{{ version }}</template>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ $ts.description }}</template>
+ <template #value>{{ $instance.description }}</template>
</MkKeyValue>
- <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
- </FormSection>
- <FormSection>
- <FormSplit>
- <MkKeyValue class="_formBlock">
- <template #key>{{ $ts.administrator }}</template>
- <template #value>{{ $instance.maintainerName }}</template>
- </MkKeyValue>
- <MkKeyValue class="_formBlock">
- <template #key>{{ $ts.contact }}</template>
- <template #value>{{ $instance.maintainerEmail }}</template>
+ <FormSection>
+ <MkKeyValue class="_formBlock" :copy="version">
+ <template #key>Misskey</template>
+ <template #value>{{ version }}</template>
</MkKeyValue>
- </FormSplit>
- <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink>
- </FormSection>
+ <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
+ </FormSection>
- <FormSuspense :p="initStats">
<FormSection>
- <template #label>{{ $ts.statistics }}</template>
<FormSplit>
<MkKeyValue class="_formBlock">
- <template #key>{{ $ts.users }}</template>
- <template #value>{{ number(stats.originalUsersCount) }}</template>
+ <template #key>{{ $ts.administrator }}</template>
+ <template #value>{{ $instance.maintainerName }}</template>
</MkKeyValue>
<MkKeyValue class="_formBlock">
- <template #key>{{ $ts.notes }}</template>
- <template #value>{{ number(stats.originalNotesCount) }}</template>
+ <template #key>{{ $ts.contact }}</template>
+ <template #value>{{ $instance.maintainerEmail }}</template>
</MkKeyValue>
</FormSplit>
+ <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink>
</FormSection>
- </FormSuspense>
- <FormSection>
- <template #label>Well-known resources</template>
- <div class="_formLinks">
- <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
- <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
- <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
- <FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
- <FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
- </div>
- </FormSection>
- </div>
-</MkSpacer>
-<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20">
- <MkInstanceStats :chart-limit="500" :detailed="true"/>
-</MkSpacer>
+ <FormSuspense :p="initStats">
+ <FormSection>
+ <template #label>{{ $ts.statistics }}</template>
+ <FormSplit>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ $ts.users }}</template>
+ <template #value>{{ number(stats.originalUsersCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ $ts.notes }}</template>
+ <template #value>{{ number(stats.originalNotesCount) }}</template>
+ </MkKeyValue>
+ </FormSplit>
+ </FormSection>
+ </FormSuspense>
+
+ <FormSection>
+ <template #label>Well-known resources</template>
+ <div class="_formLinks">
+ <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
+ <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
+ <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
+ <FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
+ <FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
+ </div>
+ </FormSection>
+ </div>
+ </MkSpacer>
+ <MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20">
+ <XEmojis/>
+ </MkSpacer>
+ <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20">
+ <XFederation/>
+ </MkSpacer>
+ <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20">
+ <MkInstanceStats :chart-limit="500" :detailed="true"/>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
-import { version, instanceName } from '@/config';
+import XEmojis from './about.emojis.vue';
+import XFederation from './about.federation.vue';
+import { version, instanceName , host } from '@/config';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSuspense from '@/components/form/suspense.vue';
@@ -81,42 +92,53 @@ import MkKeyValue from '@/components/key-value.vue';
import MkInstanceStats from '@/components/instance-stats.vue';
import * as os from '@/os';
import number from '@/filters/number';
-import * as symbols from '@/symbols';
-import { host } from '@/config';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = withDefaults(defineProps<{
+ initialTab?: string;
+}>(), {
+ initialTab: 'overview',
+});
let stats = $ref(null);
-let tab = $ref('overview');
+let tab = $ref(props.initialTab);
const initStats = () => os.api('stats', {
}).then((res) => {
stats = res;
});
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.instanceInfo,
- icon: 'fas fa-info-circle',
- bg: 'var(--bg)',
- tabs: [{
- active: tab === 'overview',
- title: i18n.ts.overview,
- onClick: () => { tab = 'overview'; },
- }, {
- active: tab === 'charts',
- title: i18n.ts.charts,
- icon: 'fas fa-chart-bar',
- onClick: () => { tab = 'charts'; },
- },],
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+}, {
+ key: 'emojis',
+ title: i18n.ts.customEmojis,
+ icon: 'fas fa-laugh',
+}, {
+ key: 'federation',
+ title: i18n.ts.federation,
+ icon: 'fas fa-globe',
+}, {
+ key: 'charts',
+ title: i18n.ts.charts,
+ icon: 'fas fa-chart-simple',
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.instanceInfo,
+ icon: 'fas fa-info-circle',
+})));
</script>
<style lang="scss" scoped>
.fwhjspax {
text-align: center;
border-radius: 10px;
- overflow: clip;
+ overflow: hidden; overflow: clip;
background-size: cover;
background-position: center center;
diff --git a/packages/client/src/pages/admin-file.vue b/packages/client/src/pages/admin-file.vue
new file mode 100644
index 0000000000..f96a41a7ea
--- /dev/null
+++ b/packages/client/src/pages/admin-file.vue
@@ -0,0 +1,160 @@
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32">
+ <div v-if="tab === 'overview'" class="cxqhhsmd _formRoot">
+ <a class="_formBlock thumbnail" :href="file.url" target="_blank">
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ </a>
+ <div class="_formBlock">
+ <MkKeyValue :copy="file.type" oneline style="margin: 1em 0;">
+ <template #key>MIME Type</template>
+ <template #value><span class="_monospace">{{ file.type }}</span></template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>Size</template>
+ <template #value><span class="_monospace">{{ bytes(file.size) }}</span></template>
+ </MkKeyValue>
+ <MkKeyValue :copy="file.id" oneline style="margin: 1em 0;">
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ file.id }}</span></template>
+ </MkKeyValue>
+ <MkKeyValue :copy="file.md5" oneline style="margin: 1em 0;">
+ <template #key>MD5</template>
+ <template #value><span class="_monospace">{{ file.md5 }}</span></template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.createdAt }}</template>
+ <template #value><span class="_monospace"><MkTime :time="file.createdAt" mode="detail" style="display: block;"/></span></template>
+ </MkKeyValue>
+ </div>
+ <MkA v-if="file.user" class="user" :to="`/user-info/${file.user.id}`">
+ <MkUserCardMini :user="file.user"/>
+ </MkA>
+ <div class="_formBlock">
+ <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
+ </div>
+
+ <div class="_formBlock">
+ <MkButton danger @click="del"><i class="fas fa-trash-alt"></i> {{ i18n.ts.delete }}</MkButton>
+ </div>
+ </div>
+ <div v-else-if="tab === 'ip' && info" class="_formRoot">
+ <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
+ <MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline>
+ <template #key>IP</template>
+ <template #value>{{ info.requestIp }}</template>
+ </MkKeyValue>
+ <FormSection v-if="info.requestHeaders">
+ <template #label>Headers</template>
+ <MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace">
+ <template #key>{{ k }}</template>
+ <template #value>{{ v }}</template>
+ </MkKeyValue>
+ </FormSection>
+ </div>
+ <div v-else-if="tab === 'raw'" class="_formRoot">
+ <MkObjectView v-if="info" tall :value="info">
+ </MkObjectView>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkObjectView from '@/components/object-view.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import MkKeyValue from '@/components/key-value.vue';
+import FormSection from '@/components/form/section.vue';
+import MkUserCardMini from '@/components/user-card-mini.vue';
+import MkInfo from '@/components/ui/info.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { acct } from '@/filters/user';
+import { iAmAdmin, iAmModerator } from '@/account';
+
+let tab = $ref('overview');
+let file: any = $ref(null);
+let info: any = $ref(null);
+let isSensitive: boolean = $ref(false);
+
+const props = defineProps<{
+ fileId: string,
+}>();
+
+async function fetch() {
+ file = await os.api('drive/files/show', { fileId: props.fileId });
+ info = await os.api('admin/drive/show-file', { fileId: props.fileId });
+ isSensitive = file.isSensitive;
+}
+
+fetch();
+
+async function del() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.t('removeAreYouSure', { x: file.name }),
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('drive/files/delete', {
+ fileId: file.id,
+ });
+}
+
+async function toggleIsSensitive(v) {
+ await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v });
+ isSensitive = v;
+}
+
+const headerActions = $computed(() => [{
+ text: i18n.ts.openInNewTab,
+ icon: 'fas fa-external-link-alt',
+ handler: () => {
+ window.open(file.url, '_blank');
+ },
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'fas fa-info-circle',
+}, iAmModerator ? {
+ key: 'ip',
+ title: 'IP',
+ icon: 'fas fa-bars-staggered',
+} : null, {
+ key: 'raw',
+ title: 'Raw data',
+ icon: 'fas fa-code',
+}]);
+
+definePageMetadata(computed(() => ({
+ title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
+ icon: 'fas fa-file',
+})));
+</script>
+
+<style lang="scss" scoped>
+.cxqhhsmd {
+ > .thumbnail {
+ display: block;
+
+ > .thumbnail {
+ height: 300px;
+ max-width: 100%;
+ }
+ }
+
+ > .user {
+ &:hover {
+ text-decoration: none;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue
new file mode 100644
index 0000000000..aea2663c39
--- /dev/null
+++ b/packages/client/src/pages/admin/_header_.vue
@@ -0,0 +1,292 @@
+<template>
+<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick">
+ <template v-if="metadata">
+ <div class="titleContainer" @click="showTabsPopup">
+ <i v-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+ <div class="title">
+ <div class="title">{{ metadata.title }}</div>
+ </div>
+ </div>
+ <div class="tabs">
+ <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
+ <i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+ <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+ </button>
+ <div ref="tabHighlightEl" class="highlight"></div>
+ </div>
+ </template>
+ <div class="buttons right">
+ <template v-if="actions">
+ <template v-for="action in actions">
+ <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+ <button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+ </template>
+ </template>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { url } from '@/config';
+import { scrollToTop } from '@/scripts/scroll';
+import MkButton from '@/components/ui/button.vue';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+import { injectPageMetadata } from '@/scripts/page-metadata';
+
+type Tab = {
+ key?: string | null;
+ title: string;
+ icon?: string;
+ iconOnly?: boolean;
+ onClick?: (ev: MouseEvent) => void;
+};
+
+const props = defineProps<{
+ tabs?: Tab[];
+ tab?: string;
+ actions?: {
+ text: string;
+ icon: string;
+ asFullButton?: boolean;
+ handler: (ev: MouseEvent) => void;
+ }[];
+ thin?: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update:tab', key: string);
+}>();
+
+const metadata = injectPageMetadata();
+
+const el = ref<HTMLElement>(null);
+const tabRefs = {};
+const tabHighlightEl = $ref<HTMLElement | null>(null);
+const bg = ref(null);
+const height = ref(0);
+const hasTabs = computed(() => {
+ return props.tabs && props.tabs.length > 0;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+ if (!hasTabs.value) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ const menu = props.tabs.map(tab => ({
+ text: tab.title,
+ icon: tab.icon,
+ active: tab.key != null && tab.key === props.tab,
+ action: (ev) => {
+ onTabClick(tab, ev);
+ },
+ }));
+ popupMenu(menu, ev.currentTarget ?? ev.target);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+ ev.stopPropagation();
+};
+
+const onClick = () => {
+ scrollToTop(el.value, { behavior: 'smooth' });
+};
+
+function onTabMousedown(tab: Tab, ev: MouseEvent): void {
+ // ユーザビリティの観点からmousedown時にはonClickは呼ばない
+ if (tab.key) {
+ emit('update:tab', tab.key);
+ }
+}
+
+function onTabClick(tab: Tab, ev: MouseEvent): void {
+ if (tab.onClick) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ tab.onClick(ev);
+ }
+ if (tab.key) {
+ emit('update:tab', tab.key);
+ }
+}
+
+const calcBg = () => {
+ const rawBg = metadata?.bg || 'var(--bg)';
+ const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ tinyBg.setAlpha(0.85);
+ bg.value = tinyBg.toRgbString();
+};
+
+onMounted(() => {
+ calcBg();
+ globalEvents.on('themeChanged', calcBg);
+
+ watch(() => [props.tab, props.tabs], () => {
+ nextTick(() => {
+ const tabEl = tabRefs[props.tab];
+ if (tabEl && tabHighlightEl) {
+ // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+ // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+ const parentRect = tabEl.parentElement.getBoundingClientRect();
+ const rect = tabEl.getBoundingClientRect();
+ tabHighlightEl.style.width = rect.width + 'px';
+ tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+ }
+ });
+ }, {
+ immediate: true,
+ });
+});
+
+onUnmounted(() => {
+ globalEvents.off('themeChanged', calcBg);
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkc {
+ --height: 60px;
+ display: flex;
+ width: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+
+ > .buttons {
+ --margin: 8px;
+ display: flex;
+ align-items: center;
+ height: var(--height);
+ margin: 0 var(--margin);
+
+ &.right {
+ margin-left: auto;
+ }
+
+ &:empty {
+ width: var(--height);
+ }
+
+ > .button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: calc(var(--height) - (var(--margin) * 2));
+ width: calc(var(--height) - (var(--margin) * 2));
+ box-sizing: border-box;
+ position: relative;
+ border-radius: 5px;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &.highlighted {
+ color: var(--accent);
+ }
+ }
+
+ > .fullButton {
+ & + .fullButton {
+ margin-left: 12px;
+ }
+ }
+ }
+
+ > .titleContainer {
+ display: flex;
+ align-items: center;
+ max-width: 400px;
+ overflow: auto;
+ white-space: nowrap;
+ text-align: left;
+ font-weight: bold;
+ flex-shrink: 0;
+ margin-left: 24px;
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: bottom;
+ margin: 0 8px;
+ pointer-events: none;
+ }
+
+ > .icon {
+ margin-right: 8px;
+ width: 16px;
+ text-align: center;
+ }
+
+ > .title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.1;
+
+ > .subtitle {
+ opacity: 0.6;
+ font-size: 0.8em;
+ font-weight: normal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.activeTab {
+ text-align: center;
+
+ > .chevron {
+ display: inline-block;
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+ }
+
+ > .tabs {
+ position: relative;
+ margin-left: 16px;
+ font-size: 0.8em;
+ overflow: auto;
+ white-space: nowrap;
+
+ > .tab {
+ display: inline-block;
+ position: relative;
+ padding: 0 10px;
+ height: 100%;
+ font-weight: normal;
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &.active {
+ opacity: 1;
+ }
+
+ > .icon + .title {
+ margin-left: 8px;
+ }
+ }
+
+ > .highlight {
+ position: absolute;
+ bottom: 0;
+ height: 3px;
+ background: var(--accent);
+ border-radius: 999px;
+ transition: all 0.2s ease;
+ pointer-events: none;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
index e1d0361c0b..11cf284b22 100644
--- a/packages/client/src/pages/admin/abuses.vue
+++ b/packages/client/src/pages/admin/abuses.vue
@@ -1,56 +1,62 @@
<template>
-<div class="lcixvhis">
- <div class="_section reports">
- <div class="_content">
- <div class="inputs" style="display: flex;">
- <MkSelect v-model="state" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.state }}</template>
- <option value="all">{{ $ts.all }}</option>
- <option value="unresolved">{{ $ts.unresolved }}</option>
- <option value="resolved">{{ $ts.resolved }}</option>
- </MkSelect>
- <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.reporteeOrigin }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.reporterOrigin }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- </div>
- <!-- TODO
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="lcixvhis">
+ <div class="_section reports">
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <MkSelect v-model="state" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="unresolved">{{ $ts.unresolved }}</option>
+ <option value="resolved">{{ $ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporteeOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporterOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <!-- TODO
<div class="inputs" style="display: flex; padding-top: 1.2em;">
- <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false">
+ <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
<span>{{ $ts.username }}</span>
</MkInput>
- <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" :disabled="pagination.params().origin === 'local'">
+ <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'">
<span>{{ $ts.host }}</span>
</MkInput>
</div>
-->
- <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
- <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
- </MkPagination>
+ <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
+ <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
+ </MkPagination>
+ </div>
+ </div>
</div>
- </div>
-</div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
+import XHeader from './_header_.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
import XAbuseReport from '@/components/abuse-report.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let reports = $ref<InstanceType<typeof MkPagination>>();
@@ -74,12 +80,13 @@ function resolved(reportId) {
reports.removeItem(item => item.id === reportId);
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.abuseReports,
- icon: 'fas fa-exclamation-circle',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.abuseReports,
+ icon: 'fas fa-exclamation-circle',
});
</script>
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
index b18e08db96..21feafc0bb 100644
--- a/packages/client/src/pages/admin/ads.vue
+++ b/packages/client/src/pages/admin/ads.vue
@@ -1,21 +1,23 @@
<template>
-<MkSpacer :content-max="900">
- <div class="uqshojas">
- <div v-for="ad in ads" class="_panel _formRoot ad">
- <MkAd v-if="ad.url" :specify="ad"/>
- <MkInput v-model="ad.url" type="url" class="_formBlock">
- <template #label>URL</template>
- </MkInput>
- <MkInput v-model="ad.imageUrl" class="_formBlock">
- <template #label>{{ i18n.ts.imageUrl }}</template>
- </MkInput>
- <FormRadios v-model="ad.place" class="_formBlock">
- <template #label>Form</template>
- <option value="square">square</option>
- <option value="horizontal">horizontal</option>
- <option value="horizontal-big">horizontal-big</option>
- </FormRadios>
- <!--
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="uqshojas">
+ <div v-for="ad in ads" class="_panel _formRoot ad">
+ <MkAd v-if="ad.url" :specify="ad"/>
+ <MkInput v-model="ad.url" type="url" class="_formBlock">
+ <template #label>URL</template>
+ </MkInput>
+ <MkInput v-model="ad.imageUrl" class="_formBlock">
+ <template #label>{{ i18n.ts.imageUrl }}</template>
+ </MkInput>
+ <FormRadios v-model="ad.place" class="_formBlock">
+ <template #label>Form</template>
+ <option value="square">square</option>
+ <option value="horizontal">horizontal</option>
+ <option value="horizontal-big">horizontal-big</option>
+ </FormRadios>
+ <!--
<div style="margin: 32px 0;">
{{ i18n.ts.priority }}
<MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio>
@@ -23,36 +25,38 @@
<MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
</div>
-->
- <FormSplit>
- <MkInput v-model="ad.ratio" type="number">
- <template #label>{{ i18n.ts.ratio }}</template>
- </MkInput>
- <MkInput v-model="ad.expiresAt" type="date">
- <template #label>{{ i18n.ts.expiration }}</template>
- </MkInput>
- </FormSplit>
- <MkTextarea v-model="ad.memo" class="_formBlock">
- <template #label>{{ i18n.ts.memo }}</template>
- </MkTextarea>
- <div class="buttons _formBlock">
- <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+ <FormSplit>
+ <MkInput v-model="ad.ratio" type="number">
+ <template #label>{{ i18n.ts.ratio }}</template>
+ </MkInput>
+ <MkInput v-model="ad.expiresAt" type="date">
+ <template #label>{{ i18n.ts.expiration }}</template>
+ </MkInput>
+ </FormSplit>
+ <MkTextarea v-model="ad.memo" class="_formBlock">
+ <template #label>{{ i18n.ts.memo }}</template>
+ </MkTextarea>
+ <div class="buttons _formBlock">
+ <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
</div>
</div>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import FormRadios from '@/components/form/radios.vue';
import FormSplit from '@/components/form/split.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let ads: any[] = $ref([]);
@@ -81,7 +85,7 @@ function remove(ad) {
if (canceled) return;
ads = ads.filter(x => x !== ad);
os.apiWithDialog('admin/ad/delete', {
- id: ad.id
+ id: ad.id,
});
});
}
@@ -90,28 +94,28 @@ function save(ad) {
if (ad.id == null) {
os.apiWithDialog('admin/ad/create', {
...ad,
- expiresAt: new Date(ad.expiresAt).getTime()
+ expiresAt: new Date(ad.expiresAt).getTime(),
});
} else {
os.apiWithDialog('admin/ad/update', {
...ad,
- expiresAt: new Date(ad.expiresAt).getTime()
+ expiresAt: new Date(ad.expiresAt).getTime(),
});
}
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.ads,
- icon: 'fas fa-audio-description',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.add,
- handler: add,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.add,
+ handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.ads,
+ icon: 'fas fa-audio-description',
});
</script>
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
index 97774975de..5107c2f302 100644
--- a/packages/client/src/pages/admin/announcements.vue
+++ b/packages/client/src/pages/admin/announcements.vue
@@ -1,34 +1,40 @@
<template>
-<div class="ztgjmzrw">
- <section v-for="announcement in announcements" class="_card _gap announcements">
- <div class="_content announcement">
- <MkInput v-model="announcement.title">
- <template #label>{{ i18n.ts.title }}</template>
- </MkInput>
- <MkTextarea v-model="announcement.text">
- <template #label>{{ i18n.ts.text }}</template>
- </MkTextarea>
- <MkInput v-model="announcement.imageUrl">
- <template #label>{{ i18n.ts.imageUrl }}</template>
- </MkInput>
- <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
- <div class="buttons">
- <MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
- </div>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="ztgjmzrw">
+ <section v-for="announcement in announcements" class="_card _gap announcements">
+ <div class="_content announcement">
+ <MkInput v-model="announcement.title">
+ <template #label>{{ i18n.ts.title }}</template>
+ </MkInput>
+ <MkTextarea v-model="announcement.text">
+ <template #label>{{ i18n.ts.text }}</template>
+ </MkTextarea>
+ <MkInput v-model="announcement.imageUrl">
+ <template #label>{{ i18n.ts.imageUrl }}</template>
+ </MkInput>
+ <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
+ <div class="buttons">
+ <MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
</div>
- </section>
-</div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let announcements: any[] = $ref([]);
@@ -41,7 +47,7 @@ function add() {
id: null,
title: '',
text: '',
- imageUrl: null
+ imageUrl: null,
});
}
@@ -61,41 +67,41 @@ function save(announcement) {
os.api('admin/announcements/create', announcement).then(() => {
os.alert({
type: 'success',
- text: i18n.ts.saved
+ text: i18n.ts.saved,
});
}).catch(err => {
os.alert({
type: 'error',
- text: err
+ text: err,
});
});
} else {
os.api('admin/announcements/update', announcement).then(() => {
os.alert({
type: 'success',
- text: i18n.ts.saved
+ text: i18n.ts.saved,
});
}).catch(err => {
os.alert({
type: 'error',
- text: err
+ text: err,
});
});
}
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.announcements,
- icon: 'fas fa-broadcast-tower',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.add,
- handler: add,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.add,
+ handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.announcements,
+ icon: 'fas fa-broadcast-tower',
});
</script>
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
index 30fee5015a..d316f973bc 100644
--- a/packages/client/src/pages/admin/bot-protection.vue
+++ b/packages/client/src/pages/admin/bot-protection.vue
@@ -51,7 +51,6 @@ import FormButton from '@/components/ui/button.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSlot from '@/components/form/slot.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
const MkCaptcha = defineAsyncComponent(() => import('@/components/captcha.vue'));
@@ -62,27 +61,22 @@ let hcaptchaSecretKey: string | null = $ref(null);
let recaptchaSiteKey: string | null = $ref(null);
let recaptchaSecretKey: string | null = $ref(null);
-const enableHcaptcha = $computed(() => provider === 'hcaptcha');
-const enableRecaptcha = $computed(() => provider === 'recaptcha');
-
async function init() {
const meta = await os.api('admin/meta');
- enableHcaptcha = meta.enableHcaptcha;
hcaptchaSiteKey = meta.hcaptchaSiteKey;
hcaptchaSecretKey = meta.hcaptchaSecretKey;
- enableRecaptcha = meta.enableRecaptcha;
recaptchaSiteKey = meta.recaptchaSiteKey;
recaptchaSecretKey = meta.recaptchaSecretKey;
- provider = enableHcaptcha ? 'hcaptcha' : enableRecaptcha ? 'recaptcha' : null;
+ provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : null;
}
function save() {
os.apiWithDialog('admin/update-meta', {
- enableHcaptcha,
+ enableHcaptcha: provider === 'hcaptcha',
hcaptchaSiteKey,
hcaptchaSecretKey,
- enableRecaptcha,
+ enableRecaptcha: provider === 'recaptcha',
recaptchaSiteKey,
recaptchaSecretKey,
}).then(() => {
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
index d3519922b1..ca8718ef63 100644
--- a/packages/client/src/pages/admin/database.vue
+++ b/packages/client/src/pages/admin/database.vue
@@ -1,12 +1,13 @@
-<template>
-<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
<FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
<MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
<template #key>{{ table[0] }}</template>
<template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template>
</MkKeyValue>
</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -14,18 +15,19 @@ import { } from 'vue';
import FormSuspense from '@/components/form/suspense.vue';
import MkKeyValue from '@/components/key-value.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.database,
- icon: 'fas fa-database',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.database,
+ icon: 'fas fa-database',
});
</script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
index aa13043193..46cfd3db72 100644
--- a/packages/client/src/pages/admin/email-settings.vue
+++ b/packages/client/src/pages/admin/email-settings.vue
@@ -1,49 +1,53 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- <div class="_formRoot">
- <FormSwitch v-model="enableEmail" class="_formBlock">
- <template #label>{{ i18n.ts.enableEmail }}</template>
- <template #caption>{{ i18n.ts.emailConfigInfo }}</template>
- </FormSwitch>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormSwitch v-model="enableEmail" class="_formBlock">
+ <template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template>
+ <template #caption>{{ i18n.ts.emailConfigInfo }}</template>
+ </FormSwitch>
- <template v-if="enableEmail">
- <FormInput v-model="email" type="email" class="_formBlock">
- <template #label>{{ i18n.ts.emailAddress }}</template>
- </FormInput>
+ <template v-if="enableEmail">
+ <FormInput v-model="email" type="email" class="_formBlock">
+ <template #label>{{ i18n.ts.emailAddress }}</template>
+ </FormInput>
- <FormSection>
- <template #label>{{ i18n.ts.smtpConfig }}</template>
- <FormSplit :min-width="280">
- <FormInput v-model="smtpHost" class="_formBlock">
- <template #label>{{ i18n.ts.smtpHost }}</template>
- </FormInput>
- <FormInput v-model="smtpPort" type="number" class="_formBlock">
- <template #label>{{ i18n.ts.smtpPort }}</template>
- </FormInput>
- </FormSplit>
- <FormSplit :min-width="280">
- <FormInput v-model="smtpUser" class="_formBlock">
- <template #label>{{ i18n.ts.smtpUser }}</template>
- </FormInput>
- <FormInput v-model="smtpPass" type="password" class="_formBlock">
- <template #label>{{ i18n.ts.smtpPass }}</template>
- </FormInput>
- </FormSplit>
- <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
- <FormSwitch v-model="smtpSecure" class="_formBlock">
- <template #label>{{ i18n.ts.smtpSecure }}</template>
- <template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
- </FormSwitch>
- </FormSection>
- </template>
- </div>
- </FormSuspense>
-</MkSpacer>
+ <FormSection>
+ <template #label>{{ i18n.ts.smtpConfig }}</template>
+ <FormSplit :min-width="280">
+ <FormInput v-model="smtpHost" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpHost }}</template>
+ </FormInput>
+ <FormInput v-model="smtpPort" type="number" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpPort }}</template>
+ </FormInput>
+ </FormSplit>
+ <FormSplit :min-width="280">
+ <FormInput v-model="smtpUser" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpUser }}</template>
+ </FormInput>
+ <FormInput v-model="smtpPass" type="password" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpPass }}</template>
+ </FormInput>
+ </FormSplit>
+ <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
+ <FormSwitch v-model="smtpSecure" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpSecure }}</template>
+ <template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
+ </FormSwitch>
+ </FormSection>
+ </template>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormInput from '@/components/form/input.vue';
import FormInfo from '@/components/ui/info.vue';
@@ -51,9 +55,9 @@ import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import FormSection from '@/components/form/section.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance, instance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let enableEmail: boolean = $ref(false);
let email: any = $ref(null);
@@ -78,13 +82,13 @@ async function testEmail() {
const { canceled, result: destination } = await os.inputText({
title: i18n.ts.destination,
type: 'email',
- placeholder: instance.maintainerEmail
+ placeholder: instance.maintainerEmail,
});
if (canceled) return;
os.apiWithDialog('admin/send-email', {
to: destination,
subject: 'Test email',
- text: 'Yo'
+ text: 'Yo',
});
}
@@ -102,21 +106,21 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.emailServer,
- icon: 'fas fa-envelope',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- text: i18n.ts.testEmail,
- handler: testEmail,
- }, {
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ text: i18n.ts.testEmail,
+ handler: testEmail,
+}, {
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.emailServer,
+ icon: 'fas fa-envelope',
});
</script>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
index 8ca5b3d65c..5ed2b14789 100644
--- a/packages/client/src/pages/admin/emojis.vue
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -1,69 +1,75 @@
<template>
-<MkSpacer :content-max="900">
- <div class="ogwlenmc">
- <div v-if="tab === 'local'" class="local">
- <MkInput v-model="query" :debounce="true" type="search">
- <template #prefix><i class="fas fa-search"></i></template>
- <template #label>{{ $ts.search }}</template>
- </MkInput>
- <MkSwitch v-model="selectMode" style="margin: 8px 0;">
- <template #label>Select mode</template>
- </MkSwitch>
- <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
- <MkButton inline @click="selectAll">Select all</MkButton>
- <MkButton inline @click="setCategoryBulk">Set category</MkButton>
- <MkButton inline @click="addTagBulk">Add tag</MkButton>
- <MkButton inline @click="removeTagBulk">Remove tag</MkButton>
- <MkButton inline @click="setTagBulk">Set tag</MkButton>
- <MkButton inline danger @click="delBulk">Delete</MkButton>
- </div>
- <MkPagination ref="emojisPaginationComponent" :pagination="pagination">
- <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
- <template v-slot="{items}">
- <div class="ldhfsamy">
- <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
- <img :src="emoji.url" class="img" :alt="emoji.name"/>
- <div class="body">
- <div class="name _monospace">{{ emoji.name }}</div>
- <div class="info">{{ emoji.category }}</div>
- </div>
- </button>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="ogwlenmc">
+ <div v-if="tab === 'local'" class="local">
+ <MkInput v-model="query" :debounce="true" type="search">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkSwitch v-model="selectMode" style="margin: 8px 0;">
+ <template #label>Select mode</template>
+ </MkSwitch>
+ <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <MkButton inline @click="selectAll">Select all</MkButton>
+ <MkButton inline @click="setCategoryBulk">Set category</MkButton>
+ <MkButton inline @click="addTagBulk">Add tag</MkButton>
+ <MkButton inline @click="removeTagBulk">Remove tag</MkButton>
+ <MkButton inline @click="setTagBulk">Set tag</MkButton>
+ <MkButton inline danger @click="delBulk">Delete</MkButton>
</div>
- </template>
- </MkPagination>
- </div>
+ <MkPagination ref="emojisPaginationComponent" :pagination="pagination">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <div class="name _monospace">{{ emoji.name }}</div>
+ <div class="info">{{ emoji.category }}</div>
+ </div>
+ </button>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
- <div v-else-if="tab === 'remote'" class="remote">
- <FormSplit>
- <MkInput v-model="queryRemote" :debounce="true" type="search">
- <template #prefix><i class="fas fa-search"></i></template>
- <template #label>{{ $ts.search }}</template>
- </MkInput>
- <MkInput v-model="host" :debounce="true">
- <template #label>{{ $ts.host }}</template>
- </MkInput>
- </FormSplit>
- <MkPagination :pagination="remotePagination">
- <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
- <template v-slot="{items}">
- <div class="ldhfsamy">
- <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
- <img :src="emoji.url" class="img" :alt="emoji.name"/>
- <div class="body">
- <div class="name _monospace">{{ emoji.name }}</div>
- <div class="info">{{ emoji.host }}</div>
+ <div v-else-if="tab === 'remote'" class="remote">
+ <FormSplit>
+ <MkInput v-model="queryRemote" :debounce="true" type="search">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkInput v-model="host" :debounce="true">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ </FormSplit>
+ <MkPagination :pagination="remotePagination">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <div class="name _monospace">{{ emoji.name }}</div>
+ <div class="info">{{ emoji.host }}</div>
+ </div>
+ </div>
</div>
- </div>
- </div>
- </template>
- </MkPagination>
- </div>
- </div>
-</MkSpacer>
+ </template>
+ </MkPagination>
+ </div>
+ </div>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkPagination from '@/components/ui/pagination.vue';
@@ -72,8 +78,8 @@ import MkSwitch from '@/components/form/switch.vue';
import FormSplit from '@/components/form/split.vue';
import { selectFile, selectFiles } from '@/scripts/select-file';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>();
@@ -131,13 +137,13 @@ const add = async (ev: MouseEvent) => {
const edit = (emoji) => {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
- emoji: emoji
+ emoji: emoji,
}, {
done: result => {
if (result.updated) {
emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
...oldEmoji,
- ...result.updated
+ ...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
@@ -159,7 +165,7 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
}, {
text: i18n.ts.import,
icon: 'fas fa-plus',
- action: () => { im(emoji); }
+ action: () => { im(emoji); },
}], ev.currentTarget ?? ev.target);
};
@@ -181,7 +187,7 @@ const menu = (ev: MouseEvent) => {
text: err.message,
});
});
- }
+ },
}, {
icon: 'fas fa-upload',
text: i18n.ts.import,
@@ -201,7 +207,7 @@ const menu = (ev: MouseEvent) => {
text: err.message,
});
});
- }
+ },
}], ev.currentTarget ?? ev.target);
};
@@ -265,31 +271,28 @@ const delBulk = async () => {
emojisPaginationComponent.value.reload();
};
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.customEmojis,
- icon: 'fas fa-laugh',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.addEmoji,
- handler: add,
- }, {
- icon: 'fas fa-ellipsis-h',
- handler: menu,
- }],
- tabs: [{
- active: tab.value === 'local',
- title: i18n.ts.local,
- onClick: () => { tab.value = 'local'; },
- }, {
- active: tab.value === 'remote',
- title: i18n.ts.remote,
- onClick: () => { tab.value = 'remote'; },
- },]
- })),
-});
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.addEmoji,
+ handler: add,
+}, {
+ icon: 'fas fa-ellipsis-h',
+ handler: menu,
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'local',
+ title: i18n.ts.local,
+}, {
+ key: 'remote',
+ title: i18n.ts.remote,
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.customEmojis,
+ icon: 'fas fa-laugh',
+})));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue
deleted file mode 100644
index 0765548aab..0000000000
--- a/packages/client/src/pages/admin/file-dialog.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<template>
-<XModalWindow ref="dialog"
- :width="370"
- @close="$refs.dialog.close()"
- @closed="$emit('closed')"
->
- <template v-if="file" #header>{{ file.name }}</template>
- <div v-if="file" class="cxqhhsmd">
- <div class="_section">
- <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
- <div class="info">
- <span style="margin-right: 1em;">{{ file.type }}</span>
- <span>{{ bytes(file.size) }}</span>
- <MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
- </div>
- </div>
- <div class="_section">
- <div class="_content">
- <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
- </div>
- </div>
- <div class="_section">
- <div class="_content">
- <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
- <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
- </div>
- </div>
- <div v-if="info" class="_section">
- <details class="_content rawdata">
- <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
- </details>
- </div>
- </div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import XModalWindow from '@/components/ui/modal-window.vue';
-import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
-import bytes from '@/filters/bytes';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-let file: any = $ref(null);
-let info: any = $ref(null);
-let isSensitive: boolean = $ref(false);
-
-const props = defineProps<{
- fileId: string,
-}>();
-
-async function fetch() {
- file = await os.api('drive/files/show', { fileId: props.fileId });
- info = await os.api('admin/drive/show-file', { fileId: props.fileId });
- isSensitive = file.isSensitive;
-}
-
-fetch();
-
-function showUser() {
- os.pageWindow(`/user-info/${file.userId}`);
-}
-
-async function del() {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: i18n.t('removeAreYouSure', { x: file.name }),
- });
- if (canceled) return;
-
- os.apiWithDialog('drive/files/delete', {
- fileId: file.id
- });
-}
-
-async function toggleIsSensitive(v) {
- await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v });
- isSensitive = v;
-}
-</script>
-
-<style lang="scss" scoped>
-.cxqhhsmd {
- > ._section {
- > .thumbnail {
- height: 150px;
- max-width: 100%;
- }
-
- > .info {
- text-align: center;
- margin-top: 8px;
- }
-
- > .rawdata {
- overflow: auto;
- }
- }
-}
-</style>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
index 3cda688698..dd309180a7 100644
--- a/packages/client/src/pages/admin/files.vue
+++ b/packages/client/src/pages/admin/files.vue
@@ -1,81 +1,61 @@
<template>
-<div class="xrmjdkdw">
- <MkContainer :foldable="true" class="lookup">
- <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template>
- <div class="xrmjdkdw-lookup">
- <MkInput v-model="q" class="item" type="text" @enter="find()">
- <template #label>{{ $ts.fileIdOrUrl }}</template>
- </MkInput>
- <MkButton primary @click="find()"><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
- </div>
- </MkContainer>
-
- <div class="_section">
- <div class="_content">
- <div class="inputs" style="display: flex;">
- <MkSelect v-model="origin" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.instance }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
- <template #label>{{ $ts.host }}</template>
- </MkInput>
- </div>
- <div class="inputs" style="display: flex; padding-top: 1.2em;">
- <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
- <template #label>MIME type</template>
- </MkInput>
- </div>
- <MkPagination v-slot="{items}" :pagination="pagination" class="urempief">
- <button v-for="file in items" :key="file.id" class="file _panel _button _gap" @click="show(file, $event)">
- <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
- <div class="body">
- <div>
- <small style="opacity: 0.7;">{{ file.name }}</small>
- </div>
- <div>
- <MkAcct v-if="file.user" :user="file.user"/>
- <div v-else>{{ $ts.system }}</div>
- </div>
- <div>
- <span style="margin-right: 1em;">{{ file.type }}</span>
- <span>{{ bytes(file.size) }}</span>
- </div>
- <div>
- <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
- </div>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :actions="headerActions"/></template>
+ <MkSpacer :content-max="900">
+ <div class="xrmjdkdw">
+ <div>
+ <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <MkSelect v-model="origin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
</div>
- </button>
- </MkPagination>
- </div>
- </div>
+ <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;">
+ <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>User ID</template>
+ </MkInput>
+ <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>MIME type</template>
+ </MkInput>
+ </div>
+ <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/>
+ </div>
+ </div>
+ </MkSpacer>
+ </MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
-import MkPagination from '@/components/ui/pagination.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import MkFileListForAdmin from '@/components/file-list-for-admin.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-let q = $ref(null);
let origin = $ref('local');
let type = $ref(null);
let searchHost = $ref('');
+let userId = $ref('');
+let viewMode = $ref('grid');
const pagination = {
endpoint: 'admin/drive/files' as const,
limit: 10,
params: computed(() => ({
type: (type && type !== '') ? type : null,
+ userId: (userId && userId !== '') ? userId : null,
origin: origin,
hostname: (searchHost && searchHost !== '') ? searchHost : null,
})),
@@ -93,83 +73,48 @@ function clear() {
}
function show(file) {
- os.popup(defineAsyncComponent(() => import('./file-dialog.vue')), {
- fileId: file.id
- }, {}, 'closed');
+ os.pageWindow(`/admin/file/${file.id}`);
}
-function find() {
+async function find() {
+ const { canceled, result: q } = await os.inputText({
+ title: i18n.ts.fileIdOrUrl,
+ allowEmpty: false,
+ });
+ if (canceled) return;
+
os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
show(file);
}).catch(err => {
if (err.code === 'NO_SUCH_FILE') {
os.alert({
type: 'error',
- text: i18n.ts.notFound
+ text: i18n.ts.notFound,
});
}
});
}
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.files,
- icon: 'fas fa-cloud',
- bg: 'var(--bg)',
- actions: [{
- text: i18n.ts.clearCachedFiles,
- icon: 'fas fa-trash-alt',
- handler: clear,
- }],
- })),
-});
+const headerActions = $computed(() => [{
+ text: i18n.ts.lookup,
+ icon: 'fas fa-search',
+ handler: find,
+}, {
+ text: i18n.ts.clearCachedFiles,
+ icon: 'fas fa-trash-alt',
+ handler: clear,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.files,
+ icon: 'fas fa-cloud',
+})));
</script>
<style lang="scss" scoped>
.xrmjdkdw {
margin: var(--margin);
-
- > .lookup {
- margin-bottom: 16px;
- }
-
- .urempief {
- margin-top: var(--margin);
-
- > .file {
- display: flex;
- width: 100%;
- box-sizing: border-box;
- text-align: left;
- align-items: center;
-
- &:hover {
- color: var(--accent);
- }
-
- > .thumbnail {
- width: 128px;
- height: 128px;
- }
-
- > .body {
- margin-left: 0.3em;
- padding: 8px;
- flex: 1;
-
- @media (max-width: 500px) {
- font-size: 14px;
- }
- }
- }
- }
-}
-
-.xrmjdkdw-lookup {
- padding: 16px;
-
- > .item {
- margin-bottom: 16px;
- }
}
</style>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
index 9b7fa5678e..f0ac5b3fc9 100644
--- a/packages/client/src/pages/admin/index.vue
+++ b/packages/client/src/pages/admin/index.vue
@@ -1,50 +1,46 @@
<template>
<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
- <div v-if="!narrow || initialPage == null" class="nav">
- <MkHeader :info="header"></MkHeader>
-
+ <div v-if="!narrow || initialPage == null" class="nav">
<MkSpacer :content-max="700" :margin-min="16">
<div class="lxpfedzu">
<div class="banner">
<img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
</div>
+ <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ $ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ $ts.check }}</MkA></MkInfo>
<MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noEmailServer" warn class="info">{{ $ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
</div>
</MkSpacer>
</div>
<div v-if="!(narrow && initialPage == null)" class="main">
- <MkStickyContainer>
- <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
- <component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/>
- </MkStickyContainer>
+ <component :is="component" :key="initialPage" v-bind="pageProps"/>
</div>
</div>
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
+import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
import { i18n } from '@/i18n';
import MkSuperMenu from '@/components/ui/super-menu.vue';
import MkInfo from '@/components/ui/info.vue';
import { scroll } from '@/scripts/scroll';
import { instance } from '@/instance';
-import * as symbols from '@/symbols';
import * as os from '@/os';
import { lookupUser } from '@/scripts/lookup-user';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
const isEmpty = (x: string | null) => x == null || x === '';
-const nav = new MisskeyNavigator();
+const router = useRouter();
const indexInfo = {
title: i18n.ts.controlPanel,
icon: 'fas fa-cog',
- bg: 'var(--bg)',
hideHeader: true,
};
@@ -63,6 +59,15 @@ let el = $ref(null);
let pageProps = $ref({});
let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail);
let noBotProtection = !instance.enableHcaptcha && !instance.enableRecaptcha;
+let noEmailServer = !instance.enableEmail;
+let thereIsUnresolvedAbuseReport = $ref(false);
+
+os.api('admin/abuse-user-reports', {
+ state: 'unresolved',
+ limit: 1,
+}).then(reports => {
+ if (reports.length > 0) thereIsUnresolvedAbuseReport = true;
+});
const NARROW_THRESHOLD = 600;
const ro = new ResizeObserver((entries, observer) => {
@@ -103,7 +108,7 @@ const menuDef = $computed(() => [{
}, {
icon: 'fas fa-globe',
text: i18n.ts.federation,
- to: '/admin/federation',
+ to: '/about#federation',
active: props.initialPage === 'federation',
}, {
icon: 'fas fa-clipboard-list',
@@ -195,7 +200,7 @@ const component = $computed(() => {
case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
case 'users': return defineAsyncComponent(() => import('./users.vue'));
case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
- case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
+ //case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
case 'files': return defineAsyncComponent(() => import('./files.vue'));
case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
@@ -224,7 +229,7 @@ watch(component, () => {
watch(() => props.initialPage, () => {
if (props.initialPage == null && !narrow) {
- nav.push('/admin/overview');
+ router.push('/admin/overview');
} else {
if (props.initialPage == null) {
INFO = indexInfo;
@@ -234,7 +239,7 @@ watch(() => props.initialPage, () => {
watch(narrow, () => {
if (props.initialPage == null && !narrow) {
- nav.push('/admin/overview');
+ router.push('/admin/overview');
}
});
@@ -243,7 +248,7 @@ onMounted(() => {
narrow = el.offsetWidth < NARROW_THRESHOLD;
if (props.initialPage == null && !narrow) {
- nav.push('/admin/overview');
+ router.push('/admin/overview');
}
});
@@ -251,19 +256,19 @@ onUnmounted(() => {
ro.disconnect();
});
-const pageChanged = (page) => {
- if (page == null) {
+provideMetadataReceiver((info) => {
+ if (info == null) {
childInfo = null;
} else {
- childInfo = page[symbols.PAGE_INFO];
+ childInfo = info;
}
-};
+});
const invite = () => {
os.api('admin/invite').then(x => {
os.alert({
type: 'info',
- text: x.code
+ text: x.code,
});
}).catch(err => {
os.alert({
@@ -279,33 +284,38 @@ const lookup = (ev) => {
icon: 'fas fa-user',
action: () => {
lookupUser();
- }
+ },
}, {
text: i18n.ts.note,
icon: 'fas fa-pencil-alt',
action: () => {
alert('TODO');
- }
+ },
}, {
text: i18n.ts.file,
icon: 'fas fa-cloud',
action: () => {
alert('TODO');
- }
+ },
}, {
text: i18n.ts.instance,
icon: 'fas fa-globe',
action: () => {
alert('TODO');
- }
+ },
}], ev.currentTarget ?? ev.target);
};
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+
defineExpose({
- [symbols.PAGE_INFO]: INFO,
header: {
title: i18n.ts.controlPanel,
- }
+ },
});
</script>
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
index 3347846a80..6d479e8f0d 100644
--- a/packages/client/src/pages/admin/instance-block.vue
+++ b/packages/client/src/pages/admin/instance-block.vue
@@ -1,25 +1,29 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- <FormTextarea v-model="blockedHosts" class="_formBlock">
- <span>{{ i18n.ts.blockedInstances }}</span>
- <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
- </FormTextarea>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <FormTextarea v-model="blockedHosts" class="_formBlock">
+ <span>{{ i18n.ts.blockedInstances }}</span>
+ <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
+ </FormTextarea>
- <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
- </FormSuspense>
-</MkSpacer>
+ <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import FormButton from '@/components/ui/button.vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let blockedHosts: string = $ref('');
@@ -36,11 +40,12 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.instanceBlocking,
- icon: 'fas fa-ban',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.instanceBlocking,
+ icon: 'fas fa-ban',
});
</script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
index d6061d0e51..9964426a68 100644
--- a/packages/client/src/pages/admin/integrations.vue
+++ b/packages/client/src/pages/admin/integrations.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<FormFolder class="_formBlock">
<template #icon><i class="fab fa-twitter"></i></template>
@@ -20,19 +21,19 @@
<XDiscord/>
</FormFolder>
</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormSuspense from '@/components/form/suspense.vue';
import XTwitter from './integrations.twitter.vue';
import XGithub from './integrations.github.vue';
import XDiscord from './integrations.discord.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormFolder from '@/components/form/folder.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let enableTwitterIntegration: boolean = $ref(false);
let enableGithubIntegration: boolean = $ref(false);
@@ -45,11 +46,12 @@ async function init() {
enableDiscordIntegration = meta.enableDiscordIntegration;
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.integration,
- icon: 'fas fa-share-alt',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.integration,
+ icon: 'fas fa-share-alt',
});
</script>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
index d109db9c38..5cc3018532 100644
--- a/packages/client/src/pages/admin/object-storage.vue
+++ b/packages/client/src/pages/admin/object-storage.vue
@@ -1,82 +1,85 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- <div class="_formRoot">
- <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
- <template v-if="useObjectStorage">
- <FormInput v-model="objectStorageBaseUrl" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
- <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
- </FormInput>
-
- <FormInput v-model="objectStorageBucket" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageBucket }}</template>
- <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
- </FormInput>
-
- <FormInput v-model="objectStoragePrefix" class="_formBlock">
- <template #label>{{ i18n.ts.objectStoragePrefix }}</template>
- <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
- </FormInput>
+ <template v-if="useObjectStorage">
+ <FormInput v-model="objectStorageBaseUrl" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
+ <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
+ </FormInput>
- <FormInput v-model="objectStorageEndpoint" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
- <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
- </FormInput>
+ <FormInput v-model="objectStorageBucket" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageBucket }}</template>
+ <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
+ </FormInput>
- <FormInput v-model="objectStorageRegion" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageRegion }}</template>
- <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
- </FormInput>
+ <FormInput v-model="objectStoragePrefix" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStoragePrefix }}</template>
+ <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
+ </FormInput>
- <FormSplit :min-width="280">
- <FormInput v-model="objectStorageAccessKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>Access key</template>
+ <FormInput v-model="objectStorageEndpoint" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
+ <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
</FormInput>
- <FormInput v-model="objectStorageSecretKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>Secret key</template>
+ <FormInput v-model="objectStorageRegion" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageRegion }}</template>
+ <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
</FormInput>
- </FormSplit>
- <FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
- <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
- </FormSwitch>
+ <FormSplit :min-width="280">
+ <FormInput v-model="objectStorageAccessKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Access key</template>
+ </FormInput>
- <FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
- <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
- </FormSwitch>
+ <FormInput v-model="objectStorageSecretKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Secret key</template>
+ </FormInput>
+ </FormSplit>
- <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
- </FormSwitch>
+ <FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
+ <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
+ </FormSwitch>
- <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
- <template #label>s3ForcePathStyle</template>
- </FormSwitch>
- </template>
- </div>
- </FormSuspense>
-</MkSpacer>
+ <FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
+ <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
+ <template #label>s3ForcePathStyle</template>
+ </FormSwitch>
+ </template>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormInput from '@/components/form/input.vue';
-import FormGroup from '@/components/form/group.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import FormSection from '@/components/form/section.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let useObjectStorage: boolean = $ref(false);
let objectStorageBaseUrl: string | null = $ref(null);
@@ -129,17 +132,17 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.objectStorage,
- icon: 'fas fa-cloud',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.objectStorage,
+ icon: 'fas fa-cloud',
});
</script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
index 552b05f347..ee4e8edba0 100644
--- a/packages/client/src/pages/admin/other-settings.vue
+++ b/packages/client/src/pages/admin/other-settings.vue
@@ -1,18 +1,22 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- none
- </FormSuspense>
-</MkSpacer>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ none
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
async function init() {
await os.api('admin/meta');
@@ -24,17 +28,17 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.other,
- icon: 'fas fa-cogs',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.other,
+ icon: 'fas fa-cogs',
});
</script>
diff --git a/packages/client/src/pages/admin/overview.federation.vue b/packages/client/src/pages/admin/overview.federation.vue
new file mode 100644
index 0000000000..6c99cad33c
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.federation.vue
@@ -0,0 +1,100 @@
+<template>
+<div class="wbrkwale">
+ <MkLoading v-if="fetching"/>
+ <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
+ <MkA v-for="(instance, i) in instances" :key="instance.id" :to="`/instance-info/${instance.host}`" class="instance">
+ <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/>
+ <div class="body">
+ <div class="name">{{ instance.name ?? instance.host }}</div>
+ <div class="host">{{ instance.host }}</div>
+ </div>
+ <MkMiniChart class="chart" :src="charts[i].requests.received"/>
+ </MkA>
+ </transition-group>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import MkMiniChart from '@/components/mini-chart.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+
+const instances = ref([]);
+const charts = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+ const fetchedInstances = await os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 5,
+ });
+ const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
+ instances.value = fetchedInstances;
+ charts.value = fetchedCharts;
+ fetching.value = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+ immediate: true,
+ afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwale {
+ > .instances {
+ .chart-move {
+ transition: transform 1s ease;
+ }
+
+ > .instance {
+ display: flex;
+ align-items: center;
+ padding: 16px 20px;
+
+ &:not(:last-child) {
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > img {
+ display: block;
+ width: 34px;
+ height: 34px;
+ object-fit: cover;
+ border-radius: 4px;
+ margin-right: 12px;
+ }
+
+ > .body {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+ padding-right: 8px;
+
+ > .name {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .host {
+ margin: 0;
+ font-size: 75%;
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .chart {
+ height: 30px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/overview.pie.vue b/packages/client/src/pages/admin/overview.pie.vue
new file mode 100644
index 0000000000..d3b2032876
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.pie.vue
@@ -0,0 +1,108 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ DoughnutController,
+} from 'chart.js';
+import number from '@/filters/number';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ DoughnutController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const props = defineProps<{
+ data: { name: string; value: number; color: string; onClick?: () => void }[];
+}>();
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+let chartInstance: Chart;
+
+onMounted(() => {
+ chartInstance = new Chart(chartEl.value, {
+ type: 'doughnut',
+ data: {
+ labels: props.data.map(x => x.name),
+ datasets: [{
+ backgroundColor: props.data.map(x => x.color),
+ borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
+ borderWidth: 2,
+ hoverOffset: 0,
+ data: props.data.map(x => x.value),
+ }],
+ },
+ options: {
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 16,
+ },
+ },
+ onClick: (ev) => {
+ const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0];
+ if (hit && props.data[hit.index].onClick) {
+ props.data[hit.index].onClick();
+ }
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ },
+ },
+ });
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/overview.queue-chart.vue b/packages/client/src/pages/admin/overview.queue-chart.vue
new file mode 100644
index 0000000000..a2b748ad38
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.queue-chart.vue
@@ -0,0 +1,211 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const props = defineProps<{
+ domain: string;
+ connection: any;
+}>();
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+let chartInstance: Chart;
+
+const onStats = (stats) => {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 100) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ chartInstance.update();
+};
+
+const onStatsLog = (statsLog) => {
+ for (const stats of [...statsLog].reverse()) {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 100) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ }
+ chartInstance.update();
+};
+
+onMounted(() => {
+ chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Process',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00E396',
+ backgroundColor: alpha('#00E396', 0.1),
+ data: [],
+ }, {
+ label: 'Active',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00BCD4',
+ backgroundColor: alpha('#00BCD4', 0.1),
+ data: [],
+ }, {
+ label: 'Waiting',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#FFB300',
+ backgroundColor: alpha('#FFB300', 0.1),
+ data: [],
+ }, {
+ label: 'Delayed',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#E53935',
+ borderDash: [5, 5],
+ fill: false,
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ display: false,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ maxTicksLimit: 10,
+ },
+ },
+ y: {
+ display: false,
+ min: 0,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ },
+ },
+ });
+
+ props.connection.on('stats', onStats);
+ props.connection.on('statsLog', onStatsLog);
+});
+
+onUnmounted(() => {
+ props.connection.off('stats', onStats);
+ props.connection.off('statsLog', onStatsLog);
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/overview.user.vue b/packages/client/src/pages/admin/overview.user.vue
new file mode 100644
index 0000000000..d70336f3c2
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.user.vue
@@ -0,0 +1,76 @@
+<template>
+<MkA :class="[$style.root]" :to="`/user-info/${user.id}`">
+ <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+ <div class="body">
+ <span class="name"><MkUserName class="name" :user="user"/></span>
+ <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
+ </div>
+ <MkMiniChart v-if="chart" class="chart" :src="chart.inc"/>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import MkMiniChart from '@/components/mini-chart.vue';
+import * as os from '@/os';
+import { acct } from '@/filters/user';
+
+const props = defineProps<{
+ user: misskey.entities.User;
+}>();
+
+let chart = $ref(null);
+
+os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16, span: 'day' }).then(res => {
+ chart = res;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ $bodyTitleHieght: 18px;
+ $bodyInfoHieght: 16px;
+
+ display: flex;
+ align-items: center;
+
+ > :global(.avatar) {
+ display: block;
+ width: ($bodyTitleHieght + $bodyInfoHieght);
+ height: ($bodyTitleHieght + $bodyInfoHieght);
+ margin-right: 12px;
+ }
+
+ > :global(.body) {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+ padding-right: 8px;
+
+ > :global(.name) {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: $bodyTitleHieght;
+ }
+
+ > :global(.sub) {
+ display: block;
+ width: 100%;
+ font-size: 95%;
+ opacity: 0.7;
+ line-height: $bodyInfoHieght;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > :global(.chart) {
+ height: 30px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
index cc69424c3b..7e085106b9 100644
--- a/packages/client/src/pages/admin/overview.vue
+++ b/packages/client/src/pages/admin/overview.vue
@@ -1,112 +1,458 @@
<template>
-<div v-size="{ max: [740] }" class="edbbcaef">
- <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)">
- <div class="number _panel">
- <div class="label">Users</div>
- <div class="value _monospace">
- {{ number(stats.originalUsersCount) }}
- <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+<MkSpacer :content-max="900">
+ <div ref="rootEl" v-size="{ max: [740] }" class="edbbcaef">
+ <div class="left">
+ <div v-if="stats" class="container stats">
+ <div class="title">Stats</div>
+ <div class="body">
+ <div class="number _panel">
+ <div class="label">Users</div>
+ <div class="value _monospace">
+ {{ number(stats.originalUsersCount) }}
+ <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Notes</div>
+ <div class="value _monospace">
+ {{ number(stats.originalNotesCount) }}
+ <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ </div>
</div>
- </div>
- <div class="number _panel">
- <div class="label">Notes</div>
- <div class="value _monospace">
- {{ number(stats.originalNotesCount) }}
- <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
- </div>
- </div>
- </div>
- <MkContainer :foldable="true" class="charts">
- <template #header><i class="fas fa-chart-bar"></i>{{ i18n.ts.charts }}</template>
- <div style="padding: 12px;">
- <MkInstanceStats :chart-limit="500" :detailed="true"/>
- </div>
- </MkContainer>
+ <div class="container queue">
+ <div class="title">Job queue</div>
+ <div class="body">
+ <div class="chart deliver">
+ <div class="title">Deliver</div>
+ <XQueueChart :connection="queueStatsConnection" domain="deliver"/>
+ </div>
+ <div class="chart inbox">
+ <div class="title">Inbox</div>
+ <XQueueChart :connection="queueStatsConnection" domain="inbox"/>
+ </div>
+ </div>
+ </div>
- <div class="queue">
- <MkContainer :foldable="true" :thin="true" class="deliver">
- <template #header>Queue: deliver</template>
- <MkQueueChart :connection="queueStatsConnection" domain="deliver"/>
- </MkContainer>
- <MkContainer :foldable="true" :thin="true" class="inbox">
- <template #header>Queue: inbox</template>
- <MkQueueChart :connection="queueStatsConnection" domain="inbox"/>
- </MkContainer>
- </div>
+ <div class="container users">
+ <div class="title">New users</div>
+ <div v-if="newUsers" class="body">
+ <XUser v-for="user in newUsers" :key="user.id" class="user" :user="user"/>
+ </div>
+ </div>
- <!--<XMetrics/>-->
+ <div class="container files">
+ <div class="title">Recent files</div>
+ <div class="body">
+ <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/>
+ </div>
+ </div>
- <MkFolder style="margin: var(--margin)">
- <template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template>
- <div class="cfcdecdf">
- <div class="number _panel">
- <div class="label">Misskey</div>
- <div class="value _monospace">{{ version }}</div>
+ <div class="container env">
+ <div class="title">Enviroment</div>
+ <div class="body">
+ <div class="number _panel">
+ <div class="label">Misskey</div>
+ <div class="value _monospace">{{ version }}</div>
+ </div>
+ <div v-if="serverInfo" class="number _panel">
+ <div class="label">Node.js</div>
+ <div class="value _monospace">{{ serverInfo.node }}</div>
+ </div>
+ <div v-if="serverInfo" class="number _panel">
+ <div class="label">PostgreSQL</div>
+ <div class="value _monospace">{{ serverInfo.psql }}</div>
+ </div>
+ <div v-if="serverInfo" class="number _panel">
+ <div class="label">Redis</div>
+ <div class="value _monospace">{{ serverInfo.redis }}</div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Vue</div>
+ <div class="value _monospace">{{ vueVersion }}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="right">
+ <div class="container charts">
+ <div class="title">Active users</div>
+ <div class="body">
+ <canvas ref="chartEl"></canvas>
+ </div>
</div>
- <div v-if="serverInfo" class="number _panel">
- <div class="label">Node.js</div>
- <div class="value _monospace">{{ serverInfo.node }}</div>
+ <div class="container federation">
+ <div class="title">Active instances</div>
+ <div class="body">
+ <XFederation/>
+ </div>
</div>
- <div v-if="serverInfo" class="number _panel">
- <div class="label">PostgreSQL</div>
- <div class="value _monospace">{{ serverInfo.psql }}</div>
+ <div v-if="stats" class="container federationStats">
+ <div class="title">Federation</div>
+ <div class="body">
+ <div class="number _panel">
+ <div class="label">Sub</div>
+ <div class="value _monospace">
+ {{ number(federationSubActive) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Pub</div>
+ <div class="value _monospace">
+ {{ number(federationPubActive) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ </div>
</div>
- <div v-if="serverInfo" class="number _panel">
- <div class="label">Redis</div>
- <div class="value _monospace">{{ serverInfo.redis }}</div>
+ <div class="container tagCloud">
+ <div class="body">
+ <MkTagCloud v-if="activeInstances">
+ <li v-for="instance in activeInstances">
+ <a @click.prevent="onInstanceClick(instance)">
+ <img style="width: 32px;" :src="instance.iconUrl">
+ </a>
+ </li>
+ </MkTagCloud>
+ </div>
</div>
- <div class="number _panel">
- <div class="label">Vue</div>
- <div class="value _monospace">{{ vueVersion }}</div>
+ <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="container federationPies">
+ <div class="body">
+ <div class="chart deliver">
+ <div class="title">Sub</div>
+ <XPie :data="topSubInstancesForPie"/>
+ <div class="subTitle">Top 10</div>
+ </div>
+ <div class="chart inbox">
+ <div class="title">Pub</div>
+ <XPie :data="topPubInstancesForPie"/>
+ <div class="subTitle">Top 10</div>
+ </div>
+ </div>
</div>
</div>
- </MkFolder>
-</div>
+ </div>
+</MkSpacer>
</template>
<script lang="ts" setup>
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import MkInstanceStats from '@/components/instance-stats.vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import MagicGrid from 'magic-grid';
+import XMetrics from './metrics.vue';
+import XFederation from './overview.federation.vue';
+import XQueueChart from './overview.queue-chart.vue';
+import XUser from './overview.user.vue';
+import XPie from './overview.pie.vue';
import MkNumberDiff from '@/components/number-diff.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkFolder from '@/components/ui/folder.vue';
-import MkQueueChart from '@/components/queue-chart.vue';
+import MkTagCloud from '@/components/tag-cloud.vue';
import { version, url } from '@/config';
import number from '@/filters/number';
-import XMetrics from './metrics.vue';
import * as os from '@/os';
import { stream } from '@/stream';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import MkFileListForAdmin from '@/components/file-list-for-admin.vue';
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ //gradient,
+);
+
+const rootEl = $ref<HTMLElement>();
+const chartEl = $ref<HTMLCanvasElement>(null);
let stats: any = $ref(null);
let serverInfo: any = $ref(null);
+let topSubInstancesForPie: any = $ref(null);
+let topPubInstancesForPie: any = $ref(null);
let usersComparedToThePrevDay: any = $ref(null);
let notesComparedToThePrevDay: any = $ref(null);
+let federationPubActive = $ref<number | null>(null);
+let federationPubActiveDiff = $ref<number | null>(null);
+let federationSubActive = $ref<number | null>(null);
+let federationSubActiveDiff = $ref<number | null>(null);
+let newUsers = $ref(null);
+let activeInstances = $shallowRef(null);
const queueStatsConnection = markRaw(stream.useChannel('queueStats'));
+const now = new Date();
+let chartInstance: Chart = null;
+const chartLimit = 30;
+const filesPagination = {
+ endpoint: 'admin/drive/files' as const,
+ limit: 9,
+ noPaging: true,
+};
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+async function renderChart() {
+ if (chartInstance) {
+ chartInstance.destroy();
+ }
+
+ const getDate = (ago: number) => {
+ const y = now.getFullYear();
+ const m = now.getMonth();
+ const d = now.getDate();
+
+ return new Date(y, m, d - ago);
+ };
+
+ const format = (arr) => {
+ return arr.map((v, i) => ({
+ x: getDate(i).getTime(),
+ y: v,
+ }));
+ };
+
+ const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
+
+ const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+ const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ const color = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent'));
+
+ chartInstance = new Chart(chartEl, {
+ type: 'bar',
+ data: {
+ //labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
+ datasets: [{
+ parsing: false,
+ label: 'a',
+ data: format(raw.readWrite).slice().reverse(),
+ tension: 0.3,
+ pointRadius: 0,
+ borderWidth: 0,
+ borderJoinStyle: 'round',
+ borderRadius: 3,
+ backgroundColor: color,
+ /*gradient: props.bar ? undefined : {
+ backgroundColor: {
+ axis: 'y',
+ colors: {
+ 0: alpha(x.color ? x.color : getColor(i), 0),
+ [maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2),
+ },
+ },
+ },*/
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ clip: 8,
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ display: false,
+ stacked: true,
+ offset: false,
+ time: {
+ stepSize: 1,
+ unit: 'month',
+ },
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ },
+ adapters: {
+ date: {
+ locale: enUS,
+ },
+ },
+ min: getDate(chartLimit).getTime(),
+ },
+ y: {
+ display: false,
+ position: 'left',
+ stacked: true,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ //mirror: true,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index',
+ },
+ elements: {
+ point: {
+ hoverRadius: 5,
+ hoverBorderWidth: 2,
+ },
+ },
+ animation: false,
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ //gradient,
+ },
+ },
+ plugins: [{
+ id: 'vLine',
+ beforeDraw(chart, args, options) {
+ if (chart.tooltip._active && chart.tooltip._active.length) {
+ const activePoint = chart.tooltip._active[0];
+ const ctx = chart.ctx;
+ const x = activePoint.element.x;
+ const topY = chart.scales.y.top;
+ const bottomY = chart.scales.y.bottom;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(x, bottomY);
+ ctx.lineTo(x, topY);
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = vLineColor;
+ ctx.stroke();
+ ctx.restore();
+ }
+ },
+ }],
+ });
+}
+
+function onInstanceClick(i) {
+ os.pageWindow(`/instance-info/${i.host}`);
+}
+
+onMounted(async () => {
+ /*
+ const magicGrid = new MagicGrid({
+ container: rootEl,
+ static: true,
+ animate: true,
+ });
+
+ magicGrid.listen();
+ */
+
+ renderChart();
-onMounted(async () => {
os.api('stats', {}).then(statsResponse => {
stats = statsResponse;
- os.api('charts/users', { limit: 2, span: 'day' }).then(chart => {
+ os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => {
usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1];
});
- os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+ os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => {
notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1];
});
});
+ os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => {
+ federationPubActive = chart.pubActive[0];
+ federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
+ federationSubActive = chart.subActive[0];
+ federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
+ });
+
+ os.apiGet('federation/stats', { limit: 10 }).then(res => {
+ topSubInstancesForPie = res.topSubInstances.map(x => ({
+ name: x.host,
+ color: x.themeColor,
+ value: x.followersCount,
+ onClick: () => {
+ os.pageWindow(`/instance-info/${x.host}`);
+ },
+ })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
+ topPubInstancesForPie = res.topPubInstances.map(x => ({
+ name: x.host,
+ color: x.themeColor,
+ value: x.followingCount,
+ onClick: () => {
+ os.pageWindow(`/instance-info/${x.host}`);
+ },
+ })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
+ });
+
os.api('admin/server-info').then(serverInfoResponse => {
serverInfo = serverInfoResponse;
});
+ os.api('admin/show-users', {
+ limit: 5,
+ sort: '+createdAt',
+ }).then(res => {
+ newUsers = res;
+ });
+
+ os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 25,
+ }).then(res => {
+ activeInstances = res;
+ });
+
nextTick(() => {
queueStatsConnection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
- length: 200
+ length: 100,
});
});
});
@@ -115,74 +461,177 @@ onBeforeUnmount(() => {
queueStatsConnection.dispose();
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.dashboard,
- icon: 'fas fa-tachometer-alt',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.dashboard,
+ icon: 'fas fa-tachometer-alt',
});
</script>
<style lang="scss" scoped>
.edbbcaef {
- .cfcdecdf {
- display: grid;
- grid-gap: 8px;
- grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
+ display: flex;
- > .number {
- padding: 12px 16px;
+ > .left, > .right {
+ box-sizing: border-box;
+ width: 50%;
- > .label {
- opacity: 0.7;
- font-size: 0.8em;
- }
+ > .container {
+ margin: 32px 0;
- > .value {
+ > .title {
font-weight: bold;
- font-size: 1.2em;
+ margin-bottom: 16px;
+ }
+
+ &.stats, &.federationStats {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+
+ > .number {
+ padding: 14px 20px;
+
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
+
+ > .value {
+ font-weight: bold;
+ font-size: 1.5em;
- > .diff {
- font-size: 0.8em;
+ > .diff {
+ font-size: 0.7em;
+ }
+ }
+ }
}
}
- }
- }
- > .charts {
- margin: var(--margin);
- }
+ &.env {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
- > .queue {
- margin: var(--margin);
- display: flex;
+ > .number {
+ padding: 14px 20px;
- > .deliver,
- > .inbox {
- flex: 1;
- width: 50%;
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
- &:not(:first-child) {
- margin-left: var(--margin);
+ > .value {
+ font-size: 1.1em;
+ }
+ }
+ }
}
- }
- }
- &.max-width_740px {
- > .queue {
- display: block;
+ &.charts {
+ > .body {
+ padding: 32px;
+ background: var(--panel);
+ border-radius: var(--radius);
+ }
+ }
- > .deliver,
- > .inbox {
- width: 100%;
+ &.users {
+ > .body {
+ background: var(--panel);
+ border-radius: var(--radius);
- &:not(:first-child) {
- margin-top: var(--margin);
- margin-left: 0;
+ > .user {
+ padding: 16px 20px;
+
+ &:not(:last-child) {
+ border-bottom: solid 0.5px var(--divider);
+ }
+ }
+ }
+ }
+
+ &.federation {
+ > .body {
+ background: var(--panel);
+ border-radius: var(--radius);
+ overflow: hidden; overflow: clip;
+ }
+ }
+
+ &.queue {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+
+ > .chart {
+ position: relative;
+ padding: 20px;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ font-size: 90%;
+ }
+ }
+ }
+ }
+
+ &.federationPies {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+
+ > .chart {
+ position: relative;
+ padding: 20px;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ font-size: 90%;
+ }
+
+ > .subTitle {
+ position: absolute;
+ bottom: 20px;
+ right: 20px;
+ font-size: 85%;
+ }
+ }
+ }
+ }
+
+ &.tagCloud {
+ > .body {
+ background: var(--panel);
+ border-radius: var(--radius);
+ overflow: hidden; overflow: clip;
}
}
}
}
+
+ > .left {
+ padding-right: 16px;
+ }
+
+ > .right {
+ padding-left: 16px;
+ }
}
</style>
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
index 727e20e7e5..0951d26c24 100644
--- a/packages/client/src/pages/admin/proxy-account.vue
+++ b/packages/client/src/pages/admin/proxy-account.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkKeyValue class="_formBlock">
@@ -9,7 +10,7 @@
<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -19,9 +20,9 @@ import FormButton from '@/components/ui/button.vue';
import MkInfo from '@/components/ui/info.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let proxyAccount: any = $ref(null);
let proxyAccountId: any = $ref(null);
@@ -50,11 +51,12 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.proxyAccount,
- icon: 'fas fa-ghost',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.proxyAccount,
+ icon: 'fas fa-ghost',
});
</script>
diff --git a/packages/client/src/pages/admin/queue.chart.chart.vue b/packages/client/src/pages/admin/queue.chart.chart.vue
new file mode 100644
index 0000000000..96156f8e67
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.chart.chart.vue
@@ -0,0 +1,181 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { watch, onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const props = defineProps<{
+ type: string;
+}>();
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+let chartInstance: Chart;
+
+function setData(values) {
+ if (chartInstance == null) return;
+ for (const value of values) {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(value);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ }
+ }
+ chartInstance.update();
+}
+
+function pushData(value) {
+ if (chartInstance == null) return;
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(value);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ }
+ chartInstance.update();
+}
+
+const label =
+ props.type === 'process' ? 'Process' :
+ props.type === 'active' ? 'Active' :
+ props.type === 'delayed' ? 'Delayed' :
+ props.type === 'waiting' ? 'Waiting' :
+ '?' as never;
+
+const color =
+ props.type === 'process' ? '#00E396' :
+ props.type === 'active' ? '#00BCD4' :
+ props.type === 'delayed' ? '#E53935' :
+ props.type === 'waiting' ? '#FFB300' :
+ '?' as never;
+
+onMounted(() => {
+ chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: label,
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: color,
+ backgroundColor: alpha(color, 0.1),
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: false,
+ maxTicksLimit: 10,
+ },
+ },
+ y: {
+ min: 0,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ },
+ },
+ });
+});
+
+defineExpose({
+ setData,
+ pushData,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue
index be63830bdd..c213037b65 100644
--- a/packages/client/src/pages/admin/queue.chart.vue
+++ b/packages/client/src/pages/admin/queue.chart.vue
@@ -1,80 +1,148 @@
<template>
-<div class="_debobigegoItem">
- <div class="_debobigegoLabel"><slot name="title"></slot></div>
- <div class="_debobigegoPanel pumxzjhg">
- <div class="_table status">
- <div class="_row">
- <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
- <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
- <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
- <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
- </div>
+<div class="pumxzjhg">
+ <div class="_table status">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
+ <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
+ <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
+ <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+ </div>
+ </div>
+ <div class="charts">
+ <div class="chart">
+ <div class="title">Process</div>
+ <XChart ref="chartProcess" type="process"/>
</div>
- <div class="">
- <MkQueueChart :domain="domain" :connection="connection"/>
+ <div class="chart">
+ <div class="title">Active</div>
+ <XChart ref="chartActive" type="active"/>
</div>
- <div class="jobs">
- <div v-if="jobs.length > 0">
- <div v-for="job in jobs" :key="job[0]">
- <span>{{ job[0] }}</span>
- <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
- </div>
+ <div class="chart">
+ <div class="title">Delayed</div>
+ <XChart ref="chartDelayed" type="delayed"/>
+ </div>
+ <div class="chart">
+ <div class="title">Waiting</div>
+ <XChart ref="chartWaiting" type="waiting"/>
+ </div>
+ </div>
+ <div class="jobs">
+ <div v-if="jobs.length > 0">
+ <div v-for="job in jobs" :key="job[0]">
+ <span>{{ job[0] }}</span>
+ <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
</div>
- <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
</div>
+ <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
+import { markRaw, onMounted, onUnmounted, ref } from 'vue';
+import XChart from './queue.chart.chart.vue';
import number from '@/filters/number';
-import MkQueueChart from '@/components/queue-chart.vue';
import * as os from '@/os';
+import { stream } from '@/stream';
+
+const connection = markRaw(stream.useChannel('queueStats'));
const activeSincePrevTick = ref(0);
const active = ref(0);
-const waiting = ref(0);
const delayed = ref(0);
+const waiting = ref(0);
const jobs = ref([]);
+let chartProcess = $ref<InstanceType<typeof XChart>>();
+let chartActive = $ref<InstanceType<typeof XChart>>();
+let chartDelayed = $ref<InstanceType<typeof XChart>>();
+let chartWaiting = $ref<InstanceType<typeof XChart>>();
const props = defineProps<{
- domain: string,
- connection: any,
+ domain: string;
}>();
+const onStats = (stats) => {
+ activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+ active.value = stats[props.domain].active;
+ delayed.value = stats[props.domain].delayed;
+ waiting.value = stats[props.domain].waiting;
+
+ chartProcess.pushData(stats[props.domain].activeSincePrevTick);
+ chartActive.pushData(stats[props.domain].active);
+ chartDelayed.pushData(stats[props.domain].delayed);
+ chartWaiting.pushData(stats[props.domain].waiting);
+};
+
+const onStatsLog = (statsLog) => {
+ const dataProcess = [];
+ const dataActive = [];
+ const dataDelayed = [];
+ const dataWaiting = [];
+
+ for (const stats of [...statsLog].reverse()) {
+ dataProcess.push(stats[props.domain].activeSincePrevTick);
+ dataActive.push(stats[props.domain].active);
+ dataDelayed.push(stats[props.domain].delayed);
+ dataWaiting.push(stats[props.domain].waiting);
+ }
+
+ chartProcess.setData(dataProcess);
+ chartActive.setData(dataActive);
+ chartDelayed.setData(dataDelayed);
+ chartWaiting.setData(dataWaiting);
+};
+
onMounted(() => {
os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
jobs.value = result;
});
- const onStats = (stats) => {
- activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
- active.value = stats[props.domain].active;
- waiting.value = stats[props.domain].waiting;
- delayed.value = stats[props.domain].delayed;
- };
-
- props.connection.on('stats', onStats);
-
- onUnmounted(() => {
- props.connection.off('stats', onStats);
+ connection.on('stats', onStats);
+ connection.on('statsLog', onStatsLog);
+ connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200,
});
});
+
+onUnmounted(() => {
+ connection.off('stats', onStats);
+ connection.off('statsLog', onStatsLog);
+ connection.dispose();
+});
</script>
<style lang="scss" scoped>
.pumxzjhg {
> .status {
padding: 16px;
- border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .charts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+
+ > .chart {
+ min-width: 0;
+ padding: 16px;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ margin-bottom: 8px;
+ }
+ }
}
> .jobs {
+ margin-top: 16px;
padding: 16px;
- border-top: solid 0.5px var(--divider);
max-height: 180px;
overflow: auto;
+ background: var(--panel);
+ border-radius: var(--radius);
}
+
}
</style>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
index 656b18199f..6ccb464d17 100644
--- a/packages/client/src/pages/admin/queue.vue
+++ b/packages/client/src/pages/admin/queue.vue
@@ -1,26 +1,24 @@
<template>
-<MkSpacer :content-max="800">
- <XQueue :connection="connection" domain="inbox">
- <template #title>In</template>
- </XQueue>
- <XQueue :connection="connection" domain="deliver">
- <template #title>Out</template>
- </XQueue>
- <MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton>
-</MkSpacer>
+<MkStickyContainer>
+ <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <XQueue v-if="tab === 'deliver'" domain="deliver"/>
+ <XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import MkButton from '@/components/ui/button.vue';
import XQueue from './queue.chart.vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import { stream } from '@/stream';
-import * as symbols from '@/symbols';
import * as config from '@/config';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-const connection = markRaw(stream.useChannel('queueStats'));
+let tab = $ref('deliver');
function clear() {
os.confirm({
@@ -34,32 +32,25 @@ function clear() {
});
}
-onMounted(() => {
- nextTick(() => {
- connection.send('requestLog', {
- id: Math.random().toString().substr(2, 8),
- length: 200
- });
- });
-});
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-up-right-from-square',
+ text: i18n.ts.dashboard,
+ handler: () => {
+ window.open(config.url + '/queue', '_blank');
+ },
+}]);
-onBeforeUnmount(() => {
- connection.dispose();
-});
+const headerTabs = $computed(() => [{
+ key: 'deliver',
+ title: 'Deliver',
+}, {
+ key: 'inbox',
+ title: 'Inbox',
+}]);
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.jobQueue,
- icon: 'fas fa-clipboard-list',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-up-right-from-square',
- text: i18n.ts.dashboard,
- handler: () => {
- window.open(config.url + '/queue', '_blank');
- },
- }],
- }
+definePageMetadata({
+ title: i18n.ts.jobQueue,
+ icon: 'fas fa-clipboard-list',
});
</script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
index 1a36bb4753..42347c0e7d 100644
--- a/packages/client/src/pages/admin/relays.vue
+++ b/packages/client/src/pages/admin/relays.vue
@@ -1,24 +1,28 @@
<template>
-<MkSpacer :content-max="800">
- <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
- <div>{{ relay.inbox }}</div>
- <div class="status">
- <i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i>
- <i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
- <i v-else class="fas fa-clock icon requesting"></i>
- <span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
+ <div>{{ relay.inbox }}</div>
+ <div class="status">
+ <i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i>
+ <i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
+ <i v-else class="fas fa-clock icon requesting"></i>
+ <span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+ </div>
+ <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
</div>
- <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let relays: any[] = $ref([]);
@@ -26,30 +30,30 @@ async function addRelay() {
const { canceled, result: inbox } = await os.inputText({
title: i18n.ts.addRelay,
type: 'url',
- placeholder: i18n.ts.inboxUrl
+ placeholder: i18n.ts.inboxUrl,
});
if (canceled) return;
os.api('admin/relays/add', {
- inbox
+ inbox,
}).then((relay: any) => {
refresh();
}).catch((err: any) => {
os.alert({
type: 'error',
- text: err.message || err
+ text: err.message || err,
});
});
}
function remove(inbox: string) {
os.api('admin/relays/remove', {
- inbox
+ inbox,
}).then(() => {
refresh();
}).catch((err: any) => {
os.alert({
type: 'error',
- text: err.message || err
+ text: err.message || err,
});
});
}
@@ -62,18 +66,18 @@ function refresh() {
refresh();
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.relays,
- icon: 'fas fa-globe',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.addRelay,
- handler: addRelay,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.addRelay,
+ handler: addRelay,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.relays,
+ icon: 'fas fa-globe',
});
</script>
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
index 6b8f70cca5..c4a4994bb8 100644
--- a/packages/client/src/pages/admin/security.vue
+++ b/packages/client/src/pages/admin/security.vue
@@ -1,73 +1,160 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- <div class="_formRoot">
- <FormFolder class="_formBlock">
- <template #icon><i class="fas fa-shield-alt"></i></template>
- <template #label>{{ i18n.ts.botProtection }}</template>
- <template v-if="enableHcaptcha" #suffix>hCaptcha</template>
- <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
- <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormFolder class="_formBlock">
+ <template #icon><i class="fas fa-shield-alt"></i></template>
+ <template #label>{{ i18n.ts.botProtection }}</template>
+ <template v-if="enableHcaptcha" #suffix>hCaptcha</template>
+ <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
+ <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
- <XBotProtection/>
- </FormFolder>
+ <XBotProtection/>
+ </FormFolder>
- <FormFolder class="_formBlock">
- <template #label>Summaly Proxy</template>
+ <FormFolder class="_formBlock">
+ <template #icon><i class="fas fa-eye-slash"></i></template>
+ <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
+ <template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
+ <template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
+ <template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
+ <template v-else #suffix>{{ i18n.ts.none }}</template>
- <div class="_formRoot">
- <FormInput v-model="summalyProxy" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>Summaly Proxy URL</template>
- </FormInput>
+ <div class="_formRoot">
+ <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span>
- <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
- </div>
- </FormFolder>
- </div>
- </FormSuspense>
-</MkSpacer>
+ <FormRadios v-model="sensitiveMediaDetection" class="_formBlock">
+ <option value="none">{{ i18n.ts.none }}</option>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.localOnly }}</option>
+ <option value="remote">{{ i18n.ts.remoteOnly }}</option>
+ </FormRadios>
+
+ <FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
+ <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
+ </FormRange>
+
+ <FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
+ <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
+ <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
+ </FormSwitch>
+
+ <!-- 現状 false positive が多すぎて実用に耐えない
+ <FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
+ </FormSwitch>
+ -->
+
+ <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+ </FormFolder>
+
+ <FormFolder class="_formBlock">
+ <template #label>Log IP address</template>
+ <template v-if="enableIpLogging" #suffix>Enabled</template>
+ <template v-else #suffix>Disabled</template>
+
+ <div class="_formRoot">
+ <FormSwitch v-model="enableIpLogging" class="_formBlock" @update:modelValue="save">
+ <template #label>Enable</template>
+ </FormSwitch>
+ </div>
+ </FormFolder>
+
+ <FormFolder class="_formBlock">
+ <template #label>Summaly Proxy</template>
+
+ <div class="_formRoot">
+ <FormInput v-model="summalyProxy" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>Summaly Proxy URL</template>
+ </FormInput>
+
+ <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+ </FormFolder>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XBotProtection from './bot-protection.vue';
+import XHeader from './_header_.vue';
import FormFolder from '@/components/form/folder.vue';
+import FormRadios from '@/components/form/radios.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormInfo from '@/components/ui/info.vue';
import FormSuspense from '@/components/form/suspense.vue';
-import FormSection from '@/components/form/section.vue';
+import FormRange from '@/components/form/range.vue';
import FormInput from '@/components/form/input.vue';
import FormButton from '@/components/ui/button.vue';
-import XBotProtection from './bot-protection.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let summalyProxy: string = $ref('');
let enableHcaptcha: boolean = $ref(false);
let enableRecaptcha: boolean = $ref(false);
+let sensitiveMediaDetection: string = $ref('none');
+let sensitiveMediaDetectionSensitivity: number = $ref(0);
+let setSensitiveFlagAutomatically: boolean = $ref(false);
+let enableSensitiveMediaDetectionForVideos: boolean = $ref(false);
+let enableIpLogging: boolean = $ref(false);
async function init() {
const meta = await os.api('admin/meta');
summalyProxy = meta.summalyProxy;
enableHcaptcha = meta.enableHcaptcha;
enableRecaptcha = meta.enableRecaptcha;
+ sensitiveMediaDetection = meta.sensitiveMediaDetection;
+ sensitiveMediaDetectionSensitivity =
+ meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
+ meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
+ meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
+ meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
+ meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0;
+ setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically;
+ enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos;
+ enableIpLogging = meta.enableIpLogging;
}
function save() {
os.apiWithDialog('admin/update-meta', {
summalyProxy,
+ sensitiveMediaDetection,
+ sensitiveMediaDetectionSensitivity:
+ sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' :
+ sensitiveMediaDetectionSensitivity === 1 ? 'low' :
+ sensitiveMediaDetectionSensitivity === 2 ? 'medium' :
+ sensitiveMediaDetectionSensitivity === 3 ? 'high' :
+ sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' :
+ 0,
+ setSensitiveFlagAutomatically,
+ enableSensitiveMediaDetectionForVideos,
+ enableIpLogging,
}).then(() => {
fetchInstance();
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.security,
- icon: 'fas fa-lock',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.security,
+ icon: 'fas fa-lock',
});
</script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
index 6dc30fe50b..496eb46ea4 100644
--- a/packages/client/src/pages/admin/settings.vue
+++ b/packages/client/src/pages/admin/settings.vue
@@ -1,149 +1,155 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- <div class="_formRoot">
- <FormInput v-model="name" class="_formBlock">
- <template #label>{{ i18n.ts.instanceName }}</template>
- </FormInput>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormInput v-model="name" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceName }}</template>
+ </FormInput>
- <FormTextarea v-model="description" class="_formBlock">
- <template #label>{{ i18n.ts.instanceDescription }}</template>
- </FormTextarea>
+ <FormTextarea v-model="description" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceDescription }}</template>
+ </FormTextarea>
- <FormInput v-model="tosUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.tosUrl }}</template>
- </FormInput>
+ <FormInput v-model="tosUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>{{ i18n.ts.tosUrl }}</template>
+ </FormInput>
- <FormSplit :min-width="300">
- <FormInput v-model="maintainerName" class="_formBlock">
- <template #label>{{ i18n.ts.maintainerName }}</template>
- </FormInput>
+ <FormSplit :min-width="300">
+ <FormInput v-model="maintainerName" class="_formBlock">
+ <template #label>{{ i18n.ts.maintainerName }}</template>
+ </FormInput>
- <FormInput v-model="maintainerEmail" type="email" class="_formBlock">
- <template #prefix><i class="fas fa-envelope"></i></template>
- <template #label>{{ i18n.ts.maintainerEmail }}</template>
- </FormInput>
- </FormSplit>
+ <FormInput v-model="maintainerEmail" type="email" class="_formBlock">
+ <template #prefix><i class="fas fa-envelope"></i></template>
+ <template #label>{{ i18n.ts.maintainerEmail }}</template>
+ </FormInput>
+ </FormSplit>
- <FormTextarea v-model="pinnedUsers" class="_formBlock">
- <template #label>{{ i18n.ts.pinnedUsers }}</template>
- <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
- </FormTextarea>
+ <FormTextarea v-model="pinnedUsers" class="_formBlock">
+ <template #label>{{ i18n.ts.pinnedUsers }}</template>
+ <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
+ </FormTextarea>
- <FormSection>
- <FormSwitch v-model="enableRegistration" class="_formBlock">
- <template #label>{{ i18n.ts.enableRegistration }}</template>
- </FormSwitch>
+ <FormSection>
+ <FormSwitch v-model="enableRegistration" class="_formBlock">
+ <template #label>{{ i18n.ts.enableRegistration }}</template>
+ </FormSwitch>
- <FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
- <template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
- </FormSwitch>
- </FormSection>
+ <FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
+ <template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
+ </FormSwitch>
+ </FormSection>
- <FormSection>
- <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
- <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
- <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
- </FormSection>
+ <FormSection>
+ <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
+ <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
+ <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
+ </FormSection>
- <FormSection>
- <template #label>{{ i18n.ts.theme }}</template>
+ <FormSection>
+ <template #label>{{ i18n.ts.theme }}</template>
- <FormInput v-model="iconUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.iconUrl }}</template>
- </FormInput>
+ <FormInput v-model="iconUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>{{ i18n.ts.iconUrl }}</template>
+ </FormInput>
- <FormInput v-model="bannerUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.bannerUrl }}</template>
- </FormInput>
+ <FormInput v-model="bannerUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>{{ i18n.ts.bannerUrl }}</template>
+ </FormInput>
- <FormInput v-model="backgroundImageUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.backgroundImageUrl }}</template>
- </FormInput>
+ <FormInput v-model="backgroundImageUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>{{ i18n.ts.backgroundImageUrl }}</template>
+ </FormInput>
- <FormInput v-model="themeColor" class="_formBlock">
- <template #prefix><i class="fas fa-palette"></i></template>
- <template #label>{{ i18n.ts.themeColor }}</template>
- <template #caption>#RRGGBB</template>
- </FormInput>
+ <FormInput v-model="themeColor" class="_formBlock">
+ <template #prefix><i class="fas fa-palette"></i></template>
+ <template #label>{{ i18n.ts.themeColor }}</template>
+ <template #caption>#RRGGBB</template>
+ </FormInput>
- <FormTextarea v-model="defaultLightTheme" class="_formBlock">
- <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
- <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
- </FormTextarea>
+ <FormTextarea v-model="defaultLightTheme" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
+ <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+ </FormTextarea>
- <FormTextarea v-model="defaultDarkTheme" class="_formBlock">
- <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
- <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
- </FormTextarea>
- </FormSection>
+ <FormTextarea v-model="defaultDarkTheme" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
+ <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+ </FormTextarea>
+ </FormSection>
- <FormSection>
- <template #label>{{ i18n.ts.files }}</template>
+ <FormSection>
+ <template #label>{{ i18n.ts.files }}</template>
- <FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
- <template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
- <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
- </FormSwitch>
+ <FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
+ <template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
+ <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
+ </FormSwitch>
- <FormSplit :min-width="280">
- <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
- <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
- <template #suffix>MB</template>
- <template #caption>{{ i18n.ts.inMb }}</template>
- </FormInput>
+ <FormSplit :min-width="280">
+ <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
+ <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
+ <template #suffix>MB</template>
+ <template #caption>{{ i18n.ts.inMb }}</template>
+ </FormInput>
- <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
- <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
- <template #suffix>MB</template>
- <template #caption>{{ i18n.ts.inMb }}</template>
- </FormInput>
- </FormSplit>
- </FormSection>
+ <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
+ <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
+ <template #suffix>MB</template>
+ <template #caption>{{ i18n.ts.inMb }}</template>
+ </FormInput>
+ </FormSplit>
+ </FormSection>
- <FormSection>
- <template #label>ServiceWorker</template>
+ <FormSection>
+ <template #label>ServiceWorker</template>
- <FormSwitch v-model="enableServiceWorker" class="_formBlock">
- <template #label>{{ i18n.ts.enableServiceworker }}</template>
- <template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
- </FormSwitch>
+ <FormSwitch v-model="enableServiceWorker" class="_formBlock">
+ <template #label>{{ i18n.ts.enableServiceworker }}</template>
+ <template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
+ </FormSwitch>
- <template v-if="enableServiceWorker">
- <FormInput v-model="swPublicKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>Public key</template>
- </FormInput>
+ <template v-if="enableServiceWorker">
+ <FormInput v-model="swPublicKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Public key</template>
+ </FormInput>
- <FormInput v-model="swPrivateKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>Private key</template>
- </FormInput>
- </template>
- </FormSection>
+ <FormInput v-model="swPrivateKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Private key</template>
+ </FormInput>
+ </template>
+ </FormSection>
- <FormSection>
- <template #label>DeepL Translation</template>
+ <FormSection>
+ <template #label>DeepL Translation</template>
- <FormInput v-model="deeplAuthKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>DeepL Auth Key</template>
- </FormInput>
- <FormSwitch v-model="deeplIsPro" class="_formBlock">
- <template #label>Pro account</template>
- </FormSwitch>
- </FormSection>
- </div>
- </FormSuspense>
-</MkSpacer>
+ <FormInput v-model="deeplAuthKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>DeepL Auth Key</template>
+ </FormInput>
+ <FormSwitch v-model="deeplIsPro" class="_formBlock">
+ <template #label>Pro account</template>
+ </FormSwitch>
+ </FormSection>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormInput from '@/components/form/input.vue';
import FormTextarea from '@/components/form/textarea.vue';
@@ -152,9 +158,9 @@ import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let name: string | null = $ref(null);
let description: string | null = $ref(null);
@@ -240,17 +246,17 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.general,
- icon: 'fas fa-cog',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.general,
+ icon: 'fas fa-cog',
});
</script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
index f05aa5ff45..c6755672f7 100644
--- a/packages/client/src/pages/admin/users.vue
+++ b/packages/client/src/pages/admin/users.vue
@@ -1,76 +1,68 @@
<template>
-<div class="lknzcolw">
- <div class="users">
- <div class="inputs">
- <MkSelect v-model="sort" style="flex: 1;">
- <template #label>{{ $ts.sort }}</template>
- <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
- <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
- <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
- <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
- </MkSelect>
- <MkSelect v-model="state" style="flex: 1;">
- <template #label>{{ $ts.state }}</template>
- <option value="all">{{ $ts.all }}</option>
- <option value="available">{{ $ts.normal }}</option>
- <option value="admin">{{ $ts.administrator }}</option>
- <option value="moderator">{{ $ts.moderator }}</option>
- <option value="silenced">{{ $ts.silence }}</option>
- <option value="suspended">{{ $ts.suspend }}</option>
- </MkSelect>
- <MkSelect v-model="origin" style="flex: 1;">
- <template #label>{{ $ts.instance }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- </div>
- <div class="inputs">
- <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
- <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()">
- <template #prefix>@</template>
- <template #label>{{ $ts.host }}</template>
- </MkInput>
- </div>
-
- <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
- <button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
- <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
- <div class="body">
- <header>
- <MkUserName class="name" :user="user"/>
- <span class="acct">@{{ acct(user) }}</span>
- <span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span>
- <span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span>
- <span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
- <span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
- </header>
- <div>
- <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="lknzcolw">
+ <div class="users">
+ <div class="inputs">
+ <MkSelect v-model="sort" style="flex: 1;">
+ <template #label>{{ $ts.sort }}</template>
+ <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+ <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+ </MkSelect>
+ <MkSelect v-model="state" style="flex: 1;">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="available">{{ $ts.normal }}</option>
+ <option value="admin">{{ $ts.administrator }}</option>
+ <option value="moderator">{{ $ts.moderator }}</option>
+ <option value="silenced">{{ $ts.silence }}</option>
+ <option value="suspended">{{ $ts.suspend }}</option>
+ </MkSelect>
+ <MkSelect v-model="origin" style="flex: 1;">
+ <template #label>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
</div>
- <div>
- <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+ <div class="inputs">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()">
+ <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()">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
</div>
+
+ <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
+ <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ </MkPagination>
</div>
- </button>
- </MkPagination>
- </div>
+ </div>
+ </MkSpacer>
+ </MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
+import XHeader from './_header_.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
-import { acct } from '@/filters/user';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { lookupUser } from '@/scripts/lookup-user';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkUserCardMini from '@/components/user-card-mini.vue';
let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
@@ -89,7 +81,7 @@ const pagination = {
username: searchUsername,
hostname: searchHost,
})),
- offsetMode: true
+ offsetMode: true,
};
function searchUser() {
@@ -106,7 +98,7 @@ async function addUser() {
const { canceled: canceled2, result: password } = await os.inputText({
title: i18n.ts.password,
- type: 'password'
+ type: 'password',
});
if (canceled2) return;
@@ -122,34 +114,33 @@ function show(user) {
os.pageWindow(`/user-info/${user.id}`);
}
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.users,
- icon: 'fas fa-users',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-search',
- text: i18n.ts.search,
- handler: searchUser
- }, {
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.addUser,
- handler: addUser
- }, {
- asFullButton: true,
- icon: 'fas fa-search',
- text: i18n.ts.lookup,
- handler: lookupUser
- }],
- })),
-});
+const headerActions = $computed(() => [{
+ icon: 'fas fa-search',
+ text: i18n.ts.search,
+ handler: searchUser,
+}, {
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.addUser,
+ handler: addUser,
+}, {
+ asFullButton: true,
+ icon: 'fas fa-search',
+ text: i18n.ts.lookup,
+ handler: lookupUser,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.users,
+ icon: 'fas fa-users',
+})));
</script>
<style lang="scss" scoped>
.lknzcolw {
> .users {
- margin: var(--margin);
> .inputs {
display: flex;
@@ -166,54 +157,12 @@ defineExpose({
> .users {
margin-top: var(--margin);
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ grid-gap: 12px;
- > .user {
- display: flex;
- width: 100%;
- box-sizing: border-box;
- text-align: left;
- align-items: center;
- padding: 16px;
-
- &:hover {
- color: var(--accent);
- }
-
- > .avatar {
- width: 60px;
- height: 60px;
- }
-
- > .body {
- margin-left: 0.3em;
- padding: 0 8px;
- flex: 1;
-
- @media (max-width: 500px) {
- font-size: 14px;
- }
-
- > header {
- > .name {
- font-weight: bold;
- }
-
- > .acct {
- margin-left: 8px;
- opacity: 0.7;
- }
-
- > .staff {
- margin-left: 0.5em;
- color: var(--badge);
- }
-
- > .punished {
- margin-left: 0.5em;
- color: #4dabf7;
- }
- }
- }
+ > .user:hover {
+ text-decoration: none;
}
}
}
diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue
index 53727823a4..aeb85b6557 100644
--- a/packages/client/src/pages/announcements.vue
+++ b/packages/client/src/pages/announcements.vue
@@ -1,57 +1,52 @@
<template>
-<MkSpacer :content-max="800">
- <MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
- <section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
- <div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
- <div class="_content">
- <Mfm :text="announcement.text"/>
- <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
- </div>
- <div v-if="$i && !announcement.isRead" class="_footer">
- <MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
- </div>
- </section>
- </MkPagination>
-</MkSpacer>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
+ <section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
+ <div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
+ <div class="_content">
+ <Mfm :text="announcement.text"/>
+ <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+ </div>
+ <div v-if="$i && !announcement.isRead" class="_footer">
+ <MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
+ </div>
+ </section>
+ </MkPagination>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-export default defineComponent({
- components: {
- MkPagination,
- MkButton
- },
+const pagination = {
+ endpoint: 'announcements' as const,
+ limit: 10,
+};
- data() {
- return {
- [symbols.PAGE_INFO]: {
- title: this.$ts.announcements,
- icon: 'fas fa-broadcast-tower',
- bg: 'var(--bg)',
- },
- pagination: {
- endpoint: 'announcements' as const,
- limit: 10,
- },
- };
- },
+// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
+function read(items, announcement, i) {
+ items[i] = {
+ ...announcement,
+ isRead: true,
+ };
+ os.api('i/read-announcement', { announcementId: announcement.id });
+}
- methods: {
- // TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
- read(items, announcement, i) {
- items[i] = {
- ...announcement,
- isRead: true,
- };
- os.api('i/read-announcement', { announcementId: announcement.id });
- },
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.announcements,
+ icon: 'fas fa-broadcast-tower',
});
</script>
diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue
index c38f285725..309f94f9f5 100644
--- a/packages/client/src/pages/antenna-timeline.vue
+++ b/packages/client/src/pages/antenna-timeline.vue
@@ -1,104 +1,92 @@
<template>
-<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
- <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
- <div class="tl _block">
- <XTimeline ref="tl" :key="antennaId"
- class="tl"
- src="antenna"
- :antenna="antennaId"
- :sound="true"
- @queue="queueUpdated"
- />
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
+ <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline
+ ref="tlEl" :key="antennaId"
+ class="tl"
+ src="antenna"
+ :antenna="antennaId"
+ :sound="true"
+ @queue="queueUpdated"
+ />
+ </div>
</div>
-</div>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
import XTimeline from '@/components/timeline.vue';
import { scroll } from '@/scripts/scroll';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- XTimeline,
- },
+const router = useRouter();
- props: {
- antennaId: {
- type: String,
- required: true
- }
- },
+const props = defineProps<{
+ antennaId: string;
+}>();
- data() {
- return {
- antenna: null,
- queue: 0,
- [symbols.PAGE_INFO]: computed(() => this.antenna ? {
- title: this.antenna.name,
- icon: 'fas fa-satellite',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-calendar-alt',
- text: this.$ts.jumpToSpecifiedDate,
- handler: this.timetravel
- }, {
- icon: 'fas fa-cog',
- text: this.$ts.settings,
- handler: this.settings
- }],
- } : null),
- };
- },
+let antenna = $ref(null);
+let queue = $ref(0);
+let rootEl = $ref<HTMLElement>();
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+const keymap = $computed(() => ({
+ 't': focus,
+}));
- computed: {
- keymap(): any {
- return {
- 't': this.focus
- };
- },
- },
+function queueUpdated(q) {
+ queue = q;
+}
- watch: {
- antennaId: {
- async handler() {
- this.antenna = await os.api('antennas/show', {
- antennaId: this.antennaId
- });
- },
- immediate: true
- }
- },
+function top() {
+ scroll(rootEl, { top: 0 });
+}
- methods: {
- queueUpdated(q) {
- this.queue = q;
- },
+async function timetravel() {
+ const { canceled, result: date } = await os.inputDate({
+ title: i18n.ts.date,
+ });
+ if (canceled) return;
- top() {
- scroll(this.$el, { top: 0 });
- },
+ tlEl.timetravel(date);
+}
- async timetravel() {
- const { canceled, result: date } = await os.inputDate({
- title: this.$ts.date,
- });
- if (canceled) return;
+function settings() {
+ router.push(`/my/antennas/${props.antennaId}`);
+}
- this.$refs.tl.timetravel(date);
- },
+function focus() {
+ tlEl.focus();
+}
- settings() {
- this.$router.push(`/my/antennas/${this.antennaId}`);
- },
+watch(() => props.antennaId, async () => {
+ antenna = await os.api('antennas/show', {
+ antennaId: props.antennaId,
+ });
+}, { immediate: true });
- focus() {
- (this.$refs.tl as any).focus();
- }
- }
-});
+const headerActions = $computed(() => antenna ? [{
+ icon: 'fas fa-calendar-alt',
+ text: i18n.ts.jumpToSpecifiedDate,
+ handler: timetravel,
+}, {
+ icon: 'fas fa-cog',
+ text: i18n.ts.settings,
+ handler: settings,
+}] : []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => antenna ? {
+ title: antenna.name,
+ icon: 'fas fa-satellite',
+} : null));
</script>
<style lang="scss" scoped>
@@ -122,7 +110,7 @@ export default defineComponent({
> .tl {
background: var(--bg);
border-radius: var(--radius);
- overflow: clip;
+ overflow: hidden; overflow: clip;
}
&.min-width_800px {
diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue
index 88acbcd3a3..2f8eeadff1 100644
--- a/packages/client/src/pages/api-console.vue
+++ b/packages/client/src/pages/api-console.vue
@@ -1,40 +1,43 @@
<template>
-<MkSpacer :content-max="700">
- <div class="_formRoot">
- <div class="_formBlock">
- <MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()">
- <template #label>Endpoint</template>
- </MkInput>
- <MkTextarea v-model="body" class="_formBlock" code>
- <template #label>Params (JSON or JSON5)</template>
- </MkTextarea>
- <MkSwitch v-model="withCredential" class="_formBlock">
- With credential
- </MkSwitch>
- <MkButton class="_formBlock" primary :disabled="sending" @click="send">
- <template v-if="sending"><MkEllipsis/></template>
- <template v-else><i class="fas fa-paper-plane"></i> Send</template>
- </MkButton>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
+ <div class="_formRoot">
+ <div class="_formBlock">
+ <MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()">
+ <template #label>Endpoint</template>
+ </MkInput>
+ <MkTextarea v-model="body" class="_formBlock" code>
+ <template #label>Params (JSON or JSON5)</template>
+ </MkTextarea>
+ <MkSwitch v-model="withCredential" class="_formBlock">
+ With credential
+ </MkSwitch>
+ <MkButton class="_formBlock" primary :disabled="sending" @click="send">
+ <template v-if="sending"><MkEllipsis/></template>
+ <template v-else><i class="fas fa-paper-plane"></i> Send</template>
+ </MkButton>
+ </div>
+ <div v-if="res" class="_formBlock">
+ <MkTextarea v-model="res" code readonly tall>
+ <template #label>Response</template>
+ </MkTextarea>
+ </div>
</div>
- <div v-if="res" class="_formBlock">
- <MkTextarea v-model="res" code readonly tall>
- <template #label>Response</template>
- </MkTextarea>
- </div>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import JSON5 from 'json5';
+import { Endpoints } from 'misskey-js';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkSwitch from '@/components/form/switch.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { Endpoints } from 'misskey-js';
+import { definePageMetadata } from '@/scripts/page-metadata';
const body = ref('{}');
const endpoint = ref('');
@@ -75,10 +78,12 @@ function onEndpointChange() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: 'API console',
- icon: 'fas fa-terminal'
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'API console',
+ icon: 'fas fa-terminal',
});
</script>
diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue
index e65161dd2b..9457cd6b2f 100644
--- a/packages/client/src/pages/auth.vue
+++ b/packages/client/src/pages/auth.vue
@@ -15,7 +15,7 @@
<h1>{{ $ts._auth.denied }}</h1>
</div>
<div v-if="state == 'accepted'" class="accepted">
- <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$ts.allowed }}</h1>
+ <h1>{{ session.app.isAuthorized ? $t('already-authorized') : $ts.allowed }}</h1>
<p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p>
<p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p>
</div>
@@ -40,24 +40,20 @@ export default defineComponent({
XForm,
MkSignin,
},
+ props: ['token'],
data() {
return {
state: null,
session: null,
- fetching: true
+ fetching: true,
};
},
- computed: {
- token(): string {
- return this.$route.params.token;
- }
- },
mounted() {
if (!this.$i) return;
// Fetch session
os.api('auth/session/show', {
- token: this.token
+ token: this.token,
}).then(session => {
this.session = session;
this.fetching = false;
@@ -65,7 +61,7 @@ export default defineComponent({
// 既に連携していた場合
if (this.session.app.isAuthorized) {
os.api('auth/accept', {
- token: this.session.token
+ token: this.session.token,
}).then(() => {
this.accepted();
});
@@ -85,8 +81,8 @@ export default defineComponent({
}
}, onLogin(res) {
login(res.i);
- }
- }
+ },
+ },
});
</script>
diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue
index ea3a5dab76..0fa1f69518 100644
--- a/packages/client/src/pages/channel-editor.vue
+++ b/packages/client/src/pages/channel-editor.vue
@@ -1,127 +1,120 @@
<template>
-<MkSpacer :content-max="700">
- <div class="_formRoot">
- <MkInput v-model="name" class="_formBlock">
- <template #label>{{ $ts.name }}</template>
- </MkInput>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
+ <div class="_formRoot">
+ <MkInput v-model="name" class="_formBlock">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
- <MkTextarea v-model="description" class="_formBlock">
- <template #label>{{ $ts.description }}</template>
- </MkTextarea>
+ <MkTextarea v-model="description" class="_formBlock">
+ <template #label>{{ $ts.description }}</template>
+ </MkTextarea>
- <div class="banner">
- <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
- <div v-else-if="bannerUrl">
- <img :src="bannerUrl" style="width: 100%;"/>
- <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+ <div class="banner">
+ <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
+ <div v-else-if="bannerUrl">
+ <img :src="bannerUrl" style="width: 100%;"/>
+ <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+ </div>
+ </div>
+ <div class="_formBlock">
+ <MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
</div>
</div>
- <div class="_formBlock">
- <MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
- </div>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkTextarea, MkButton, MkInput,
- },
+const router = useRouter();
- props: {
- channelId: {
- type: String,
- required: false
- },
- },
+const props = defineProps<{
+ channelId?: string;
+}>();
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.channelId ? {
- title: this.$ts._channel.edit,
- icon: 'fas fa-satellite-dish',
- bg: 'var(--bg)',
- } : {
- title: this.$ts._channel.create,
- icon: 'fas fa-satellite-dish',
- bg: 'var(--bg)',
- }),
- channel: null,
- name: null,
- description: null,
- bannerUrl: null,
- bannerId: null,
- };
- },
+let channel = $ref(null);
+let name = $ref(null);
+let description = $ref(null);
+let bannerUrl = $ref<string | null>(null);
+let bannerId = $ref<string | null>(null);
- watch: {
- async bannerId() {
- if (this.bannerId == null) {
- this.bannerUrl = null;
- } else {
- this.bannerUrl = (await os.api('drive/files/show', {
- fileId: this.bannerId,
- })).url;
- }
- },
- },
+watch(() => bannerId, async () => {
+ if (bannerId == null) {
+ bannerUrl = null;
+ } else {
+ bannerUrl = (await os.api('drive/files/show', {
+ fileId: bannerId,
+ })).url;
+ }
+});
- async created() {
- if (this.channelId) {
- this.channel = await os.api('channels/show', {
- channelId: this.channelId,
- });
+async function fetchChannel() {
+ if (props.channelId == null) return;
- this.name = this.channel.name;
- this.description = this.channel.description;
- this.bannerId = this.channel.bannerId;
- this.bannerUrl = this.channel.bannerUrl;
- }
- },
+ channel = await os.api('channels/show', {
+ channelId: props.channelId,
+ });
- methods: {
- save() {
- const params = {
- name: this.name,
- description: this.description,
- bannerId: this.bannerId,
- };
+ name = channel.name;
+ description = channel.description;
+ bannerId = channel.bannerId;
+ bannerUrl = channel.bannerUrl;
+}
- if (this.channelId) {
- params.channelId = this.channelId;
- os.api('channels/update', params)
- .then(channel => {
- os.success();
- });
- } else {
- os.api('channels/create', params)
- .then(channel => {
- os.success();
- this.$router.push(`/channels/${channel.id}`);
- });
- }
- },
+fetchChannel();
- setBannerImage(evt) {
- selectFile(evt.currentTarget ?? evt.target, null).then(file => {
- this.bannerId = file.id;
- });
- },
+function save() {
+ const params = {
+ name: name,
+ description: description,
+ bannerId: bannerId,
+ };
- removeBannerImage() {
- this.bannerId = null;
- }
+ if (props.channelId) {
+ params.channelId = props.channelId;
+ os.api('channels/update', params).then(() => {
+ os.success();
+ });
+ } else {
+ os.api('channels/create', params).then(created => {
+ os.success();
+ router.push(`/channels/${created.id}`);
+ });
}
-});
+}
+
+function setBannerImage(evt) {
+ selectFile(evt.currentTarget ?? evt.target, null).then(file => {
+ bannerId = file.id;
+ });
+}
+
+function removeBannerImage() {
+ bannerId = null;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.channelId ? {
+ title: i18n.ts._channel.edit,
+ icon: 'fas fa-satellite-dish',
+} : {
+ title: i18n.ts._channel.create,
+ icon: 'fas fa-satellite-dish',
+}));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue
index c9a8f36844..1443a9b644 100644
--- a/packages/client/src/pages/channel.vue
+++ b/packages/client/src/pages/channel.vue
@@ -1,98 +1,86 @@
<template>
-<MkSpacer :content-max="700">
- <div v-if="channel">
- <div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
- <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
- <button class="_button toggle" @click="() => showBanner = !showBanner">
- <template v-if="showBanner"><i class="fas fa-angle-up"></i></template>
- <template v-else><i class="fas fa-angle-down"></i></template>
- </button>
- <div v-if="!showBanner" class="hideOverlay">
- </div>
- <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
- <div class="status">
- <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
- <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
+ <div v-if="channel">
+ <div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
+ <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
+ <button class="_button toggle" @click="() => showBanner = !showBanner">
+ <template v-if="showBanner"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ <div v-if="!showBanner" class="hideOverlay">
+ </div>
+ <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
+ <div class="status">
+ <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
+ <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+ </div>
+ <div class="fade"></div>
+ </div>
+ <div v-if="channel.description" class="description">
+ <Mfm :text="channel.description" :is-note="false" :i="$i"/>
</div>
- <div class="fade"></div>
- </div>
- <div v-if="channel.description" class="description">
- <Mfm :text="channel.description" :is-note="false" :i="$i"/>
</div>
- </div>
- <XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
+ <XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
- <XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
- </div>
-</MkSpacer>
+ <XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
import MkContainer from '@/components/ui/container.vue';
import XPostForm from '@/components/post-form.vue';
import XTimeline from '@/components/timeline.vue';
import XChannelFollowButton from '@/components/channel-follow-button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-export default defineComponent({
- components: {
- MkContainer,
- XPostForm,
- XTimeline,
- XChannelFollowButton
- },
+const router = useRouter();
- props: {
- channelId: {
- type: String,
- required: true
- }
- },
+const props = defineProps<{
+ channelId: string;
+}>();
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.channel ? {
- title: this.channel.name,
- icon: 'fas fa-satellite-dish',
- bg: 'var(--bg)',
- actions: [...(this.$i && this.$i.id === this.channel.userId ? [{
- icon: 'fas fa-cog',
- text: this.$ts.edit,
- handler: this.edit,
- }] : [])],
- } : null),
- channel: null,
- showBanner: true,
- pagination: {
- endpoint: 'channels/timeline' as const,
- limit: 10,
- params: computed(() => ({
- channelId: this.channelId,
- }))
- },
- };
- },
+let channel = $ref(null);
+let showBanner = $ref(true);
+const pagination = {
+ endpoint: 'channels/timeline' as const,
+ limit: 10,
+ params: computed(() => ({
+ channelId: props.channelId,
+ })),
+};
- watch: {
- channelId: {
- async handler() {
- this.channel = await os.api('channels/show', {
- channelId: this.channelId,
- });
- },
- immediate: true
- }
- },
+watch(() => props.channelId, async () => {
+ channel = await os.api('channels/show', {
+ channelId: props.channelId,
+ });
+}, { immediate: true });
- methods: {
- edit() {
- this.$router.push(`/channels/${this.channel.id}/edit`);
- }
- },
-});
+function edit() {
+ router.push(`/channels/${channel.id}/edit`);
+}
+
+const headerActions = $computed(() => channel && channel.userId ? [{
+ icon: 'fas fa-cog',
+ text: i18n.ts.edit,
+ handler: edit,
+}] : null);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => channel ? {
+ title: channel.name,
+ icon: 'fas fa-satellite-dish',
+} : null));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
index 4e538a6da3..63612bc57f 100644
--- a/packages/client/src/pages/channels.vue
+++ b/packages/client/src/pages/channels.vue
@@ -1,82 +1,79 @@
<template>
-<MkSpacer :content-max="700">
- <div v-if="tab === 'featured'" class="_content grwlizim featured">
- <MkPagination v-slot="{items}" :pagination="featuredPagination">
- <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
- </MkPagination>
- </div>
- <div v-else-if="tab === 'following'" class="_content grwlizim following">
- <MkPagination v-slot="{items}" :pagination="followingPagination">
- <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
- </MkPagination>
- </div>
- <div v-else-if="tab === 'owned'" class="_content grwlizim owned">
- <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
- <MkPagination v-slot="{items}" :pagination="ownedPagination">
- <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
- </MkPagination>
- </div>
-</MkSpacer>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
+ <div v-if="tab === 'featured'" class="_content grwlizim featured">
+ <MkPagination v-slot="{items}" :pagination="featuredPagination">
+ <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+ </MkPagination>
+ </div>
+ <div v-else-if="tab === 'following'" class="_content grwlizim following">
+ <MkPagination v-slot="{items}" :pagination="followingPagination">
+ <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+ </MkPagination>
+ </div>
+ <div v-else-if="tab === 'owned'" class="_content grwlizim owned">
+ <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+ <MkPagination v-slot="{items}" :pagination="ownedPagination">
+ <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+ </MkPagination>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, inject } from 'vue';
import MkChannelPreview from '@/components/channel-preview.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkChannelPreview, MkPagination, MkButton,
- },
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => ({
- title: this.$ts.channel,
- icon: 'fas fa-satellite-dish',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-plus',
- text: this.$ts.create,
- handler: this.create,
- }],
- tabs: [{
- active: this.tab === 'featured',
- title: this.$ts._channel.featured,
- icon: 'fas fa-fire-alt',
- onClick: () => { this.tab = 'featured'; },
- }, {
- active: this.tab === 'following',
- title: this.$ts._channel.following,
- icon: 'fas fa-heart',
- onClick: () => { this.tab = 'following'; },
- }, {
- active: this.tab === 'owned',
- title: this.$ts._channel.owned,
- icon: 'fas fa-edit',
- onClick: () => { this.tab = 'owned'; },
- },]
- })),
- tab: 'featured',
- featuredPagination: {
- endpoint: 'channels/featured' as const,
- noPaging: true,
- },
- followingPagination: {
- endpoint: 'channels/followed' as const,
- limit: 5,
- },
- ownedPagination: {
- endpoint: 'channels/owned' as const,
- limit: 5,
- },
- };
- },
- methods: {
- create() {
- this.$router.push(`/channels/new`);
- }
- }
-});
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagination = {
+ endpoint: 'channels/featured' as const,
+ noPaging: true,
+};
+const followingPagination = {
+ endpoint: 'channels/followed' as const,
+ limit: 5,
+};
+const ownedPagination = {
+ endpoint: 'channels/owned' as const,
+ limit: 5,
+};
+
+function create() {
+ router.push('/channels/new');
+}
+
+const headerActions = $computed(() => [{
+ icon: 'fas fa-plus',
+ text: i18n.ts.create,
+ handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'featured',
+ title: i18n.ts._channel.featured,
+ icon: 'fas fa-fire-alt',
+}, {
+ key: 'following',
+ title: i18n.ts._channel.following,
+ icon: 'fas fa-heart',
+}, {
+ key: 'owned',
+ title: i18n.ts._channel.owned,
+ icon: 'fas fa-edit',
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.channel,
+ icon: 'fas fa-satellite-dish',
+})));
</script>
diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue
index c999f1bfc9..608e4ba7ee 100644
--- a/packages/client/src/pages/clip.vue
+++ b/packages/client/src/pages/clip.vue
@@ -1,136 +1,108 @@
<template>
-<MkSpacer :content-max="800">
- <div v-if="clip">
- <div class="okzinsic _panel">
- <div v-if="clip.description" class="description">
- <Mfm :text="clip.description" :is-note="false" :i="$i"/>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions"/></template>
+ <MkSpacer :content-max="800">
+ <div v-if="clip">
+ <div class="okzinsic _panel">
+ <div v-if="clip.description" class="description">
+ <Mfm :text="clip.description" :is-note="false" :i="$i"/>
+ </div>
+ <div class="user">
+ <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
+ </div>
</div>
- <div class="user">
- <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
- </div>
- </div>
- <XNotes :pagination="pagination" :detail="true"/>
- </div>
-</MkSpacer>
+ <XNotes :pagination="pagination" :detail="true"/>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import MkContainer from '@/components/ui/container.vue';
-import XPostForm from '@/components/post-form.vue';
+<script lang="ts" setup>
+import { computed, watch, provide } from 'vue';
+import * as misskey from 'misskey-js';
import XNotes from '@/components/notes.vue';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
-export default defineComponent({
- components: {
- MkContainer,
- XPostForm,
- XNotes,
- },
+const props = defineProps<{
+ clipId: string,
+}>();
- props: {
- clipId: {
- type: String,
- required: true
- }
- },
+let clip: misskey.entities.Clip = $ref<misskey.entities.Clip>();
+const pagination = {
+ endpoint: 'clips/notes' as const,
+ limit: 10,
+ params: computed(() => ({
+ clipId: props.clipId,
+ })),
+};
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.clip ? {
- title: this.clip.name,
- icon: 'fas fa-paperclip',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-ellipsis-h',
- handler: this.menu
- }],
- } : null),
- clip: null,
- pagination: {
- endpoint: 'clips/notes' as const,
- limit: 10,
- params: computed(() => ({
- clipId: this.clipId,
- }))
- },
- };
- },
+const isOwned: boolean | null = $computed<boolean | null>(() => $i && clip && ($i.id === clip.userId));
- computed: {
- isOwned(): boolean {
- return this.$i && this.clip && (this.$i.id === this.clip.userId);
- }
- },
+watch(() => props.clipId, async () => {
+ clip = await os.api('clips/show', {
+ clipId: props.clipId,
+ });
+}, {
+ immediate: true,
+});
- watch: {
- clipId: {
- async handler() {
- this.clip = await os.api('clips/show', {
- clipId: this.clipId,
- });
- },
- immediate: true
- }
- },
+provide('currentClipPage', $$(clip));
- created() {
+const headerActions = $computed(() => clip && isOwned ? [{
+ icon: 'fas fa-pencil-alt',
+ text: i18n.ts.edit,
+ handler: async (): Promise<void> => {
+ const { canceled, result } = await os.form(clip.name, {
+ name: {
+ type: 'string',
+ label: i18n.ts.name,
+ default: clip.name,
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: i18n.ts.description,
+ default: clip.description,
+ },
+ isPublic: {
+ type: 'boolean',
+ label: i18n.ts.public,
+ default: clip.isPublic,
+ },
+ });
+ if (canceled) return;
+ os.apiWithDialog('clips/update', {
+ clipId: clip.id,
+ ...result,
+ });
},
+}, {
+ icon: 'fas fa-trash-alt',
+ text: i18n.ts.delete,
+ danger: true,
+ handler: async (): Promise<void> => {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.t('deleteAreYouSure', { x: clip.name }),
+ });
+ if (canceled) return;
- methods: {
- menu(ev) {
- os.popupMenu([this.isOwned ? {
- icon: 'fas fa-pencil-alt',
- text: this.$ts.edit,
- action: async () => {
- const { canceled, result } = await os.form(this.clip.name, {
- name: {
- type: 'string',
- label: this.$ts.name,
- default: this.clip.name
- },
- description: {
- type: 'string',
- required: false,
- multiline: true,
- label: this.$ts.description,
- default: this.clip.description
- },
- isPublic: {
- type: 'boolean',
- label: this.$ts.public,
- default: this.clip.isPublic
- }
- });
- if (canceled) return;
-
- os.apiWithDialog('clips/update', {
- clipId: this.clip.id,
- ...result
- });
- }
- } : undefined, this.isOwned ? {
- icon: 'fas fa-trash-alt',
- text: this.$ts.delete,
- danger: true,
- action: async () => {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: this.$t('deleteAreYouSure', { x: this.clip.name }),
- });
- if (canceled) return;
+ await os.apiWithDialog('clips/delete', {
+ clipId: clip.id,
+ });
+ },
+}] : null);
- await os.apiWithDialog('clips/delete', {
- clipId: this.clip.id,
- });
- }
- } : undefined], ev.currentTarget ?? ev.target);
- }
- }
-});
+definePageMetadata(computed(() => clip ? {
+ title: clip.name,
+ icon: 'fas fa-paperclip',
+} : null));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue
index 68777bb083..988a1bf3df 100644
--- a/packages/client/src/pages/drive.vue
+++ b/packages/client/src/pages/drive.vue
@@ -8,17 +8,18 @@
import { computed } from 'vue';
import XDrive from '@/components/drive.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let folder = $ref(null);
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: folder ? folder.name : i18n.ts.drive,
- icon: 'fas fa-cloud',
- bg: 'var(--bg)',
- hideHeader: true,
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: folder ? folder.name : i18n.ts.drive,
+ icon: 'fas fa-cloud',
+ hideHeader: true,
+})));
</script>
diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue
deleted file mode 100644
index f44b29df04..0000000000
--- a/packages/client/src/pages/emojis.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<template>
-<div :class="$style.root">
- <XCategory v-if="tab === 'category'"/>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { ref, computed } from 'vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-import XCategory from './emojis.category.vue';
-import { i18n } from '@/i18n';
-
-const tab = ref('category');
-
-function menu(ev) {
- os.popupMenu([{
- icon: 'fas fa-download',
- text: i18n.ts.export,
- action: async () => {
- os.api('export-custom-emojis', {
- })
- .then(() => {
- os.alert({
- type: 'info',
- text: i18n.ts.exportRequested,
- });
- }).catch((err) => {
- os.alert({
- type: 'error',
- text: err.message,
- });
- });
- }
- }], ev.currentTarget ?? ev.target);
-}
-
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.customEmojis,
- icon: 'fas fa-laugh',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-ellipsis-h',
- handler: menu,
- }],
- },
-});
-</script>
-
-<style lang="scss" module>
-.root {
- max-width: 1000px;
- margin: 0 auto;
-}
-</style>
diff --git a/packages/client/src/pages/explore.featured.vue b/packages/client/src/pages/explore.featured.vue
new file mode 100644
index 0000000000..0f32804b72
--- /dev/null
+++ b/packages/client/src/pages/explore.featured.vue
@@ -0,0 +1,30 @@
+<template>
+<MkSpacer :content-max="800">
+ <MkTab v-model="tab" style="margin-bottom: var(--margin);">
+ <option value="notes">{{ i18n.ts.notes }}</option>
+ <option value="polls">{{ i18n.ts.poll }}</option>
+ </MkTab>
+ <XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/>
+ <XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import XNotes from '@/components/notes.vue';
+import MkTab from '@/components/tab.vue';
+import { i18n } from '@/i18n';
+
+const paginationForNotes = {
+ endpoint: 'notes/featured' as const,
+ limit: 10,
+ offsetMode: true,
+};
+
+const paginationForPolls = {
+ endpoint: 'notes/polls/recommendation' as const,
+ limit: 10,
+ offsetMode: true,
+};
+
+let tab = $ref('notes');
+</script>
diff --git a/packages/client/src/pages/explore.users.vue b/packages/client/src/pages/explore.users.vue
new file mode 100644
index 0000000000..bdc96b33a3
--- /dev/null
+++ b/packages/client/src/pages/explore.users.vue
@@ -0,0 +1,143 @@
+<template>
+<MkSpacer :content-max="1200">
+ <div v-if="origin === 'local'">
+ <template v-if="tag == null">
+ <MkFolder class="_gap" persist-key="explore-pinned-users">
+ <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template>
+ <XUserList :pagination="pinnedUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-popular-users">
+ <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
+ <XUserList :pagination="popularUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-recently-updated-users">
+ <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
+ <XUserList :pagination="recentlyUpdatedUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-recently-registered-users">
+ <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template>
+ <XUserList :pagination="recentlyRegisteredUsers"/>
+ </MkFolder>
+ </template>
+ </div>
+ <div v-else>
+ <MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap">
+ <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template>
+
+ <div class="vxjfqztj">
+ <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
+ <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
+ <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
+ <XUserList :pagination="tagUsers"/>
+ </MkFolder>
+
+ <template v-if="tag == null">
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
+ <XUserList :pagination="popularUsersF"/>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
+ <XUserList :pagination="recentlyUpdatedUsersF"/>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template>
+ <XUserList :pagination="recentlyRegisteredUsersF"/>
+ </MkFolder>
+ </template>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import XUserList from '@/components/user-list.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+
+const props = defineProps<{
+ origin: 'local' | 'remote';
+ tag?: string;
+}>();
+
+let tagsEl = $ref<InstanceType<typeof MkFolder>>();
+let tagsLocal = $ref([]);
+let tagsRemote = $ref([]);
+
+watch(() => props.tag, () => {
+ if (tagsEl) tagsEl.toggleContent(props.tag == null);
+});
+
+const tagUsers = $computed(() => ({
+ endpoint: 'hashtags/users' as const,
+ limit: 30,
+ params: {
+ tag: props.tag,
+ origin: 'combined',
+ sort: '+follower',
+ },
+}));
+
+const pinnedUsers = { endpoint: 'pinned-users' };
+const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'local',
+ sort: '+follower',
+} };
+const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ sort: '+updatedAt',
+} };
+const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ state: 'alive',
+ sort: '+createdAt',
+} };
+const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'remote',
+ sort: '+follower',
+} };
+const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+updatedAt',
+} };
+const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+createdAt',
+} };
+
+os.api('hashtags/list', {
+ sort: '+attachedLocalUsers',
+ attachedToLocalUserOnly: true,
+ limit: 30,
+}).then(tags => {
+ tagsLocal = tags;
+});
+os.api('hashtags/list', {
+ sort: '+attachedRemoteUsers',
+ attachedToRemoteUserOnly: true,
+ limit: 30,
+}).then(tags => {
+ tagsRemote = tags;
+});
+</script>
+
+<style lang="scss" scoped>
+.vxjfqztj {
+ > * {
+ margin-right: 16px;
+
+ &.local {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue
index 04cc3662a7..c0b9438a50 100644
--- a/packages/client/src/pages/explore.vue
+++ b/packages/client/src/pages/explore.vue
@@ -1,261 +1,94 @@
<template>
-<div>
- <MkSpacer :content-max="1200">
- <div class="lznhrdub">
- <div v-if="tab === 'local'">
- <div v-if="meta && stats && tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }">
- <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header>
- <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div>
- </div>
-
- <template v-if="tag == null">
- <MkFolder class="_gap" persist-key="explore-pinned-users">
- <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template>
- <XUserList :pagination="pinnedUsers"/>
- </MkFolder>
- <MkFolder class="_gap" persist-key="explore-popular-users">
- <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
- <XUserList :pagination="popularUsers"/>
- </MkFolder>
- <MkFolder class="_gap" persist-key="explore-recently-updated-users">
- <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
- <XUserList :pagination="recentlyUpdatedUsers"/>
- </MkFolder>
- <MkFolder class="_gap" persist-key="explore-recently-registered-users">
- <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template>
- <XUserList :pagination="recentlyRegisteredUsers"/>
- </MkFolder>
- </template>
- </div>
- <div v-else-if="tab === 'remote'">
- <div v-if="tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: `url(/client-assets/fedi.jpg)` }">
- <header><span>{{ $ts.exploreFediverse }}</span></header>
- </div>
-
- <MkFolder ref="tags" :foldable="true" :expanded="false" class="_gap">
- <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template>
-
- <div class="vxjfqztj">
- <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
- <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA>
- </div>
- </MkFolder>
-
- <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
- <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
- <XUserList :pagination="tagUsers"/>
- </MkFolder>
-
- <template v-if="tag == null">
- <MkFolder class="_gap">
- <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
- <XUserList :pagination="popularUsersF"/>
- </MkFolder>
- <MkFolder class="_gap">
- <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
- <XUserList :pagination="recentlyUpdatedUsersF"/>
- </MkFolder>
- <MkFolder class="_gap">
- <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template>
- <XUserList :pagination="recentlyRegisteredUsersF"/>
- </MkFolder>
- </template>
- </div>
- <div v-else-if="tab === 'search'">
- <div class="_isolated">
- <MkInput v-model="searchQuery" :debounce="true" type="search">
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <div class="lznhrdub">
+ <div v-if="tab === 'featured'">
+ <XFeatured/>
+ </div>
+ <div v-else-if="tab === 'localUsers'">
+ <XUsers origin="local"/>
+ </div>
+ <div v-else-if="tab === 'remoteUsers'">
+ <XUsers origin="remote"/>
+ </div>
+ <div v-else-if="tab === 'search'">
+ <MkSpacer :content-max="1200">
+ <div>
+ <MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock">
<template #prefix><i class="fas fa-search"></i></template>
<template #label>{{ $ts.searchUser }}</template>
</MkInput>
- <MkRadios v-model="searchOrigin">
+ <MkRadios v-model="searchOrigin" class="_formBlock">
<option value="combined">{{ $ts.all }}</option>
<option value="local">{{ $ts.local }}</option>
<option value="remote">{{ $ts.remote }}</option>
</MkRadios>
</div>
- <XUserList v-if="searchQuery" ref="search" class="_gap" :pagination="searchPagination"/>
- </div>
+ <XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/>
+ </MkSpacer>
</div>
- </MkSpacer>
-</div>
+ </div>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import XUserList from '@/components/user-list.vue';
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import XFeatured from './explore.featured.vue';
+import XUsers from './explore.users.vue';
import MkFolder from '@/components/ui/folder.vue';
import MkInput from '@/components/form/input.vue';
import MkRadios from '@/components/form/radios.vue';
import number from '@/filters/number';
import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
- components: {
- XUserList,
- MkFolder,
- MkInput,
- MkRadios,
- },
-
- props: {
- tag: {
- type: String,
- required: false
- }
- },
-
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => ({
- title: this.$ts.explore,
- icon: 'fas fa-hashtag',
- bg: 'var(--bg)',
- tabs: [{
- active: this.tab === 'local',
- title: this.$ts.local,
- onClick: () => { this.tab = 'local'; },
- }, {
- active: this.tab === 'remote',
- title: this.$ts.remote,
- onClick: () => { this.tab = 'remote'; },
- }, {
- active: this.tab === 'search',
- title: this.$ts.search,
- onClick: () => { this.tab = 'search'; },
- },]
- })),
- tab: 'local',
- pinnedUsers: { endpoint: 'pinned-users' },
- popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
- state: 'alive',
- origin: 'local',
- sort: '+follower',
- } },
- recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
- origin: 'local',
- sort: '+updatedAt',
- } },
- recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
- origin: 'local',
- state: 'alive',
- sort: '+createdAt',
- } },
- popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
- state: 'alive',
- origin: 'remote',
- sort: '+follower',
- } },
- recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
- origin: 'combined',
- sort: '+updatedAt',
- } },
- recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
- origin: 'combined',
- sort: '+createdAt',
- } },
- searchPagination: {
- endpoint: 'users/search' as const,
- limit: 10,
- params: computed(() => (this.searchQuery && this.searchQuery !== '') ? {
- query: this.searchQuery,
- origin: this.searchOrigin,
- } : null)
- },
- tagsLocal: [],
- tagsRemote: [],
- stats: null,
- searchQuery: null,
- searchOrigin: 'combined',
- num: number,
- };
- },
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+import XUserList from '@/components/user-list.vue';
- computed: {
- meta() {
- return this.$instance;
- },
- tagUsers(): any {
- return {
- endpoint: 'hashtags/users' as const,
- limit: 30,
- params: {
- tag: this.tag,
- origin: 'combined',
- sort: '+follower',
- }
- };
- },
- },
+const props = defineProps<{
+ tag?: string;
+}>();
- watch: {
- tag() {
- if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
- },
- },
+let tab = $ref('featured');
+let tagsEl = $ref<InstanceType<typeof MkFolder>>();
+let searchQuery = $ref(null);
+let searchOrigin = $ref('combined');
- created() {
- os.api('hashtags/list', {
- sort: '+attachedLocalUsers',
- attachedToLocalUserOnly: true,
- limit: 30
- }).then(tags => {
- this.tagsLocal = tags;
- });
- os.api('hashtags/list', {
- sort: '+attachedRemoteUsers',
- attachedToRemoteUserOnly: true,
- limit: 30
- }).then(tags => {
- this.tagsRemote = tags;
- });
- os.api('stats').then(stats => {
- this.stats = stats;
- });
- },
+watch(() => props.tag, () => {
+ if (tagsEl) tagsEl.toggleContent(props.tag == null);
});
-</script>
-
-<style lang="scss" scoped>
-.localfedi7 {
- color: #fff;
- padding: 16px;
- height: 80px;
- background-position: 50%;
- background-size: cover;
- margin-bottom: var(--margin);
-
- > * {
- &:not(:last-child) {
- margin-bottom: 8px;
- }
- > span {
- display: inline-block;
- padding: 6px 8px;
- background: rgba(0, 0, 0, 0.7);
- }
- }
+const searchPagination = {
+ endpoint: 'users/search' as const,
+ limit: 10,
+ params: computed(() => (searchQuery && searchQuery !== '') ? {
+ query: searchQuery,
+ origin: searchOrigin,
+ } : null),
+};
- > header {
- font-size: 20px;
- font-weight: bold;
- }
+const headerActions = $computed(() => []);
- > div {
- font-size: 14px;
- opacity: 0.8;
- }
-}
+const headerTabs = $computed(() => [{
+ key: 'featured',
+ icon: 'fas fa-bolt',
+ title: i18n.ts.featured,
+}, {
+ key: 'localUsers',
+ icon: 'fas fa-users',
+ title: i18n.ts.users,
+}, {
+ key: 'remoteUsers',
+ icon: 'fas fa-users',
+ title: i18n.ts.remote,
+}, {
+ key: 'search',
+ title: i18n.ts.search,
+}]);
-.vxjfqztj {
- > * {
- margin-right: 16px;
-
- &.local {
- font-weight: bold;
- }
- }
-}
-</style>
+definePageMetadata(computed(() => ({
+ title: i18n.ts.explore,
+ icon: 'fas fa-hashtag',
+})));
+</script>
diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue
index b4f6ff35bc..6f75d68def 100644
--- a/packages/client/src/pages/favorites.vue
+++ b/packages/client/src/pages/favorites.vue
@@ -1,20 +1,23 @@
<template>
-<MkSpacer :content-max="800">
- <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>
+<MkStickyContainer>
+ <template #header><MkPageHeader/></template>
+ <MkSpacer :content-max="800">
+ <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"/>
- </XList>
- </template>
- </MkPagination>
-</MkSpacer>
+ <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"/>
+ </XList>
+ </template>
+ </MkPagination>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -22,8 +25,8 @@ 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';
+import { definePageMetadata } from '@/scripts/page-metadata';
const pagination = {
endpoint: 'i/favorites' as const,
@@ -32,12 +35,9 @@ const pagination = {
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.favorites,
- icon: 'fas fa-star',
- bg: 'var(--bg)',
- },
+definePageMetadata({
+ title: i18n.ts.favorites,
+ icon: 'fas fa-star',
});
</script>
diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue
deleted file mode 100644
index 14fe0cb740..0000000000
--- a/packages/client/src/pages/featured.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-<template>
-<MkSpacer :content-max="800">
- <XNotes ref="notes" :pagination="pagination"/>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
-import { i18n } from '@/i18n';
-
-const pagination = {
- endpoint: 'notes/featured' as const,
- limit: 10,
- offsetMode: true,
-};
-
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.featured,
- icon: 'fas fa-fire-alt',
- bg: 'var(--bg)',
- },
-});
-</script>
diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue
deleted file mode 100644
index 447918905b..0000000000
--- a/packages/client/src/pages/federation.vue
+++ /dev/null
@@ -1,236 +0,0 @@
-<template>
-<MkSpacer :content-max="1000">
- <div class="taeiyria">
- <div class="query">
- <MkInput v-model="host" :debounce="true" class="">
- <template #prefix><i class="fas fa-search"></i></template>
- <template #label>{{ $ts.host }}</template>
- </MkInput>
- <FormSplit style="margin-top: var(--margin);">
- <MkSelect v-model="state">
- <template #label>{{ $ts.state }}</template>
- <option value="all">{{ $ts.all }}</option>
- <option value="federating">{{ $ts.federating }}</option>
- <option value="subscribing">{{ $ts.subscribing }}</option>
- <option value="publishing">{{ $ts.publishing }}</option>
- <option value="suspended">{{ $ts.suspended }}</option>
- <option value="blocked">{{ $ts.blocked }}</option>
- <option value="notResponding">{{ $ts.notResponding }}</option>
- </MkSelect>
- <MkSelect v-model="sort">
- <template #label>{{ $ts.sort }}</template>
- <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
- <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
- <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
- <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
- <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
- <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
- <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
- <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
- <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
- <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
- <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
- <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
- <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
- <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
- </MkSelect>
- </FormSplit>
- </div>
-
- <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
- <div class="dqokceoi">
- <MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`">
- <div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div>
- <div class="table">
- <div class="cell">
- <div class="key">{{ $ts.registeredAt }}</div>
- <div class="value"><MkTime :time="instance.caughtAt"/></div>
- </div>
- <div class="cell">
- <div class="key">{{ $ts.software }}</div>
- <div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div>
- </div>
- <div class="cell">
- <div class="key">{{ $ts.version }}</div>
- <div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div>
- </div>
- <div class="cell">
- <div class="key">{{ $ts.users }}</div>
- <div class="value">{{ instance.usersCount }}</div>
- </div>
- <div class="cell">
- <div class="key">{{ $ts.notes }}</div>
- <div class="value">{{ instance.notesCount }}</div>
- </div>
- <div class="cell">
- <div class="key">{{ $ts.sent }}</div>
- <div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
- </div>
- <div class="cell">
- <div class="key">{{ $ts.received }}</div>
- <div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
- </div>
- </div>
- <div class="footer">
- <span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span>
- <span class="pubSub">
- <span v-if="instance.followersCount > 0" class="sub"><i class="fas fa-caret-down icon"></i>Sub</span>
- <span v-else class="sub"><i class="fas fa-caret-down icon"></i>-</span>
- <span v-if="instance.followingCount > 0" class="pub"><i class="fas fa-caret-up icon"></i>Pub</span>
- <span v-else class="pub"><i class="fas fa-caret-up icon"></i>-</span>
- </span>
- <span class="right">
- <span class="latestStatus">{{ instance.latestStatus || '-' }}</span>
- <span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span>
- </span>
- </div>
- </MkA>
- </div>
- </MkPagination>
- </div>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import { computed } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import MkInput from '@/components/form/input.vue';
-import MkSelect from '@/components/form/select.vue';
-import MkPagination from '@/components/ui/pagination.vue';
-import FormSplit from '@/components/form/split.vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { i18n } from '@/i18n';
-
-let host = $ref('');
-let state = $ref('federating');
-let sort = $ref('+pubSub');
-const pagination = {
- endpoint: 'federation/instances' as const,
- limit: 10,
- offsetMode: true,
- params: computed(() => ({
- sort: sort,
- host: host !== '' ? host : null,
- ...(
- state === 'federating' ? { federating: true } :
- state === 'subscribing' ? { subscribing: true } :
- state === 'publishing' ? { publishing: true } :
- state === 'suspended' ? { suspended: true } :
- state === 'blocked' ? { blocked: true } :
- state === 'notResponding' ? { notResponding: true } :
- {})
- }))
-};
-
-function getStatus(instance) {
- if (instance.isSuspended) return 'suspended';
- if (instance.isNotResponding) return 'error';
- return 'alive';
-}
-
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.federation,
- icon: 'fas fa-globe',
- bg: 'var(--bg)',
- },
-});
-</script>
-
-<style lang="scss" scoped>
-.taeiyria {
- > .query {
- background: var(--bg);
- margin-bottom: 16px;
- }
-}
-
-.dqokceoi {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
- grid-gap: 12px;
-
- > .instance {
- padding: 16px;
- background: var(--panel);
- border-radius: 8px;
-
- &:hover {
- text-decoration: none;
- }
-
- > .host {
- font-weight: bold;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-
- > img {
- width: 18px;
- height: 18px;
- margin-right: 6px;
- vertical-align: middle;
- }
- }
-
- > .table {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
- grid-gap: 6px;
- margin: 6px 0;
- font-size: 70%;
-
- > .cell {
- > .key, > .value {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- > .key {
- opacity: 0.7;
- }
-
- > .value {
- }
- }
- }
-
- > .footer {
- display: flex;
- align-items: center;
- font-size: 0.9em;
-
- > .status {
- &.suspended {
- opacity: 0.5;
- }
-
- &.error {
- color: var(--error);
- }
-
- &.alive {
- color: var(--success);
- }
- }
-
- > .pubSub {
- margin-left: 8px;
- }
-
- > .right {
- margin-left: auto;
-
- > .latestStatus {
- border: solid 1px var(--divider);
- border-radius: 4px;
- margin: 0 8px;
- padding: 0 4px;
- }
- }
- }
- }
-}
-</style>
diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue
index 6adc1a404b..1f4dc9e938 100644
--- a/packages/client/src/pages/follow-requests.vue
+++ b/packages/client/src/pages/follow-requests.vue
@@ -1,34 +1,37 @@
<template>
-<div>
- <MkPagination ref="paginationComponent" :pagination="pagination">
- <template #empty>
- <div class="_fullinfo">
- <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
- <div>{{ $ts.noFollowRequests }}</div>
- </div>
- </template>
- <template v-slot="{items}">
- <div class="mk-follow-requests">
- <div v-for="req in items" :key="req.id" class="user _panel">
- <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
- <div class="body">
- <div class="name">
- <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA>
- <p class="acct">@{{ acct(req.follower) }}</p>
- </div>
- <div v-if="req.follower.description" class="description" :title="req.follower.description">
- <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
- </div>
- <div class="actions">
- <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button>
- <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button>
+<MkStickyContainer>
+ <template #header><MkPageHeader/></template>
+ <MkSpacer :content-max="800">
+ <MkPagination ref="paginationComponent" :pagination="pagination">
+ <template #empty>
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noFollowRequests }}</div>
+ </div>
+ </template>
+ <template #default="{items}">
+ <div class="mk-follow-requests">
+ <div v-for="req in items" :key="req.id" class="user _panel">
+ <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
+ <div class="body">
+ <div class="name">
+ <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA>
+ <p class="acct">@{{ acct(req.follower) }}</p>
+ </div>
+ <div v-if="req.follower.description" class="description" :title="req.follower.description">
+ <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
+ </div>
+ <div class="actions">
+ <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button>
+ <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button>
+ </div>
</div>
</div>
</div>
- </div>
- </template>
- </MkPagination>
-</div>
+ </template>
+ </MkPagination>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -36,8 +39,8 @@ import { ref, computed } from 'vue';
import MkPagination from '@/components/ui/pagination.vue';
import { userPage, acct } from '@/filters/user';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const paginationComponent = ref<InstanceType<typeof MkPagination>>();
@@ -58,13 +61,14 @@ function reject(user) {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.followRequests,
- icon: 'fas fa-user-clock',
- bg: 'var(--bg)',
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.followRequests,
+ icon: 'fas fa-user-clock',
+})));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue
index e69e0481e0..0c1cb7733b 100644
--- a/packages/client/src/pages/follow.vue
+++ b/packages/client/src/pages/follow.vue
@@ -5,8 +5,9 @@
<script lang="ts">
import { defineComponent } from 'vue';
-import * as os from '@/os';
import * as Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
export default defineComponent({
created() {
@@ -17,17 +18,17 @@ export default defineComponent({
if (acct.startsWith('https://')) {
promise = os.api('ap/show', {
- uri: acct
+ uri: acct,
});
promise.then(res => {
if (res.type === 'User') {
this.follow(res.object);
} else if (res.type === 'Note') {
- this.$router.push(`/notes/${res.object.id}`);
+ mainRouter.push(`/notes/${res.object.id}`);
} else {
os.alert({
type: 'error',
- text: 'Not a user'
+ text: 'Not a user',
}).then(() => {
window.close();
});
@@ -56,9 +57,9 @@ export default defineComponent({
}
os.apiWithDialog('following/create', {
- userId: user.id
+ userId: user.id,
});
- }
- }
+ },
+ },
});
</script>
diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue
index bc87160c44..f8a5d54f71 100644
--- a/packages/client/src/pages/gallery/edit.vue
+++ b/packages/client/src/pages/gallery/edit.vue
@@ -1,140 +1,125 @@
<template>
-<div>
- <FormSuspense :p="init">
- <FormInput v-model="title">
- <template #label>{{ $ts.title }}</template>
- </FormInput>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <FormInput v-model="title">
+ <template #label>{{ $ts.title }}</template>
+ </FormInput>
- <FormTextarea v-model="description" :max="500">
- <template #label>{{ $ts.description }}</template>
- </FormTextarea>
+ <FormTextarea v-model="description" :max="500">
+ <template #label>{{ $ts.description }}</template>
+ </FormTextarea>
- <FormGroup>
- <div v-for="file in files" :key="file.id" class="_formGroup wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
- <div class="name">{{ file.name }}</div>
- <button v-tooltip="$ts.remove" class="remove _button" @click="remove(file)"><i class="fas fa-times"></i></button>
+ <div class="">
+ <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
+ <div class="name">{{ file.name }}</div>
+ <button v-tooltip="$ts.remove" class="remove _button" @click="remove(file)"><i class="fas fa-times"></i></button>
+ </div>
+ <FormButton primary @click="selectFile"><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton>
</div>
- <FormButton primary @click="selectFile"><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton>
- </FormGroup>
- <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch>
+ <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch>
- <FormButton v-if="postId" primary @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
- <FormButton v-else primary @click="save"><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton>
+ <FormButton v-if="postId" primary @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ <FormButton v-else primary @click="save"><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton>
- <FormButton v-if="postId" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton>
- </FormSuspense>
-</div>
+ <FormButton v-if="postId" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
import FormButton from '@/components/ui/button.vue';
import FormInput from '@/components/form/input.vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormSwitch from '@/components/form/switch.vue';
-import FormGroup from '@/components/form/group.vue';
import FormSuspense from '@/components/form/suspense.vue';
import { selectFiles } from '@/scripts/select-file';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- FormButton,
- FormInput,
- FormTextarea,
- FormSwitch,
- FormGroup,
- FormSuspense,
- },
+const router = useRouter();
- props: {
- postId: {
- type: String,
- required: false,
- default: null,
- }
- },
-
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.postId ? {
- title: this.$ts.edit,
- icon: 'fas fa-pencil-alt'
- } : {
- title: this.$ts.postToGallery,
- icon: 'fas fa-pencil-alt'
- }),
- init: null,
- files: [],
- description: null,
- title: null,
- isSensitive: false,
- };
- },
+const props = defineProps<{
+ postId?: string;
+}>();
- watch: {
- postId: {
- handler() {
- this.init = () => this.postId ? os.api('gallery/posts/show', {
- postId: this.postId
- }).then(post => {
- this.files = post.files;
- this.title = post.title;
- this.description = post.description;
- this.isSensitive = post.isSensitive;
- }) : Promise.resolve(null);
- },
- immediate: true,
- }
- },
+let init = $ref(null);
+let files = $ref([]);
+let description = $ref(null);
+let title = $ref(null);
+let isSensitive = $ref(false);
- methods: {
- selectFile(evt) {
- selectFiles(evt.currentTarget ?? evt.target, null).then(files => {
- this.files = this.files.concat(files);
- });
- },
-
- remove(file) {
- this.files = this.files.filter(f => f.id !== file.id);
- },
+function selectFile(evt) {
+ selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
+ files = files.concat(selected);
+ });
+}
- async save() {
- if (this.postId) {
- await os.apiWithDialog('gallery/posts/update', {
- postId: this.postId,
- title: this.title,
- description: this.description,
- fileIds: this.files.map(file => file.id),
- isSensitive: this.isSensitive,
- });
- this.$router.push(`/gallery/${this.postId}`);
- } else {
- const post = await os.apiWithDialog('gallery/posts/create', {
- title: this.title,
- description: this.description,
- fileIds: this.files.map(file => file.id),
- isSensitive: this.isSensitive,
- });
- this.$router.push(`/gallery/${post.id}`);
- }
- },
+function remove(file) {
+ files = files.filter(f => f.id !== file.id);
+}
- async del() {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: this.$ts.deleteConfirm,
- });
- if (canceled) return;
- await os.apiWithDialog('gallery/posts/delete', {
- postId: this.postId,
- });
- this.$router.push(`/gallery`);
- }
+async function save() {
+ if (props.postId) {
+ await os.apiWithDialog('gallery/posts/update', {
+ postId: props.postId,
+ title: title,
+ description: description,
+ fileIds: files.map(file => file.id),
+ isSensitive: isSensitive,
+ });
+ router.push(`/gallery/${props.postId}`);
+ } else {
+ const created = await os.apiWithDialog('gallery/posts/create', {
+ title: title,
+ description: description,
+ fileIds: files.map(file => file.id),
+ isSensitive: isSensitive,
+ });
+ router.push(`/gallery/${created.id}`);
}
-});
+}
+
+async function del() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteConfirm,
+ });
+ if (canceled) return;
+ await os.apiWithDialog('gallery/posts/delete', {
+ postId: props.postId,
+ });
+ router.push('/gallery');
+}
+
+watch(() => props.postId, () => {
+ init = () => props.postId ? os.api('gallery/posts/show', {
+ postId: props.postId,
+ }).then(post => {
+ files = post.files;
+ title = post.title;
+ description = post.description;
+ isSensitive = post.isSensitive;
+ }) : Promise.resolve(null);
+}, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.postId ? {
+ title: i18n.ts.edit,
+ icon: 'fas fa-pencil-alt',
+} : {
+ title: i18n.ts.postToGallery,
+ icon: 'fas fa-pencil-alt',
+}));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue
index a19d69d5c2..6b406af742 100644
--- a/packages/client/src/pages/gallery/index.vue
+++ b/packages/client/src/pages/gallery/index.vue
@@ -1,49 +1,48 @@
<template>
-<div class="xprsixdl _root">
- <MkTab v-if="$i" v-model="tab">
- <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
- <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
- <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
- </MkTab>
-
- <div v-if="tab === 'explore'">
- <MkFolder class="_gap">
- <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
- <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
- <div class="vfpdbgtk">
- <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
- </div>
- </MkPagination>
- </MkFolder>
- <MkFolder class="_gap">
- <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
- <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
- <div class="vfpdbgtk">
- <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
- </div>
- </MkPagination>
- </MkFolder>
- </div>
- <div v-else-if="tab === 'liked'">
- <MkPagination v-slot="{items}" :pagination="likedPostsPagination">
- <div class="vfpdbgtk">
- <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="1400">
+ <div class="_root">
+ <div v-if="tab === 'explore'">
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
+ <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+ </div>
+ </MkPagination>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
+ <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+ </div>
+ </MkPagination>
+ </MkFolder>
+ </div>
+ <div v-else-if="tab === 'liked'">
+ <MkPagination v-slot="{items}" :pagination="likedPostsPagination">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
+ </div>
+ </MkPagination>
</div>
- </MkPagination>
- </div>
- <div v-else-if="tab === 'my'">
- <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
- <MkPagination v-slot="{items}" :pagination="myPostsPagination">
- <div class="vfpdbgtk">
- <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+ <div v-else-if="tab === 'my'">
+ <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
+ <MkPagination v-slot="{items}" :pagination="myPostsPagination">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+ </div>
+ </MkPagination>
</div>
- </MkPagination>
- </div>
-</div>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
import XUserList from '@/components/user-list.vue';
import MkFolder from '@/components/ui/folder.vue';
import MkInput from '@/components/form/input.vue';
@@ -53,92 +52,80 @@ import MkPagination from '@/components/ui/pagination.vue';
import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
import number from '@/filters/number';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
-export default defineComponent({
- components: {
- XUserList,
- MkFolder,
- MkInput,
- MkButton,
- MkTab,
- MkPagination,
- MkGalleryPostPreview,
- },
+const router = useRouter();
- props: {
- tag: {
- type: String,
- required: false
- }
- },
+const props = defineProps<{
+ tag?: string;
+}>();
- data() {
- return {
- [symbols.PAGE_INFO]: {
- title: this.$ts.gallery,
- icon: 'fas fa-icons'
- },
- tab: 'explore',
- recentPostsPagination: {
- endpoint: 'gallery/posts' as const,
- limit: 6,
- },
- popularPostsPagination: {
- endpoint: 'gallery/featured' as const,
- limit: 5,
- },
- myPostsPagination: {
- endpoint: 'i/gallery/posts' as const,
- limit: 5,
- },
- likedPostsPagination: {
- endpoint: 'i/gallery/likes' as const,
- limit: 5,
- },
- tags: [],
- };
- },
+let tab = $ref('explore');
+let tags = $ref([]);
+let tagsRef = $ref();
- computed: {
- meta() {
- return this.$instance;
- },
- tagUsers(): any {
- return {
- endpoint: 'hashtags/users' as const,
- limit: 30,
- params: {
- tag: this.tag,
- origin: 'combined',
- sort: '+follower',
- }
- };
- },
- },
+const recentPostsPagination = {
+ endpoint: 'gallery/posts' as const,
+ limit: 6,
+};
+const popularPostsPagination = {
+ endpoint: 'gallery/featured' as const,
+ limit: 5,
+};
+const myPostsPagination = {
+ endpoint: 'i/gallery/posts' as const,
+ limit: 5,
+};
+const likedPostsPagination = {
+ endpoint: 'i/gallery/likes' as const,
+ limit: 5,
+};
- watch: {
- tag() {
- if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
- },
+const tagUsersPagination = $computed(() => ({
+ endpoint: 'hashtags/users' as const,
+ limit: 30,
+ params: {
+ tag: this.tag,
+ origin: 'combined',
+ sort: '+follower',
},
+}));
- created() {
+watch(() => props.tag, () => {
+ if (tagsRef) tagsRef.tags.toggleContent(props.tag == null);
+});
+const headerActions = $computed(() => [{
+ icon: 'fas fa-plus',
+ text: i18n.ts.create,
+ handler: () => {
+ router.push('/gallery/new');
},
+}]);
- methods: {
+const headerTabs = $computed(() => [{
+ key: 'explore',
+ title: i18n.ts.gallery,
+ icon: 'fas fa-icons',
+}, {
+ key: 'liked',
+ title: i18n.ts._gallery.liked,
+ icon: 'fas fa-heart',
+}, {
+ key: 'my',
+ title: i18n.ts._gallery.my,
+ icon: 'fas fa-edit',
+}]);
- }
+definePageMetadata({
+ title: i18n.ts.gallery,
+ icon: 'fas fa-icons',
});
</script>
<style lang="scss" scoped>
-.xprsixdl {
- max-width: 1400px;
- margin: 0 auto;
-}
-
.vfpdbgtk {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue
index 1ca3443e56..e87a541e98 100644
--- a/packages/client/src/pages/gallery/post.vue
+++ b/packages/client/src/pages/gallery/post.vue
@@ -1,171 +1,155 @@
<template>
-<div class="_root">
- <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
- <div v-if="post" class="rkxwuolj">
- <div class="files">
- <div v-for="file in post.files" :key="file.id" class="file">
- <img :src="file.url"/>
- </div>
- </div>
- <div class="body _block">
- <div class="title">{{ post.title }}</div>
- <div class="description"><Mfm :text="post.description"/></div>
- <div class="info">
- <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
- </div>
- <div class="actions">
- <div class="like">
- <MkButton v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
- <MkButton v-else v-tooltip="$ts._gallery.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32">
+ <div class="_root">
+ <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+ <div v-if="post" class="rkxwuolj">
+ <div class="files">
+ <div v-for="file in post.files" :key="file.id" class="file">
+ <img :src="file.url"/>
+ </div>
</div>
- <div class="other">
- <button v-if="$i && $i.id === post.user.id" v-tooltip="$ts.edit" v-click-anime class="_button" @click="edit"><i class="fas fa-pencil-alt fa-fw"></i></button>
- <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button>
- <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button>
+ <div class="body _block">
+ <div class="title">{{ post.title }}</div>
+ <div class="description"><Mfm :text="post.description"/></div>
+ <div class="info">
+ <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
+ </div>
+ <div class="actions">
+ <div class="like">
+ <MkButton v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
+ <MkButton v-else v-tooltip="$ts._gallery.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton>
+ </div>
+ <div class="other">
+ <button v-if="$i && $i.id === post.user.id" v-tooltip="$ts.edit" v-click-anime class="_button" @click="edit"><i class="fas fa-pencil-alt fa-fw"></i></button>
+ <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button>
+ <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button>
+ </div>
+ </div>
+ <div class="user">
+ <MkAvatar :user="post.user" class="avatar"/>
+ <div class="name">
+ <MkUserName :user="post.user" style="display: block;"/>
+ <MkAcct :user="post.user"/>
+ </div>
+ <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ </div>
</div>
+ <MkAd :prefer="['horizontal', 'horizontal-big']"/>
+ <MkContainer :max-height="300" :foldable="true" class="other">
+ <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
+ <MkPagination v-slot="{items}" :pagination="otherPostsPagination">
+ <div class="sdrarzaf">
+ <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+ </div>
+ </MkPagination>
+ </MkContainer>
</div>
- <div class="user">
- <MkAvatar :user="post.user" class="avatar"/>
- <div class="name">
- <MkUserName :user="post.user" style="display: block;"/>
- <MkAcct :user="post.user"/>
- </div>
- <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
- </div>
- </div>
- <MkAd :prefer="['horizontal', 'horizontal-big']"/>
- <MkContainer :max-height="300" :foldable="true" class="other">
- <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
- <MkPagination v-slot="{items}" :pagination="otherPostsPagination">
- <div class="sdrarzaf">
- <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
- </div>
- </MkPagination>
- </MkContainer>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
</div>
- <MkError v-else-if="error" @retry="fetch()"/>
- <MkLoading v-else/>
- </transition>
-</div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, inject, watch } from 'vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import MkContainer from '@/components/ui/container.vue';
import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
import MkFollowButton from '@/components/follow-button.vue';
import { url } from '@/config';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-export default defineComponent({
- components: {
- MkContainer,
- ImgWithBlurhash,
- MkPagination,
- MkGalleryPostPreview,
- MkButton,
- MkFollowButton,
- },
- props: {
- postId: {
- type: String,
- required: true
- }
- },
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.post ? {
- title: this.post.title,
- avatar: this.post.user,
- path: `/gallery/${this.post.id}`,
- share: {
- title: this.post.title,
- text: this.post.description,
- },
- actions: [{
- icon: 'fas fa-pencil-alt',
- text: this.$ts.edit,
- handler: this.edit
- }]
- } : null),
- otherPostsPagination: {
- endpoint: 'users/gallery/posts' as const,
- limit: 6,
- params: computed(() => ({
- userId: this.post.user.id
- })),
- },
- post: null,
- error: null,
- };
- },
+const router = useRouter();
+
+const props = defineProps<{
+ postId: string;
+}>();
+
+let post = $ref(null);
+let error = $ref(null);
+const otherPostsPagination = {
+ endpoint: 'users/gallery/posts' as const,
+ limit: 6,
+ params: computed(() => ({
+ userId: post.user.id,
+ })),
+};
+
+function fetchPost() {
+ post = null;
+ os.api('gallery/posts/show', {
+ postId: props.postId,
+ }).then(_post => {
+ post = _post;
+ }).catch(_error => {
+ error = _error;
+ });
+}
- watch: {
- postId: 'fetch'
- },
+function share() {
+ navigator.share({
+ title: post.title,
+ text: post.description,
+ url: `${url}/gallery/${post.id}`,
+ });
+}
- created() {
- this.fetch();
- },
+function shareWithNote() {
+ os.post({
+ initialText: `${post.title} ${url}/gallery/${post.id}`,
+ });
+}
- methods: {
- fetch() {
- this.post = null;
- os.api('gallery/posts/show', {
- postId: this.postId
- }).then(post => {
- this.post = post;
- }).catch(err => {
- this.error = err;
- });
- },
+function like() {
+ os.apiWithDialog('gallery/posts/like', {
+ postId: props.postId,
+ }).then(() => {
+ post.isLiked = true;
+ post.likedCount++;
+ });
+}
- share() {
- navigator.share({
- title: this.post.title,
- text: this.post.description,
- url: `${url}/gallery/${this.post.id}`
- });
- },
+async function unlike() {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.unlikeConfirm,
+ });
+ if (confirm.canceled) return;
+ os.apiWithDialog('gallery/posts/unlike', {
+ postId: props.postId,
+ }).then(() => {
+ post.isLiked = false;
+ post.likedCount--;
+ });
+}
- shareWithNote() {
- os.post({
- initialText: `${this.post.title} ${url}/gallery/${this.post.id}`
- });
- },
+function edit() {
+ router.push(`/gallery/${post.id}/edit`);
+}
- like() {
- os.apiWithDialog('gallery/posts/like', {
- postId: this.postId,
- }).then(() => {
- this.post.isLiked = true;
- this.post.likedCount++;
- });
- },
+watch(() => props.postId, fetchPost, { immediate: true });
- async unlike() {
- const confirm = await os.confirm({
- type: 'warning',
- text: this.$ts.unlikeConfirm,
- });
- if (confirm.canceled) return;
- os.apiWithDialog('gallery/posts/unlike', {
- postId: this.postId,
- }).then(() => {
- this.post.isLiked = false;
- this.post.likedCount--;
- });
- },
+const headerActions = $computed(() => [{
+ icon: 'fas fa-pencil-alt',
+ text: i18n.ts.edit,
+ handler: edit,
+}]);
- edit() {
- this.$router.push(`/gallery/${this.post.id}/edit`);
- }
- }
-});
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => post ? {
+ title: post.title,
+ avatar: post.user,
+} : null));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue
index f19cb9d1a2..d4d338b125 100644
--- a/packages/client/src/pages/instance-info.vue
+++ b/packages/client/src/pages/instance-info.vue
@@ -1,69 +1,80 @@
<template>
-<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
- <div v-if="instance" class="_formRoot">
- <div class="fnfelxur">
- <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/>
- </div>
- <MkKeyValue :copy="host" oneline style="margin: 1em 0;">
- <template #key>Host</template>
- <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template>
- </MkKeyValue>
- <MkKeyValue oneline style="margin: 1em 0;">
- <template #key>Name</template>
- <template #value>{{ instance.name || `(${$ts.unknown})` }}</template>
- </MkKeyValue>
- <MkKeyValue>
- <template #key>{{ $ts.description }}</template>
- <template #value>{{ instance.description }}</template>
- </MkKeyValue>
- <MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.software }}</template>
- <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }} / {{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template>
- </MkKeyValue>
- <MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.administrator }}</template>
- <template #value>{{ instance.maintainerName || `(${$ts.unknown})` }} ({{ instance.maintainerEmail || `(${$ts.unknown})` }})</template>
- </MkKeyValue>
-
- <FormSection v-if="iAmModerator">
- <template #label>Moderation</template>
- <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch>
- <FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch>
- <MkButton @click="refreshMetadata">Refresh metadata</MkButton>
- </FormSection>
-
- <FormSection>
- <MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.registeredAt }}</template>
- <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template>
- </MkKeyValue>
- <MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.updatedAt }}</template>
- <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32">
+ <div v-if="tab === 'overview'" class="_formRoot">
+ <div class="fnfelxur">
+ <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/>
+ <span class="name">{{ instance.name || `(${$ts.unknown})` }}</span>
+ </div>
+ <MkKeyValue :copy="host" oneline style="margin: 1em 0;">
+ <template #key>Host</template>
+ <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template>
</MkKeyValue>
<MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.latestRequestSentAt }}</template>
- <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template>
+ <template #key>{{ $ts.software }}</template>
+ <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }} / {{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template>
</MkKeyValue>
<MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.latestStatus }}</template>
- <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template>
+ <template #key>{{ $ts.administrator }}</template>
+ <template #value>{{ instance.maintainerName || `(${$ts.unknown})` }} ({{ instance.maintainerEmail || `(${$ts.unknown})` }})</template>
</MkKeyValue>
- <MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.latestRequestReceivedAt }}</template>
- <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
+ <MkKeyValue>
+ <template #key>{{ $ts.description }}</template>
+ <template #value>{{ instance.description }}</template>
</MkKeyValue>
- </FormSection>
+
+ <FormSection v-if="iAmModerator">
+ <template #label>Moderation</template>
+ <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch>
+ <FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch>
+ <MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton>
+ </FormSection>
+
+ <FormSection>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.registeredAt }}</template>
+ <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.latestRequestSentAt }}</template>
+ <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.latestStatus }}</template>
+ <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.latestRequestReceivedAt }}</template>
+ <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
+ </MkKeyValue>
+ </FormSection>
- <FormSection>
- <MkKeyValue oneline style="margin: 1em 0;">
- <template #key>Open Registrations</template>
- <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template>
- </MkKeyValue>
- </FormSection>
+ <FormSection>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>Following (Pub)</template>
+ <template #value>{{ number(instance.followingCount) }}</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>Followers (Sub)</template>
+ <template #value>{{ number(instance.followersCount) }}</template>
+ </MkKeyValue>
+ </FormSection>
- <FormSection>
- <template #label>{{ $ts.statistics }}</template>
+ <FormSection>
+ <template #label>Well-known resources</template>
+ <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink>
+ <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink>
+ <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink>
+ <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink>
+ <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
+ </FormSection>
+ </div>
+ <div v-else-if="tab === 'chart'" class="_formRoot">
<div class="cmhjzshl">
<div class="selects">
<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
@@ -79,30 +90,28 @@
<option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
<option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
</MkSelect>
- <MkSelect v-model="chartSpan" style="margin: 0;">
- <option value="hour">{{ $ts.perHour }}</option>
- <option value="day">{{ $ts.perDay }}</option>
- </MkSelect>
</div>
- <div class="chart">
- <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
+ <div class="charts">
+ <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
+ <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
+ <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
+ <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart>
</div>
</div>
- </FormSection>
-
- <MkObjectView tall :value="instance">
- </MkObjectView>
-
- <FormSection>
- <template #label>Well-known resources</template>
- <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink>
- <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink>
- <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink>
- <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink>
- <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
- </FormSection>
- </div>
-</MkSpacer>
+ </div>
+ <div v-else-if="tab === 'users'" class="_formRoot">
+ <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;">
+ <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ </MkPagination>
+ </div>
+ <div v-else-if="tab === 'raw'" class="_formRoot">
+ <MkObjectView tall :value="instance">
+ </MkObjectView>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -120,33 +129,46 @@ import FormSwitch from '@/components/form/switch.vue';
import * as os from '@/os';
import number from '@/filters/number';
import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
import { iAmModerator } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import MkUserCardMini from '@/components/user-card-mini.vue';
+import MkPagination from '@/components/ui/pagination.vue';
const props = defineProps<{
host: string;
}>();
+let tab = $ref('overview');
+let chartSrc = $ref('instance-requests');
let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null);
let instance = $ref<misskey.entities.Instance | null>(null);
let suspended = $ref(false);
let isBlocked = $ref(false);
-let chartSrc = $ref('instance-requests');
-let chartSpan = $ref('hour');
+
+const usersPagination = {
+ endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
+ limit: 10,
+ params: {
+ sort: '+updatedAt',
+ state: 'all',
+ hostname: props.host,
+ },
+ offsetMode: true,
+};
async function fetch() {
- meta = await os.api('meta', { detail: true });
instance = await os.api('federation/show-instance', {
host: props.host,
});
suspended = instance.isSuspended;
- isBlocked = meta.blockedHosts.includes(instance.host);
+ isBlocked = instance.isBlocked;
}
async function toggleBlock(ev) {
if (meta == null) return;
await os.api('admin/update-meta', {
- blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host)
+ blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
});
}
@@ -168,30 +190,53 @@ function refreshMetadata() {
fetch();
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: props.host,
- icon: 'fas fa-info-circle',
- bg: 'var(--bg)',
- actions: [{
- text: `https://${props.host}`,
- icon: 'fas fa-external-link-alt',
- handler: () => {
- window.open(`https://${props.host}`, '_blank');
- }
- }],
+const headerActions = $computed(() => [{
+ text: `https://${props.host}`,
+ icon: 'fas fa-external-link-alt',
+ handler: () => {
+ window.open(`https://${props.host}`, '_blank');
},
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'fas fa-info-circle',
+}, {
+ key: 'chart',
+ title: i18n.ts.charts,
+ icon: 'fas fa-chart-simple',
+}, {
+ key: 'users',
+ title: i18n.ts.users,
+ icon: 'fas fa-users',
+}, {
+ key: 'raw',
+ title: 'Raw',
+ icon: 'fas fa-code',
+}]);
+
+definePageMetadata({
+ title: props.host,
+ icon: 'fas fa-server',
});
</script>
<style lang="scss" scoped>
.fnfelxur {
+ display: flex;
+ align-items: center;
+
> .icon {
display: block;
- margin: 0;
+ margin: 0 16px 0 0;
height: 64px;
border-radius: 8px;
}
+
+ > .name {
+ word-break: break-all;
+ }
}
.cmhjzshl {
@@ -199,5 +244,12 @@ defineExpose({
display: flex;
margin: 0 0 16px 0;
}
+
+ > .charts {
+ > .label {
+ margin-bottom: 12px;
+ font-weight: bold;
+ }
+ }
}
</style>
diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue
deleted file mode 100644
index 9b57c956bf..0000000000
--- a/packages/client/src/pages/mentions.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<template>
-<MkSpacer :content-max="800">
- <XNotes :pagination="pagination"/>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
-import { i18n } from '@/i18n';
-
-const pagination = {
- endpoint: 'notes/mentions' as const,
- limit: 10,
-};
-
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.mentions,
- icon: 'fas fa-at',
- bg: 'var(--bg)',
- },
-});
-</script>
diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue
deleted file mode 100644
index 9c5fb9b341..0000000000
--- a/packages/client/src/pages/messages.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<template>
-<MkSpacer :content-max="800">
- <XNotes :pagination="pagination"/>
-</MkSpacer>
-</template>
-
-<script lang="ts" setup>
-import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
-import { i18n } from '@/i18n';
-
-const pagination = {
- endpoint: 'notes/mentions' as const,
- limit: 10,
- params: {
- visibility: 'specified'
- },
-};
-
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.directNotes,
- icon: 'fas fa-envelope',
- bg: 'var(--bg)',
- },
-});
-</script>
diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue
index 7c1d3e3cbe..7df4c846fb 100644
--- a/packages/client/src/pages/messaging/index.vue
+++ b/packages/client/src/pages/messaging/index.vue
@@ -1,165 +1,164 @@
<template>
-<MkSpacer :content-max="800">
- <div v-size="{ max: [400] }" class="yweeujhr">
- <MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <div v-size="{ max: [400] }" class="yweeujhr">
+ <MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
- <div v-if="messages.length > 0" class="history">
- <MkA v-for="(message, i) in messages"
- :key="message.id"
- v-anim="i"
- class="message _block"
- :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
- :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
- :data-index="i"
- >
- <div>
- <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
- <header v-if="message.groupId">
- <span class="name">{{ message.group.name }}</span>
- <MkTime :time="message.createdAt" class="time"/>
- </header>
- <header v-else>
- <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
- <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
- <MkTime :time="message.createdAt" class="time"/>
- </header>
- <div class="body">
- <p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
+ <div v-if="messages.length > 0" class="history">
+ <MkA
+ v-for="(message, i) in messages"
+ :key="message.id"
+ v-anim="i"
+ class="message _block"
+ :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
+ :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
+ :data-index="i"
+ >
+ <div>
+ <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
+ <header v-if="message.groupId">
+ <span class="name">{{ message.group.name }}</span>
+ <MkTime :time="message.createdAt" class="time"/>
+ </header>
+ <header v-else>
+ <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
+ <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
+ <MkTime :time="message.createdAt" class="time"/>
+ </header>
+ <div class="body">
+ <p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
+ </div>
</div>
- </div>
- </MkA>
+ </MkA>
+ </div>
+ <div v-if="!fetching && messages.length == 0" class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noHistory }}</div>
+ </div>
+ <MkLoading v-if="fetching"/>
</div>
- <div v-if="!fetching && messages.length == 0" class="_fullinfo">
- <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
- <div>{{ $ts.noHistory }}</div>
- </div>
- <MkLoading v-if="fetching"/>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+<script lang="ts" setup>
+import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue';
import * as Acct from 'misskey-js/built/acct';
import MkButton from '@/components/ui/button.vue';
import { acct } from '@/filters/user';
import * as os from '@/os';
import { stream } from '@/stream';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
-export default defineComponent({
- components: {
- MkButton
- },
+const router = useRouter();
- data() {
- return {
- [symbols.PAGE_INFO]: {
- title: this.$ts.messaging,
- icon: 'fas fa-comments',
- bg: 'var(--bg)',
- },
- fetching: true,
- moreFetching: false,
- messages: [],
- connection: null,
- };
- },
+let fetching = $ref(true);
+let moreFetching = $ref(false);
+let messages = $ref([]);
+let connection = $ref(null);
- mounted() {
- this.connection = markRaw(stream.useChannel('messagingIndex'));
+const getAcct = Acct.toString;
- this.connection.on('message', this.onMessage);
- this.connection.on('read', this.onRead);
+function isMe(message) {
+ return message.userId === $i.id;
+}
- os.api('messaging/history', { group: false }).then(userMessages => {
- os.api('messaging/history', { group: true }).then(groupMessages => {
- const messages = userMessages.concat(groupMessages);
- messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
- this.messages = messages;
- this.fetching = false;
- });
- });
- },
+function onMessage(message) {
+ if (message.recipientId) {
+ messages = messages.filter(m => !(
+ (m.recipientId === message.recipientId && m.userId === message.userId) ||
+ (m.recipientId === message.userId && m.userId === message.recipientId)));
- beforeUnmount() {
- this.connection.dispose();
- },
+ messages.unshift(message);
+ } else if (message.groupId) {
+ messages = messages.filter(m => m.groupId !== message.groupId);
+ messages.unshift(message);
+ }
+}
- methods: {
- getAcct: Acct.toString,
+function onRead(ids) {
+ for (const id of ids) {
+ const found = messages.find(m => m.id === id);
+ if (found) {
+ if (found.recipientId) {
+ found.isRead = true;
+ } else if (found.groupId) {
+ found.reads.push($i.id);
+ }
+ }
+ }
+}
- isMe(message) {
- return message.userId === this.$i.id;
- },
+function start(ev) {
+ os.popupMenu([{
+ text: i18n.ts.messagingWithUser,
+ icon: 'fas fa-user',
+ action: () => { startUser(); },
+ }, {
+ text: i18n.ts.messagingWithGroup,
+ icon: 'fas fa-users',
+ action: () => { startGroup(); },
+ }], ev.currentTarget ?? ev.target);
+}
- onMessage(message) {
- if (message.recipientId) {
- this.messages = this.messages.filter(m => !(
- (m.recipientId === message.recipientId && m.userId === message.userId) ||
- (m.recipientId === message.userId && m.userId === message.recipientId)));
+async function startUser() {
+ os.selectUser().then(user => {
+ router.push(`/my/messaging/${Acct.toString(user)}`);
+ });
+}
- this.messages.unshift(message);
- } else if (message.groupId) {
- this.messages = this.messages.filter(m => m.groupId !== message.groupId);
- this.messages.unshift(message);
- }
- },
+async function startGroup() {
+ const groups1 = await os.api('users/groups/owned');
+ const groups2 = await os.api('users/groups/joined');
+ if (groups1.length === 0 && groups2.length === 0) {
+ os.alert({
+ type: 'warning',
+ title: i18n.ts.youHaveNoGroups,
+ text: i18n.ts.joinOrCreateGroup,
+ });
+ return;
+ }
+ const { canceled, result: group } = await os.select({
+ title: i18n.ts.group,
+ items: groups1.concat(groups2).map(group => ({
+ value: group, text: group.name,
+ })),
+ });
+ if (canceled) return;
+ router.push(`/my/messaging/group/${group.id}`);
+}
- onRead(ids) {
- for (const id of ids) {
- const found = this.messages.find(m => m.id === id);
- if (found) {
- if (found.recipientId) {
- found.isRead = true;
- } else if (found.groupId) {
- found.reads.push(this.$i.id);
- }
- }
- }
- },
+onMounted(() => {
+ connection = markRaw(stream.useChannel('messagingIndex'));
+
+ connection.on('message', onMessage);
+ connection.on('read', onRead);
- start(ev) {
- os.popupMenu([{
- text: this.$ts.messagingWithUser,
- icon: 'fas fa-user',
- action: () => { this.startUser(); }
- }, {
- text: this.$ts.messagingWithGroup,
- icon: 'fas fa-users',
- action: () => { this.startGroup(); }
- }], ev.currentTarget ?? ev.target);
- },
+ os.api('messaging/history', { group: false }).then(userMessages => {
+ os.api('messaging/history', { group: true }).then(groupMessages => {
+ const _messages = userMessages.concat(groupMessages);
+ _messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ messages = _messages;
+ fetching = false;
+ });
+ });
+});
+
+onUnmounted(() => {
+ if (connection) connection.dispose();
+});
- async startUser() {
- os.selectUser().then(user => {
- this.$router.push(`/my/messaging/${Acct.toString(user)}`);
- });
- },
+const headerActions = $computed(() => []);
- async startGroup() {
- const groups1 = await os.api('users/groups/owned');
- const groups2 = await os.api('users/groups/joined');
- if (groups1.length === 0 && groups2.length === 0) {
- os.alert({
- type: 'warning',
- title: this.$ts.youHaveNoGroups,
- text: this.$ts.joinOrCreateGroup,
- });
- return;
- }
- const { canceled, result: group } = await os.select({
- title: this.$ts.group,
- items: groups1.concat(groups2).map(group => ({
- value: group, text: group.name
- }))
- });
- if (canceled) return;
- this.$router.push(`/my/messaging/group/${group.id}`);
- },
+const headerTabs = $computed(() => []);
- acct
- }
+definePageMetadata({
+ title: i18n.ts.messaging,
+ icon: 'fas fa-comments',
});
</script>
diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue
index 8e779c4f39..38bab90502 100644
--- a/packages/client/src/pages/messaging/messaging-room.form.vue
+++ b/packages/client/src/pages/messaging/messaging-room.form.vue
@@ -1,223 +1,223 @@
<template>
-<div class="pemppnzi _block"
+<div
+ class="pemppnzi _block"
@dragover.stop="onDragover"
@drop.stop="onDrop"
>
<textarea
- ref="text"
+ ref="textEl"
v-model="text"
- :placeholder="$ts.inputMessageHere"
+ :placeholder="i18n.ts.inputMessageHere"
@keydown="onKeydown"
@compositionupdate="onCompositionUpdate"
@paste="onPaste"
></textarea>
- <div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
- <button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send">
- <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
- </button>
- <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
- <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
- <input ref="file" type="file" @change="onChangeFile"/>
+ <footer>
+ <div v-if="file" class="file" @click="file = null">{{ file.name }}</div>
+ <div class="buttons">
+ <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
+ <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
+ <button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
+ <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
+ </button>
+ </div>
+ </footer>
+ <input ref="fileEl" type="file" @change="onChangeFile"/>
</div>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import insertTextAtCursor from 'insert-text-at-cursor';
+<script lang="ts" setup>
+import { onMounted, watch } from 'vue';
+import * as Misskey from 'misskey-js';
import autosize from 'autosize';
+//import insertTextAtCursor from 'insert-text-at-cursor';
+import { throttle } from 'throttle-debounce';
import { formatTimeString } from '@/scripts/format-time-string';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
import { stream } from '@/stream';
-import { Autocomplete } from '@/scripts/autocomplete';
-import { throttle } from 'throttle-debounce';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+//import { Autocomplete } from '@/scripts/autocomplete';
import { uploadFile } from '@/scripts/upload';
-export default defineComponent({
- props: {
- user: {
- type: Object,
- requird: false,
- },
- group: {
- type: Object,
- requird: false,
- },
- },
- data() {
- return {
- text: null,
- file: null,
- sending: false,
- typing: throttle(3000, () => {
- stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
- }),
- };
- },
- computed: {
- draftKey(): string {
- return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
- },
- canSend(): boolean {
- return (this.text != null && this.text !== '') || this.file != null;
- },
- room(): any {
- return this.$parent;
+const props = defineProps<{
+ user?: Misskey.entities.UserDetailed | null;
+ group?: Misskey.entities.UserGroup | null;
+}>();
+
+let textEl = $ref<HTMLTextAreaElement>();
+let fileEl = $ref<HTMLInputElement>();
+
+let text = $ref<string>('');
+let file = $ref<Misskey.entities.DriveFile | null>(null);
+let sending = $ref(false);
+const typing = throttle(3000, () => {
+ stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id });
+});
+
+let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
+let canSend = $computed(() => (text != null && text !== '') || file != null);
+
+watch([$$(text), $$(file)], saveDraft);
+
+async function onPaste(ev: ClipboardEvent) {
+ if (!ev.clipboardData) return;
+
+ const clipboardData = ev.clipboardData;
+ const items = clipboardData.items;
+
+ if (items.length === 1) {
+ if (items[0].kind === 'file') {
+ const pastedFile = items[0].getAsFile();
+ if (!pastedFile) return;
+ const lio = pastedFile.name.lastIndexOf('.');
+ const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
+ const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext;
+ if (formatted) upload(pastedFile, formatted);
}
- },
- watch: {
- text() {
- this.saveDraft();
- },
- file() {
- this.saveDraft();
+ } else {
+ if (items[0].kind === 'file') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.onlyOneFileCanBeAttached,
+ });
}
- },
- mounted() {
- autosize(this.$refs.text);
+ }
+}
- // TODO: detach when unmount
- // TODO
- //new Autocomplete(this.$refs.text, this, { model: 'text' });
+function onDragover(ev: DragEvent) {
+ if (!ev.dataTransfer) return;
- // 書きかけの投稿を復元
- const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
- if (draft) {
- this.text = draft.data.text;
- this.file = draft.data.file;
- }
- },
- methods: {
- async onPaste(evt: ClipboardEvent) {
- const items = evt.clipboardData.items;
+ const isFile = ev.dataTransfer.items[0].kind === 'file';
+ const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+ if (isFile || isDriveFile) {
+ ev.preventDefault();
+ ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
+ }
+}
- if (items.length === 1) {
- if (items[0].kind === 'file') {
- const file = items[0].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, '1')}${ext}`;
- if (formatted) this.upload(file, formatted);
- }
- } else {
- if (items[0].kind === 'file') {
- os.alert({
- type: 'error',
- text: this.$ts.onlyOneFileCanBeAttached
- });
- }
- }
- },
+function onDrop(ev: DragEvent): void {
+ if (!ev.dataTransfer) return;
- onDragover(evt) {
- const isFile = evt.dataTransfer.items[0].kind === 'file';
- const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
- if (isFile || isDriveFile) {
- evt.preventDefault();
- evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
- }
- },
+ // ファイルだったら
+ if (ev.dataTransfer.files.length === 1) {
+ ev.preventDefault();
+ upload(ev.dataTransfer.files[0]);
+ return;
+ } else if (ev.dataTransfer.files.length > 1) {
+ ev.preventDefault();
+ os.alert({
+ type: 'error',
+ text: i18n.ts.onlyOneFileCanBeAttached,
+ });
+ return;
+ }
- onDrop(evt): void {
- // ファイルだったら
- if (evt.dataTransfer.files.length === 1) {
- evt.preventDefault();
- this.upload(evt.dataTransfer.files[0]);
- return;
- } else if (evt.dataTransfer.files.length > 1) {
- evt.preventDefault();
- os.alert({
- type: 'error',
- text: this.$ts.onlyOneFileCanBeAttached
- });
- return;
- }
+ //#region ドライブのファイル
+ const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile !== '') {
+ file = JSON.parse(driveFile);
+ ev.preventDefault();
+ }
+ //#endregion
+}
- //#region ドライブのファイル
- const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile !== '') {
- this.file = JSON.parse(driveFile);
- evt.preventDefault();
- }
- //#endregion
- },
+function onKeydown(ev: KeyboardEvent) {
+ typing();
+ if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) {
+ send();
+ }
+}
- onKeydown(evt) {
- this.typing();
- if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) {
- this.send();
- }
- },
+function onCompositionUpdate() {
+ typing();
+}
- onCompositionUpdate() {
- this.typing();
- },
+function chooseFile(ev: MouseEvent) {
+ selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
+ file = selectedFile;
+ });
+}
- chooseFile(evt) {
- selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => {
- this.file = file;
- });
- },
+function onChangeFile() {
+ if (fileEl.files![0]) upload(fileEl.files[0]);
+}
- onChangeFile() {
- this.upload((this.$refs.file as any).files[0]);
- },
+function upload(fileToUpload: File, name?: string) {
+ uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => {
+ file = res;
+ });
+}
- upload(file: File, name?: string) {
- uploadFile(file, this.$store.state.uploadFolder, name).then(res => {
- this.file = res;
- });
- },
+function send() {
+ sending = true;
+ os.api('messaging/messages/create', {
+ userId: props.user ? props.user.id : undefined,
+ groupId: props.group ? props.group.id : undefined,
+ text: text ? text : undefined,
+ fileId: file ? file.id : undefined,
+ }).then(message => {
+ clear();
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ sending = false;
+ });
+}
- send() {
- this.sending = true;
- os.api('messaging/messages/create', {
- userId: this.user ? this.user.id : undefined,
- groupId: this.group ? this.group.id : undefined,
- text: this.text ? this.text : undefined,
- fileId: this.file ? this.file.id : undefined
- }).then(message => {
- this.clear();
- }).catch(err => {
- console.error(err);
- }).then(() => {
- this.sending = false;
- });
- },
+function clear() {
+ text = '';
+ file = null;
+ deleteDraft();
+}
+
+function saveDraft() {
+ const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
- clear() {
- this.text = '';
- this.file = null;
- this.deleteDraft();
+ drafts[draftKey] = {
+ updatedAt: new Date(),
+ // eslint-disable-next-line id-denylist
+ data: {
+ text: text,
+ file: file,
},
+ };
- saveDraft() {
- const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+ localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
- drafts[this.draftKey] = {
- updatedAt: new Date(),
- data: {
- text: this.text,
- file: this.file
- }
- };
+function deleteDraft() {
+ const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
- localStorage.setItem('message_drafts', JSON.stringify(drafts));
- },
+ delete drafts[draftKey];
- deleteDraft() {
- const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+ localStorage.setItem('message_drafts', JSON.stringify(drafts));
+}
- delete drafts[this.draftKey];
+async function insertEmoji(ev: MouseEvent) {
+ os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl);
+}
- localStorage.setItem('message_drafts', JSON.stringify(drafts));
- },
+onMounted(() => {
+ autosize(textEl);
- async insertEmoji(ev) {
- os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text);
- }
+ // TODO: detach when unmount
+ // TODO
+ //new Autocomplete(textEl, this, { model: 'text' });
+
+ // 書きかけの投稿を復元
+ const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey];
+ if (draft) {
+ text = draft.data.text;
+ file = draft.data.file;
}
});
+
+defineExpose({
+ file,
+ upload,
+});
</script>
<style lang="scss" scoped>
@@ -230,7 +230,7 @@ export default defineComponent({
width: 100%;
min-width: 100%;
max-width: 100%;
- height: 80px;
+ min-height: 80px;
margin: 0;
padding: 16px 16px 0 16px;
resize: none;
@@ -245,26 +245,16 @@ export default defineComponent({
color: var(--fg);
}
- > .file {
- padding: 8px;
- color: #444;
- background: #eee;
- cursor: pointer;
- }
-
- > .send {
- position: absolute;
+ footer {
+ position: sticky;
bottom: 0;
- right: 0;
- margin: 0;
- padding: 16px;
- font-size: 1em;
- transition: color 0.1s ease;
- color: var(--accent);
+ background: var(--panel);
- &:active {
- color: var(--accentDarken);
- transition: color 0s ease;
+ > .file {
+ padding: 8px;
+ color: var(--fg);
+ background: transparent;
+ cursor: pointer;
}
}
@@ -316,21 +306,39 @@ export default defineComponent({
}
}
- ._button {
- margin: 0;
- padding: 16px;
- font-size: 1em;
- font-weight: normal;
- text-decoration: none;
- transition: color 0.1s ease;
+ .buttons {
+ display: flex;
- &:hover {
- color: var(--accent);
+ ._button {
+ margin: 0;
+ padding: 16px;
+ font-size: 1em;
+ font-weight: normal;
+ text-decoration: none;
+ transition: color 0.1s ease;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ &:active {
+ color: var(--accentDarken);
+ transition: color 0s ease;
+ }
}
- &:active {
- color: var(--accentDarken);
- transition: color 0s ease;
+ > .send {
+ margin-left: auto;
+ color: var(--accent);
+
+ &:hover {
+ color: var(--accentLighten);
+ }
+
+ &:active {
+ color: var(--accentDarken);
+ transition: color 0s ease;
+ }
}
}
diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue
index 4315bbecdb..393d2a17b2 100644
--- a/packages/client/src/pages/messaging/messaging-room.message.vue
+++ b/packages/client/src/pages/messaging/messaging-room.message.vue
@@ -35,45 +35,28 @@
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import MkUrlPreview from '@/components/url-preview.vue';
import * as os from '@/os';
+import { $i } from '@/account';
-export default defineComponent({
- components: {
- MkUrlPreview
- },
- props: {
- message: {
- required: true
- },
- isGroup: {
- required: false
- }
- },
- computed: {
- isMe(): boolean {
- return this.message.userId === this.$i.id;
- },
- urls(): string[] {
- if (this.message.text) {
- return extractUrlFromMfm(mfm.parse(this.message.text));
- } else {
- return [];
- }
- }
- },
- methods: {
- del() {
- os.api('messaging/messages/delete', {
- messageId: this.message.id
- });
- }
- }
-});
+const props = defineProps<{
+ message: Misskey.entities.MessagingMessage;
+ isGroup?: boolean;
+}>();
+
+const isMe = $computed(() => props.message.userId === $i?.id);
+const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
+
+function del(): void {
+ os.api('messaging/messages/delete', {
+ messageId: props.message.id,
+ });
+}
</script>
<style lang="scss" scoped>
@@ -266,6 +249,7 @@ export default defineComponent({
&.isMe {
flex-direction: row-reverse;
padding-right: var(--margin);
+ right: var(--margin); // 削除時にposition: absoluteになったときに使う
> .content {
padding-right: 16px;
diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue
index fd1962218a..2e00c3ab19 100644
--- a/packages/client/src/pages/messaging/messaging-room.vue
+++ b/packages/client/src/pages/messaging/messaging-room.vue
@@ -1,379 +1,300 @@
<template>
-<div class="_section"
+<div
+ ref="rootEl"
+ class="_section"
@dragover.prevent.stop="onDragover"
@drop.prevent.stop="onDrop"
>
<div class="_content mk-messaging-room">
<div class="body">
- <MkLoading v-if="fetching"/>
- <p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
- <p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
- <button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages">
- <template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
- </button>
- <XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed>
- <XMessage :key="message.id" :message="message" :is-group="group != null"/>
- </XList>
+ <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination">
+ <template #empty>
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ i18n.ts.noMessagesYet }}</div>
+ </div>
+ </template>
+
+ <template #default="{ items: messages, fetching: pFetching }">
+ <XList
+ v-if="messages.length > 0"
+ v-slot="{ item: message }"
+ :class="{ messages: true, 'deny-move-transition': pFetching }"
+ :items="messages"
+ direction="up"
+ reversed
+ >
+ <XMessage :key="message.id" :message="message" :is-group="group != null"/>
+ </XList>
+ </template>
+ </MkPagination>
</div>
<footer>
<div v-if="typers.length > 0" class="typers">
- <I18n :src="$ts.typingUsers" text-tag="span" class="users">
+ <I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
<template #users>
- <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
+ <b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b>
</template>
</I18n>
<MkEllipsis/>
</div>
- <transition :name="$store.state.animation ? 'fade' : ''">
+ <transition :name="animation ? 'fade' : ''">
<div v-show="showIndicator" class="new-message">
- <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
+ <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button>
</div>
</transition>
- <XForm v-if="!fetching" ref="form" :user="user" :group="group" class="form"/>
+ <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/>
</footer>
</div>
</div>
</template>
-<script lang="ts">
-import { computed, defineComponent, markRaw } from 'vue';
-import XList from '@/components/date-separated-list.vue';
+<script lang="ts" setup>
+import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue';
+import * as Misskey from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
import XMessage from './messaging-room.message.vue';
import XForm from './messaging-room.form.vue';
-import * as Acct from 'misskey-js/built/acct';
-import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
+import XList from '@/components/date-separated-list.vue';
+import MkPagination, { Paging } from '@/components/ui/pagination.vue';
+import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll';
import * as os from '@/os';
import { stream } from '@/stream';
-import { popout } from '@/scripts/popout';
import * as sound from '@/scripts/sound';
-import * as symbols from '@/symbols';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+import { definePageMetadata } from '@/scripts/page-metadata';
-const Component = defineComponent({
- components: {
- XMessage,
- XForm,
- XList,
- },
+const props = defineProps<{
+ userAcct?: string;
+ groupId?: string;
+}>();
- inject: ['inWindow'],
+let rootEl = $ref<HTMLDivElement>();
+let formEl = $ref<InstanceType<typeof XForm>>();
+let pagingComponent = $ref<InstanceType<typeof MkPagination>>();
- props: {
- userAcct: {
- type: String,
- required: false,
- },
- groupId: {
- type: String,
- required: false,
- },
- },
+let fetching = $ref(true);
+let user: Misskey.entities.UserDetailed | null = $ref(null);
+let group: Misskey.entities.UserGroup | null = $ref(null);
+let typers: Misskey.entities.User[] = $ref([]);
+let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null);
+let showIndicator = $ref(false);
+const {
+ animation,
+} = defaultStore.reactiveState;
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? {
- userName: this.user,
- avatar: this.user,
- action: {
- icon: 'fas fa-ellipsis-h',
- handler: this.menu,
- },
- } : {
- title: this.group.name,
- icon: 'fas fa-users',
- action: {
- icon: 'fas fa-ellipsis-h',
- handler: this.menu,
- },
- } : null),
- fetching: true,
- user: null,
- group: null,
- fetchingMoreMessages: false,
- messages: [],
- existMoreMessages: false,
- connection: null,
- showIndicator: false,
- timer: null,
- typers: [],
- ilObserver: new IntersectionObserver(
- (entries) => entries.some((entry) => entry.isIntersecting)
- && !this.fetching
- && !this.fetchingMoreMessages
- && this.existMoreMessages
- && this.fetchMoreMessages()
- ),
- };
- },
-
- computed: {
- form(): any {
- return this.$refs.form;
- }
- },
-
- watch: {
- userAcct: 'fetch',
- groupId: 'fetch',
- },
-
- mounted() {
- this.fetch();
- if (this.$store.state.enableInfiniteScroll) {
- this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element));
- }
- },
+let pagination: Paging | null = $ref(null);
- beforeUnmount() {
- this.connection.dispose();
-
- document.removeEventListener('visibilitychange', this.onVisibilitychange);
-
- this.ilObserver.disconnect();
- },
-
- methods: {
- async fetch() {
- this.fetching = true;
- if (this.userAcct) {
- const user = await os.api('users/show', Acct.parse(this.userAcct));
- this.user = user;
- } else {
- const group = await os.api('users/groups/show', { groupId: this.groupId });
- this.group = group;
- }
+watch([() => props.userAcct, () => props.groupId], () => {
+ if (connection) connection.dispose();
+ fetch();
+});
- this.connection = markRaw(stream.useChannel('messaging', {
- otherparty: this.user ? this.user.id : undefined,
- group: this.group ? this.group.id : undefined,
- }));
+async function fetch() {
+ fetching = true;
- this.connection.on('message', this.onMessage);
- this.connection.on('read', this.onRead);
- this.connection.on('deleted', this.onDeleted);
- this.connection.on('typers', typers => {
- this.typers = typers.filter(u => u.id !== this.$i.id);
- });
+ if (props.userAcct) {
+ const acct = Acct.parse(props.userAcct);
+ user = await os.api('users/show', { username: acct.username, host: acct.host || undefined });
+ group = null;
+
+ pagination = {
+ endpoint: 'messaging/messages',
+ limit: 20,
+ params: {
+ userId: user.id,
+ },
+ reversed: true,
+ pageEl: $$(rootEl).value,
+ };
+ connection = stream.useChannel('messaging', {
+ otherparty: user.id,
+ });
+ } else {
+ user = null;
+ group = await os.api('users/groups/show', { groupId: props.groupId });
- document.addEventListener('visibilitychange', this.onVisibilitychange);
+ pagination = {
+ endpoint: 'messaging/messages',
+ limit: 20,
+ params: {
+ groupId: group?.id,
+ },
+ reversed: true,
+ pageEl: $$(rootEl).value,
+ };
+ connection = stream.useChannel('messaging', {
+ group: group?.id,
+ });
+ }
- this.fetchMessages().then(() => {
- this.scrollToBottom();
+ connection.on('message', onMessage);
+ connection.on('read', onRead);
+ connection.on('deleted', onDeleted);
+ connection.on('typers', _typers => {
+ typers = _typers.filter(u => u.id !== $i?.id);
+ });
- // もっと見るの交差検知を発火させないためにfetchは
- // スクロールが終わるまでfalseにしておく
- // scrollendのようなイベントはないのでsetTimeoutで
- window.setTimeout(() => this.fetching = false, 300);
- });
- },
+ document.addEventListener('visibilitychange', onVisibilitychange);
- onDragover(evt) {
- const isFile = evt.dataTransfer.items[0].kind === 'file';
- const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+ nextTick(() => {
+ thisScrollToBottom();
+ window.setTimeout(() => {
+ fetching = false;
+ }, 300);
+ });
+}
- if (isFile || isDriveFile) {
- evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
- } else {
- evt.dataTransfer.dropEffect = 'none';
- }
- },
+function onDragover(ev: DragEvent) {
+ if (!ev.dataTransfer) return;
- onDrop(evt): void {
- // ファイルだったら
- if (evt.dataTransfer.files.length === 1) {
- this.form.upload(evt.dataTransfer.files[0]);
- return;
- } else if (evt.dataTransfer.files.length > 1) {
- os.alert({
- type: 'error',
- text: this.$ts.onlyOneFileCanBeAttached
- });
- return;
- }
-
- //#region ドライブのファイル
- const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile !== '') {
- const file = JSON.parse(driveFile);
- this.form.file = file;
- }
- //#endregion
- },
+ const isFile = ev.dataTransfer.items[0].kind === 'file';
+ const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
- fetchMessages() {
- return new Promise((resolve, reject) => {
- const max = this.existMoreMessages ? 20 : 10;
+ if (isFile || isDriveFile) {
+ ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
+ } else {
+ ev.dataTransfer.dropEffect = 'none';
+ }
+}
- os.api('messaging/messages', {
- userId: this.user ? this.user.id : undefined,
- groupId: this.group ? this.group.id : undefined,
- limit: max + 1,
- untilId: this.existMoreMessages ? this.messages[0].id : undefined
- }).then(messages => {
- if (messages.length === max + 1) {
- this.existMoreMessages = true;
- messages.pop();
- } else {
- this.existMoreMessages = false;
- }
+function onDrop(ev: DragEvent): void {
+ if (!ev.dataTransfer) return;
- this.messages.unshift.apply(this.messages, messages.reverse());
- resolve();
- });
- });
- },
+ // ファイルだったら
+ if (ev.dataTransfer.files.length === 1) {
+ formEl.upload(ev.dataTransfer.files[0]);
+ return;
+ } else if (ev.dataTransfer.files.length > 1) {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.onlyOneFileCanBeAttached,
+ });
+ return;
+ }
- fetchMoreMessages() {
- this.fetchingMoreMessages = true;
- this.fetchMessages().then(() => {
- this.fetchingMoreMessages = false;
- });
- },
+ //#region ドライブのファイル
+ const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile !== '') {
+ const file = JSON.parse(driveFile);
+ formEl.file = file;
+ }
+ //#endregion
+}
- onMessage(message) {
- sound.play('chat');
+function onMessage(message) {
+ sound.play('chat');
- const _isBottom = isBottom(this.$el, 64);
+ const _isBottom = isBottomVisible(rootEl, 64);
- this.messages.push(message);
- if (message.userId !== this.$i.id && !document.hidden) {
- this.connection.send('read', {
- id: message.id
- });
- }
+ pagingComponent.prepend(message);
+ if (message.userId !== $i?.id && !document.hidden) {
+ connection?.send('read', {
+ id: message.id,
+ });
+ }
- if (_isBottom) {
- // Scroll to bottom
- this.$nextTick(() => {
- this.scrollToBottom();
- });
- } else if (message.userId !== this.$i.id) {
- // Notify
- this.notifyNewMessage();
- }
- },
+ if (_isBottom) {
+ // Scroll to bottom
+ nextTick(() => {
+ thisScrollToBottom();
+ });
+ } else if (message.userId !== $i?.id) {
+ // Notify
+ notifyNewMessage();
+ }
+}
- onRead(x) {
- if (this.user) {
- if (!Array.isArray(x)) x = [x];
- for (const id of x) {
- if (this.messages.some(x => x.id === id)) {
- const exist = this.messages.map(x => x.id).indexOf(id);
- this.messages[exist] = {
- ...this.messages[exist],
- isRead: true,
- };
- }
- }
- } else if (this.group) {
- for (const id of x.ids) {
- if (this.messages.some(x => x.id === id)) {
- const exist = this.messages.map(x => x.id).indexOf(id);
- this.messages[exist] = {
- ...this.messages[exist],
- reads: [...this.messages[exist].reads, x.userId]
- };
- }
- }
+function onRead(x) {
+ if (user) {
+ if (!Array.isArray(x)) x = [x];
+ for (const id of x) {
+ if (pagingComponent.items.some(y => y.id === id)) {
+ const exist = pagingComponent.items.map(y => y.id).indexOf(id);
+ pagingComponent.items[exist] = {
+ ...pagingComponent.items[exist],
+ isRead: true,
+ };
}
- },
-
- onDeleted(id) {
- const msg = this.messages.find(m => m.id === id);
- if (msg) {
- this.messages = this.messages.filter(m => m.id !== msg.id);
+ }
+ } else if (group) {
+ for (const id of x.ids) {
+ if (pagingComponent.items.some(y => y.id === id)) {
+ const exist = pagingComponent.items.map(y => y.id).indexOf(id);
+ pagingComponent.items[exist] = {
+ ...pagingComponent.items[exist],
+ reads: [...pagingComponent.items[exist].reads, x.userId],
+ };
}
- },
-
- scrollToBottom() {
- scroll(this.$el, { top: this.$el.offsetHeight });
- },
-
- onIndicatorClick() {
- this.showIndicator = false;
- this.scrollToBottom();
- },
+ }
+ }
+}
- notifyNewMessage() {
- this.showIndicator = true;
+function onDeleted(id) {
+ const msg = pagingComponent.items.find(m => m.id === id);
+ if (msg) {
+ pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id);
+ }
+}
- onScrollBottom(this.$el, () => {
- this.showIndicator = false;
- });
+function thisScrollToBottom() {
+ scrollToBottom($$(rootEl).value, { behavior: 'smooth' });
+}
- if (this.timer) window.clearTimeout(this.timer);
+function onIndicatorClick() {
+ showIndicator = false;
+ thisScrollToBottom();
+}
- this.timer = window.setTimeout(() => {
- this.showIndicator = false;
- }, 4000);
- },
+let scrollRemove: (() => void) | null = $ref(null);
- onVisibilitychange() {
- if (document.hidden) return;
- for (const message of this.messages) {
- if (message.userId !== this.$i.id && !message.isRead) {
- this.connection.send('read', {
- id: message.id
- });
- }
- }
- },
+function notifyNewMessage() {
+ showIndicator = true;
- menu(ev) {
- const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`;
+ scrollRemove = onScrollBottom(rootEl, () => {
+ showIndicator = false;
+ scrollRemove = null;
+ });
+}
- os.popupMenu([this.inWindow ? undefined : {
- text: this.$ts.openInWindow,
- icon: 'fas fa-window-maximize',
- action: () => {
- os.pageWindow(path);
- this.$router.back();
- },
- }, this.inWindow ? undefined : {
- text: this.$ts.popout,
- icon: 'fas fa-external-link-alt',
- action: () => {
- popout(path);
- this.$router.back();
- },
- }], ev.currentTarget ?? ev.target);
+function onVisibilitychange() {
+ if (document.hidden) return;
+ for (const message of pagingComponent.items) {
+ if (message.userId !== $i?.id && !message.isRead) {
+ connection?.send('read', {
+ id: message.id,
+ });
}
}
+}
+
+onMounted(() => {
+ fetch();
});
-export default Component;
+onBeforeUnmount(() => {
+ connection?.dispose();
+ document.removeEventListener('visibilitychange', onVisibilitychange);
+ if (scrollRemove) scrollRemove();
+});
+
+definePageMetadata(computed(() => !fetching ? user ? {
+ userName: user,
+ avatar: user,
+} : {
+ title: group?.name,
+ icon: 'fas fa-users',
+} : null));
</script>
<style lang="scss" scoped>
.mk-messaging-room {
- > .body {
- > .empty {
- width: 100%;
- margin: 0;
- padding: 16px 8px 8px 8px;
- text-align: center;
- font-size: 0.8em;
- opacity: 0.5;
-
- i {
- margin-right: 4px;
- }
- }
-
- > .no-history {
- display: block;
- margin: 0;
- padding: 16px;
- text-align: center;
- font-size: 0.8em;
- color: var(--messagingRoomInfo);
- opacity: 0.5;
-
- i {
- margin-right: 4px;
- }
- }
+ position: relative;
- > .more {
+ > .body {
+ .more {
display: block;
margin: 16px auto;
padding: 0 12px;
@@ -399,7 +320,9 @@ export default Component;
}
}
- > .messages {
+ .messages {
+ padding: 8px 0;
+
> ::v-deep(*) {
margin-bottom: 16px;
}
@@ -408,29 +331,31 @@ export default Component;
> footer {
width: 100%;
- position: relative;
+ position: sticky;
+ z-index: 2;
+ bottom: 0;
+ padding-top: 8px;
+
+ @media (max-width: 500px) {
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
+ }
> .new-message {
- position: absolute;
- top: -48px;
width: 100%;
- padding: 8px 0;
+ padding-bottom: 8px;
text-align: center;
> button {
display: inline-block;
margin: 0;
- padding: 0 12px 0 30px;
+ padding: 0 12px;
line-height: 32px;
font-size: 12px;
border-radius: 16px;
> i {
- position: absolute;
- top: 0;
- left: 10px;
- line-height: 32px;
- font-size: 16px;
+ display: inline-block;
+ margin-right: 8px;
}
}
}
@@ -455,6 +380,8 @@ export default Component;
}
> .form {
+ max-height: 12em;
+ overflow-y: scroll;
border-top: solid 0.5px var(--divider);
}
}
diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue
index 2c10494ede..3315479abf 100644
--- a/packages/client/src/pages/mfm-cheat-sheet.vue
+++ b/packages/client/src/pages/mfm-cheat-sheet.vue
@@ -1,127 +1,129 @@
<template>
-<div class="mwysmxbg">
- <div class="_isolated">{{ $ts._mfm.intro }}</div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.mention }}</div>
- <div class="content">
- <p>{{ $ts._mfm.mentionDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_mention"/>
- <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+<MkStickyContainer>
+ <template #header><MkPageHeader/></template>
+ <div class="mwysmxbg">
+ <div>{{ $ts._mfm.intro }}</div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.mention }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.mentionDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_mention"/>
+ <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.hashtag }}</div>
- <div class="content">
- <p>{{ $ts._mfm.hashtagDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_hashtag"/>
- <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.hashtag }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.hashtagDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_hashtag"/>
+ <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.url }}</div>
- <div class="content">
- <p>{{ $ts._mfm.urlDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_url"/>
- <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.url }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.urlDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_url"/>
+ <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.link }}</div>
- <div class="content">
- <p>{{ $ts._mfm.linkDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_link"/>
- <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.link }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.linkDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_link"/>
+ <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.emoji }}</div>
- <div class="content">
- <p>{{ $ts._mfm.emojiDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_emoji"/>
- <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.emoji }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.emojiDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_emoji"/>
+ <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.bold }}</div>
- <div class="content">
- <p>{{ $ts._mfm.boldDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_bold"/>
- <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.bold }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.boldDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_bold"/>
+ <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.small }}</div>
- <div class="content">
- <p>{{ $ts._mfm.smallDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_small"/>
- <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.small }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.smallDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_small"/>
+ <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.quote }}</div>
- <div class="content">
- <p>{{ $ts._mfm.quoteDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_quote"/>
- <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.quote }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.quoteDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_quote"/>
+ <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.center }}</div>
- <div class="content">
- <p>{{ $ts._mfm.centerDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_center"/>
- <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.center }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.centerDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_center"/>
+ <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.inlineCode }}</div>
- <div class="content">
- <p>{{ $ts._mfm.inlineCodeDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_inlineCode"/>
- <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.inlineCode }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.inlineCodeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_inlineCode"/>
+ <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.blockCode }}</div>
- <div class="content">
- <p>{{ $ts._mfm.blockCodeDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_blockCode"/>
- <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.blockCode }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.blockCodeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_blockCode"/>
+ <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.inlineMath }}</div>
- <div class="content">
- <p>{{ $ts._mfm.inlineMathDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_inlineMath"/>
- <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.inlineMath }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.inlineMathDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_inlineMath"/>
+ <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <!-- deprecated
+ <!-- deprecated
<div class="section _block">
<div class="title">{{ $ts._mfm.search }}</div>
<div class="content">
@@ -133,216 +135,210 @@
</div>
</div>
-->
- <div class="section _block">
- <div class="title">{{ $ts._mfm.flip }}</div>
- <div class="content">
- <p>{{ $ts._mfm.flipDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_flip"/>
- <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.flip }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.flipDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_flip"/>
+ <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.font }}</div>
- <div class="content">
- <p>{{ $ts._mfm.fontDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_font"/>
- <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.font }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.fontDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_font"/>
+ <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.x2 }}</div>
- <div class="content">
- <p>{{ $ts._mfm.x2Description }}</p>
- <div class="preview">
- <Mfm :text="preview_x2"/>
- <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.x2 }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.x2Description }}</p>
+ <div class="preview">
+ <Mfm :text="preview_x2"/>
+ <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.x3 }}</div>
- <div class="content">
- <p>{{ $ts._mfm.x3Description }}</p>
- <div class="preview">
- <Mfm :text="preview_x3"/>
- <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.x3 }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.x3Description }}</p>
+ <div class="preview">
+ <Mfm :text="preview_x3"/>
+ <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.x4 }}</div>
- <div class="content">
- <p>{{ $ts._mfm.x4Description }}</p>
- <div class="preview">
- <Mfm :text="preview_x4"/>
- <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.x4 }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.x4Description }}</p>
+ <div class="preview">
+ <Mfm :text="preview_x4"/>
+ <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.blur }}</div>
- <div class="content">
- <p>{{ $ts._mfm.blurDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_blur"/>
- <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.blur }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.blurDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_blur"/>
+ <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.jelly }}</div>
- <div class="content">
- <p>{{ $ts._mfm.jellyDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_jelly"/>
- <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.jelly }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.jellyDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_jelly"/>
+ <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.tada }}</div>
- <div class="content">
- <p>{{ $ts._mfm.tadaDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_tada"/>
- <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.tada }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.tadaDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_tada"/>
+ <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.jump }}</div>
- <div class="content">
- <p>{{ $ts._mfm.jumpDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_jump"/>
- <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.jump }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.jumpDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_jump"/>
+ <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.bounce }}</div>
- <div class="content">
- <p>{{ $ts._mfm.bounceDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_bounce"/>
- <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.bounce }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.bounceDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_bounce"/>
+ <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.spin }}</div>
- <div class="content">
- <p>{{ $ts._mfm.spinDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_spin"/>
- <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.spin }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.spinDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_spin"/>
+ <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.shake }}</div>
- <div class="content">
- <p>{{ $ts._mfm.shakeDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_shake"/>
- <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.shake }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.shakeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_shake"/>
+ <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.twitch }}</div>
- <div class="content">
- <p>{{ $ts._mfm.twitchDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_twitch"/>
- <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.twitch }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.twitchDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_twitch"/>
+ <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.rainbow }}</div>
- <div class="content">
- <p>{{ $ts._mfm.rainbowDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_rainbow"/>
- <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.rainbow }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.rainbowDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_rainbow"/>
+ <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.sparkle }}</div>
- <div class="content">
- <p>{{ $ts._mfm.sparkleDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_sparkle"/>
- <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.sparkle }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.sparkleDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_sparkle"/>
+ <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+ </div>
</div>
</div>
- </div>
- <div class="section _block">
- <div class="title">{{ $ts._mfm.rotate }}</div>
- <div class="content">
- <p>{{ $ts._mfm.rotateDescription }}</p>
- <div class="preview">
- <Mfm :text="preview_rotate"/>
- <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.rotate }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.rotateDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_rotate"/>
+ <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
+ </div>
</div>
</div>
</div>
-</div>
+</MkStickyContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
import { defineComponent } from 'vue';
import MkTextarea from '@/components/form/textarea.vue';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
-export default defineComponent({
- components: {
- MkTextarea
- },
+const preview_mention = '@example';
+const preview_hashtag = '#test';
+const preview_url = 'https://example.com';
+const preview_link = `[${i18n.ts._mfm.dummy}](https://example.com)`;
+const preview_emoji = instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:';
+const preview_bold = `**${i18n.ts._mfm.dummy}**`;
+const preview_small = `<small>${i18n.ts._mfm.dummy}</small>`;
+const preview_center = `<center>${i18n.ts._mfm.dummy}</center>`;
+const preview_inlineCode = '`<: "Hello, world!"`';
+const preview_blockCode = '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```';
+const preview_inlineMath = '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)';
+const preview_quote = `> ${i18n.ts._mfm.dummy}`;
+const preview_search = `${i18n.ts._mfm.dummy} 検索`;
+const preview_jelly = '$[jelly 🍮] $[jelly.speed=5s 🍮]';
+const preview_tada = '$[tada 🍮] $[tada.speed=5s 🍮]';
+const preview_jump = '$[jump 🍮] $[jump.speed=5s 🍮]';
+const preview_bounce = '$[bounce 🍮] $[bounce.speed=5s 🍮]';
+const preview_shake = '$[shake 🍮] $[shake.speed=5s 🍮]';
+const preview_twitch = '$[twitch 🍮] $[twitch.speed=5s 🍮]';
+const preview_spin = '$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]';
+const preview_flip = `$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`;
+const preview_font = `$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`;
+const preview_x2 = '$[x2 🍮]';
+const preview_x3 = '$[x3 🍮]';
+const preview_x4 = '$[x4 🍮]';
+const preview_blur = `$[blur ${i18n.ts._mfm.dummy}]`;
+const preview_rainbow = '$[rainbow 🍮] $[rainbow.speed=5s 🍮]';
+const preview_sparkle = '$[sparkle 🍮]';
+const preview_rotate = '$[rotate 🍮]';
- data() {
- return {
- [symbols.PAGE_INFO]: {
- title: this.$ts._mfm.cheatSheet,
- icon: 'fas fa-question-circle',
- },
- preview_mention: '@example',
- preview_hashtag: '#test',
- preview_url: `https://example.com`,
- preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`,
- preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`,
- preview_bold: `**${this.$ts._mfm.dummy}**`,
- preview_small: `<small>${this.$ts._mfm.dummy}</small>`,
- preview_center: `<center>${this.$ts._mfm.dummy}</center>`,
- preview_inlineCode: '`<: "Hello, world!"`',
- preview_blockCode: '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```',
- preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)',
- preview_quote: `> ${this.$ts._mfm.dummy}`,
- preview_search: `${this.$ts._mfm.dummy} 検索`,
- preview_jelly: `$[jelly 🍮] $[jelly.speed=5s 🍮]`,
- preview_tada: `$[tada 🍮] $[tada.speed=5s 🍮]`,
- preview_jump: `$[jump 🍮] $[jump.speed=5s 🍮]`,
- preview_bounce: `$[bounce 🍮] $[bounce.speed=5s 🍮]`,
- preview_shake: `$[shake 🍮] $[shake.speed=5s 🍮]`,
- preview_twitch: `$[twitch 🍮] $[twitch.speed=5s 🍮]`,
- preview_spin: `$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]`,
- preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`,
- preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`,
- preview_x2: `$[x2 🍮]`,
- preview_x3: `$[x3 🍮]`,
- preview_x4: `$[x4 🍮]`,
- preview_blur: `$[blur ${this.$ts._mfm.dummy}]`,
- preview_rainbow: `$[rainbow 🍮] $[rainbow.speed=5s 🍮]`,
- preview_sparkle: `$[sparkle 🍮]`,
- preview_rotate: `$[rotate 🍮]`,
- };
- },
+definePageMetadata({
+ title: i18n.ts._mfm.cheatSheet,
+ icon: 'fas fa-question-circle',
});
</script>
diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue
index 4032d7723e..4b3ac7761e 100644
--- a/packages/client/src/pages/miauth.vue
+++ b/packages/client/src/pages/miauth.vue
@@ -49,28 +49,12 @@ export default defineComponent({
MkSignin,
MkButton,
},
+ props: ['session', 'callback', 'name', 'icon', 'permission'],
data() {
return {
- state: null
+ state: null,
};
},
- computed: {
- session(): string {
- return this.$route.params.session;
- },
- callback(): string {
- return this.$route.query.callback;
- },
- name(): string {
- return this.$route.query.name;
- },
- icon(): string {
- return this.$route.query.icon;
- },
- permission(): string[] {
- return this.$route.query.permission ? this.$route.query.permission.split(',') : [];
- },
- },
methods: {
async accept() {
this.state = 'waiting';
@@ -84,7 +68,7 @@ export default defineComponent({
this.state = 'accepted';
if (this.callback) {
location.href = appendQuery(this.callback, query({
- session: this.session
+ session: this.session,
}));
}
},
@@ -93,8 +77,8 @@ export default defineComponent({
},
onLogin(res) {
login(res.i);
- }
- }
+ },
+ },
});
</script>
diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue
index a08bece731..dc10bece81 100644
--- a/packages/client/src/pages/my-antennas/create.vue
+++ b/packages/client/src/pages/my-antennas/create.vue
@@ -5,11 +5,13 @@
</template>
<script lang="ts" setup>
-import { } from 'vue';
+import { inject } from 'vue';
import XAntenna from './editor.vue';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { useRouter } from '@/router';
+
+const router = useRouter();
let draft = $ref({
name: '',
@@ -22,19 +24,20 @@ let draft = $ref({
withReplies: false,
caseSensitive: false,
withFile: false,
- notify: false
+ notify: false,
});
function onAntennaCreated() {
router.push('/my/antennas');
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.manageAntennas,
- icon: 'fas fa-satellite',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.manageAntennas,
+ icon: 'fas fa-satellite',
});
</script>
diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue
index 38e56ce35d..53f9b07db0 100644
--- a/packages/client/src/pages/my-antennas/edit.vue
+++ b/packages/client/src/pages/my-antennas/edit.vue
@@ -5,14 +5,14 @@
</template>
<script lang="ts" setup>
-import { watch } from 'vue';
+import { inject, watch } from 'vue';
import XAntenna from './editor.vue';
-import * as symbols from '@/symbols';
import * as os from '@/os';
-import { MisskeyNavigator } from '@/scripts/navigate';
import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
-const nav = new MisskeyNavigator();
+const router = useRouter();
let antenna: any = $ref(null);
@@ -21,18 +21,20 @@ const props = defineProps<{
}>();
function onAntennaUpdated() {
- nav.push('/my/antennas');
+ router.push('/my/antennas');
}
os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => {
antenna = antennaResponse;
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.manageAntennas,
- icon: 'fas fa-satellite',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.manageAntennas,
+ icon: 'fas fa-satellite',
});
</script>
diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue
index 6f3c4afbfe..9470257c6c 100644
--- a/packages/client/src/pages/my-antennas/editor.vue
+++ b/packages/client/src/pages/my-antennas/editor.vue
@@ -46,6 +46,7 @@
<script lang="ts" setup>
import { watch } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue
index a568f64c52..70e444da52 100644
--- a/packages/client/src/pages/my-antennas/index.vue
+++ b/packages/client/src/pages/my-antennas/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
<div class="ieepwinx">
<MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ i18n.ts.add }}</MkButton>
@@ -11,27 +12,28 @@
</MkPagination>
</div>
</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const pagination = {
endpoint: 'antennas/list' as const,
limit: 10,
};
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.manageAntennas,
- icon: 'fas fa-satellite',
- bg: 'var(--bg)'
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.manageAntennas,
+ icon: 'fas fa-satellite',
});
</script>
diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue
index e287357a42..ac5a3578f8 100644
--- a/packages/client/src/pages/my-clips/index.vue
+++ b/packages/client/src/pages/my-clips/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
<div class="qtcaoidl">
<MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
@@ -10,7 +11,7 @@
</MkA>
</MkPagination>
</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -18,8 +19,8 @@ import { } from 'vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const pagination = {
endpoint: 'clips/list' as const,
@@ -61,15 +62,16 @@ function onClipDeleted() {
pagingComponent.reload();
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.clip,
- icon: 'fas fa-paperclip',
- bg: 'var(--bg)',
- action: {
- icon: 'fas fa-plus',
- handler: create
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.clip,
+ icon: 'fas fa-paperclip',
+ action: {
+ icon: 'fas fa-plus',
+ handler: create,
},
});
</script>
diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue
deleted file mode 100644
index 92c0483af9..0000000000
--- a/packages/client/src/pages/my-groups/group.vue
+++ /dev/null
@@ -1,178 +0,0 @@
-<template>
-<div class="mk-group-page">
- <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
- <div v-if="group" class="_section">
- <div class="_content" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
- <MkButton inline @click="invite()">{{ $ts.invite }}</MkButton>
- <MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton>
- <MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton>
- <MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton>
- </div>
- </div>
- </transition>
-
- <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
- <div v-if="group" class="_section members _gap">
- <div class="_title">{{ $ts.members }}</div>
- <div class="_content">
- <div class="users">
- <div v-for="user in users" :key="user.id" class="user _panel">
- <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
- <div class="body">
- <MkUserName :user="user" class="name"/>
- <MkAcct :user="user" class="acct"/>
- </div>
- <div class="action">
- <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </transition>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
- components: {
- MkButton
- },
-
- props: {
- groupId: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.group ? {
- title: this.group.name,
- icon: 'fas fa-users',
- } : null),
- group: null,
- users: [],
- };
- },
-
- watch: {
- groupId: 'fetch',
- },
-
- created() {
- this.fetch();
- },
-
- methods: {
- fetch() {
- os.api('users/groups/show', {
- groupId: this.groupId
- }).then(group => {
- this.group = group;
- os.api('users/show', {
- userIds: this.group.userIds
- }).then(users => {
- this.users = users;
- });
- });
- },
-
- invite() {
- os.selectUser().then(user => {
- os.apiWithDialog('users/groups/invite', {
- groupId: this.group.id,
- userId: user.id
- });
- });
- },
-
- removeUser(user) {
- os.api('users/groups/pull', {
- groupId: this.group.id,
- userId: user.id
- }).then(() => {
- this.users = this.users.filter(x => x.id !== user.id);
- });
- },
-
- async renameGroup() {
- const { canceled, result: name } = await os.inputText({
- title: this.$ts.groupName,
- default: this.group.name
- });
- if (canceled) return;
-
- await os.api('users/groups/update', {
- groupId: this.group.id,
- name: name
- });
-
- this.group.name = name;
- },
-
- transfer() {
- os.selectUser().then(user => {
- os.apiWithDialog('users/groups/transfer', {
- groupId: this.group.id,
- userId: user.id
- });
- });
- },
-
- async deleteGroup() {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: this.$t('removeAreYouSure', { x: this.group.name }),
- });
- if (canceled) return;
-
- await os.apiWithDialog('users/groups/delete', {
- groupId: this.group.id
- });
- this.$router.push('/my/groups');
- }
- }
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-group-page {
- > .members {
- > ._content {
- > .users {
- > .user {
- display: flex;
- align-items: center;
- padding: 16px;
-
- > .avatar {
- width: 50px;
- height: 50px;
- }
-
- > .body {
- flex: 1;
- padding: 8px;
-
- > .name {
- display: block;
- font-weight: bold;
- }
-
- > .acct {
- opacity: 0.5;
- }
- }
- }
- }
- }
- }
-}
-</style>
diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue
deleted file mode 100644
index 4b2b2963a8..0000000000
--- a/packages/client/src/pages/my-groups/index.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-<template>
-<MkSpacer :content-max="700">
- <div v-if="tab === 'owned'" class="_content">
- <MkButton primary style="margin: 0 auto var(--margin) auto;" @click="create"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton>
-
- <MkPagination v-slot="{items}" ref="owned" :pagination="ownedPagination">
- <div v-for="group in items" :key="group.id" class="_card">
- <div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div>
- <div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
- </div>
- </MkPagination>
- </div>
-
- <div v-else-if="tab === 'joined'" class="_content">
- <MkPagination v-slot="{items}" ref="joined" :pagination="joinedPagination">
- <div v-for="group in items" :key="group.id" class="_card">
- <div class="_title">{{ group.name }}</div>
- <div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
- <div class="_footer">
- <MkButton danger @click="leave(group)">{{ $ts.leaveGroup }}</MkButton>
- </div>
- </div>
- </MkPagination>
- </div>
-
- <div v-else-if="tab === 'invites'" class="_content">
- <MkPagination v-slot="{items}" ref="invitations" :pagination="invitationPagination">
- <div v-for="invitation in items" :key="invitation.id" class="_card">
- <div class="_title">{{ invitation.group.name }}</div>
- <div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div>
- <div class="_footer">
- <MkButton primary inline @click="acceptInvite(invitation)"><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton>
- <MkButton primary inline @click="rejectInvite(invitation)"><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton>
- </div>
- </div>
- </MkPagination>
- </div>
-</MkSpacer>
-</template>
-
-<script lang="ts">
-import { defineComponent, computed } from 'vue';
-import MkPagination from '@/components/ui/pagination.vue';
-import MkButton from '@/components/ui/button.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkAvatars from '@/components/avatars.vue';
-import MkTab from '@/components/tab.vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
- components: {
- MkPagination,
- MkButton,
- MkContainer,
- MkTab,
- MkAvatars,
- },
-
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => ({
- title: this.$ts.groups,
- icon: 'fas fa-users',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-plus',
- text: this.$ts.createGroup,
- handler: this.create,
- }],
- tabs: [{
- active: this.tab === 'owned',
- title: this.$ts.ownedGroups,
- icon: 'fas fa-user-tie',
- onClick: () => { this.tab = 'owned'; },
- }, {
- active: this.tab === 'joined',
- title: this.$ts.joinedGroups,
- icon: 'fas fa-id-badge',
- onClick: () => { this.tab = 'joined'; },
- }, {
- active: this.tab === 'invites',
- title: this.$ts.invites,
- icon: 'fas fa-envelope-open-text',
- onClick: () => { this.tab = 'invites'; },
- },]
- })),
- tab: 'owned',
- ownedPagination: {
- endpoint: 'users/groups/owned' as const,
- limit: 10,
- },
- joinedPagination: {
- endpoint: 'users/groups/joined' as const,
- limit: 10,
- },
- invitationPagination: {
- endpoint: 'i/user-group-invites' as const,
- limit: 10,
- },
- };
- },
-
- methods: {
- async create() {
- const { canceled, result: name } = await os.inputText({
- title: this.$ts.groupName,
- });
- if (canceled) return;
- await os.api('users/groups/create', { name: name });
- this.$refs.owned.reload();
- os.success();
- },
- acceptInvite(invitation) {
- os.api('users/groups/invitations/accept', {
- invitationId: invitation.id
- }).then(() => {
- os.success();
- this.$refs.invitations.reload();
- this.$refs.joined.reload();
- });
- },
- rejectInvite(invitation) {
- os.api('users/groups/invitations/reject', {
- invitationId: invitation.id
- }).then(() => {
- this.$refs.invitations.reload();
- });
- },
- async leave(group) {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: this.$t('leaveGroupConfirm', { name: group.name }),
- });
- if (canceled) return;
- os.apiWithDialog('users/groups/leave', {
- groupId: group.id,
- }).then(() => {
- this.$refs.joined.reload();
- });
- }
- }
-});
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue
index 9ed9e2960e..03b638151e 100644
--- a/packages/client/src/pages/my-lists/index.vue
+++ b/packages/client/src/pages/my-lists/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
<div class="qkcjvfiv">
<MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton>
@@ -10,7 +11,7 @@
</MkA>
</MkPagination>
</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -19,8 +20,8 @@ import MkPagination from '@/components/ui/pagination.vue';
import MkButton from '@/components/ui/button.vue';
import MkAvatars from '@/components/avatars.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
@@ -38,15 +39,16 @@ async function create() {
pagingComponent.reload();
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.manageLists,
- icon: 'fas fa-list-ul',
- bg: 'var(--bg)',
- action: {
- icon: 'fas fa-plus',
- handler: create,
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.manageLists,
+ icon: 'fas fa-list-ul',
+ action: {
+ icon: 'fas fa-plus',
+ handler: create,
},
});
</script>
diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue
index bc24f58431..892878ae88 100644
--- a/packages/client/src/pages/my-lists/list.vue
+++ b/packages/client/src/pages/my-lists/list.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
<div class="mk-list-page">
<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
<div v-if="list" class="_section">
@@ -31,104 +32,96 @@
</div>
</transition>
</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkButton
- },
+const props = defineProps<{
+ listId: string;
+}>();
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.list ? {
- title: this.list.name,
- icon: 'fas fa-list-ul',
- bg: 'var(--bg)',
- } : null),
- list: null,
- users: [],
- };
- },
+let list = $ref(null);
+let users = $ref([]);
- watch: {
- $route: 'fetch'
- },
+function fetchList() {
+ os.api('users/lists/show', {
+ listId: props.listId,
+ }).then(_list => {
+ list = _list;
+ os.api('users/show', {
+ userIds: list.userIds,
+ }).then(_users => {
+ users = _users;
+ });
+ });
+}
- created() {
- this.fetch();
- },
+function addUser() {
+ os.selectUser().then(user => {
+ os.apiWithDialog('users/lists/push', {
+ listId: list.id,
+ userId: user.id,
+ }).then(() => {
+ users.push(user);
+ });
+ });
+}
- methods: {
- fetch() {
- os.api('users/lists/show', {
- listId: this.$route.params.list
- }).then(list => {
- this.list = list;
- os.api('users/show', {
- userIds: this.list.userIds
- }).then(users => {
- this.users = users;
- });
- });
- },
+function removeUser(user) {
+ os.api('users/lists/pull', {
+ listId: list.id,
+ userId: user.id,
+ }).then(() => {
+ users = users.filter(x => x.id !== user.id);
+ });
+}
- addUser() {
- os.selectUser().then(user => {
- os.apiWithDialog('users/lists/push', {
- listId: this.list.id,
- userId: user.id
- }).then(() => {
- this.users.push(user);
- });
- });
- },
+async function renameList() {
+ const { canceled, result: name } = await os.inputText({
+ title: i18n.ts.enterListName,
+ default: list.name,
+ });
+ if (canceled) return;
- removeUser(user) {
- os.api('users/lists/pull', {
- listId: this.list.id,
- userId: user.id
- }).then(() => {
- this.users = this.users.filter(x => x.id !== user.id);
- });
- },
+ await os.api('users/lists/update', {
+ listId: list.id,
+ name: name,
+ });
- async renameList() {
- const { canceled, result: name } = await os.inputText({
- title: this.$ts.enterListName,
- default: this.list.name
- });
- if (canceled) return;
+ list.name = name;
+}
- await os.api('users/lists/update', {
- listId: this.list.id,
- name: name
- });
+async function deleteList() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.t('removeAreYouSure', { x: list.name }),
+ });
+ if (canceled) return;
- this.list.name = name;
- },
+ await os.api('users/lists/delete', {
+ listId: list.id,
+ });
+ os.success();
+ mainRouter.push('/my/lists');
+}
- async deleteList() {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: this.$t('removeAreYouSure', { x: this.list.name }),
- });
- if (canceled) return;
+watch(() => props.listId, fetchList, { immediate: true });
- await os.api('users/lists/delete', {
- listId: this.list.id
- });
- os.success();
- this.$router.push('/my/lists');
- }
- }
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+ title: list.name,
+ icon: 'fas fa-list-ul',
+} : null));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue
index cdeb54b88b..a819cce961 100644
--- a/packages/client/src/pages/not-found.vue
+++ b/packages/client/src/pages/not-found.vue
@@ -8,14 +8,15 @@
</template>
<script lang="ts" setup>
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.notFound,
- icon: 'fas fa-exclamation-triangle',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.notFound,
+ icon: 'fas fa-exclamation-triangle',
});
</script>
diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue
index f0a18ecc36..5e153482d6 100644
--- a/packages/client/src/pages/note.vue
+++ b/packages/client/src/pages/note.vue
@@ -1,147 +1,140 @@
<template>
-<MkSpacer :content-max="800">
- <div class="fcuexfpr">
- <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
- <div v-if="note" class="note">
- <div v-if="showNext" class="_gap">
- <XNotes class="_content" :pagination="next" :no-gap="true"/>
- </div>
-
- <div class="main _gap">
- <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton>
- <div class="note _gap">
- <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri" class="_isolated"/>
- <XNoteDetailed :key="note.id" v-model:note="note" class="_isolated note"/>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <div class="fcuexfpr">
+ <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+ <div v-if="note" class="note">
+ <div v-if="showNext" class="_gap">
+ <XNotes class="_content" :pagination="nextPagination" :no-gap="true"/>
</div>
- <div v-if="clips && clips.length > 0" class="_content clips _gap">
- <div class="title">{{ $ts.clip }}</div>
- <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
- <b>{{ item.name }}</b>
- <div v-if="item.description" class="description">{{ item.description }}</div>
- <div class="user">
- <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/>
- </div>
- </MkA>
+
+ <div class="main _gap">
+ <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton>
+ <div class="note _gap">
+ <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
+ <XNoteDetailed :key="note.id" v-model:note="note" class="note"/>
+ </div>
+ <div v-if="clips && clips.length > 0" class="_content clips _gap">
+ <div class="title">{{ $ts.clip }}</div>
+ <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+ <b>{{ item.name }}</b>
+ <div v-if="item.description" class="description">{{ item.description }}</div>
+ <div class="user">
+ <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/>
+ </div>
+ </MkA>
+ </div>
+ <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton>
</div>
- <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton>
- </div>
- <div v-if="showPrev" class="_gap">
- <XNotes class="_content" :pagination="prev" :no-gap="true"/>
+ <div v-if="showPrev" class="_gap">
+ <XNotes class="_content" :pagination="prevPagination" :no-gap="true"/>
+ </div>
</div>
- </div>
- <MkError v-else-if="error" @retry="fetch()"/>
- <MkLoading v-else/>
- </transition>
- </div>
-</MkSpacer>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
+import * as misskey from 'misskey-js';
import XNote from '@/components/note.vue';
import XNoteDetailed from '@/components/note-detailed.vue';
import XNotes from '@/components/notes.vue';
import MkRemoteCaution from '@/components/remote-caution.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- XNote,
- XNoteDetailed,
- XNotes,
- MkRemoteCaution,
- MkButton,
- },
- props: {
- noteId: {
- type: String,
- required: true
- }
- },
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.note ? {
- title: this.$ts.note,
- subtitle: new Date(this.note.createdAt).toLocaleString(),
- avatar: this.note.user,
- path: `/notes/${this.note.id}`,
- share: {
- title: this.$t('noteOf', { user: this.note.user.name }),
- text: this.note.text,
- },
- bg: 'var(--bg)',
- } : null),
- note: null,
- clips: null,
- hasPrev: false,
- hasNext: false,
- showPrev: false,
- showNext: false,
- error: null,
- prev: {
- endpoint: 'users/notes' as const,
- limit: 10,
- params: computed(() => ({
- userId: this.note.userId,
- untilId: this.note.id,
- })),
- },
- next: {
- reversed: true,
- endpoint: 'users/notes' as const,
- limit: 10,
- params: computed(() => ({
- userId: this.note.userId,
- sinceId: this.note.id,
- })),
- },
- };
- },
- watch: {
- noteId: 'fetch'
- },
- created() {
- this.fetch();
- },
- methods: {
- fetch() {
- this.hasPrev = false;
- this.hasNext = false;
- this.showPrev = false;
- this.showNext = false;
- this.note = null;
- os.api('notes/show', {
- noteId: this.noteId
- }).then(note => {
- this.note = note;
- Promise.all([
- os.api('notes/clips', {
- noteId: note.id,
- }),
- os.api('users/notes', {
- userId: note.userId,
- untilId: note.id,
- limit: 1,
- }),
- os.api('users/notes', {
- userId: note.userId,
- sinceId: note.id,
- limit: 1,
- }),
- ]).then(([clips, prev, next]) => {
- this.clips = clips;
- this.hasPrev = prev.length !== 0;
- this.hasNext = next.length !== 0;
- });
- }).catch(err => {
- this.error = err;
- });
- }
- }
+const props = defineProps<{
+ noteId: string;
+}>();
+
+let note = $ref<null | misskey.entities.Note>();
+let clips = $ref();
+let hasPrev = $ref(false);
+let hasNext = $ref(false);
+let showPrev = $ref(false);
+let showNext = $ref(false);
+let error = $ref();
+
+const prevPagination = {
+ endpoint: 'users/notes' as const,
+ limit: 10,
+ params: computed(() => note ? ({
+ userId: note.userId,
+ untilId: note.id,
+ }) : null),
+};
+
+const nextPagination = {
+ reversed: true,
+ endpoint: 'users/notes' as const,
+ limit: 10,
+ params: computed(() => note ? ({
+ userId: note.userId,
+ sinceId: note.id,
+ }) : null),
+};
+
+function fetchNote() {
+ hasPrev = false;
+ hasNext = false;
+ showPrev = false;
+ showNext = false;
+ note = null;
+ os.api('notes/show', {
+ noteId: props.noteId,
+ }).then(res => {
+ note = res;
+ Promise.all([
+ os.api('notes/clips', {
+ noteId: note.id,
+ }),
+ os.api('users/notes', {
+ userId: note.userId,
+ untilId: note.id,
+ limit: 1,
+ }),
+ os.api('users/notes', {
+ userId: note.userId,
+ sinceId: note.id,
+ limit: 1,
+ }),
+ ]).then(([_clips, prev, next]) => {
+ clips = _clips;
+ hasPrev = prev.length !== 0;
+ hasNext = next.length !== 0;
+ });
+ }).catch(err => {
+ error = err;
+ });
+}
+
+watch(() => props.noteId, fetchNote, {
+ immediate: true,
});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => note ? {
+ title: i18n.ts.note,
+ subtitle: new Date(note.createdAt).toLocaleString(),
+ avatar: note.user,
+ path: `/notes/${note.id}`,
+ share: {
+ title: i18n.t('noteOf', { user: note.user.name }),
+ text: note.text,
+ },
+} : null));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue
index 36e423e534..acf338c2c2 100644
--- a/packages/client/src/pages/notifications.vue
+++ b/packages/client/src/pages/notifications.vue
@@ -1,21 +1,45 @@
<template>
-<MkSpacer :content-max="800">
- <div class="clupoqwt">
- <XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
- </div>
-</MkSpacer>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <div v-if="tab === 'all' || tab === 'unread'">
+ <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
+ </div>
+ <div v-else-if="tab === 'mentions'">
+ <XNotes :pagination="mentionsPagination"/>
+ </div>
+ <div v-else-if="tab === 'directNotes'">
+ <XNotes :pagination="directNotesPagination"/>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
+import { notificationTypes } from 'misskey-js';
import XNotifications from '@/components/notifications.vue';
+import XNotes from '@/components/notes.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { notificationTypes } from 'misskey-js';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let tab = $ref('all');
let includeTypes = $ref<string[] | null>(null);
+let unreadOnly = $computed(() => tab === 'unread');
+
+const mentionsPagination = {
+ endpoint: 'notes/mentions' as const,
+ limit: 10,
+};
+
+const directNotesPagination = {
+ endpoint: 'notes/mentions' as const,
+ limit: 10,
+ params: {
+ visibility: 'specified',
+ },
+};
function setFilter(ev) {
const typeItems = notificationTypes.map(t => ({
@@ -23,49 +47,49 @@ function setFilter(ev) {
active: includeTypes && includeTypes.includes(t),
action: () => {
includeTypes = [t];
- }
+ },
}));
const items = includeTypes != null ? [{
icon: 'fas fa-times',
text: i18n.ts.clear,
action: () => {
includeTypes = null;
- }
+ },
}, null, ...typeItems] : typeItems;
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.notifications,
- icon: 'fas fa-bell',
- bg: 'var(--bg)',
- actions: [{
- text: i18n.ts.filter,
- icon: 'fas fa-filter',
- highlighted: includeTypes != null,
- handler: setFilter,
- }, {
- text: i18n.ts.markAllAsRead,
- icon: 'fas fa-check',
- handler: () => {
- os.apiWithDialog('notifications/mark-all-as-read');
- },
- }],
- tabs: [{
- active: tab === 'all',
- title: i18n.ts.all,
- onClick: () => { tab = 'all'; },
- }, {
- active: tab === 'unread',
- title: i18n.ts.unread,
- onClick: () => { tab = 'unread'; },
- },]
- })),
-});
-</script>
+const headerActions = $computed(() => [tab === 'all' ? {
+ text: i18n.ts.filter,
+ icon: 'fas fa-filter',
+ highlighted: includeTypes != null,
+ handler: setFilter,
+} : undefined, tab === 'all' ? {
+ text: i18n.ts.markAllAsRead,
+ icon: 'fas fa-check',
+ handler: () => {
+ os.apiWithDialog('notifications/mark-all-as-read');
+ },
+} : undefined].filter(x => x !== undefined));
-<style lang="scss" scoped>
-.clupoqwt {
-}
-</style>
+const headerTabs = $computed(() => [{
+ key: 'all',
+ title: i18n.ts.all,
+}, {
+ key: 'unread',
+ title: i18n.ts.unread,
+}, {
+ key: 'mentions',
+ title: i18n.ts.mentions,
+ icon: 'fas fa-at',
+}, {
+ key: 'directNotes',
+ title: i18n.ts.directNotes,
+ icon: 'fas fa-envelope',
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.notifications,
+ icon: 'fas fa-bell',
+})));
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue
index 827679d6a9..4c2e0e4eb4 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue
@@ -38,44 +38,28 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkSelect from '@/components/form/select.vue';
import MkInput from '@/components/form/input.vue';
import MkSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkSelect, MkInput, MkSwitch
- },
-
- props: {
- value: {
- required: true
- },
- hpml: {
- required: true,
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.text == null) this.value.text = '';
- if (this.value.action == null) this.value.action = 'dialog';
- if (this.value.content == null) this.value.content = null;
- if (this.value.event == null) this.value.event = null;
- if (this.value.message == null) this.value.message = null;
- if (this.value.primary == null) this.value.primary = false;
- if (this.value.var == null) this.value.var = null;
- if (this.value.fn == null) this.value.fn = null;
- },
+withDefaults(defineProps<{
+ value: any,
+ hpml: any
+}>(), {
+ value: {
+ text: '',
+ action: 'dialog',
+ content: null,
+ event: null,
+ message: null,
+ primary: false,
+ var: null,
+ fn: null
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue
index ba5d0ba1f7..191321ae14 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue
@@ -20,33 +20,19 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkInput
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.name == null) this.value.name = '';
- if (this.value.width == null) this.value.width = 300;
- if (this.value.height == null) this.value.height = 200;
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ name: '',
+ width: 300,
+ height: 200
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue
index dc98a610ba..1a2078448d 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue
@@ -18,31 +18,17 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkInput
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.name == null) this.value.name = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ name: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue
index be3a520ea5..d763070b15 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue
@@ -25,54 +25,39 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent, defineAsyncComponent } from 'vue';
+import { defineAsyncComponent, inject } from 'vue';
import { v4 as uuid } from 'uuid';
import XContainer from '../page-editor.container.vue';
import MkSelect from '@/components/form/select.vue';
import * as os from '@/os';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- XContainer, MkSelect,
- XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')),
- },
+const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue'));
- inject: ['getPageBlockList'],
-
- props: {
- value: {
- required: true
- },
- hpml: {
- required: true,
- },
- },
-
- data() {
- return {
- };
- },
+const props = withDefaults(defineProps<{
+ value: any,
+ hpml: any
+}>(), {
+ value: {
+ children: [],
+ var: null
+ }
+});
- created() {
- if (this.value.children == null) this.value.children = [];
- if (this.value.var === undefined) this.value.var = null;
- },
+const getPageBlockList = inject<(any) => any>('getPageBlockList');
- methods: {
- async add() {
- const { canceled, result: type } = await os.select({
- title: this.$ts._pages.chooseBlock,
- groupedItems: this.getPageBlockList()
- });
- if (canceled) return;
+async function add() {
+ const { canceled, result: type } = await os.select({
+ title: i18n.ts._pages.chooseBlock,
+ groupedItems: getPageBlockList()
+ });
+ if (canceled) return;
- const id = uuid();
- this.value.children.push({ id, type });
- },
- }
-});
+ const id = uuid();
+ props.value.children.push({ id, type });
+}
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
index 9a6adab30a..b22bf1cb34 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
@@ -14,53 +14,39 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { onMounted } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkDriveFileThumbnail
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- file: null,
- };
- },
+const props = withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ fileId: null
+ }
+});
- created() {
- if (this.value.fileId === undefined) this.value.fileId = null;
- },
+let file: any = $ref(null);
- mounted() {
- if (this.value.fileId == null) {
- this.choose();
- } else {
- os.api('drive/files/show', {
- fileId: this.value.fileId
- }).then(file => {
- this.file = file;
- });
- }
- },
+async function choose() {
+ os.selectDriveFile(false).then((fileResponse: any) => {
+ file = fileResponse;
+ props.value.fileId = fileResponse.id;
+ });
+}
- methods: {
- async choose() {
- os.selectDriveFile(false).then(file => {
- this.file = file;
- this.value.fileId = file.id;
- });
- },
+onMounted(async () => {
+ if (props.value.fileId == null) {
+ await choose();
+ } else {
+ os.api('drive/files/show', {
+ fileId: props.value.fileId
+ }).then(fileResponse => {
+ file = fileResponse;
+ });
}
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
index 2d4d9c5dcc..27f9f961f3 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
@@ -16,9 +16,9 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { watch } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkInput from '@/components/form/input.vue';
import MkSwitch from '@/components/form/switch.vue';
@@ -26,42 +26,27 @@ import XNote from '@/components/note.vue';
import XNoteDetailed from '@/components/note-detailed.vue';
import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkInput, MkSwitch, XNote, XNoteDetailed,
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- id: this.value.note,
- note: null,
- };
- },
+const props = withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ note: null,
+ detailed: false
+ }
+});
- watch: {
- id: {
- async handler() {
- if (this.id && (this.id.startsWith('http://') || this.id.startsWith('https://'))) {
- this.value.note = this.id.endsWith('/') ? this.id.substr(0, this.id.length - 1).split('/').pop() : this.id.split('/').pop();
- } else {
- this.value.note = this.id;
- }
+let id: any = $ref(props.value.note);
+let note: any = $ref(null);
- this.note = await os.api('notes/show', { noteId: this.value.note });
- },
- immediate: true
- },
- },
+watch(id, async () => {
+ if (id && (id.startsWith('http://') || id.startsWith('https://'))) {
+ props.value.note = (id.endsWith('/') ? id.slice(0, -1) : id).split('/').pop();
+ } else {
+ props.value.note = id;
+ }
- created() {
- if (this.value.note == null) this.value.note = null;
- if (this.value.detailed == null) this.value.detailed = false;
- },
+ note = await os.api('notes/show', { noteId: props.value.note });
+}, {
+ immediate: true
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue
index 9083f0c493..479a859e76 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue
@@ -18,31 +18,17 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkInput
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.name == null) this.value.name = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ name: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue
index 3af720f4b7..f8c42c296b 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue
@@ -11,35 +11,21 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkInput from '@/components/form/input.vue';
import MkSwitch from '@/components/form/switch.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkTextarea, MkInput, MkSwitch
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.text == null) this.value.text = '';
- if (this.value.attachCanvasImage == null) this.value.attachCanvasImage = false;
- if (this.value.canvasId == null) this.value.canvasId = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ text: '',
+ attachCanvasImage: false,
+ canvasId: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue
index 2502a54d79..4b28f120a9 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue
@@ -12,41 +12,28 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { watch } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkTextarea, MkInput
- },
- props: {
- value: {
- required: true
- },
- },
- data() {
- return {
- values: '',
- };
- },
- watch: {
- values: {
- handler() {
- this.value.values = this.values.split('\n');
- },
- deep: true
- }
- },
- created() {
- if (this.value.name == null) this.value.name = '';
- if (this.value.title == null) this.value.title = '';
- if (this.value.values == null) this.value.values = [];
- this.values = this.value.values.join('\n');
- },
+const props = withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ name: '',
+ title: '',
+ values: []
+ }
+});
+
+let values: string = $ref(props.value.values.join('\n'));
+
+watch(values, () => {
+ props.value.values = values.split('\n');
+}, {
+ deep: true
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
index 1684895fe1..7276cc1e1b 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
@@ -17,66 +17,51 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent, defineAsyncComponent } from 'vue';
+import { defineAsyncComponent, inject, onMounted } from 'vue';
import { v4 as uuid } from 'uuid';
import XContainer from '../page-editor.container.vue';
import * as os from '@/os';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- XContainer,
- XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')),
- },
+const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue'));
- inject: ['getPageBlockList'],
-
- props: {
- value: {
- required: true
- },
- hpml: {
- required: true,
- },
- },
-
- data() {
- return {
- };
- },
+const props = withDefaults(defineProps<{
+ value: any,
+ hpml: any
+}>(), {
+ value: {
+ title: null,
+ children: []
+ }
+});
- created() {
- if (this.value.title == null) this.value.title = null;
- if (this.value.children == null) this.value.children = [];
- },
+const getPageBlockList = inject<(any) => any>('getPageBlockList');
- mounted() {
- if (this.value.title == null) {
- this.rename();
- }
- },
+async function rename() {
+ const { canceled, result: title } = await os.inputText({
+ title: 'Enter title',
+ default: props.value.title
+ });
+ if (canceled) return;
+ props.value.title = title;
+}
- methods: {
- async rename() {
- const { canceled, result: title } = await os.inputText({
- title: 'Enter title',
- default: this.value.title
- });
- if (canceled) return;
- this.value.title = title;
- },
+async function add() {
+ const { canceled, result: type } = await os.select({
+ title: i18n.ts._pages.chooseBlock,
+ groupedItems: getPageBlockList()
+ });
+ if (canceled) return;
- async add() {
- const { canceled, result: type } = await os.select({
- title: this.$ts._pages.chooseBlock,
- groupedItems: this.getPageBlockList()
- });
- if (canceled) return;
+ const id = uuid();
+ props.value.children.push({ id, type });
+}
- const id = uuid();
- this.value.children.push({ id, type });
- },
+onMounted(() => {
+ if (props.value.title == null) {
+ rename();
}
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue
index b989dce0ac..ded57cf304 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue
@@ -11,33 +11,19 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkSwitch from '@/components/form/switch.vue';
import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkSwitch, MkInput
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.name == null) this.value.name = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ name: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue
index b25ac38d51..1e269ae58c 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue
@@ -11,31 +11,17 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkInput
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.name == null) this.value.name = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ name: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
index f23a8ded90..e0ebe68dda 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
@@ -9,31 +9,17 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.text == null) this.value.text = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ text: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue
index f61f0cb1b7..1bb4aaa543 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue
@@ -11,32 +11,18 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkInput from '@/components/form/input.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer, MkTextarea, MkInput
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.name == null) this.value.name = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ name: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue
index bbabe28488..dca7de8df9 100644
--- a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue
@@ -9,31 +9,17 @@
</XContainer>
</template>
-<script lang="ts">
+<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
-import { defineComponent } from 'vue';
+import { } from 'vue';
import XContainer from '../page-editor.container.vue';
-import * as os from '@/os';
-export default defineComponent({
- components: {
- XContainer
- },
-
- props: {
- value: {
- required: true
- },
- },
-
- data() {
- return {
- };
- },
-
- created() {
- if (this.value.text == null) this.value.text = '';
- },
+withDefaults(defineProps<{
+ value: any
+}>(), {
+ value: {
+ text: ''
+ }
});
</script>
diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue
index 9566592618..aaa61e6e36 100644
--- a/packages/client/src/pages/page-editor/page-editor.vue
+++ b/packages/client/src/pages/page-editor/page-editor.vue
@@ -1,85 +1,88 @@
<template>
-<MkSpacer :content-max="700">
- <div class="jqqmcavi">
- <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton>
- <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
- <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton>
- <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
- </div>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
+ <div class="jqqmcavi">
+ <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton>
+ <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton>
+ <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
- <div v-if="tab === 'settings'">
- <div class="_formRoot">
- <MkInput v-model="title" class="_formBlock">
- <template #label>{{ $ts._pages.title }}</template>
- </MkInput>
+ <div v-if="tab === 'settings'">
+ <div class="_formRoot">
+ <MkInput v-model="title" class="_formBlock">
+ <template #label>{{ $ts._pages.title }}</template>
+ </MkInput>
- <MkInput v-model="summary" class="_formBlock">
- <template #label>{{ $ts._pages.summary }}</template>
- </MkInput>
+ <MkInput v-model="summary" class="_formBlock">
+ <template #label>{{ $ts._pages.summary }}</template>
+ </MkInput>
- <MkInput v-model="name" class="_formBlock">
- <template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
- <template #label>{{ $ts._pages.url }}</template>
- </MkInput>
+ <MkInput v-model="name" class="_formBlock">
+ <template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
+ <template #label>{{ $ts._pages.url }}</template>
+ </MkInput>
- <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch>
+ <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch>
- <MkSelect v-model="font" class="_formBlock">
- <template #label>{{ $ts._pages.font }}</template>
- <option value="serif">{{ $ts._pages.fontSerif }}</option>
- <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option>
- </MkSelect>
+ <MkSelect v-model="font" class="_formBlock">
+ <template #label>{{ $ts._pages.font }}</template>
+ <option value="serif">{{ $ts._pages.fontSerif }}</option>
+ <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option>
+ </MkSelect>
- <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch>
+ <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch>
- <div class="eyeCatch">
- <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton>
- <div v-else-if="eyeCatchingImage">
- <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
- <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton>
+ <div class="eyeCatch">
+ <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton>
+ <div v-else-if="eyeCatchingImage">
+ <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
+ <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton>
+ </div>
</div>
</div>
</div>
- </div>
- <div v-else-if="tab === 'contents'">
- <div>
- <XBlocks v-model="content" class="content" :hpml="hpml"/>
+ <div v-else-if="tab === 'contents'">
+ <div>
+ <XBlocks v-model="content" class="content" :hpml="hpml"/>
- <MkButton v-if="!readonly" @click="add()"><i class="fas fa-plus"></i></MkButton>
+ <MkButton v-if="!readonly" @click="add()"><i class="fas fa-plus"></i></MkButton>
+ </div>
</div>
- </div>
- <div v-else-if="tab === 'variables'">
- <div class="qmuvgica">
- <XDraggable v-show="variables.length > 0" v-model="variables" tag="div" class="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
- <template #item="{element}">
- <XVariable
- :modelValue="element"
- :removable="true"
- :hpml="hpml"
- :name="element.name"
- :title="element.name"
- :draggable="true"
- @remove="() => removeVariable(element)"
- />
- </template>
- </XDraggable>
+ <div v-else-if="tab === 'variables'">
+ <div class="qmuvgica">
+ <XDraggable v-show="variables.length > 0" v-model="variables" tag="div" class="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
+ <template #item="{element}">
+ <XVariable
+ :model-value="element"
+ :removable="true"
+ :hpml="hpml"
+ :name="element.name"
+ :title="element.name"
+ :draggable="true"
+ @remove="() => removeVariable(element)"
+ />
+ </template>
+ </XDraggable>
- <MkButton v-if="!readonly" class="add" @click="addVariable()"><i class="fas fa-plus"></i></MkButton>
+ <MkButton v-if="!readonly" class="add" @click="addVariable()"><i class="fas fa-plus"></i></MkButton>
+ </div>
</div>
- </div>
- <div v-else-if="tab === 'script'">
- <div>
- <MkTextarea v-model="script" class="_code"/>
+ <div v-else-if="tab === 'script'">
+ <div>
+ <MkTextarea v-model="script" class="_code"/>
+ </div>
</div>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, provide, watch } from 'vue';
import 'prismjs';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
@@ -90,7 +93,6 @@ import { v4 as uuid } from 'uuid';
import XVariable from './page-editor.script-block.vue';
import XBlocks from './page-editor.blocks.vue';
import MkTextarea from '@/components/form/textarea.vue';
-import MkContainer from '@/components/ui/container.vue';
import MkButton from '@/components/ui/button.vue';
import MkSelect from '@/components/form/select.vue';
import MkSwitch from '@/components/form/switch.vue';
@@ -101,367 +103,343 @@ import { url } from '@/config';
import { collectPageVars } from '@/scripts/collect-page-vars';
import * as os from '@/os';
import { selectFile } from '@/scripts/select-file';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+const XDraggable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
-export default defineComponent({
- components: {
- XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
- XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput,
- },
+const props = defineProps<{
+ initPageId?: string;
+ initPageName?: string;
+ initUser?: string;
+}>();
- provide() {
- return {
- readonly: this.readonly,
- getScriptBlockList: this.getScriptBlockList,
- getPageBlockList: this.getPageBlockList
- };
- },
+let tab = $ref('settings');
+let author = $ref($i);
+let readonly = $ref(false);
+let page = $ref(null);
+let pageId = $ref(null);
+let currentName = $ref(null);
+let title = $ref('');
+let summary = $ref(null);
+let name = $ref(Date.now().toString());
+let eyeCatchingImage = $ref(null);
+let eyeCatchingImageId = $ref(null);
+let font = $ref('sans-serif');
+let content = $ref([]);
+let alignCenter = $ref(false);
+let hideTitleWhenPinned = $ref(false);
+let variables = $ref([]);
+let hpml = $ref(null);
+let script = $ref('');
- props: {
- initPageId: {
- type: String,
- required: false
- },
- initPageName: {
- type: String,
- required: false
- },
- initUser: {
- type: String,
- required: false
- },
- },
+provide('readonly', readonly);
+provide('getScriptBlockList', getScriptBlockList);
+provide('getPageBlockList', getPageBlockList);
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => {
- let title = this.$ts._pages.newPage;
- if (this.initPageId) {
- title = this.$ts._pages.editPage;
- }
- else if (this.initPageName && this.initUser) {
- title = this.$ts._pages.readPage;
- }
- return {
- title: title,
- icon: 'fas fa-pencil-alt',
- bg: 'var(--bg)',
- tabs: [{
- active: this.tab === 'settings',
- title: this.$ts._pages.pageSetting,
- icon: 'fas fa-cog',
- onClick: () => { this.tab = 'settings'; },
- }, {
- active: this.tab === 'contents',
- title: this.$ts._pages.contents,
- icon: 'fas fa-sticky-note',
- onClick: () => { this.tab = 'contents'; },
- }, {
- active: this.tab === 'variables',
- title: this.$ts._pages.variables,
- icon: 'fas fa-magic',
- onClick: () => { this.tab = 'variables'; },
- }, {
- active: this.tab === 'script',
- title: this.$ts.script,
- icon: 'fas fa-code',
- onClick: () => { this.tab = 'script'; },
- }],
- };
- }),
- tab: 'settings',
- author: this.$i,
- readonly: false,
- page: null,
- pageId: null,
- currentName: null,
- title: '',
- summary: null,
- name: Date.now().toString(),
- eyeCatchingImage: null,
- eyeCatchingImageId: null,
- font: 'sans-serif',
- content: [],
- alignCenter: false,
- hideTitleWhenPinned: false,
- variables: [],
- hpml: null,
- script: '',
- url,
- };
- },
+watch($$(eyeCatchingImageId), async () => {
+ if (eyeCatchingImageId == null) {
+ eyeCatchingImage = null;
+ } else {
+ eyeCatchingImage = await os.api('drive/files/show', {
+ fileId: eyeCatchingImageId,
+ });
+ }
+});
+
+function getSaveOptions() {
+ return {
+ title: title.trim(),
+ name: name.trim(),
+ summary: summary,
+ font: font,
+ script: script,
+ hideTitleWhenPinned: hideTitleWhenPinned,
+ alignCenter: alignCenter,
+ content: content,
+ variables: variables,
+ eyeCatchingImageId: eyeCatchingImageId,
+ };
+}
+
+function save() {
+ const options = getSaveOptions();
- watch: {
- async eyeCatchingImageId() {
- if (this.eyeCatchingImageId == null) {
- this.eyeCatchingImage = null;
- } else {
- this.eyeCatchingImage = await os.api('drive/files/show', {
- fileId: this.eyeCatchingImageId,
+ const onError = err => {
+ if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') {
+ if (err.info.param === 'name') {
+ os.alert({
+ type: 'error',
+ title: i18n.ts._pages.invalidNameTitle,
+ text: i18n.ts._pages.invalidNameText,
});
}
- },
- },
+ } else if (err.code === 'NAME_ALREADY_EXISTS') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts._pages.nameAlreadyExists,
+ });
+ }
+ };
- async created() {
- this.hpml = new HpmlTypeChecker();
+ if (pageId) {
+ options.pageId = pageId;
+ os.api('pages/update', options)
+ .then(page => {
+ currentName = name.trim();
+ os.alert({
+ type: 'success',
+ text: i18n.ts._pages.updated,
+ });
+ }).catch(onError);
+ } else {
+ os.api('pages/create', options)
+ .then(created => {
+ pageId = created.id;
+ currentName = name.trim();
+ os.alert({
+ type: 'success',
+ text: i18n.ts._pages.created,
+ });
+ mainRouter.push(`/pages/edit/${pageId}`);
+ }).catch(onError);
+ }
+}
- this.$watch('variables', () => {
- this.hpml.variables = this.variables;
- }, { deep: true });
+function del() {
+ os.confirm({
+ type: 'warning',
+ text: i18n.t('removeAreYouSure', { x: title.trim() }),
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.api('pages/delete', {
+ pageId: pageId,
+ }).then(() => {
+ os.alert({
+ type: 'success',
+ text: i18n.ts._pages.deleted,
+ });
+ mainRouter.push('/pages');
+ });
+ });
+}
- this.$watch('content', () => {
- this.hpml.pageVars = collectPageVars(this.content);
- }, { deep: true });
+function duplicate() {
+ title = title + ' - copy';
+ name = name + '-copy';
+ os.api('pages/create', getSaveOptions()).then(created => {
+ pageId = created.id;
+ currentName = name.trim();
+ os.alert({
+ type: 'success',
+ text: i18n.ts._pages.created,
+ });
+ mainRouter.push(`/pages/edit/${pageId}`);
+ });
+}
- if (this.initPageId) {
- this.page = await os.api('pages/show', {
- pageId: this.initPageId,
- });
- } else if (this.initPageName && this.initUser) {
- this.page = await os.api('pages/show', {
- name: this.initPageName,
- username: this.initUser,
- });
- this.readonly = true;
- }
+async function add() {
+ const { canceled, result: type } = await os.select({
+ type: null,
+ title: i18n.ts._pages.chooseBlock,
+ groupedItems: getPageBlockList(),
+ });
+ if (canceled) return;
- if (this.page) {
- this.author = this.page.user;
- this.pageId = this.page.id;
- this.title = this.page.title;
- this.name = this.page.name;
- this.currentName = this.page.name;
- this.summary = this.page.summary;
- this.font = this.page.font;
- this.script = this.page.script;
- this.hideTitleWhenPinned = this.page.hideTitleWhenPinned;
- this.alignCenter = this.page.alignCenter;
- this.content = this.page.content;
- this.variables = this.page.variables;
- this.eyeCatchingImageId = this.page.eyeCatchingImageId;
- } else {
- const id = uuid();
- this.content = [{
- id,
- type: 'text',
- text: 'Hello World!'
- }];
- }
- },
+ const id = uuid();
+ content.push({ id, type });
+}
- methods: {
- getSaveOptions() {
- return {
- title: this.title.trim(),
- name: this.name.trim(),
- summary: this.summary,
- font: this.font,
- script: this.script,
- hideTitleWhenPinned: this.hideTitleWhenPinned,
- alignCenter: this.alignCenter,
- content: this.content,
- variables: this.variables,
- eyeCatchingImageId: this.eyeCatchingImageId,
- };
- },
+async function addVariable() {
+ let { canceled, result: name } = await os.inputText({
+ title: i18n.ts._pages.enterVariableName,
+ });
+ if (canceled) return;
- save() {
- const options = this.getSaveOptions();
+ name = name.trim();
- const onError = err => {
- if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
- if (err.info.param == 'name') {
- os.alert({
- type: 'error',
- title: this.$ts._pages.invalidNameTitle,
- text: this.$ts._pages.invalidNameText
- });
- }
- } else if (err.code == 'NAME_ALREADY_EXISTS') {
- os.alert({
- type: 'error',
- text: this.$ts._pages.nameAlreadyExists
- });
- }
- };
+ if (hpml.isUsedName(name)) {
+ os.alert({
+ type: 'error',
+ text: i18n.ts._pages.variableNameIsAlreadyUsed,
+ });
+ return;
+ }
- if (this.pageId) {
- options.pageId = this.pageId;
- os.api('pages/update', options)
- .then(page => {
- this.currentName = this.name.trim();
- os.alert({
- type: 'success',
- text: this.$ts._pages.updated
- });
- }).catch(onError);
- } else {
- os.api('pages/create', options)
- .then(page => {
- this.pageId = page.id;
- this.currentName = this.name.trim();
- os.alert({
- type: 'success',
- text: this.$ts._pages.created
- });
- this.$router.push(`/pages/edit/${this.pageId}`);
- }).catch(onError);
- }
- },
+ const id = uuid();
+ variables.push({ id, name, type: null });
+}
- del() {
- os.confirm({
- type: 'warning',
- text: this.$t('removeAreYouSure', { x: this.title.trim() }),
- }).then(({ canceled }) => {
- if (canceled) return;
- os.api('pages/delete', {
- pageId: this.pageId,
- }).then(() => {
- os.alert({
- type: 'success',
- text: this.$ts._pages.deleted
- });
- this.$router.push(`/pages`);
- });
- });
- },
+function removeVariable(v) {
+ variables = variables.filter(x => x.name !== v.name);
+}
- duplicate() {
- this.title = this.title + ' - copy';
- this.name = this.name + '-copy';
- os.api('pages/create', this.getSaveOptions()).then(page => {
- this.pageId = page.id;
- this.currentName = this.name.trim();
- os.alert({
- type: 'success',
- text: this.$ts._pages.created
- });
- this.$router.push(`/pages/edit/${this.pageId}`);
- });
- },
+function getPageBlockList() {
+ return [{
+ label: i18n.ts._pages.contentBlocks,
+ items: [
+ { value: 'section', text: i18n.ts._pages.blocks.section },
+ { value: 'text', text: i18n.ts._pages.blocks.text },
+ { value: 'image', text: i18n.ts._pages.blocks.image },
+ { value: 'textarea', text: i18n.ts._pages.blocks.textarea },
+ { value: 'note', text: i18n.ts._pages.blocks.note },
+ { value: 'canvas', text: i18n.ts._pages.blocks.canvas },
+ ],
+ }, {
+ label: i18n.ts._pages.inputBlocks,
+ items: [
+ { value: 'button', text: i18n.ts._pages.blocks.button },
+ { value: 'radioButton', text: i18n.ts._pages.blocks.radioButton },
+ { value: 'textInput', text: i18n.ts._pages.blocks.textInput },
+ { value: 'textareaInput', text: i18n.ts._pages.blocks.textareaInput },
+ { value: 'numberInput', text: i18n.ts._pages.blocks.numberInput },
+ { value: 'switch', text: i18n.ts._pages.blocks.switch },
+ { value: 'counter', text: i18n.ts._pages.blocks.counter },
+ ],
+ }, {
+ label: i18n.ts._pages.specialBlocks,
+ items: [
+ { value: 'if', text: i18n.ts._pages.blocks.if },
+ { value: 'post', text: i18n.ts._pages.blocks.post },
+ ],
+ }];
+}
- async add() {
- const { canceled, result: type } = await os.select({
- type: null,
- title: this.$ts._pages.chooseBlock,
- groupedItems: this.getPageBlockList()
- });
- if (canceled) return;
+function getScriptBlockList(type: string = null) {
+ const list = [];
- const id = uuid();
- this.content.push({ id, type });
- },
+ const blocks = blockDefs.filter(block => type == null || block.out == null || block.out === type || typeof block.out === 'number');
- async addVariable() {
- let { canceled, result: name } = await os.inputText({
- title: this.$ts._pages.enterVariableName,
+ for (const block of blocks) {
+ const category = list.find(x => x.category === block.category);
+ if (category) {
+ category.items.push({
+ value: block.type,
+ text: i18n.t(`_pages.script.blocks.${block.type}`),
});
- if (canceled) return;
+ } else {
+ list.push({
+ category: block.category,
+ label: i18n.t(`_pages.script.categories.${block.category}`),
+ items: [{
+ value: block.type,
+ text: i18n.t(`_pages.script.blocks.${block.type}`),
+ }],
+ });
+ }
+ }
- name = name.trim();
+ const userFns = variables.filter(x => x.type === 'fn');
+ if (userFns.length > 0) {
+ list.unshift({
+ label: i18n.t('_pages.script.categories.fn'),
+ items: userFns.map(v => ({
+ value: 'fn:' + v.name,
+ text: v.name,
+ })),
+ });
+ }
- if (this.hpml.isUsedName(name)) {
- os.alert({
- type: 'error',
- text: this.$ts._pages.variableNameIsAlreadyUsed
- });
- return;
- }
+ return list;
+}
- const id = uuid();
- this.variables.push({ id, name, type: null });
- },
+function setEyeCatchingImage(img) {
+ selectFile(img.currentTarget ?? img.target, null).then(file => {
+ eyeCatchingImageId = file.id;
+ });
+}
- removeVariable(v) {
- this.variables = this.variables.filter(x => x.name !== v.name);
- },
+function removeEyeCatchingImage() {
+ eyeCatchingImageId = null;
+}
- getPageBlockList() {
- return [{
- label: this.$ts._pages.contentBlocks,
- items: [
- { value: 'section', text: this.$ts._pages.blocks.section },
- { value: 'text', text: this.$ts._pages.blocks.text },
- { value: 'image', text: this.$ts._pages.blocks.image },
- { value: 'textarea', text: this.$ts._pages.blocks.textarea },
- { value: 'note', text: this.$ts._pages.blocks.note },
- { value: 'canvas', text: this.$ts._pages.blocks.canvas },
- ]
- }, {
- label: this.$ts._pages.inputBlocks,
- items: [
- { value: 'button', text: this.$ts._pages.blocks.button },
- { value: 'radioButton', text: this.$ts._pages.blocks.radioButton },
- { value: 'textInput', text: this.$ts._pages.blocks.textInput },
- { value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput },
- { value: 'numberInput', text: this.$ts._pages.blocks.numberInput },
- { value: 'switch', text: this.$ts._pages.blocks.switch },
- { value: 'counter', text: this.$ts._pages.blocks.counter }
- ]
- }, {
- label: this.$ts._pages.specialBlocks,
- items: [
- { value: 'if', text: this.$ts._pages.blocks.if },
- { value: 'post', text: this.$ts._pages.blocks.post }
- ]
- }];
- },
+function highlighter(code) {
+ return highlight(code, languages.js, 'javascript');
+}
- getScriptBlockList(type: string = null) {
- const list = [];
+async function init() {
+ hpml = new HpmlTypeChecker();
- const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
+ watch($$(variables), () => {
+ hpml.variables = variables;
+ }, { deep: true });
- for (const block of blocks) {
- const category = list.find(x => x.category === block.category);
- if (category) {
- category.items.push({
- value: block.type,
- text: this.$t(`_pages.script.blocks.${block.type}`)
- });
- } else {
- list.push({
- category: block.category,
- label: this.$t(`_pages.script.categories.${block.category}`),
- items: [{
- value: block.type,
- text: this.$t(`_pages.script.blocks.${block.type}`)
- }]
- });
- }
- }
+ watch($$(content), () => {
+ hpml.pageVars = collectPageVars(content);
+ }, { deep: true });
- const userFns = this.variables.filter(x => x.type === 'fn');
- if (userFns.length > 0) {
- list.unshift({
- label: this.$t(`_pages.script.categories.fn`),
- items: userFns.map(v => ({
- value: 'fn:' + v.name,
- text: v.name
- }))
- });
- }
+ if (props.initPageId) {
+ page = await os.api('pages/show', {
+ pageId: props.initPageId,
+ });
+ } else if (props.initPageName && props.initUser) {
+ page = await os.api('pages/show', {
+ name: props.initPageName,
+ username: props.initUser,
+ });
+ readonly = true;
+ }
- return list;
- },
+ if (page) {
+ author = page.user;
+ pageId = page.id;
+ title = page.title;
+ name = page.name;
+ currentName = page.name;
+ summary = page.summary;
+ font = page.font;
+ script = page.script;
+ hideTitleWhenPinned = page.hideTitleWhenPinned;
+ alignCenter = page.alignCenter;
+ content = page.content;
+ variables = page.variables;
+ eyeCatchingImageId = page.eyeCatchingImageId;
+ } else {
+ const id = uuid();
+ content = [{
+ id,
+ type: 'text',
+ text: 'Hello World!',
+ }];
+ }
+}
- setEyeCatchingImage(e) {
- selectFile(e.currentTarget ?? e.target, null).then(file => {
- this.eyeCatchingImageId = file.id;
- });
- },
+init();
- removeEyeCatchingImage() {
- this.eyeCatchingImageId = null;
- },
+const headerActions = $computed(() => []);
- highlighter(code) {
- return highlight(code, languages.js, 'javascript');
- },
+const headerTabs = $computed(() => [{
+ key: 'settings',
+ title: i18n.ts._pages.pageSetting,
+ icon: 'fas fa-cog',
+}, {
+ key: 'contents',
+ title: i18n.ts._pages.contents,
+ icon: 'fas fa-sticky-note',
+}, {
+ key: 'variables',
+ title: i18n.ts._pages.variables,
+ icon: 'fas fa-magic',
+}, {
+ key: 'script',
+ title: i18n.ts.script,
+ icon: 'fas fa-code',
+}]);
+
+definePageMetadata(computed(() => {
+ let title = i18n.ts._pages.newPage;
+ if (props.initPageId) {
+ title = i18n.ts._pages.editPage;
}
-});
+ else if (props.initPageName && props.initUser) {
+ title = i18n.ts._pages.readPage;
+ }
+ return {
+ title: title,
+ icon: 'fas fa-pencil-alt',
+ };
+}));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue
index 5bca971438..c60b7069e9 100644
--- a/packages/client/src/pages/page.vue
+++ b/packages/client/src/pages/page.vue
@@ -1,193 +1,166 @@
<template>
-<MkSpacer :content-max="700">
- <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
- <div v-if="page" :key="page.id" v-size="{ max: [450] }" class="xcukqgmh">
- <div class="_block main">
- <!--
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
+ <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
+ <div v-if="page" :key="page.id" v-size="{ max: [450] }" class="xcukqgmh">
+ <div class="_block main">
+ <!--
<div class="header">
<h1>{{ page.title }}</h1>
</div>
-->
- <div class="banner">
- <img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
- </div>
- <div class="content">
- <XPage :page="page"/>
- </div>
- <div class="actions">
- <div class="like">
- <MkButton v-if="page.isLiked" v-tooltip="$ts._pages.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
- <MkButton v-else v-tooltip="$ts._pages.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+ <div class="banner">
+ <img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
</div>
- <div class="other">
- <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button>
- <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button>
+ <div class="content">
+ <XPage :page="page"/>
</div>
- </div>
- <div class="user">
- <MkAvatar :user="page.user" class="avatar"/>
- <div class="name">
- <MkUserName :user="page.user" style="display: block;"/>
- <MkAcct :user="page.user"/>
+ <div class="actions">
+ <div class="like">
+ <MkButton v-if="page.isLiked" v-tooltip="$ts._pages.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+ <MkButton v-else v-tooltip="$ts._pages.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
+ </div>
+ <div class="other">
+ <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button>
+ <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button>
+ </div>
+ </div>
+ <div class="user">
+ <MkAvatar :user="page.user" class="avatar"/>
+ <div class="name">
+ <MkUserName :user="page.user" style="display: block;"/>
+ <MkAcct :user="page.user"/>
+ </div>
+ <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ </div>
+ <div class="links">
+ <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA>
+ <template v-if="$i && $i.id === page.userId">
+ <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA>
+ <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ $ts.unpin }}</button>
+ <button v-else class="link _textButton" @click="pin(true)">{{ $ts.pin }}</button>
+ </template>
</div>
- <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
</div>
- <div class="links">
- <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA>
- <template v-if="$i && $i.id === page.userId">
- <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA>
- <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ $ts.unpin }}</button>
- <button v-else class="link _textButton" @click="pin(true)">{{ $ts.pin }}</button>
- </template>
+ <div class="footer">
+ <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
+ <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
</div>
+ <MkAd :prefer="['horizontal', 'horizontal-big']"/>
+ <MkContainer :max-height="300" :foldable="true" class="other">
+ <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
+ <MkPagination v-slot="{items}" :pagination="otherPostsPagination">
+ <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/>
+ </MkPagination>
+ </MkContainer>
</div>
- <div class="footer">
- <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
- <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
- </div>
- <MkAd :prefer="['horizontal', 'horizontal-big']"/>
- <MkContainer :max-height="300" :foldable="true" class="other">
- <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
- <MkPagination v-slot="{items}" :pagination="otherPostsPagination">
- <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/>
- </MkPagination>
- </MkContainer>
- </div>
- <MkError v-else-if="error" @retry="fetch()"/>
- <MkLoading v-else/>
- </transition>
-</MkSpacer>
+ <MkError v-else-if="error" @retry="fetchPage()"/>
+ <MkLoading v-else/>
+ </transition>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
import XPage from '@/components/page/page.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { url } from '@/config';
import MkFollowButton from '@/components/follow-button.vue';
import MkContainer from '@/components/ui/container.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkPagePreview from '@/components/page-preview.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-export default defineComponent({
- components: {
- XPage,
- MkButton,
- MkFollowButton,
- MkContainer,
- MkPagination,
- MkPagePreview,
- },
+const props = defineProps<{
+ pageName: string;
+ username: string;
+}>();
- props: {
- pageName: {
- type: String,
- required: true
- },
- username: {
- type: String,
- required: true
- },
- },
+let page = $ref(null);
+let error = $ref(null);
+const otherPostsPagination = {
+ endpoint: 'users/pages' as const,
+ limit: 6,
+ params: computed(() => ({
+ userId: page.user.id,
+ })),
+};
+const path = $computed(() => props.username + '/' + props.pageName);
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.page ? {
- title: computed(() => this.page.title || this.page.name),
- avatar: this.page.user,
- path: `/@${this.page.user.username}/pages/${this.page.name}`,
- share: {
- title: this.page.title || this.page.name,
- text: this.page.summary,
- },
- } : null),
- page: null,
- error: null,
- otherPostsPagination: {
- endpoint: 'users/pages' as const,
- limit: 6,
- params: computed(() => ({
- userId: this.page.user.id
- })),
- },
- };
- },
+function fetchPage() {
+ page = null;
+ os.api('pages/show', {
+ name: props.pageName,
+ username: props.username,
+ }).then(_page => {
+ page = _page;
+ }).catch(err => {
+ error = err;
+ });
+}
- computed: {
- path(): string {
- return this.username + '/' + this.pageName;
- }
- },
+function share() {
+ navigator.share({
+ title: page.title ?? page.name,
+ text: page.summary,
+ url: `${url}/@${page.user.username}/pages/${page.name}`,
+ });
+}
- watch: {
- path() {
- this.fetch();
- }
- },
+function shareWithNote() {
+ os.post({
+ initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
+ });
+}
- created() {
- this.fetch();
- },
+function like() {
+ os.apiWithDialog('pages/like', {
+ pageId: page.id,
+ }).then(() => {
+ page.isLiked = true;
+ page.likedCount++;
+ });
+}
- methods: {
- fetch() {
- this.page = null;
- os.api('pages/show', {
- name: this.pageName,
- username: this.username,
- }).then(page => {
- this.page = page;
- }).catch(err => {
- this.error = err;
- });
- },
+async function unlike() {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.unlikeConfirm,
+ });
+ if (confirm.canceled) return;
+ os.apiWithDialog('pages/unlike', {
+ pageId: page.id,
+ }).then(() => {
+ page.isLiked = false;
+ page.likedCount--;
+ });
+}
- share() {
- navigator.share({
- title: this.page.title || this.page.name,
- text: this.page.summary,
- url: `${url}/@${this.page.user.username}/pages/${this.page.name}`
- });
- },
+function pin(pin) {
+ os.apiWithDialog('i/update', {
+ pinnedPageId: pin ? page.id : null,
+ });
+}
- shareWithNote() {
- os.post({
- initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}`
- });
- },
+watch(() => path, fetchPage, { immediate: true });
- like() {
- os.apiWithDialog('pages/like', {
- pageId: this.page.id,
- }).then(() => {
- this.page.isLiked = true;
- this.page.likedCount++;
- });
- },
+const headerActions = $computed(() => []);
- async unlike() {
- const confirm = await os.confirm({
- type: 'warning',
- text: this.$ts.unlikeConfirm,
- });
- if (confirm.canceled) return;
- os.apiWithDialog('pages/unlike', {
- pageId: this.page.id,
- }).then(() => {
- this.page.isLiked = false;
- this.page.likedCount--;
- });
- },
+const headerTabs = $computed(() => []);
- pin(pin) {
- os.apiWithDialog('i/update', {
- pinnedPageId: pin ? this.page.id : null,
- });
- }
- }
-});
+definePageMetadata(computed(() => page ? {
+ title: computed(() => page.title || page.name),
+ avatar: page.user,
+ path: `/@${page.user.username}/pages/${page.name}`,
+ share: {
+ title: page.title || page.name,
+ text: page.summary,
+ },
+} : null));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue
index dcccf7f7c4..62c675e41e 100644
--- a/packages/client/src/pages/pages.vue
+++ b/packages/client/src/pages/pages.vue
@@ -1,86 +1,83 @@
<template>
-<MkSpacer :content-max="700">
- <div v-if="tab === 'featured'" class="rknalgpo">
- <MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
- <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
- </MkPagination>
- </div>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700">
+ <div v-if="tab === 'featured'" class="rknalgpo">
+ <MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
+ <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+ </MkPagination>
+ </div>
- <div v-else-if="tab === 'my'" class="rknalgpo my">
- <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
- <MkPagination v-slot="{items}" :pagination="myPagesPagination">
- <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
- </MkPagination>
- </div>
+ <div v-else-if="tab === 'my'" class="rknalgpo my">
+ <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+ <MkPagination v-slot="{items}" :pagination="myPagesPagination">
+ <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+ </MkPagination>
+ </div>
- <div v-else-if="tab === 'liked'" class="rknalgpo">
- <MkPagination v-slot="{items}" :pagination="likedPagesPagination">
- <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
- </MkPagination>
- </div>
-</MkSpacer>
+ <div v-else-if="tab === 'liked'" class="rknalgpo">
+ <MkPagination v-slot="{items}" :pagination="likedPagesPagination">
+ <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
+ </MkPagination>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject } from 'vue';
import MkPagePreview from '@/components/page-preview.vue';
import MkPagination from '@/components/ui/pagination.vue';
import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-export default defineComponent({
- components: {
- MkPagePreview, MkPagination, MkButton
- },
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => ({
- title: this.$ts.pages,
- icon: 'fas fa-sticky-note',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-plus',
- text: this.$ts.create,
- handler: this.create,
- }],
- tabs: [{
- active: this.tab === 'featured',
- title: this.$ts._pages.featured,
- icon: 'fas fa-fire-alt',
- onClick: () => { this.tab = 'featured'; },
- }, {
- active: this.tab === 'my',
- title: this.$ts._pages.my,
- icon: 'fas fa-edit',
- onClick: () => { this.tab = 'my'; },
- }, {
- active: this.tab === 'liked',
- title: this.$ts._pages.liked,
- icon: 'fas fa-heart',
- onClick: () => { this.tab = 'liked'; },
- },]
- })),
- tab: 'featured',
- featuredPagesPagination: {
- endpoint: 'pages/featured' as const,
- noPaging: true,
- },
- myPagesPagination: {
- endpoint: 'i/pages' as const,
- limit: 5,
- },
- likedPagesPagination: {
- endpoint: 'i/page-likes' as const,
- limit: 5,
- },
- };
- },
- methods: {
- create() {
- this.$router.push(`/pages/new`);
- }
- }
-});
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagesPagination = {
+ endpoint: 'pages/featured' as const,
+ noPaging: true,
+};
+const myPagesPagination = {
+ endpoint: 'i/pages' as const,
+ limit: 5,
+};
+const likedPagesPagination = {
+ endpoint: 'i/page-likes' as const,
+ limit: 5,
+};
+
+function create() {
+ router.push('/pages/new');
+}
+
+const headerActions = $computed(() => [{
+ icon: 'fas fa-plus',
+ text: i18n.ts.create,
+ handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'featured',
+ title: i18n.ts._pages.featured,
+ icon: 'fas fa-fire-alt',
+}, {
+ key: 'my',
+ title: i18n.ts._pages.my,
+ icon: 'fas fa-edit',
+}, {
+ key: 'liked',
+ title: i18n.ts._pages.liked,
+ icon: 'fas fa-heart',
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.pages,
+ icon: 'fas fa-sticky-note',
+})));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue
index 4accac4192..8f211081dd 100644
--- a/packages/client/src/pages/preview.vue
+++ b/packages/client/src/pages/preview.vue
@@ -7,16 +7,17 @@
<script lang="ts" setup>
import { computed } from 'vue';
import MkSample from '@/components/sample.vue';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.preview,
- icon: 'fas fa-eye',
- bg: 'var(--bg)',
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.preview,
+ icon: 'fas fa-eye',
+})));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue
index b3e2ca8d6f..10c41f2d21 100644
--- a/packages/client/src/pages/reset-password.vue
+++ b/packages/client/src/pages/reset-password.vue
@@ -1,14 +1,17 @@
<template>
-<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
- <div class="_formRoot">
- <FormInput v-model="password" type="password" class="_formBlock">
- <template #prefix><i class="fas fa-lock"></i></template>
- <template #label>{{ i18n.ts.newPassword }}</template>
- </FormInput>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
+ <div class="_formRoot">
+ <FormInput v-model="password" type="password" class="_formBlock">
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <template #label>{{ i18n.ts.newPassword }}</template>
+ </FormInput>
- <FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
- </div>
-</MkSpacer>
+ <FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -16,9 +19,9 @@ import { defineAsyncComponent, onMounted } from 'vue';
import FormInput from '@/components/form/input.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
const props = defineProps<{
token?: string;
@@ -31,22 +34,23 @@ async function save() {
token: props.token,
password: password,
});
- router.push('/');
+ mainRouter.push('/');
}
onMounted(() => {
if (props.token == null) {
os.popup(defineAsyncComponent(() => import('@/components/forgot-password.vue')), {}, {}, 'closed');
- router.push('/');
+ mainRouter.push('/');
}
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.resetPassword,
- icon: 'fas fa-lock',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.resetPassword,
+ icon: 'fas fa-lock',
});
</script>
diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue
index 34a41b81a5..d437601475 100644
--- a/packages/client/src/pages/scratchpad.vue
+++ b/packages/client/src/pages/scratchpad.vue
@@ -19,7 +19,7 @@
</template>
<script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
import 'prismjs';
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-clike';
@@ -32,9 +32,9 @@ import MkContainer from '@/components/ui/container.vue';
import MkButton from '@/components/ui/button.vue';
import { createAiScriptEnv } from '@/scripts/aiscript/api';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const code = ref('');
const logs = ref<any[]>([]);
@@ -67,7 +67,7 @@ async function run() {
logs.value.push({
id: Math.random(),
text: value.type === 'str' ? value.value : utils.valToString(value),
- print: true
+ print: true,
});
},
log: (type, params) => {
@@ -75,11 +75,11 @@ async function run() {
case 'end': logs.value.push({
id: Math.random(),
text: utils.valToString(params.val, true),
- print: false
+ print: false,
}); break;
default: break;
}
- }
+ },
});
let ast;
@@ -88,7 +88,7 @@ async function run() {
} catch (error) {
os.alert({
type: 'error',
- text: 'Syntax error :('
+ text: 'Syntax error :(',
});
return;
}
@@ -97,7 +97,7 @@ async function run() {
} catch (error: any) {
os.alert({
type: 'error',
- text: error.message
+ text: error.message,
});
}
}
@@ -106,11 +106,13 @@ function highlighter(code) {
return highlight(code, languages.js, 'javascript');
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.scratchpad,
- icon: 'fas fa-terminal',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.scratchpad,
+ icon: 'fas fa-terminal',
});
</script>
diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue
index ce2b7035da..404b9e3dbd 100644
--- a/packages/client/src/pages/search.vue
+++ b/packages/client/src/pages/search.vue
@@ -1,16 +1,17 @@
<template>
-<div class="_section">
- <div class="_content">
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
<XNotes ref="notes" :pagination="pagination"/>
- </div>
-</div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const props = defineProps<{
query: string;
@@ -23,14 +24,15 @@ const pagination = {
params: computed(() => ({
query: props.query,
channelId: props.channel,
- }))
+ })),
};
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.t('searchWith', { q: props.query }),
- icon: 'fas fa-search',
- bg: 'var(--bg)',
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.t('searchWith', { q: props.query }),
+ icon: 'fas fa-search',
+})));
</script>
diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue
index fb3a7a17f3..d72d3e2060 100644
--- a/packages/client/src/pages/settings/2fa.vue
+++ b/packages/client/src/pages/settings/2fa.vue
@@ -55,7 +55,7 @@
<li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li>
<li>
{{ i18n.ts._2fa.step3 }}<br>
- <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput>
+ <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput>
<MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton>
</li>
</ol>
diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue
index 12142b4dc1..65b6233693 100644
--- a/packages/client/src/pages/settings/account-info.vue
+++ b/packages/client/src/pages/settings/account-info.vue
@@ -127,30 +127,32 @@
</template>
<script lang="ts" setup>
-import { defineExpose, onMounted, ref } from 'vue';
+import { onMounted, ref } from 'vue';
import FormSection from '@/components/form/section.vue';
import MkKeyValue from '@/components/key-value.vue';
import * as os from '@/os';
import number from '@/filters/number';
import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
import { $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const stats = ref<any>({});
onMounted(() => {
os.api('users/stats', {
- userId: $i!.id
+ userId: $i!.id,
}).then(response => {
stats.value = response;
});
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.accountInfo,
- icon: 'fas fa-info-circle'
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.accountInfo,
+ icon: 'fas fa-info-circle',
});
</script>
diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue
index 5e75639c55..d1e71c4548 100644
--- a/packages/client/src/pages/settings/accounts.vue
+++ b/packages/client/src/pages/settings/accounts.vue
@@ -21,13 +21,13 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, defineExpose, ref } from 'vue';
+import { defineAsyncComponent, ref } from 'vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { getAccounts, addAccount as addAccounts, login, $i } from '@/account';
+import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const storedAccounts = ref<any>(null);
const accounts = ref<any>(null);
@@ -39,7 +39,7 @@ const init = async () => {
console.log(storedAccounts.value);
return os.api('users/show', {
- userIds: storedAccounts.value.map(x => x.id)
+ userIds: storedAccounts.value.map(x => x.id),
});
}).then(response => {
accounts.value = response;
@@ -70,6 +70,10 @@ function addAccount(ev) {
}], ev.currentTarget ?? ev.target);
}
+function removeAccount(account) {
+ _removeAccount(account.id);
+}
+
function addExistingAccount() {
os.popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, {
done: res => {
@@ -98,12 +102,13 @@ function switchAccountWithToken(token: string) {
login(token);
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.accounts,
- icon: 'fas fa-users',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.accounts,
+ icon: 'fas fa-users',
});
</script>
diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue
index e6375763f1..b8a2dedb5a 100644
--- a/packages/client/src/pages/settings/api.vue
+++ b/packages/client/src/pages/settings/api.vue
@@ -7,12 +7,12 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, defineExpose, ref } from 'vue';
+import { defineAsyncComponent, ref } from 'vue';
import FormLink from '@/components/form/link.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const isDesktop = ref(window.innerWidth >= 1100);
@@ -29,17 +29,18 @@ function generateToken() {
os.alert({
type: 'success',
title: i18n.ts.token,
- text: token
+ text: token,
});
},
}, 'closed');
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: 'API',
- icon: 'fas fa-key',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'API',
+ icon: 'fas fa-key',
});
</script>
diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue
index 7b0b5548d5..10ecbc795d 100644
--- a/packages/client/src/pages/settings/apps.vue
+++ b/packages/client/src/pages/settings/apps.vue
@@ -7,7 +7,7 @@
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
- <template v-slot="{items}">
+ <template #default="{items}">
<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
<div class="body">
@@ -38,11 +38,11 @@
</template>
<script lang="ts" setup>
-import { defineExpose, ref } from 'vue';
+import { ref } from 'vue';
import FormPagination from '@/components/ui/pagination.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const list = ref<any>(null);
@@ -50,8 +50,8 @@ const pagination = {
endpoint: 'i/apps' as const,
limit: 100,
params: {
- sort: '+lastUsedAt'
- }
+ sort: '+lastUsedAt',
+ },
};
function revoke(token) {
@@ -60,12 +60,13 @@ function revoke(token) {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.installedApps,
- icon: 'fas fa-plug',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.installedApps,
+ icon: 'fas fa-plug',
});
</script>
diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue
index 20db077ceb..d5000d3973 100644
--- a/packages/client/src/pages/settings/custom-css.vue
+++ b/packages/client/src/pages/settings/custom-css.vue
@@ -9,13 +9,13 @@
</template>
<script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormInfo from '@/components/ui/info.vue';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const localCustomCss = ref(localStorage.getItem('customCss') ?? '');
@@ -35,11 +35,12 @@ watch(localCustomCss, async () => {
await apply();
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.customCss,
- icon: 'fas fa-code',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.customCss,
+ icon: 'fas fa-code',
});
</script>
diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue
index 2d868aa0a7..c62928eeb0 100644
--- a/packages/client/src/pages/settings/deck.vue
+++ b/packages/client/src/pages/settings/deck.vue
@@ -1,9 +1,6 @@
<template>
<div class="_formRoot">
- <FormGroup>
- <template #label>{{ i18n.ts.defaultNavigationBehaviour }}</template>
- <FormSwitch v-model="navWindow">{{ i18n.ts.openInWindow }}</FormSwitch>
- </FormGroup>
+ <FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch>
<FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch>
@@ -13,56 +10,31 @@
<option value="center">{{ i18n.ts.center }}</option>
</FormRadios>
- <FormRadios v-model="columnHeaderHeight" class="_formBlock">
- <template #label>{{ i18n.ts._deck.columnHeaderHeight }}</template>
- <option :value="42">{{ i18n.ts.narrow }}</option>
- <option :value="45">{{ i18n.ts.medium }}</option>
- <option :value="48">{{ i18n.ts.wide }}</option>
- </FormRadios>
-
- <FormInput v-model="columnMargin" type="number" class="_formBlock">
- <template #label>{{ i18n.ts._deck.columnMargin }}</template>
- <template #suffix>px</template>
- </FormInput>
-
<FormLink class="_formBlock" @click="setProfile">{{ i18n.ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink>
</div>
</template>
<script lang="ts" setup>
-import { computed, defineExpose, watch } from 'vue';
+import { computed, watch } from 'vue';
import FormSwitch from '@/components/form/switch.vue';
import FormLink from '@/components/form/link.vue';
import FormRadios from '@/components/form/radios.vue';
import FormInput from '@/components/form/input.vue';
-import FormGroup from '@/components/form/group.vue';
import { deckStore } from '@/ui/deck/deck-store';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const navWindow = computed(deckStore.makeGetterSetter('navWindow'));
const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn'));
const columnAlign = computed(deckStore.makeGetterSetter('columnAlign'));
-const columnMargin = computed(deckStore.makeGetterSetter('columnMargin'));
-const columnHeaderHeight = computed(deckStore.makeGetterSetter('columnHeaderHeight'));
const profile = computed(deckStore.makeGetterSetter('profile'));
-watch(navWindow, async () => {
- const { canceled } = await os.confirm({
- type: 'info',
- text: i18n.ts.reloadToApplySetting,
- });
- if (canceled) return;
-
- unisonReload();
-});
-
async function setProfile() {
const { canceled, result: name } = await os.inputText({
title: i18n.ts._deck.profile,
- allowEmpty: false
+ allowEmpty: false,
});
if (canceled) return;
@@ -70,11 +42,12 @@ async function setProfile() {
unisonReload();
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.deck,
- icon: 'fas fa-columns',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.deck,
+ icon: 'fas fa-columns',
});
</script>
diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue
index e9f19aaf0b..3c4ea716ce 100644
--- a/packages/client/src/pages/settings/delete-account.vue
+++ b/packages/client/src/pages/settings/delete-account.vue
@@ -8,13 +8,12 @@
</template>
<script lang="ts" setup>
-import { defineExpose } from 'vue';
import FormInfo from '@/components/ui/info.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
import { signout } from '@/account';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
async function deleteAccount() {
{
@@ -27,12 +26,12 @@ async function deleteAccount() {
const { canceled, result: password } = await os.inputText({
title: i18n.ts.password,
- type: 'password'
+ type: 'password',
});
if (canceled) return;
await os.apiWithDialog('i/delete-account', {
- password: password
+ password: password,
});
await os.alert({
@@ -42,11 +41,12 @@ async function deleteAccount() {
await signout();
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts._accountDelete.accountDelete,
- icon: 'fas fa-exclamation-triangle',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._accountDelete.accountDelete,
+ icon: 'fas fa-exclamation-triangle',
});
</script>
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
index 09a2537ed5..c8c78f2923 100644
--- a/packages/client/src/pages/settings/drive.vue
+++ b/packages/client/src/pages/settings/drive.vue
@@ -28,13 +28,23 @@
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
<template #suffixIcon><i class="fas fa-folder-open"></i></template>
</FormLink>
- <FormSwitch v-model="keepOriginalUploading" class="_formBlock">{{ i18n.ts.keepOriginalUploading }}<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template></FormSwitch>
+ <FormSwitch v-model="keepOriginalUploading" class="_formBlock">
+ <template #label>{{ i18n.ts.keepOriginalUploading }}</template>
+ <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:modelValue="saveProfile()">
+ <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="autoSensitive" class="_formBlock" @update:modelValue="saveProfile()">
+ <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
+ <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template>
+ </FormSwitch>
</FormSection>
</div>
</template>
<script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
import tinycolor from 'tinycolor2';
import FormLink from '@/components/form/link.vue';
import FormSwitch from '@/components/form/switch.vue';
@@ -43,15 +53,18 @@ import MkKeyValue from '@/components/key-value.vue';
import FormSplit from '@/components/form/split.vue';
import * as os from '@/os';
import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
import { defaultStore } from '@/store';
import MkChart from '@/components/chart.vue';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
const fetching = ref(true);
const usage = ref<any>(null);
const capacity = ref<any>(null);
const uploadFolder = ref<any>(null);
+let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw);
+let autoSensitive = $ref($i.autoSensitive);
const meterStyle = computed(() => {
return {
@@ -59,8 +72,8 @@ const meterStyle = computed(() => {
background: tinycolor({
h: 180 - (usage.value / capacity.value * 180),
s: 0.7,
- l: 0.5
- })
+ l: 0.5,
+ }),
};
});
@@ -74,7 +87,7 @@ os.api('drive').then(info => {
if (defaultStore.state.uploadFolder) {
os.api('drive/folders/show', {
- folderId: defaultStore.state.uploadFolder
+ folderId: defaultStore.state.uploadFolder,
}).then(response => {
uploadFolder.value = response;
});
@@ -86,7 +99,7 @@ function chooseUploadFolder() {
os.success();
if (defaultStore.state.uploadFolder) {
uploadFolder.value = await os.api('drive/folders/show', {
- folderId: defaultStore.state.uploadFolder
+ folderId: defaultStore.state.uploadFolder,
});
} else {
uploadFolder.value = null;
@@ -94,12 +107,20 @@ function chooseUploadFolder() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.drive,
- icon: 'fas fa-cloud',
- bg: 'var(--bg)',
- }
+function saveProfile() {
+ os.api('i/update', {
+ alwaysMarkNsfw: !!alwaysMarkNsfw,
+ autoSensitive: !!autoSensitive,
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.drive,
+ icon: 'fas fa-cloud',
});
</script>
diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue
index 37f14068e2..e575af6d6b 100644
--- a/packages/client/src/pages/settings/email.vue
+++ b/packages/client/src/pages/settings/email.vue
@@ -40,27 +40,27 @@
</template>
<script lang="ts" setup>
-import { defineExpose, onMounted, ref, watch } from 'vue';
+import { onMounted, ref, watch } from 'vue';
import FormSection from '@/components/form/section.vue';
import FormInput from '@/components/form/input.vue';
import FormSwitch from '@/components/form/switch.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const emailAddress = ref($i!.email);
const onChangeReceiveAnnouncementEmail = (v) => {
os.api('i/update', {
- receiveAnnouncementEmail: v
+ receiveAnnouncementEmail: v,
});
};
const saveEmailAddress = () => {
os.inputText({
title: i18n.ts.password,
- type: 'password'
+ type: 'password',
}).then(({ canceled, result: password }) => {
if (canceled) return;
os.apiWithDialog('i/update-email', {
@@ -86,7 +86,7 @@ const saveNotificationSettings = () => {
...[emailNotification_follow.value ? 'follow' : null],
...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null],
...[emailNotification_groupInvited.value ? 'groupInvited' : null],
- ].filter(x => x != null)
+ ].filter(x => x != null),
});
};
@@ -100,11 +100,12 @@ onMounted(() => {
});
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.email,
- icon: 'fas fa-envelope',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.email,
+ icon: 'fas fa-envelope',
});
</script>
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
index 64b8cc3106..74fa0bc926 100644
--- a/packages/client/src/pages/settings/general.vue
+++ b/packages/client/src/pages/settings/general.vue
@@ -48,7 +48,8 @@
<FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch>
<FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch>
<FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch>
- <FormSwitch v-model="useOsNativeEmojis" class="_formBlock">{{ i18n.ts.useOsNativeEmojis }}
+ <FormSwitch v-model="useOsNativeEmojis" class="_formBlock">
+ {{ i18n.ts.useOsNativeEmojis }}
<div><Mfm :key="useOsNativeEmojis" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
</FormSwitch>
<FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch>
@@ -80,10 +81,10 @@
<option value="force">{{ i18n.ts._nsfw.force }}</option>
</FormSelect>
- <FormGroup>
- <template #label>{{ i18n.ts.defaultNavigationBehaviour }}</template>
- <FormSwitch v-model="defaultSideView">{{ i18n.ts.openInSideView }}</FormSwitch>
- </FormGroup>
+ <FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" class="_formBlock">
+ <template #label>{{ i18n.ts.numberOfPageCache }}</template>
+ <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template>
+ </FormRange>
<FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink>
@@ -92,11 +93,11 @@
</template>
<script lang="ts" setup>
-import { computed, defineExpose, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
import FormSwitch from '@/components/form/switch.vue';
import FormSelect from '@/components/form/select.vue';
import FormRadios from '@/components/form/radios.vue';
-import FormGroup from '@/components/form/group.vue';
+import FormRange from '@/components/form/range.vue';
import FormSection from '@/components/form/section.vue';
import FormLink from '@/components/form/link.vue';
import MkLink from '@/components/link.vue';
@@ -104,8 +105,8 @@ import { langs } from '@/config';
import { defaultStore } from '@/store';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const lang = ref(localStorage.getItem('lang'));
const fontSize = ref(localStorage.getItem('fontSize'));
@@ -136,7 +137,7 @@ const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript'));
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
-const defaultSideView = computed(defaultStore.makeGetterSetter('defaultSideView'));
+const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
@@ -173,16 +174,17 @@ watch([
aiChanMode,
showGapBetweenNotesInTimeline,
instanceTicker,
- overridedDeviceKind
+ overridedDeviceKind,
], async () => {
await reloadAsk();
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.general,
- icon: 'fas fa-cogs',
- bg: 'var(--bg)'
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.general,
+ icon: 'fas fa-cogs',
});
</script>
diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue
index 127cbcd4c1..d48dab9f8d 100644
--- a/packages/client/src/pages/settings/import-export.vue
+++ b/packages/client/src/pages/settings/import-export.vue
@@ -2,51 +2,83 @@
<div class="_formRoot">
<FormSection>
<template #label>{{ $ts._exportOrImport.allNotes }}</template>
- <MkButton :class="$style.button" inline @click="exportNotes()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <FormFolder>
+ <template #label>{{ $ts.export }}</template>
+ <template #icon><i class="fas fa-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ </FormFolder>
</FormSection>
<FormSection>
<template #label>{{ $ts._exportOrImport.followingList }}</template>
- <FormGroup>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.export }}</template>
+ <template #icon><i class="fas fa-download"></i></template>
<FormSwitch v-model="excludeMutingUsers" class="_formBlock">
{{ $ts._exportOrImport.excludeMutingUsers }}
</FormSwitch>
<FormSwitch v-model="excludeInactiveUsers" class="_formBlock">
{{ $ts._exportOrImport.excludeInactiveUsers }}
</FormSwitch>
- <MkButton :class="$style.button" inline @click="exportFollowing()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
- </FormGroup>
- <FormGroup>
- <MkButton :class="$style.button" inline @click="importFollowing($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
- </FormGroup>
+ <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.import }}</template>
+ <template #icon><i class="fas fa-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormFolder>
</FormSection>
<FormSection>
<template #label>{{ $ts._exportOrImport.userLists }}</template>
- <MkButton :class="$style.button" inline @click="exportUserLists()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
- <MkButton :class="$style.button" inline @click="importUserLists($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.export }}</template>
+ <template #icon><i class="fas fa-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.import }}</template>
+ <template #icon><i class="fas fa-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormFolder>
</FormSection>
<FormSection>
<template #label>{{ $ts._exportOrImport.muteList }}</template>
- <MkButton :class="$style.button" inline @click="exportMuting()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
- <MkButton :class="$style.button" inline @click="importMuting($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.export }}</template>
+ <template #icon><i class="fas fa-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.import }}</template>
+ <template #icon><i class="fas fa-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormFolder>
</FormSection>
<FormSection>
<template #label>{{ $ts._exportOrImport.blockingList }}</template>
- <MkButton :class="$style.button" inline @click="exportBlocking()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
- <MkButton :class="$style.button" inline @click="importBlocking($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.export }}</template>
+ <template #icon><i class="fas fa-download"></i></template>
+ <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ $ts.import }}</template>
+ <template #icon><i class="fas fa-upload"></i></template>
+ <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormFolder>
</FormSection>
</div>
</template>
<script lang="ts" setup>
-import { defineExpose, ref } from 'vue';
+import { ref } from 'vue';
import MkButton from '@/components/ui/button.vue';
import FormSection from '@/components/form/section.vue';
-import FormGroup from '@/components/form/group.vue';
+import FormFolder from '@/components/form/folder.vue';
import FormSwitch from '@/components/form/switch.vue';
import * as os from '@/os';
import { selectFile } from '@/scripts/select-file';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const excludeMutingUsers = ref(false);
const excludeInactiveUsers = ref(false);
@@ -116,12 +148,13 @@ const importBlocking = async (ev) => {
os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
};
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.importAndExport,
- icon: 'fas fa-boxes',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.importAndExport,
+ icon: 'fas fa-boxes',
});
</script>
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
index e6670ea930..76410ec12f 100644
--- a/packages/client/src/pages/settings/index.vue
+++ b/packages/client/src/pages/settings/index.vue
@@ -1,58 +1,54 @@
<template>
-<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
- <div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
- <div class="header">
- <div class="title">
- <MkA v-if="narrow" to="/settings">{{ $ts.settings }}</MkA>
- <template v-else>{{ $ts.settings }}</template>
- </div>
- <div v-if="childInfo" class="subtitle">{{ childInfo.title }}</div>
- </div>
- <div class="body">
- <div v-if="!narrow || initialPage == null" class="nav">
- <div class="baaadecd">
- <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
- <MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
+ <div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
+ <div class="body">
+ <div v-if="!narrow || initialPage == null" class="nav">
+ <div class="baaadecd">
+ <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+ </div>
</div>
- </div>
- <div v-if="!(narrow && initialPage == null)" class="main">
- <div class="bkzroven">
- <component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/>
+ <div v-if="!(narrow && initialPage == null)" class="main">
+ <div class="bkzroven">
+ <component :is="component" :key="initialPage" v-bind="pageProps"/>
+ </div>
</div>
</div>
</div>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</mkstickycontainer>
</template>
<script setup lang="ts">
-import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue';
import { i18n } from '@/i18n';
import MkInfo from '@/components/ui/info.vue';
import MkSuperMenu from '@/components/ui/super-menu.vue';
import { scroll } from '@/scripts/scroll';
-import { signout } from '@/account';
+import { signout , $i } from '@/account';
import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
import { instance } from '@/instance';
-import { $i } from '@/account';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import * as os from '@/os';
-const props = defineProps<{
- initialPage?: string
-}>();
+const props = withDefaults(defineProps<{
+ initialPage?: string;
+}>(), {
+});
const indexInfo = {
title: i18n.ts.settings,
icon: 'fas fa-cog',
- bg: 'var(--bg)',
hideHeader: true,
};
const INFO = ref(indexInfo);
const el = ref<HTMLElement | null>(null);
const childInfo = ref(null);
-const nav = new MisskeyNavigator();
+const router = useRouter();
const narrow = ref(false);
const NARROW_THRESHOLD = 600;
@@ -119,6 +115,11 @@ const menuDef = computed(() => [{
active: props.initialPage === 'theme',
}, {
icon: 'fas fa-list-ul',
+ text: i18n.ts.statusbar,
+ to: '/settings/statusbars',
+ active: props.initialPage === 'statusbars',
+ }, {
+ icon: 'fas fa-list-ul',
text: i18n.ts.menu,
to: '/settings/menu',
active: props.initialPage === 'menu',
@@ -185,11 +186,16 @@ const menuDef = computed(() => [{
type: 'button',
icon: 'fas fa-sign-in-alt fa-flip-horizontal',
text: i18n.ts.logout,
- action: () => {
+ action: async () => {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.logoutConfirm,
+ });
+ if (canceled) return;
signout();
},
danger: true,
- },],
+ }],
}]);
const pageProps = ref({});
@@ -220,6 +226,7 @@ const component = computed(() => {
case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
case 'menu': return defineAsyncComponent(() => import('./menu.vue'));
+ case 'statusbars': return defineAsyncComponent(() => import('./statusbars.vue'));
case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue'));
case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
@@ -242,7 +249,7 @@ watch(component, () => {
watch(() => props.initialPage, () => {
if (props.initialPage == null && !narrow.value) {
- nav.push('/settings/profile');
+ router.push('/settings/profile');
} else {
if (props.initialPage == null) {
INFO.value = indexInfo;
@@ -252,7 +259,7 @@ watch(() => props.initialPage, () => {
watch(narrow, () => {
if (props.initialPage == null && !narrow.value) {
- nav.push('/settings/profile');
+ router.push('/settings/profile');
}
});
@@ -261,7 +268,7 @@ onMounted(() => {
narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
if (props.initialPage == null && !narrow.value) {
- nav.push('/settings/profile');
+ router.push('/settings/profile');
}
});
@@ -271,38 +278,23 @@ onUnmounted(() => {
const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
-const pageChanged = (page) => {
- if (page == null) {
+provideMetadataReceiver((info) => {
+ if (info == null) {
childInfo.value = null;
} else {
- childInfo.value = page[symbols.PAGE_INFO];
+ childInfo.value = info;
}
-};
-
-defineExpose({
- [symbols.PAGE_INFO]: INFO,
});
-</script>
-<style lang="scss" scoped>
-.vvcocwet {
- > .header {
- display: flex;
- margin-bottom: 24px;
- font-size: 1.3em;
- font-weight: bold;
+const headerActions = $computed(() => []);
- > .title {
- display: block;
- width: 34%;
- }
+const headerTabs = $computed(() => []);
- > .subtitle {
- flex: 1;
- min-width: 0;
- }
- }
+definePageMetadata(INFO);
+</script>
+<style lang="scss" scoped>
+.vvcocwet {
> .body {
> .nav {
.baaadecd {
diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue
index bcc2ee85ad..d0ca85adca 100644
--- a/packages/client/src/pages/settings/instance-mute.vue
+++ b/packages/client/src/pages/settings/instance-mute.vue
@@ -10,14 +10,14 @@
</template>
<script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
import FormTextarea from '@/components/form/textarea.vue';
import MkInfo from '@/components/ui/info.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const instanceMutes = ref($i!.mutedInstances.join('\n'));
const changed = ref(false);
@@ -42,10 +42,12 @@ watch(instanceMutes, () => {
changed.value = true;
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.instanceMute,
- icon: 'fas fa-volume-mute'
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.instanceMute,
+ icon: 'fas fa-volume-mute',
});
</script>
diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue
index 75c6200944..ccb02e08a2 100644
--- a/packages/client/src/pages/settings/integration.vue
+++ b/packages/client/src/pages/settings/integration.vue
@@ -24,14 +24,14 @@
</template>
<script lang="ts" setup>
-import { computed, defineExpose, onMounted, ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
import { apiUrl } from '@/config';
import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
import { $i } from '@/account';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const twitterForm = ref<Window | null>(null);
const discordForm = ref<Window | null>(null);
@@ -42,7 +42,7 @@ const integrations = computed(() => $i!.integrations);
function openWindow(service: string, type: string) {
return window.open(`${apiUrl}/${type}/${service}`,
`${service}_${type}_window`,
- 'height=570, width=520'
+ 'height=570, width=520',
);
}
@@ -72,7 +72,7 @@ function disconnectGithub() {
onMounted(() => {
document.cookie = `igi=${$i!.token}; path=/;` +
- ` max-age=31536000;` +
+ ' max-age=31536000;' +
(document.location.protocol.startsWith('https') ? ' secure' : '');
watch(integrations, () => {
@@ -88,11 +88,12 @@ onMounted(() => {
});
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.integration,
- icon: 'fas fa-share-alt',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.integration,
+ icon: 'fas fa-share-alt',
});
</script>
diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue
index 2288c3f718..076654c105 100644
--- a/packages/client/src/pages/settings/menu.vue
+++ b/packages/client/src/pages/settings/menu.vue
@@ -18,16 +18,16 @@
</template>
<script lang="ts" setup>
-import { computed, defineExpose, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormRadios from '@/components/form/radios.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
import { menuDef } from '@/menu';
import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const items = ref(defaultStore.state.menu.join('\n'));
@@ -37,7 +37,7 @@ const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
async function reloadAsk() {
const { canceled } = await os.confirm({
type: 'info',
- text: i18n.ts.reloadToApplySetting
+ text: i18n.ts.reloadToApplySetting,
});
if (canceled) return;
@@ -49,10 +49,10 @@ async function addItem() {
const { canceled, result: item } = await os.select({
title: i18n.ts.addItem,
items: [...menu.map(k => ({
- value: k, text: i18n.ts[menuDef[k].title]
+ value: k, text: i18n.ts[menuDef[k].title],
})), {
- value: '-', text: i18n.ts.divider
- }]
+ value: '-', text: i18n.ts.divider,
+ }],
});
if (canceled) return;
items.value = [...split.value, item].join('\n');
@@ -76,11 +76,12 @@ watch(menuDisplay, async () => {
await reloadAsk();
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.menu,
- icon: 'fas fa-list-ul',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.menu,
+ icon: 'fas fa-list-ul',
});
</script>
diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue
index 28d11809e3..397a0c815c 100644
--- a/packages/client/src/pages/settings/mute-block.vue
+++ b/packages/client/src/pages/settings/mute-block.vue
@@ -7,7 +7,7 @@
<div v-if="tab === 'mute'">
<MkPagination :pagination="mutingPagination" class="muting">
<template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
- <template v-slot="{items}">
+ <template #default="{items}">
<FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
<MkAcct :user="mute.mutee"/>
</FormLink>
@@ -17,7 +17,7 @@
<div v-if="tab === 'block'">
<MkPagination :pagination="blockingPagination" class="blocking">
<template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
- <template v-slot="{items}">
+ <template #default="{items}">
<FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
<MkAcct :user="block.blockee"/>
</FormLink>
@@ -35,8 +35,8 @@ import FormInfo from '@/components/ui/info.vue';
import FormLink from '@/components/form/link.vue';
import { userPage } from '@/filters/user';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let tab = $ref('mute');
@@ -50,11 +50,12 @@ const blockingPagination = {
limit: 10,
};
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.muteAndBlock,
- icon: 'fas fa-ban',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.muteAndBlock,
+ icon: 'fas fa-ban',
});
</script>
diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue
index b8fff95a8d..d2a363965a 100644
--- a/packages/client/src/pages/settings/notifications.vue
+++ b/packages/client/src/pages/settings/notifications.vue
@@ -10,15 +10,15 @@
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, defineExpose } from 'vue';
+import { defineAsyncComponent } from 'vue';
+import { notificationTypes } from 'misskey-js';
import FormButton from '@/components/ui/button.vue';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
-import { notificationTypes } from 'misskey-js';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
async function readAllUnreadNotes() {
await os.api('i/read-all-unread-notes');
@@ -45,15 +45,16 @@ function configure() {
}).then(i => {
$i!.mutingNotificationTypes = i.mutingNotificationTypes;
});
- }
+ },
}, 'closed');
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.notifications,
- icon: 'fas fa-bell',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.notifications,
+ icon: 'fas fa-bell',
});
</script>
diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue
index 82e174a5b4..52ef4d401f 100644
--- a/packages/client/src/pages/settings/other.vue
+++ b/packages/client/src/pages/settings/other.vue
@@ -15,30 +15,31 @@
</template>
<script lang="ts" setup>
-import { computed, defineExpose } from 'vue';
+import { computed } from 'vue';
import FormSwitch from '@/components/form/switch.vue';
import FormLink from '@/components/form/link.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
import { $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const reportError = computed(defaultStore.makeGetterSetter('reportError'));
function onChangeInjectFeaturedNote(v) {
os.api('i/update', {
- injectFeaturedNote: v
+ injectFeaturedNote: v,
}).then((i) => {
$i!.injectFeaturedNote = i.injectFeaturedNote;
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.other,
- icon: 'fas fa-ellipsis-h',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.other,
+ icon: 'fas fa-ellipsis-h',
});
</script>
diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue
index 96c0abfd99..a4cab4b7a4 100644
--- a/packages/client/src/pages/settings/plugin.install.vue
+++ b/packages/client/src/pages/settings/plugin.install.vue
@@ -13,7 +13,7 @@
</template>
<script lang="ts" setup>
-import { defineExpose, defineAsyncComponent, nextTick, ref } from 'vue';
+import { defineAsyncComponent, nextTick, ref } from 'vue';
import { AiScript, parse } from '@syuilo/aiscript';
import { serialize } from '@syuilo/aiscript/built/serializer';
import { v4 as uuid } from 'uuid';
@@ -24,7 +24,7 @@ import * as os from '@/os';
import { ColdDeviceStorage } from '@/store';
import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
const code = ref(null);
@@ -35,7 +35,7 @@ function installPlugin({ id, meta, ast, token }) {
active: true,
configData: {},
token: token,
- ast: ast
+ ast: ast,
}));
}
@@ -46,7 +46,7 @@ async function install() {
} catch (err) {
os.alert({
type: 'error',
- text: 'Syntax error :('
+ text: 'Syntax error :(',
});
return;
}
@@ -55,7 +55,7 @@ async function install() {
if (meta == null) {
os.alert({
type: 'error',
- text: 'No metadata found :('
+ text: 'No metadata found :(',
});
return;
}
@@ -64,7 +64,7 @@ async function install() {
if (metadata == null) {
os.alert({
type: 'error',
- text: 'No metadata found :('
+ text: 'No metadata found :(',
});
return;
}
@@ -73,7 +73,7 @@ async function install() {
if (name == null || version == null || author == null) {
os.alert({
type: 'error',
- text: 'Required property not found :('
+ text: 'Required property not found :(',
});
return;
}
@@ -83,7 +83,7 @@ async function install() {
title: i18n.ts.tokenRequested,
information: i18n.ts.pluginTokenRequestedDescription,
initialName: name,
- initialPermissions: permissions
+ initialPermissions: permissions,
}, {
done: async result => {
const { name, permissions } = result;
@@ -93,17 +93,17 @@ async function install() {
permission: permissions,
});
res(token);
- }
+ },
}, 'closed');
});
installPlugin({
id: uuid(),
meta: {
- name, version, author, description, permissions, config
+ name, version, author, description, permissions, config,
},
token,
- ast: serialize(ast)
+ ast: serialize(ast),
});
os.success();
@@ -113,11 +113,12 @@ async function install() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts._plugin.install,
- icon: 'fas fa-download',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._plugin.install,
+ icon: 'fas fa-download',
});
</script>
diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue
index 873a022cbc..8e773b7990 100644
--- a/packages/client/src/pages/settings/plugin.vue
+++ b/packages/client/src/pages/settings/plugin.vue
@@ -7,7 +7,7 @@
<div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;">
<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
- <FormSwitch class="_formBlock" :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
+ <FormSwitch class="_formBlock" :model-value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
<MkKeyValue class="_formBlock">
<template #key>{{ i18n.ts.author }}</template>
@@ -32,7 +32,7 @@
</template>
<script lang="ts" setup>
-import { defineExpose, nextTick, ref } from 'vue';
+import { nextTick, ref } from 'vue';
import FormLink from '@/components/form/link.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormSection from '@/components/form/section.vue';
@@ -40,9 +40,9 @@ import MkButton from '@/components/ui/button.vue';
import MkKeyValue from '@/components/key-value.vue';
import * as os from '@/os';
import { ColdDeviceStorage } from '@/store';
-import * as symbols from '@/symbols';
import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const plugins = ref(ColdDeviceStorage.get('plugins'));
@@ -83,12 +83,13 @@ function changeActive(plugin, active) {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.plugins,
- icon: 'fas fa-plug',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.plugins,
+ icon: 'fas fa-plug',
});
</script>
diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue
index a84d2f8786..be9e34cdfb 100644
--- a/packages/client/src/pages/settings/privacy.vue
+++ b/packages/client/src/pages/settings/privacy.vue
@@ -31,8 +31,13 @@
<FormSection>
<FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch>
- <FormGroup v-if="!rememberNoteVisibility" class="_formBlock">
+ <FormFolder v-if="!rememberNoteVisibility" class="_formBlock">
<template #label>{{ $ts.defaultNoteVisibility }}</template>
+ <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ $ts._visibility.public }}</template>
+ <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ $ts._visibility.home }}</template>
+ <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ $ts._visibility.followers }}</template>
+ <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ $ts._visibility.specified }}</template>
+
<FormSelect v-model="defaultNoteVisibility" class="_formBlock">
<option value="public">{{ $ts._visibility.public }}</option>
<option value="home">{{ $ts._visibility.home }}</option>
@@ -40,7 +45,7 @@
<option value="specified">{{ $ts._visibility.specified }}</option>
</FormSelect>
<FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ $ts._visibility.localOnly }}</FormSwitch>
- </FormGroup>
+ </FormFolder>
</FormSection>
<FormSwitch v-model="keepCw" class="_formBlock" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch>
@@ -52,12 +57,12 @@ import { } from 'vue';
import FormSwitch from '@/components/form/switch.vue';
import FormSelect from '@/components/form/select.vue';
import FormSection from '@/components/form/section.vue';
-import FormGroup from '@/components/form/group.vue';
+import FormFolder from '@/components/form/folder.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
let isLocked = $ref($i.isLocked);
let autoAcceptFollowed = $ref($i.autoAcceptFollowed);
@@ -84,11 +89,12 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.privacy,
- icon: 'fas fa-lock-open',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.privacy,
+ icon: 'fas fa-lock-open',
});
</script>
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
index b64dc93cc7..2a326fc2b6 100644
--- a/packages/client/src/pages/settings/profile.vue
+++ b/packages/client/src/pages/settings/profile.vue
@@ -56,8 +56,6 @@
<FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch>
<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch>
<FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch>
-
- <FormSwitch v-model="profile.alwaysMarkNsfw" class="_formBlock">{{ i18n.ts.alwaysMarkSensitive }}</FormSwitch>
</div>
</template>
@@ -74,10 +72,10 @@ import FormSlot from '@/components/form/slot.vue';
import { host } from '@/config';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
import { $i } from '@/account';
import { langmap } from '@/scripts/langmap';
+import { definePageMetadata } from '@/scripts/page-metadata';
const profile = reactive({
name: $i.name,
@@ -88,7 +86,6 @@ const profile = reactive({
isBot: $i.isBot,
isCat: $i.isCat,
showTimelineReplies: $i.showTimelineReplies,
- alwaysMarkNsfw: $i.alwaysMarkNsfw,
});
watch(() => profile, () => {
@@ -126,7 +123,6 @@ function save() {
isBot: !!profile.isBot,
isCat: !!profile.isCat,
showTimelineReplies: !!profile.showTimelineReplies,
- alwaysMarkNsfw: !!profile.alwaysMarkNsfw,
});
}
@@ -176,12 +172,13 @@ function changeBanner(ev) {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.profile,
- icon: 'fas fa-user',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.profile,
+ icon: 'fas fa-user',
});
</script>
@@ -191,7 +188,7 @@ defineExpose({
background-size: cover;
background-position: center;
border-radius: 10px;
- overflow: clip;
+ overflow: hidden; overflow: clip;
> .avatar {
display: inline-block;
diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue
index 963ac81dfa..382e1b081e 100644
--- a/packages/client/src/pages/settings/reaction.vue
+++ b/packages/client/src/pages/settings/reaction.vue
@@ -64,8 +64,8 @@ import FormSection from '@/components/form/section.vue';
import FormSwitch from '@/components/form/switch.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let reactions = $ref(JSON.parse(JSON.stringify(defaultStore.state.reactions)));
@@ -83,7 +83,7 @@ function remove(reaction, ev: MouseEvent) {
text: i18n.ts.remove,
action: () => {
reactions = reactions.filter(x => x !== reaction);
- }
+ },
}], ev.currentTarget ?? ev.target);
}
@@ -106,7 +106,7 @@ async function setDefault() {
function chooseEmoji(ev: MouseEvent) {
os.pickEmoji(ev.currentTarget ?? ev.target, {
- showPinned: false
+ showPinned: false,
}).then(emoji => {
if (!reactions.includes(emoji)) {
reactions.push(emoji);
@@ -120,15 +120,16 @@ watch($$(reactions), () => {
deep: true,
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.reaction,
- icon: 'fas fa-laugh',
- action: {
- icon: 'fas fa-eye',
- handler: preview,
- },
- bg: 'var(--bg)',
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.reaction,
+ icon: 'fas fa-laugh',
+ action: {
+ icon: 'fas fa-eye',
+ handler: preview,
},
});
</script>
diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue
index 401648790a..eb3efa9afb 100644
--- a/packages/client/src/pages/settings/security.vue
+++ b/packages/client/src/pages/settings/security.vue
@@ -13,7 +13,7 @@
<FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template>
<MkPagination :pagination="pagination">
- <template v-slot="{items}">
+ <template #default="{items}">
<div>
<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
<header>
@@ -38,15 +38,14 @@
</template>
<script lang="ts" setup>
-import { defineExpose } from 'vue';
+import X2fa from './2fa.vue';
import FormSection from '@/components/form/section.vue';
import FormSlot from '@/components/form/slot.vue';
import FormButton from '@/components/ui/button.vue';
import MkPagination from '@/components/ui/pagination.vue';
-import X2fa from './2fa.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const pagination = {
endpoint: 'i/signin-history' as const,
@@ -56,54 +55,55 @@ const pagination = {
async function change() {
const { canceled: canceled1, result: currentPassword } = await os.inputText({
title: i18n.ts.currentPassword,
- type: 'password'
+ type: 'password',
});
if (canceled1) return;
const { canceled: canceled2, result: newPassword } = await os.inputText({
title: i18n.ts.newPassword,
- type: 'password'
+ type: 'password',
});
if (canceled2) return;
const { canceled: canceled3, result: newPassword2 } = await os.inputText({
title: i18n.ts.newPasswordRetype,
- type: 'password'
+ type: 'password',
});
if (canceled3) return;
if (newPassword !== newPassword2) {
os.alert({
type: 'error',
- text: i18n.ts.retypedNotMatch
+ text: i18n.ts.retypedNotMatch,
});
return;
}
os.apiWithDialog('i/change-password', {
currentPassword,
- newPassword
+ newPassword,
});
}
function regenerateToken() {
os.inputText({
title: i18n.ts.password,
- type: 'password'
+ type: 'password',
}).then(({ canceled, result: password }) => {
if (canceled) return;
os.api('i/regenerate_token', {
- password: password
+ password: password,
});
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.security,
- icon: 'fas fa-lock',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.security,
+ icon: 'fas fa-lock',
});
</script>
diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue
index d01e87c1f8..f29c9eb049 100644
--- a/packages/client/src/pages/settings/sounds.vue
+++ b/packages/client/src/pages/settings/sounds.vue
@@ -18,7 +18,7 @@
</template>
<script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
import FormRange from '@/components/form/range.vue';
import FormButton from '@/components/ui/button.vue';
import FormLink from '@/components/form/link.vue';
@@ -26,8 +26,8 @@ import FormSection from '@/components/form/section.vue';
import * as os from '@/os';
import { ColdDeviceStorage } from '@/store';
import { playFile } from '@/scripts/sound';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const masterVolume = computed({
get: () => {
@@ -35,19 +35,19 @@ const masterVolume = computed({
},
set: (value) => {
ColdDeviceStorage.set('sound_masterVolume', value);
- }
+ },
});
const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up');
const sounds = ref({
- note: ColdDeviceStorage.get('sound_note'),
- noteMy: ColdDeviceStorage.get('sound_noteMy'),
- notification: ColdDeviceStorage.get('sound_notification'),
- chat: ColdDeviceStorage.get('sound_chat'),
- chatBg: ColdDeviceStorage.get('sound_chatBg'),
- antenna: ColdDeviceStorage.get('sound_antenna'),
- channel: ColdDeviceStorage.get('sound_channel'),
+ note: ColdDeviceStorage.get('sound_note'),
+ noteMy: ColdDeviceStorage.get('sound_noteMy'),
+ notification: ColdDeviceStorage.get('sound_notification'),
+ chat: ColdDeviceStorage.get('sound_chat'),
+ chatBg: ColdDeviceStorage.get('sound_chatBg'),
+ antenna: ColdDeviceStorage.get('sound_antenna'),
+ channel: ColdDeviceStorage.get('sound_channel'),
});
const soundsTypes = [
@@ -95,15 +95,15 @@ async function edit(type) {
step: 0.05,
textConverter: (v) => `${Math.floor(v * 100)}%`,
label: i18n.ts.volume,
- default: sounds.value[type].volume
+ default: sounds.value[type].volume,
},
listen: {
type: 'button',
content: i18n.ts.listen,
action: (_, values) => {
playFile(values.type, values.volume);
- }
- }
+ },
+ },
});
if (canceled) return;
@@ -124,11 +124,12 @@ function reset() {
}
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.sounds,
- icon: 'fas fa-music',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.sounds,
+ icon: 'fas fa-music',
});
</script>
diff --git a/packages/client/src/pages/settings/statusbars.statusbar.vue b/packages/client/src/pages/settings/statusbars.statusbar.vue
new file mode 100644
index 0000000000..206979925e
--- /dev/null
+++ b/packages/client/src/pages/settings/statusbars.statusbar.vue
@@ -0,0 +1,136 @@
+<template>
+<div class="_formRoot">
+ <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock">
+ <template #label>{{ i18n.ts.type }}</template>
+ <option value="rss">RSS</option>
+ <option value="federation">Federation</option>
+ <option value="userList">User list timeline</option>
+ </FormSelect>
+
+ <MkInput v-model="statusbar.name" manual-save class="_formBlock">
+ <template #label>{{ i18n.ts.label }}</template>
+ </MkInput>
+
+ <MkSwitch v-model="statusbar.black" class="_formBlock">
+ <template #label>Black</template>
+ </MkSwitch>
+
+ <FormRadios v-model="statusbar.size" class="_formBlock">
+ <template #label>{{ i18n.ts.size }}</template>
+ <option value="verySmall">{{ i18n.ts.small }}+</option>
+ <option value="small">{{ i18n.ts.small }}</option>
+ <option value="medium">{{ i18n.ts.medium }}</option>
+ <option value="large">{{ i18n.ts.large }}</option>
+ <option value="veryLarge">{{ i18n.ts.large }}+</option>
+ </FormRadios>
+
+ <template v-if="statusbar.type === 'rss'">
+ <MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url">
+ <template #label>URL</template>
+ </MkInput>
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+ <template #label>{{ i18n.ts.refreshInterval }}</template>
+ </MkInput>
+ <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+ <template #label>{{ i18n.ts.speed }}</template>
+ <template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+ </FormRange>
+ <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+ <template #label>{{ i18n.ts.reverse }}</template>
+ </MkSwitch>
+ </template>
+ <template v-else-if="statusbar.type === 'federation'">
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+ <template #label>{{ i18n.ts.refreshInterval }}</template>
+ </MkInput>
+ <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+ <template #label>{{ i18n.ts.speed }}</template>
+ <template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+ </FormRange>
+ <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+ <template #label>{{ i18n.ts.reverse }}</template>
+ </MkSwitch>
+ <MkSwitch v-model="statusbar.props.colored" class="_formBlock">
+ <template #label>{{ i18n.ts.colored }}</template>
+ </MkSwitch>
+ </template>
+ <template v-else-if="statusbar.type === 'userList' && userLists != null">
+ <FormSelect v-model="statusbar.props.userListId" class="_formBlock">
+ <template #label>{{ i18n.ts.userList }}</template>
+ <option v-for="list in userLists" :value="list.id">{{ list.name }}</option>
+ </FormSelect>
+ <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number">
+ <template #label>{{ i18n.ts.refreshInterval }}</template>
+ </MkInput>
+ <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock">
+ <template #label>{{ i18n.ts.speed }}</template>
+ <template #caption>{{ i18n.ts.fast }} &lt;-&gt; {{ i18n.ts.slow }}</template>
+ </FormRange>
+ <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock">
+ <template #label>{{ i18n.ts.reverse }}</template>
+ </MkSwitch>
+ </template>
+
+ <div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, reactive, ref, watch } from 'vue';
+import FormSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormButton from '@/components/ui/button.vue';
+import FormRange from '@/components/form/range.vue';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+ _id: string;
+ userLists: any[] | null;
+}>();
+
+const statusbar = reactive(JSON.parse(JSON.stringify(defaultStore.state.statusbars.find(x => x.id === props._id))));
+
+watch(() => statusbar.type, () => {
+ if (statusbar.type === 'rss') {
+ statusbar.name = 'NEWS';
+ statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews';
+ statusbar.props.refreshIntervalSec = 120;
+ statusbar.props.display = 'marquee';
+ statusbar.props.marqueeDuration = 100;
+ statusbar.props.marqueeReverse = false;
+ } else if (statusbar.type === 'federation') {
+ statusbar.name = 'FEDERATION';
+ statusbar.props.refreshIntervalSec = 120;
+ statusbar.props.display = 'marquee';
+ statusbar.props.marqueeDuration = 100;
+ statusbar.props.marqueeReverse = false;
+ statusbar.props.colored = false;
+ } else if (statusbar.type === 'userList') {
+ statusbar.name = 'LIST TL';
+ statusbar.props.refreshIntervalSec = 120;
+ statusbar.props.display = 'marquee';
+ statusbar.props.marqueeDuration = 100;
+ statusbar.props.marqueeReverse = false;
+ }
+});
+
+watch(statusbar, save);
+
+async function save() {
+ const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id);
+ const statusbars = JSON.parse(JSON.stringify(defaultStore.state.statusbars));
+ statusbars[i] = JSON.parse(JSON.stringify(statusbar));
+ defaultStore.set('statusbars', statusbars);
+}
+
+function del() {
+ defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id));
+}
+</script>
diff --git a/packages/client/src/pages/settings/statusbars.vue b/packages/client/src/pages/settings/statusbars.vue
new file mode 100644
index 0000000000..c81bd7fbdf
--- /dev/null
+++ b/packages/client/src/pages/settings/statusbars.vue
@@ -0,0 +1,54 @@
+<template>
+<div class="_formRoot">
+ <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock">
+ <template #label>{{ x.type ?? i18n.ts.notSet }}</template>
+ <template #suffix>{{ x.name }}</template>
+ <XStatusbar :_id="x.id" :user-lists="userLists"/>
+ </FormFolder>
+ <FormButton primary @click="add">{{ i18n.ts.add }}</FormButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XStatusbar from './statusbars.statusbar.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const statusbars = defaultStore.reactiveState.statusbars;
+
+let userLists = $ref();
+
+onMounted(() => {
+ os.api('users/lists/list').then(res => {
+ userLists = res;
+ });
+});
+
+async function add() {
+ defaultStore.push('statusbars', {
+ id: uuid(),
+ type: null,
+ black: false,
+ size: 'medium',
+ props: {},
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.statusbar,
+ icon: 'fas fa-list-ul',
+ bg: 'var(--bg)',
+});
+</script>
diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue
index 25fa6c012b..2994d8fb1a 100644
--- a/packages/client/src/pages/settings/theme.install.vue
+++ b/packages/client/src/pages/settings/theme.install.vue
@@ -19,8 +19,8 @@ import FormButton from '@/components/ui/button.vue';
import { applyTheme, validateTheme } from '@/scripts/theme';
import * as os from '@/os';
import { addTheme, getThemes } from '@/theme-store';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let installThemeCode = $ref(null);
@@ -32,21 +32,21 @@ function parseThemeCode(code: string) {
} catch (err) {
os.alert({
type: 'error',
- text: i18n.ts._theme.invalid
+ text: i18n.ts._theme.invalid,
});
return false;
}
if (!validateTheme(theme)) {
os.alert({
type: 'error',
- text: i18n.ts._theme.invalid
+ text: i18n.ts._theme.invalid,
});
return false;
}
if (getThemes().some(t => t.id === theme.id)) {
os.alert({
type: 'info',
- text: i18n.ts._theme.alreadyInstalled
+ text: i18n.ts._theme.alreadyInstalled,
});
return false;
}
@@ -65,15 +65,16 @@ async function install(code: string): Promise<void> {
await addTheme(theme);
os.alert({
type: 'success',
- text: i18n.t('_theme.installed', { name: theme.name })
+ text: i18n.t('_theme.installed', { name: theme.name }),
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts._theme.install,
- icon: 'fas fa-download',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._theme.install,
+ icon: 'fas fa-download',
});
</script>
diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue
index 94b2d24455..9d28b4a316 100644
--- a/packages/client/src/pages/settings/theme.manage.vue
+++ b/packages/client/src/pages/settings/theme.manage.vue
@@ -26,7 +26,7 @@
</template>
<script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
import JSON5 from 'json5';
import FormTextarea from '@/components/form/textarea.vue';
import FormSelect from '@/components/form/select.vue';
@@ -36,8 +36,8 @@ import { Theme, getBuiltinThemesRef } from '@/scripts/theme';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import * as os from '@/os';
import { getThemes, removeTheme } from '@/theme-store';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const installedThemes = ref(getThemes());
const builtinThemes = getBuiltinThemesRef();
@@ -67,11 +67,12 @@ function uninstall() {
os.success();
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts._theme.manage,
- icon: 'fas fa-folder-open',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts._theme.manage,
+ icon: 'fas fa-folder-open',
});
</script>
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
index 5e7ffcff4b..d330e1ba25 100644
--- a/packages/client/src/pages/settings/theme.vue
+++ b/packages/client/src/pages/settings/theme.vue
@@ -1,5 +1,5 @@
<template>
-<div class="_formRoot">
+<div class="_formRoot rsljpzjq">
<div v-adaptive-border class="rfqxtzch _panel _formBlock">
<div class="toggle">
<div class="toggleWrapper">
@@ -26,18 +26,8 @@
</div>
</div>
- <template v-if="darkMode">
- <FormSelect v-model="darkThemeId" class="_formBlock">
- <template #label>{{ $ts.themeForDarkMode }}</template>
- <template #prefix><i class="fas fa-moon"></i></template>
- <optgroup :label="$ts.darkThemes">
- <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
- </optgroup>
- <optgroup :label="$ts.lightThemes">
- <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
- </optgroup>
- </FormSelect>
- <FormSelect v-model="lightThemeId" class="_formBlock">
+ <div class="selects _formBlock">
+ <FormSelect v-model="lightThemeId" large class="select">
<template #label>{{ $ts.themeForLightMode }}</template>
<template #prefix><i class="fas fa-sun"></i></template>
<optgroup :label="$ts.lightThemes">
@@ -47,19 +37,7 @@
<option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
</optgroup>
</FormSelect>
- </template>
- <template v-else>
- <FormSelect v-model="lightThemeId" class="_formBlock">
- <template #label>{{ $ts.themeForLightMode }}</template>
- <template #prefix><i class="fas fa-sun"></i></template>
- <optgroup :label="$ts.lightThemes">
- <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
- </optgroup>
- <optgroup :label="$ts.darkThemes">
- <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
- </optgroup>
- </FormSelect>
- <FormSelect v-model="darkThemeId" class="_formBlock">
+ <FormSelect v-model="darkThemeId" large class="select">
<template #label>{{ $ts.themeForDarkMode }}</template>
<template #prefix><i class="fas fa-moon"></i></template>
<optgroup :label="$ts.darkThemes">
@@ -69,7 +47,7 @@
<option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option>
</optgroup>
</FormSelect>
- </template>
+ </div>
<FormSection>
<div class="_formLinksGrid">
@@ -96,13 +74,12 @@ import FormButton from '@/components/ui/button.vue';
import { getBuiltinThemesRef } from '@/scripts/theme';
import { selectFile } from '@/scripts/select-file';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
-import { ColdDeviceStorage } from '@/store';
+import { ColdDeviceStorage , defaultStore } from '@/store';
import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
import { instance } from '@/instance';
import { uniqueBy } from '@/scripts/array';
import { fetchThemes, getThemes } from '@/theme-store';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
const installedThemes = ref(getThemes());
const builtinThemes = getBuiltinThemesRef();
@@ -120,8 +97,11 @@ const darkThemeId = computed({
return darkTheme.value.id;
},
set(id) {
- ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id));
- }
+ const t = themes.value.find(x => x.id === id);
+ if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
+ ColdDeviceStorage.set('darkTheme', t);
+ }
+ },
});
const lightTheme = ColdDeviceStorage.ref('lightTheme');
const lightThemeId = computed({
@@ -129,8 +109,11 @@ const lightThemeId = computed({
return lightTheme.value.id;
},
set(id) {
- ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id));
- }
+ const t = themes.value.find(x => x.id === id);
+ if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる
+ ColdDeviceStorage.set('lightTheme', t);
+ }
+ },
});
const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
@@ -168,12 +151,13 @@ function setWallpaper(event) {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.theme,
- icon: 'fas fa-palette',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.theme,
+ icon: 'fas fa-palette',
});
</script>
@@ -197,7 +181,7 @@ defineExpose({
> .toggleWrapper {
display: inline-block;
text-align: left;
- overflow: clip;
+ overflow: hidden; overflow: clip;
padding: 0 100px;
input {
@@ -405,4 +389,17 @@ defineExpose({
border-top: solid 0.5px var(--divider);
}
}
+
+.rsljpzjq {
+ > .selects {
+ display: flex;
+ gap: 1.5em var(--margin);
+ flex-wrap: wrap;
+
+ > .select {
+ flex: 1;
+ min-width: 280px;
+ }
+ }
+}
</style>
diff --git a/packages/client/src/pages/settings/webhook.edit.vue b/packages/client/src/pages/settings/webhook.edit.vue
index 3690526b41..618250958b 100644
--- a/packages/client/src/pages/settings/webhook.edit.vue
+++ b/packages/client/src/pages/settings/webhook.edit.vue
@@ -40,19 +40,11 @@ import FormSection from '@/components/form/section.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
-
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: 'Edit webhook',
- icon: 'fas fa-bolt',
- bg: 'var(--bg)',
- },
-});
+import { definePageMetadata } from '@/scripts/page-metadata';
const webhook = await os.api('i/webhooks/show', {
- webhookId: new URLSearchParams(window.location.search).get('id')
+ webhookId: new URLSearchParams(window.location.search).get('id'),
});
let name = $ref(webhook.name);
@@ -86,4 +78,13 @@ async function save(): Promise<void> {
active,
});
}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'Edit webhook',
+ icon: 'fas fa-bolt',
+});
</script>
diff --git a/packages/client/src/pages/settings/webhook.new.vue b/packages/client/src/pages/settings/webhook.new.vue
index 9bb492c49e..fa96c5fa4b 100644
--- a/packages/client/src/pages/settings/webhook.new.vue
+++ b/packages/client/src/pages/settings/webhook.new.vue
@@ -38,8 +38,8 @@ import FormSection from '@/components/form/section.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let name = $ref('');
let url = $ref('');
@@ -71,11 +71,12 @@ async function create(): Promise<void> {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: 'Create new webhook',
- icon: 'fas fa-bolt',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'Create new webhook',
+ icon: 'fas fa-bolt',
});
</script>
diff --git a/packages/client/src/pages/settings/webhook.vue b/packages/client/src/pages/settings/webhook.vue
index c9af8b6766..ef9b9b56f7 100644
--- a/packages/client/src/pages/settings/webhook.vue
+++ b/packages/client/src/pages/settings/webhook.vue
@@ -8,7 +8,7 @@
<FormSection>
<MkPagination :pagination="pagination">
- <template v-slot="{items}">
+ <template #default="{items}">
<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit?id=${webhook.id}`" class="_formBlock">
<template #icon>
<i v-if="webhook.active === false" class="fas fa-circle-pause"></i>
@@ -34,19 +34,20 @@ import FormSection from '@/components/form/section.vue';
import FormLink from '@/components/form/link.vue';
import { userPage } from '@/filters/user';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const pagination = {
endpoint: 'i/webhooks/list' as const,
limit: 10,
};
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: 'Webhook',
- icon: 'fas fa-bolt',
- bg: 'var(--bg)',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: 'Webhook',
+ icon: 'fas fa-bolt',
});
</script>
diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue
index 6e1a4b2ccb..5fee7cd35a 100644
--- a/packages/client/src/pages/settings/word-mute.vue
+++ b/packages/client/src/pages/settings/word-mute.vue
@@ -29,7 +29,7 @@
</template>
<script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
import FormTextarea from '@/components/form/textarea.vue';
import MkKeyValue from '@/components/key-value.vue';
import MkButton from '@/components/ui/button.vue';
@@ -37,10 +37,10 @@ import MkInfo from '@/components/ui/info.vue';
import MkTab from '@/components/tab.vue';
import * as os from '@/os';
import number from '@/filters/number';
-import * as symbols from '@/symbols';
import { defaultStore } from '@/store';
import { $i } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const render = (mutedWords) => mutedWords.map(x => {
if (Array.isArray(x)) {
@@ -87,7 +87,7 @@ async function save() {
os.alert({
type: 'error',
title: i18n.ts.regexpError,
- text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + "\n" + err.toString()
+ text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(),
});
// re-throw error so these invalid settings are not saved
throw err;
@@ -117,11 +117,12 @@ async function save() {
changed.value = false;
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.wordMute,
- icon: 'fas fa-comment-slash',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.wordMute,
+ icon: 'fas fa-comment-slash',
});
</script>
diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue
index 1700944f82..8984823b60 100644
--- a/packages/client/src/pages/share.vue
+++ b/packages/client/src/pages/share.vue
@@ -22,158 +22,144 @@
</div>
</template>
-<script lang="ts">
+<script lang="ts" setup>
// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
import { defineComponent } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import XPostForm from '@/components/post-form.vue';
-import * as os from '@/os';
import { noteVisibilities } from 'misskey-js';
import * as Acct from 'misskey-js/built/acct';
-import * as symbols from '@/symbols';
import * as Misskey from 'misskey-js';
+import MkButton from '@/components/ui/button.vue';
+import XPostForm from '@/components/post-form.vue';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- XPostForm,
- MkButton,
- },
-
- data() {
- return {
- [symbols.PAGE_INFO]: {
- title: this.$ts.share,
- icon: 'fas fa-share-alt'
- },
- state: 'fetching' as 'fetching' | 'writing' | 'posted',
+const urlParams = new URLSearchParams(window.location.search);
+const localOnlyQuery = urlParams.get('localOnly');
+const visibilityQuery = urlParams.get('visibility');
- title: null as string | null,
- initialText: null as string | null,
- reply: null as Misskey.entities.Note | null,
- renote: null as Misskey.entities.Note | null,
- visibility: null as string | null,
- localOnly: null as boolean | null,
- files: [] as Misskey.entities.DriveFile[],
- visibleUsers: [] as Misskey.entities.User[],
- };
- },
+let state = $ref('fetching' as 'fetching' | 'writing' | 'posted');
+let title = $ref(urlParams.get('title'));
+const text = urlParams.get('text');
+const url = urlParams.get('url');
+let initialText = $ref(null as string | null);
+let reply = $ref(null as Misskey.entities.Note | null);
+let renote = $ref(null as Misskey.entities.Note | null);
+let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null);
+let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null);
+let files = $ref([] as Misskey.entities.DriveFile[]);
+let visibleUsers = $ref([] as Misskey.entities.User[]);
- async created() {
- const urlParams = new URLSearchParams(window.location.search);
+async function init() {
+ let noteText = '';
+ if (title) noteText += `[ ${title} ]\n`;
+ // Googleニュース対策
+ if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, '');
+ else if (text && title !== text) noteText += `${text}\n`;
+ if (url) noteText += `${url}`;
+ initialText = noteText.trim();
- this.title = urlParams.get('title');
- const text = urlParams.get('text');
- const url = urlParams.get('url');
+ if (visibility === 'specified') {
+ const visibleUserIds = urlParams.get('visibleUserIds');
+ const visibleAccts = urlParams.get('visibleAccts');
+ await Promise.all(
+ [
+ ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
+ ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []),
+ ]
+ // TypeScriptの指示通りに変換する
+ .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
+ .map(q => os.api('users/show', q)
+ .then(user => {
+ visibleUsers.push(user);
+ }, () => {
+ console.error(`Invalid user query: ${JSON.stringify(q)}`);
+ }),
+ ),
+ );
+ }
- let noteText = '';
- if (this.title) noteText += `[ ${this.title} ]\n`;
- // Googleニュース対策
- if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, '');
- else if (text && this.title !== text) noteText += `${text}\n`;
- if (url) noteText += `${url}`;
- this.initialText = noteText.trim();
+ try {
+ //#region Reply
+ const replyId = urlParams.get('replyId');
+ const replyUri = urlParams.get('replyUri');
+ if (replyId) {
+ reply = await os.api('notes/show', {
+ noteId: replyId,
+ });
+ } else if (replyUri) {
+ const obj = await os.api('ap/show', {
+ uri: replyUri,
+ });
+ if (obj.type === 'Note') {
+ reply = obj.object;
+ }
+ }
+ //#endregion
- const visibility = urlParams.get('visibility');
- if (noteVisibilities.includes(visibility)) {
- this.visibility = visibility;
+ //#region Renote
+ const renoteId = urlParams.get('renoteId');
+ const renoteUri = urlParams.get('renoteUri');
+ if (renoteId) {
+ renote = await os.api('notes/show', {
+ noteId: renoteId,
+ });
+ } else if (renoteUri) {
+ const obj = await os.api('ap/show', {
+ uri: renoteUri,
+ });
+ if (obj.type === 'Note') {
+ renote = obj.object;
+ }
}
+ //#endregion
- if (this.visibility === 'specified') {
- const visibleUserIds = urlParams.get('visibleUserIds');
- const visibleAccts = urlParams.get('visibleAccts');
+ //#region Drive files
+ const fileIds = urlParams.get('fileIds');
+ if (fileIds) {
await Promise.all(
- [
- ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
- ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : [])
- ]
- // TypeScriptの指示通りに変換する
- .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
- .map(q => os.api('users/show', q)
- .then(user => {
- this.visibleUsers.push(user);
+ fileIds.split(',')
+ .map(fileId => os.api('drive/files/show', { fileId })
+ .then(file => {
+ files.push(file);
}, () => {
- console.error(`Invalid user query: ${JSON.stringify(q)}`);
- })
- )
+ console.error(`Failed to fetch a file ${fileId}`);
+ }),
+ ),
);
}
+ //#endregion
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ title: err.message,
+ text: err.name,
+ });
+ }
- const localOnly = urlParams.get('localOnly');
- if (localOnly === '0') this.localOnly = false;
- else if (localOnly === '1') this.localOnly = true;
+ state = 'writing';
+}
- try {
- //#region Reply
- const replyId = urlParams.get('replyId');
- const replyUri = urlParams.get('replyUri');
- if (replyId) {
- this.reply = await os.api('notes/show', {
- noteId: replyId
- });
- } else if (replyUri) {
- const obj = await os.api('ap/show', {
- uri: replyUri
- });
- if (obj.type === 'Note') {
- this.reply = obj.object;
- }
- }
- //#endregion
+init();
- //#region Renote
- const renoteId = urlParams.get('renoteId');
- const renoteUri = urlParams.get('renoteUri');
- if (renoteId) {
- this.renote = await os.api('notes/show', {
- noteId: renoteId
- });
- } else if (renoteUri) {
- const obj = await os.api('ap/show', {
- uri: renoteUri
- });
- if (obj.type === 'Note') {
- this.renote = obj.object;
- }
- }
- //#endregion
+function close(): void {
+ window.close();
- //#region Drive files
- const fileIds = urlParams.get('fileIds');
- if (fileIds) {
- await Promise.all(
- fileIds.split(',')
- .map(fileId => os.api('drive/files/show', { fileId })
- .then(file => {
- this.files.push(file);
- }, () => {
- console.error(`Failed to fetch a file ${fileId}`);
- })
- )
- );
- }
- //#endregion
- } catch (err) {
- os.alert({
- type: 'error',
- title: err.message,
- text: err.name
- });
- }
+ // 閉じなければ100ms後タイムラインに
+ window.setTimeout(() => {
+ mainRouter.push('/');
+ }, 100);
+}
- this.state = 'writing';
- },
+const headerActions = $computed(() => []);
- methods: {
- close() {
- window.close();
+const headerTabs = $computed(() => []);
- // 閉じなければ100ms後タイムラインに
- window.setTimeout(() => {
- this.$router.push('/');
- }, 100);
- }
- }
+definePageMetadata({
+ title: i18n.ts.share,
+ icon: 'fas fa-share-alt',
});
</script>
diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue
index 344c9195f7..a97990c129 100644
--- a/packages/client/src/pages/signup-complete.vue
+++ b/packages/client/src/pages/signup-complete.vue
@@ -7,9 +7,9 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { login } from '@/account';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const props = defineProps<{
code: string;
@@ -26,11 +26,13 @@ onMounted(async () => {
login(res.i, '/');
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.signup,
- icon: 'fas fa-user',
- },
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.signup,
+ icon: 'fas fa-user',
});
</script>
diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue
index 045f1ef259..406eb1c988 100644
--- a/packages/client/src/pages/tag.vue
+++ b/packages/client/src/pages/tag.vue
@@ -7,7 +7,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
const props = defineProps<{
tag: string;
@@ -21,11 +21,12 @@ const pagination = {
})),
};
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: props.tag,
- icon: 'fas fa-hashtag',
- bg: 'var(--bg)',
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: props.tag,
+ icon: 'fas fa-hashtag',
+})));
</script>
diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue
index 2a11c07fd2..548e60614b 100644
--- a/packages/client/src/pages/theme-editor.vue
+++ b/packages/client/src/pages/theme-editor.vue
@@ -1,67 +1,70 @@
<template>
-<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
- <div class="cwepdizn _formRoot">
- <FormFolder :default-open="true" class="_formBlock">
- <template #label>{{ i18n.ts.backgroundColor }}</template>
- <div class="cwepdizn-colors">
- <div class="row">
- <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
- <div class="preview" :style="{ background: color.forPreview }"></div>
- </button>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+ <div class="cwepdizn _formRoot">
+ <FormFolder :default-open="true" class="_formBlock">
+ <template #label>{{ i18n.ts.backgroundColor }}</template>
+ <div class="cwepdizn-colors">
+ <div class="row">
+ <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
+ <div class="preview" :style="{ background: color.forPreview }"></div>
+ </button>
+ </div>
+ <div class="row">
+ <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
+ <div class="preview" :style="{ background: color.forPreview }"></div>
+ </button>
+ </div>
</div>
- <div class="row">
- <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)">
- <div class="preview" :style="{ background: color.forPreview }"></div>
- </button>
- </div>
- </div>
- </FormFolder>
+ </FormFolder>
- <FormFolder :default-open="true" class="_formBlock">
- <template #label>{{ i18n.ts.accentColor }}</template>
- <div class="cwepdizn-colors">
- <div class="row">
- <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)">
- <div class="preview" :style="{ background: color }"></div>
- </button>
+ <FormFolder :default-open="true" class="_formBlock">
+ <template #label>{{ i18n.ts.accentColor }}</template>
+ <div class="cwepdizn-colors">
+ <div class="row">
+ <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)">
+ <div class="preview" :style="{ background: color }"></div>
+ </button>
+ </div>
</div>
- </div>
- </FormFolder>
+ </FormFolder>
- <FormFolder :default-open="true" class="_formBlock">
- <template #label>{{ i18n.ts.textColor }}</template>
- <div class="cwepdizn-colors">
- <div class="row">
- <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)">
- <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div>
- </button>
+ <FormFolder :default-open="true" class="_formBlock">
+ <template #label>{{ i18n.ts.textColor }}</template>
+ <div class="cwepdizn-colors">
+ <div class="row">
+ <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)">
+ <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div>
+ </button>
+ </div>
</div>
- </div>
- </FormFolder>
+ </FormFolder>
- <FormFolder :default-open="false" class="_formBlock">
- <template #icon><i class="fas fa-code"></i></template>
- <template #label>{{ i18n.ts.editCode }}</template>
+ <FormFolder :default-open="false" class="_formBlock">
+ <template #icon><i class="fas fa-code"></i></template>
+ <template #label>{{ i18n.ts.editCode }}</template>
- <div class="_formRoot">
- <FormTextarea v-model="themeCode" tall class="_formBlock">
- <template #label>{{ i18n.ts._theme.code }}</template>
- </FormTextarea>
- <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton>
- </div>
- </FormFolder>
+ <div class="_formRoot">
+ <FormTextarea v-model="themeCode" tall class="_formBlock">
+ <template #label>{{ i18n.ts._theme.code }}</template>
+ </FormTextarea>
+ <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton>
+ </div>
+ </FormFolder>
- <FormFolder :default-open="false" class="_formBlock">
- <template #label>{{ i18n.ts.addDescription }}</template>
+ <FormFolder :default-open="false" class="_formBlock">
+ <template #label>{{ i18n.ts.addDescription }}</template>
- <div class="_formRoot">
- <FormTextarea v-model="description">
- <template #label>{{ i18n.ts._theme.description }}</template>
- </FormTextarea>
- </div>
- </FormFolder>
- </div>
-</MkSpacer>
+ <div class="_formRoot">
+ <FormTextarea v-model="description">
+ <template #label>{{ i18n.ts._theme.description }}</template>
+ </FormTextarea>
+ </div>
+ </FormFolder>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -75,6 +78,7 @@ import FormButton from '@/components/ui/button.vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormFolder from '@/components/form/folder.vue';
+import { $i } from '@/account';
import { Theme, applyTheme } from '@/scripts/theme';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
@@ -82,9 +86,9 @@ import { host } from '@/config';
import * as os from '@/os';
import { ColdDeviceStorage, defaultStore } from '@/store';
import { addTheme } from '@/theme-store';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
import { useLeaveGuard } from '@/scripts/use-leave-guard';
+import { definePageMetadata } from '@/scripts/page-metadata';
const bgColors = [
{ color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' },
@@ -115,7 +119,7 @@ const fgColors = [
{ color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' },
];
-const theme = $ref<Partial<Theme>>({
+let theme = $ref<Partial<Theme>>({
base: 'light',
props: lightTheme.props,
});
@@ -188,7 +192,7 @@ async function saveAs() {
theme.name = name;
theme.author = `@${$i.username}@${toUnicode(host)}`;
if (description) theme.desc = description;
- addTheme(theme);
+ await addTheme(theme);
applyTheme(theme);
if (defaultStore.state.darkMode) {
ColdDeviceStorage.set('darkTheme', theme);
@@ -204,23 +208,23 @@ async function saveAs() {
watch($$(theme), apply, { deep: true });
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.themeEditor,
- icon: 'fas fa-palette',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-eye',
- text: i18n.ts.preview,
- handler: showPreview,
- }, {
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.saveAs,
- handler: saveAs,
- }],
- },
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-eye',
+ text: i18n.ts.preview,
+ handler: showPreview,
+}, {
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.saveAs,
+ handler: saveAs,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.themeEditor,
+ icon: 'fas fa-palette',
});
</script>
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
index fe3dbc3cff..8554a9aebc 100644
--- a/packages/client/src/pages/timeline.vue
+++ b/packages/client/src/pages/timeline.vue
@@ -1,39 +1,37 @@
<template>
-<MkSpacer :content-max="800">
- <div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
- <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
- <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
+ <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
+ <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
- <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
- <div class="tl _block">
- <XTimeline ref="tl" :key="src"
- class="tl"
- :src="src"
- :sound="true"
- @queue="queueUpdated"
- />
+ <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline
+ ref="tl" :key="src"
+ class="tl"
+ :src="src"
+ :sound="true"
+ @queue="queueUpdated"
+ />
+ </div>
</div>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-export default {
- name: 'MkTimelinePage',
-};
-</script>
-
<script lang="ts" setup>
import { defineAsyncComponent, computed, watch } from 'vue';
import XTimeline from '@/components/timeline.vue';
import XPostForm from '@/components/post-form.vue';
import { scroll } from '@/scripts/scroll';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
@@ -47,7 +45,7 @@ const tlComponent = $ref<InstanceType<typeof XTimeline>>();
const rootEl = $ref<HTMLElement>();
let queue = $ref(0);
-const src = $computed(() => defaultStore.reactiveState.tl.value.src);
+const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) });
watch ($$(src), () => queue = 0);
@@ -111,55 +109,49 @@ function focus(): void {
tlComponent.focus();
}
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.timeline,
- icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-list-ul',
- text: i18n.ts.lists,
- handler: chooseList,
- }, {
- icon: 'fas fa-satellite',
- text: i18n.ts.antennas,
- handler: chooseAntenna,
- }, {
- icon: 'fas fa-satellite-dish',
- text: i18n.ts.channel,
- handler: chooseChannel,
- }, {
- icon: 'fas fa-calendar-alt',
- text: i18n.ts.jumpToSpecifiedDate,
- handler: timetravel,
- }],
- tabs: [{
- active: src === 'home',
- title: i18n.ts._timelines.home,
- icon: 'fas fa-home',
- iconOnly: true,
- onClick: () => { saveSrc('home'); },
- }, ...(isLocalTimelineAvailable ? [{
- active: src === 'local',
- title: i18n.ts._timelines.local,
- icon: 'fas fa-comments',
- iconOnly: true,
- onClick: () => { saveSrc('local'); },
- }, {
- active: src === 'social',
- title: i18n.ts._timelines.social,
- icon: 'fas fa-share-alt',
- iconOnly: true,
- onClick: () => { saveSrc('social'); },
- }] : []), ...(isGlobalTimelineAvailable ? [{
- active: src === 'global',
- title: i18n.ts._timelines.global,
- icon: 'fas fa-globe',
- iconOnly: true,
- onClick: () => { saveSrc('global'); },
- }] : [])],
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+ key: 'home',
+ title: i18n.ts._timelines.home,
+ icon: 'fas fa-home',
+ iconOnly: true,
+}, ...(isLocalTimelineAvailable ? [{
+ key: 'local',
+ title: i18n.ts._timelines.local,
+ icon: 'fas fa-comments',
+ iconOnly: true,
+}, {
+ key: 'social',
+ title: i18n.ts._timelines.social,
+ icon: 'fas fa-share-alt',
+ iconOnly: true,
+}] : []), ...(isGlobalTimelineAvailable ? [{
+ key: 'global',
+ title: i18n.ts._timelines.global,
+ icon: 'fas fa-globe',
+ iconOnly: true,
+}] : []), {
+ icon: 'fas fa-list-ul',
+ title: i18n.ts.lists,
+ iconOnly: true,
+ onClick: chooseList,
+}, {
+ icon: 'fas fa-satellite',
+ title: i18n.ts.antennas,
+ iconOnly: true,
+ onClick: chooseAntenna,
+}, {
+ icon: 'fas fa-satellite-dish',
+ title: i18n.ts.channel,
+ iconOnly: true,
+ onClick: chooseChannel,
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.timeline,
+ icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
+})));
</script>
<style lang="scss" scoped>
@@ -185,7 +177,7 @@ defineExpose({
> .tl {
background: var(--bg);
border-radius: var(--radius);
- overflow: clip;
+ overflow: hidden; overflow: clip;
}
}
</style>
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
index 54e1f13021..fd24ec2848 100644
--- a/packages/client/src/pages/user-info.vue
+++ b/packages/client/src/pages/user-info.vue
@@ -1,258 +1,485 @@
<template>
-<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- <div class="_formRoot">
- <div class="_formBlock aeakzknw">
- <MkAvatar class="avatar" :user="user" :show-indicator="true"/>
- </div>
-
- <FormLink :to="userPage(user)">Profile</FormLink>
-
- <div class="_formBlock">
- <MkKeyValue :copy="acct(user)" oneline style="margin: 1em 0;">
- <template #key>Acct</template>
- <template #value><span class="_monospace">{{ acct(user) }}</span></template>
- </MkKeyValue>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div v-if="tab === 'overview'" class="_formRoot">
+ <div class="_formBlock aeakzknw">
+ <MkAvatar class="avatar" :user="user" :show-indicator="true"/>
+ <div class="body">
+ <span class="name"><MkUserName class="name" :user="user"/></span>
+ <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
+ <span class="state">
+ <span v-if="suspended" class="suspended">Suspended</span>
+ <span v-if="silenced" class="silenced">Silenced</span>
+ <span v-if="moderator" class="moderator">Moderator</span>
+ </span>
+ </div>
+ </div>
- <MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
- <template #key>ID</template>
- <template #value><span class="_monospace">{{ user.id }}</span></template>
- </MkKeyValue>
- </div>
+ <MkInfo v-if="user.username.includes('.')" class="_formBlock">{{ i18n.ts.isSystemAccount }}</MkInfo>
- <FormSection v-if="iAmModerator">
- <template #label>Moderation</template>
- <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch>
- <FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
- <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
- {{ $ts.reflectMayTakeTime }}
- <FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
- </FormSection>
+ <div v-if="user.url" class="_formLinksGrid _formBlock">
+ <FormLink :to="userPage(user)">Profile</FormLink>
+ <FormLink :to="user.url" :external="true">Profile (remote)</FormLink>
+ </div>
+ <FormLink v-else class="_formBlock" :to="userPage(user)">Profile</FormLink>
- <FormSection>
- <template #label>ActivityPub</template>
+ <FormLink v-if="user.host" class="_formBlock" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
<div class="_formBlock">
- <MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
- <template #key>{{ $ts.instanceInfo }}</template>
- <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template>
+ <MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ user.id }}</span></template>
</MkKeyValue>
- <MkKeyValue v-else oneline style="margin: 1em 0;">
- <template #key>{{ $ts.instanceInfo }}</template>
- <template #value>(Local user)</template>
+ <!-- 要る?
+ <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;">
+ <template #key>IP (recent)</template>
+ <template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
</MkKeyValue>
+ -->
<MkKeyValue oneline style="margin: 1em 0;">
- <template #key>{{ $ts.updatedAt }}</template>
- <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+ <template #key>{{ i18n.ts.createdAt }}</template>
+ <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.lastActiveDate }}</template>
+ <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
</MkKeyValue>
- <MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
- <template #key>Type</template>
- <template #value><span class="_monospace">{{ ap.type }}</span></template>
+ <MkKeyValue v-if="info" oneline style="margin: 1em 0;">
+ <template #key>{{ i18n.ts.email }}</template>
+ <template #value><span class="_monospace">{{ info.email }}</span></template>
</MkKeyValue>
</div>
- <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
- </FormSection>
+ <FormSection>
+ <template #label>ActivityPub</template>
+
+ <div class="_formBlock">
+ <MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.instanceInfo }}</template>
+ <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template>
+ </MkKeyValue>
+ <MkKeyValue v-else oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.instanceInfo }}</template>
+ <template #value>(Local user)</template>
+ </MkKeyValue>
+ <MkKeyValue oneline style="margin: 1em 0;">
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
+ <template #key>Type</template>
+ <template #value><span class="_monospace">{{ ap.type }}</span></template>
+ </MkKeyValue>
+ </div>
+
+ <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
+
+ <FormFolder class="_formBlock">
+ <template #label>Raw</template>
+
+ <MkObjectView v-if="ap" tall :value="ap">
+ </MkObjectView>
+ </FormFolder>
+ </FormSection>
+ </div>
+ <div v-else-if="tab === 'moderation'" class="_formRoot">
+ <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch>
+ <FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
+ <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
+ {{ $ts.reflectMayTakeTime }}
+ <div class="_formBlock">
+ <FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+ <FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ $ts.deleteAccount }}</FormButton>
+ </div>
+ <FormTextarea v-model="moderationNote" manual-save class="_formBlock">
+ <template #label>Moderation note</template>
+ </FormTextarea>
+ <FormFolder class="_formBlock">
+ <template #label>IP</template>
+ <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
+ <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo>
+ <template v-if="iAmAdmin && ips">
+ <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
+ <span class="date">{{ record.createdAt }}</span>
+ <span class="ip">{{ record.ip }}</span>
+ </div>
+ </template>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #label>{{ i18n.ts.files }}</template>
+
+ <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/>
+ </FormFolder>
+ <FormSection>
+ <template #label>Drive Capacity Override</template>
- <MkObjectView v-if="info && $i.isAdmin" tall :value="info">
- </MkObjectView>
+ <FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride">
+ <template #label>{{ i18n.ts.driveCapOverrideLabel }}</template>
+ <template #suffix>MB</template>
+ <template #caption>
+ {{ i18n.ts.driveCapOverrideCaption }}
+ </template>
+ </FormInput>
+ </FormSection>
+ </div>
+ <div v-else-if="tab === 'chart'" class="_formRoot">
+ <div class="cmhjzshm">
+ <div class="selects">
+ <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
+ <option value="per-user-notes">{{ $ts.notes }}</option>
+ </MkSelect>
+ </div>
+ <div class="charts">
+ <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
+ <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
+ <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
+ <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
+ </div>
+ </div>
+ </div>
+ <div v-else-if="tab === 'raw'" class="_formRoot">
+ <MkObjectView v-if="info && $i.isAdmin" tall :value="info">
+ </MkObjectView>
- <MkObjectView tall :value="user">
- </MkObjectView>
- </div>
- </FormSuspense>
-</MkSpacer>
+ <MkObjectView tall :value="user">
+ </MkObjectView>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { computed, defineAsyncComponent, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MkChart from '@/components/chart.vue';
import MkObjectView from '@/components/object-view.vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormButton from '@/components/ui/button.vue';
+import FormInput from '@/components/form/input.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormFolder from '@/components/form/folder.vue';
import MkKeyValue from '@/components/key-value.vue';
+import MkSelect from '@/components/form/select.vue';
import FormSuspense from '@/components/form/suspense.vue';
+import MkFileListForAdmin from '@/components/file-list-for-admin.vue';
+import MkInfo from '@/components/ui/info.vue';
import * as os from '@/os';
import number from '@/filters/number';
import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
import { url } from '@/config';
import { userPage, acct } from '@/filters/user';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { iAmAdmin, iAmModerator } from '@/account';
+import { instance } from '@/instance';
-export default defineComponent({
- components: {
- FormSection,
- FormTextarea,
- FormSwitch,
- MkObjectView,
- FormButton,
- FormLink,
- MkKeyValue,
- FormSuspense,
- },
+const props = defineProps<{
+ userId: string;
+}>();
- props: {
- userId: {
- type: String,
- required: true
- }
- },
-
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => ({
- title: this.user ? acct(this.user) : this.$ts.userInfo,
- icon: 'fas fa-info-circle',
- bg: 'var(--bg)',
- actions: this.user ? [this.user.url ? {
- text: this.user.url,
- icon: 'fas fa-external-link-alt',
- handler: () => {
- window.open(this.user.url, '_blank');
- }
- } : undefined].filter(x => x !== undefined) : [],
- })),
- init: null,
- user: null,
- info: null,
- ap: null,
- moderator: false,
- silenced: false,
- suspended: false,
- };
- },
+let tab = $ref('overview');
+let chartSrc = $ref('per-user-notes');
+let user = $ref<null | misskey.entities.UserDetailed>();
+let init = $ref<ReturnType<typeof createFetcher>>();
+let info = $ref();
+let ips = $ref(null);
+let ap = $ref(null);
+let moderator = $ref(false);
+let silenced = $ref(false);
+let suspended = $ref(false);
+let driveCapacityOverrideMb: number | null = $ref(0);
+let moderationNote = $ref('');
+const filesPagination = {
+ endpoint: 'admin/drive/files' as const,
+ limit: 10,
+ params: computed(() => ({
+ userId: props.userId,
+ })),
+};
- computed: {
- iAmModerator(): boolean {
- return this.$i && (this.$i.isAdmin || this.$i.isModerator);
- }
- },
+function createFetcher() {
+ if (iAmModerator) {
+ return () => Promise.all([os.api('users/show', {
+ userId: props.userId,
+ }), os.api('admin/show-user', {
+ userId: props.userId,
+ }), iAmAdmin ? os.api('admin/get-user-ips', {
+ userId: props.userId,
+ }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => {
+ user = _user;
+ info = _info;
+ ips = _ips;
+ moderator = info.isModerator;
+ silenced = info.isSilenced;
+ suspended = info.isSuspended;
+ driveCapacityOverrideMb = user.driveCapacityOverrideMb;
+ moderationNote = info.moderationNote;
- watch: {
- userId: {
- handler() {
- this.init = this.createFetcher();
- },
- immediate: true
- },
- user() {
- os.api('ap/get', {
- uri: this.user.uri || `${url}/users/${this.user.id}`
- }).then(res => {
- this.ap = res;
+ watch($$(moderationNote), async () => {
+ await os.api('admin/update-user-note', { userId: user.id, text: moderationNote });
+ await refreshUser();
});
- }
- },
+ });
+ } else {
+ return () => os.api('users/show', {
+ userId: props.userId,
+ }).then((res) => {
+ user = res;
+ });
+ }
+}
- methods: {
- number,
- bytes,
- userPage,
- acct,
+function refreshUser() {
+ init = createFetcher();
+}
- createFetcher() {
- if (this.iAmModerator) {
- return () => Promise.all([os.api('users/show', {
- userId: this.userId
- }), os.api('admin/show-user', {
- userId: this.userId
- })]).then(([user, info]) => {
- this.user = user;
- this.info = info;
- this.moderator = this.info.isModerator;
- this.silenced = this.info.isSilenced;
- this.suspended = this.info.isSuspended;
- });
- } else {
- return () => os.api('users/show', {
- userId: this.userId
- }).then((user) => {
- this.user = user;
- });
- }
- },
+async function updateRemoteUser() {
+ await os.apiWithDialog('federation/update-remote-user', { userId: user.id });
+ refreshUser();
+}
- refreshUser() {
- this.init = this.createFetcher();
- },
+async function resetPassword() {
+ const { password } = await os.api('admin/reset-password', {
+ userId: user.id,
+ });
- async updateRemoteUser() {
- await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id });
- this.refreshUser();
- },
+ os.alert({
+ type: 'success',
+ text: i18n.t('newPasswordIs', { password }),
+ });
+}
- async resetPassword() {
- const { password } = await os.api('admin/reset-password', {
- userId: this.user.id,
- });
+async function toggleSilence(v) {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
+ });
+ if (confirm.canceled) {
+ silenced = !v;
+ } else {
+ await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id });
+ await refreshUser();
+ }
+}
- os.alert({
- type: 'success',
- text: this.$t('newPasswordIs', { password })
- });
- },
+async function toggleSuspend(v) {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm,
+ });
+ if (confirm.canceled) {
+ suspended = !v;
+ } else {
+ await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id });
+ await refreshUser();
+ }
+}
- async toggleSilence(v) {
- const confirm = await os.confirm({
- type: 'warning',
- text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm,
- });
- if (confirm.canceled) {
- this.silenced = !v;
- } else {
- await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
- await this.refreshUser();
- }
- },
+async function toggleModerator(v) {
+ await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id });
+ await refreshUser();
+}
- async toggleSuspend(v) {
- const confirm = await os.confirm({
- type: 'warning',
- text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm,
- });
- if (confirm.canceled) {
- this.suspended = !v;
- } else {
- await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
- await this.refreshUser();
- }
- },
+async function deleteAllFiles() {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteAllFilesConfirm,
+ });
+ if (confirm.canceled) return;
+ const process = async () => {
+ await os.api('admin/delete-all-files-of-a-user', { userId: user.id });
+ os.success();
+ };
+ await process().catch(err => {
+ os.alert({
+ type: 'error',
+ text: err.toString(),
+ });
+ });
+ await refreshUser();
+}
- async toggleModerator(v) {
- await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
- await this.refreshUser();
- },
+async function applyDriveCapacityOverride() {
+ let driveCapOrMb = driveCapacityOverrideMb;
+ if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) {
+ driveCapOrMb = null;
+ }
+ try {
+ await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb });
+ await refreshUser();
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ text: err.toString(),
+ });
+ }
+}
- async deleteAllFiles() {
- const confirm = await os.confirm({
- type: 'warning',
- text: this.$ts.deleteAllFilesConfirm,
- });
- if (confirm.canceled) return;
- const process = async () => {
- await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
- os.success();
- };
- await process().catch(err => {
- os.alert({
- type: 'error',
- text: err.toString(),
- });
- });
- await this.refreshUser();
- },
+async function deleteAccount() {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteAccountConfirm,
+ });
+ if (confirm.canceled) return;
+
+ const typed = await os.inputText({
+ text: i18n.t('typeToConfirm', { x: user?.username }),
+ });
+ if (typed.canceled) return;
+
+ if (typed.result === user?.username) {
+ await os.apiWithDialog('admin/delete-account', {
+ userId: user.id,
+ });
+ } else {
+ os.alert({
+ type: 'error',
+ text: 'input not match',
+ });
}
+}
+
+watch(() => props.userId, () => {
+ init = createFetcher();
+}, {
+ immediate: true,
+});
+
+watch($$(user), () => {
+ os.api('ap/get', {
+ uri: user.uri ?? `${url}/users/${user.id}`,
+ }).then(res => {
+ ap = res;
+ });
});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'fas fa-info-circle',
+}, iAmModerator ? {
+ key: 'moderation',
+ title: i18n.ts.moderation,
+ icon: 'fas fa-shield-halved',
+} : null, {
+ key: 'chart',
+ title: i18n.ts.charts,
+ icon: 'fas fa-chart-simple',
+}, {
+ key: 'raw',
+ title: 'Raw',
+ icon: 'fas fa-code',
+}].filter(x => x != null));
+
+definePageMetadata(computed(() => ({
+ title: user ? acct(user) : i18n.ts.userInfo,
+ icon: 'fas fa-info-circle',
+})));
</script>
<style lang="scss" scoped>
.aeakzknw {
+ display: flex;
+ align-items: center;
+
> .avatar {
display: block;
width: 64px;
height: 64px;
+ margin-right: 16px;
+ }
+
+ > .body {
+ flex: 1;
+ overflow: hidden;
+
+ > .name {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .sub {
+ display: block;
+ width: 100%;
+ font-size: 85%;
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .state {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-top: 4px;
+
+ &:empty {
+ display: none;
+ }
+
+ > .suspended, > .silenced, > .moderator {
+ display: inline-block;
+ border: solid 1px;
+ border-radius: 6px;
+ padding: 2px 6px;
+ font-size: 85%;
+ }
+
+ > .suspended {
+ color: var(--error);
+ border-color: var(--error);
+ }
+
+ > .silenced {
+ color: var(--warn);
+ border-color: var(--warn);
+ }
+
+ > .moderator {
+ color: var(--success);
+ border-color: var(--success);
+ }
+ }
+ }
+}
+
+.cmhjzshm {
+ > .selects {
+ display: flex;
+ margin: 0 0 16px 0;
+ }
+
+ > .charts {
+ > .label {
+ margin-bottom: 12px;
+ font-weight: bold;
+ }
+ }
+}
+</style>
+
+<style lang="scss" module>
+.ip {
+ display: flex;
+
+ > :global(.date) {
+ opacity: 0.7;
+ }
+
+ > :global(.ip) {
+ margin-left: auto;
}
}
</style>
diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue
index 4476567cfb..3fca6f1416 100644
--- a/packages/client/src/pages/user-list-timeline.vue
+++ b/packages/client/src/pages/user-list-timeline.vue
@@ -1,104 +1,85 @@
<template>
-<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="eqqrhokj">
- <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
- <div class="tl _block">
- <XTimeline ref="tl" :key="listId"
- class="tl"
- src="list"
- :list="listId"
- :sound="true"
- @queue="queueUpdated"
- />
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <div ref="rootEl" v-size="{ min: [800] }" class="eqqrhokj">
+ <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline
+ ref="tlEl" :key="listId"
+ class="tl"
+ src="list"
+ :list="listId"
+ :sound="true"
+ @queue="queueUpdated"
+ />
+ </div>
</div>
-</div>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { computed, watch, inject } from 'vue';
import XTimeline from '@/components/timeline.vue';
import { scroll } from '@/scripts/scroll';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- XTimeline,
- },
+const router = useRouter();
- props: {
- listId: {
- type: String,
- required: true
- }
- },
+const props = defineProps<{
+ listId: string;
+}>();
- data() {
- return {
- list: null,
- queue: 0,
- [symbols.PAGE_INFO]: computed(() => this.list ? {
- title: this.list.name,
- icon: 'fas fa-list-ul',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-calendar-alt',
- text: this.$ts.jumpToSpecifiedDate,
- handler: this.timetravel
- }, {
- icon: 'fas fa-cog',
- text: this.$ts.settings,
- handler: this.settings
- }],
- } : null),
- };
- },
+let list = $ref(null);
+let queue = $ref(0);
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+let rootEl = $ref<HTMLElement>();
- computed: {
- keymap(): any {
- return {
- 't': this.focus
- };
- },
- },
+watch(() => props.listId, async () => {
+ list = await os.api('users/lists/show', {
+ listId: props.listId,
+ });
+}, { immediate: true });
- watch: {
- listId: {
- async handler() {
- this.list = await os.api('users/lists/show', {
- listId: this.listId
- });
- },
- immediate: true
- }
- },
+function queueUpdated(q) {
+ queue = q;
+}
- methods: {
- queueUpdated(q) {
- this.queue = q;
- },
+function top() {
+ scroll(rootEl, { top: 0 });
+}
- top() {
- scroll(this.$el, { top: 0 });
- },
+function settings() {
+ router.push(`/my/lists/${props.listId}`);
+}
- settings() {
- this.$router.push(`/my/lists/${this.listId}`);
- },
+async function timetravel() {
+ const { canceled, result: date } = await os.inputDate({
+ title: i18n.ts.date,
+ });
+ if (canceled) return;
- async timetravel() {
- const { canceled, result: date } = await os.inputDate({
- title: this.$ts.date,
- });
- if (canceled) return;
+ tlEl.timetravel(date);
+}
- this.$refs.tl.timetravel(date);
- },
+const headerActions = $computed(() => list ? [{
+ icon: 'fas fa-calendar-alt',
+ text: i18n.ts.jumpToSpecifiedDate,
+ handler: timetravel,
+}, {
+ icon: 'fas fa-cog',
+ text: i18n.ts.settings,
+ handler: settings,
+}] : []);
- focus() {
- (this.$refs.tl as any).focus();
- }
- }
-});
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+ title: list.name,
+ icon: 'fas fa-list-ul',
+} : null));
</script>
<style lang="scss" scoped>
@@ -122,7 +103,7 @@ export default defineComponent({
> .tl {
background: var(--bg);
border-radius: var(--radius);
- overflow: clip;
+ overflow: hidden; overflow: clip;
}
&.min-width_800px {
diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue
index 98a1fc0f86..e84b7ff57e 100644
--- a/packages/client/src/pages/user/follow-list.vue
+++ b/packages/client/src/pages/user/follow-list.vue
@@ -1,7 +1,7 @@
<template>
<div>
<MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers">
- <div class="users _isolated">
+ <div class="users">
<MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/>
</div>
</MkPagination>
diff --git a/packages/client/src/pages/user/followers.vue b/packages/client/src/pages/user/followers.vue
new file mode 100644
index 0000000000..296a4b7b4d
--- /dev/null
+++ b/packages/client/src/pages/user/followers.vue
@@ -0,0 +1,61 @@
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="1000">
+ <transition name="fade" mode="out-in">
+ <div v-if="user">
+ <XFollowList :user="user" type="followers"/>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
+import XFollowList from './follow-list.vue';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+ acct: string;
+}>(), {
+});
+
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
+
+function fetchUser(): void {
+ if (props.acct == null) return;
+ user = null;
+ os.api('users/show', Acct.parse(props.acct)).then(u => {
+ user = u;
+ }).catch(err => {
+ error = err;
+ });
+}
+
+watch(() => props.acct, fetchUser, {
+ immediate: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => user ? {
+ icon: 'fas fa-user',
+ title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+ subtitle: i18n.ts.followers,
+ userName: user,
+ avatar: user,
+} : null));
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/user/following.vue b/packages/client/src/pages/user/following.vue
new file mode 100644
index 0000000000..d1753fe7d5
--- /dev/null
+++ b/packages/client/src/pages/user/following.vue
@@ -0,0 +1,61 @@
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="1000">
+ <transition name="fade" mode="out-in">
+ <div v-if="user">
+ <XFollowList :user="user" type="following"/>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
+import XFollowList from './follow-list.vue';
+import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+ acct: string;
+}>(), {
+});
+
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
+
+function fetchUser(): void {
+ if (props.acct == null) return;
+ user = null;
+ os.api('users/show', Acct.parse(props.acct)).then(u => {
+ user = u;
+ }).catch(err => {
+ error = err;
+ });
+}
+
+watch(() => props.acct, fetchUser, {
+ immediate: true,
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => user ? {
+ icon: 'fas fa-user',
+ title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+ subtitle: i18n.ts.following,
+ userName: user,
+ avatar: user,
+} : null));
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue
index 07dda4a292..6af28d455b 100644
--- a/packages/client/src/pages/user/gallery.vue
+++ b/packages/client/src/pages/user/gallery.vue
@@ -8,36 +8,24 @@
</div>
</template>
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
import MkPagination from '@/components/ui/pagination.vue';
-export default defineComponent({
- components: {
- MkPagination,
- MkGalleryPostPreview,
- },
-
- props: {
- user: {
- type: Object,
- required: true
- },
- },
-
- data() {
- return {
- pagination: {
- endpoint: 'users/gallery/posts' as const,
- limit: 6,
- params: computed(() => ({
- userId: this.user.id
- })),
- },
- };
- },
+const props = withDefaults(defineProps<{
+ user: misskey.entities.User;
+}>(), {
});
+
+const pagination = {
+ endpoint: 'users/gallery/posts' as const,
+ limit: 6,
+ params: computed(() => ({
+ userId: props.user.id,
+ })),
+};
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue
new file mode 100644
index 0000000000..f7c25f077c
--- /dev/null
+++ b/packages/client/src/pages/user/home.vue
@@ -0,0 +1,478 @@
+<template>
+<MkSpacer :content-max="narrow ? 800 : 1100">
+ <div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
+ <div class="main">
+ <!-- TODO -->
+ <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
+ <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
+
+ <div class="profile">
+ <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
+
+ <div :key="user.id" class="_block main">
+ <div class="banner-container" :style="style">
+ <div ref="bannerEl" class="banner" :style="style"></div>
+ <div class="fade"></div>
+ <div class="title">
+ <MkUserName class="name" :user="user" :nowrap="true"/>
+ <div class="bottom">
+ <span class="username"><MkAcct :user="user" :detail="true"/></span>
+ <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+ </div>
+ </div>
+ <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
+ <div v-if="$i" class="actions">
+ <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
+ <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+ </div>
+ </div>
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="title">
+ <MkUserName :user="user" :nowrap="false" class="name"/>
+ <div class="bottom">
+ <span class="username"><MkAcct :user="user" :detail="true"/></span>
+ <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+ </div>
+ </div>
+ <div class="description">
+ <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+ <p v-else class="empty">{{ $ts.noAccountDescription }}</p>
+ </div>
+ <div class="fields system">
+ <dl v-if="user.location" class="field">
+ <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+ <dd class="value">{{ user.location }}</dd>
+ </dl>
+ <dl v-if="user.birthday" class="field">
+ <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+ <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+ </dl>
+ <dl class="field">
+ <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
+ <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+ </dl>
+ </div>
+ <div v-if="user.fields.length > 0" class="fields">
+ <dl v-for="(field, i) in user.fields" :key="i" class="field">
+ <dt class="name">
+ <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+ </dt>
+ <dd class="value">
+ <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
+ </dd>
+ </dl>
+ </div>
+ <div class="status">
+ <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
+ <b>{{ number(user.notesCount) }}</b>
+ <span>{{ $ts.notes }}</span>
+ </MkA>
+ <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+ <b>{{ number(user.followingCount) }}</b>
+ <span>{{ $ts.following }}</span>
+ </MkA>
+ <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+ <b>{{ number(user.followersCount) }}</b>
+ <span>{{ $ts.followers }}</span>
+ </MkA>
+ </div>
+ </div>
+ </div>
+
+ <div class="contents">
+ <div v-if="user.pinnedNotes.length > 0" class="_gap">
+ <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
+ </div>
+ <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
+ <template v-if="narrow">
+ <XPhotos :key="user.id" :user="user"/>
+ <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
+ </template>
+ </div>
+ <div>
+ <XUserTimeline :user="user"/>
+ </div>
+ </div>
+ <div v-if="!narrow" class="sub">
+ <XPhotos :key="user.id" :user="user"/>
+ <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
+ </div>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import calcAge from 's-age';
+import * as misskey from 'misskey-js';
+import XUserTimeline from './index.timeline.vue';
+import XNote from '@/components/note.vue';
+import MkFollowButton from '@/components/follow-button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkRemoteCaution from '@/components/remote-caution.vue';
+import MkTab from '@/components/tab.vue';
+import MkInfo from '@/components/ui/info.vue';
+import { getScrollPosition } from '@/scripts/scroll';
+import { getUserMenu } from '@/scripts/get-user-menu';
+import number from '@/filters/number';
+import { userPage, acct as getAcct } from '@/filters/user';
+import * as os from '@/os';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+
+const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
+const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
+
+const props = withDefaults(defineProps<{
+ user: misskey.entities.UserDetailed;
+}>(), {
+});
+
+const router = useRouter();
+
+let parallaxAnimationId = $ref<null | number>(null);
+let narrow = $ref<null | boolean>(null);
+let rootEl = $ref<null | HTMLElement>(null);
+let bannerEl = $ref<null | HTMLElement>(null);
+
+const style = $computed(() => {
+ if (props.user.bannerUrl == null) return {};
+ return {
+ backgroundImage: `url(${ props.user.bannerUrl })`,
+ };
+});
+
+const age = $computed(() => {
+ return calcAge(props.user.birthday);
+});
+
+function menu(ev) {
+ os.popupMenu(getUserMenu(props.user), ev.currentTarget ?? ev.target);
+}
+
+function parallaxLoop() {
+ parallaxAnimationId = window.requestAnimationFrame(parallaxLoop);
+ parallax();
+}
+
+function parallax() {
+ const banner = bannerEl as any;
+ if (banner == null) return;
+
+ const top = getScrollPosition(rootEl);
+
+ if (top < 0) return;
+
+ const z = 1.75; // 奥行き(小さいほど奥)
+ const pos = -(top / z);
+ banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
+}
+
+onMounted(() => {
+ window.requestAnimationFrame(parallaxLoop);
+ narrow = rootEl!.clientWidth < 1000;
+});
+
+onUnmounted(() => {
+ if (parallaxAnimationId) {
+ window.cancelAnimationFrame(parallaxAnimationId);
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ftskorzw {
+
+ > .main {
+
+ > .punished {
+ font-size: 0.8em;
+ padding: 16px;
+ }
+
+ > .profile {
+
+ > .main {
+ position: relative;
+ overflow: hidden;
+
+ > .banner-container {
+ position: relative;
+ height: 250px;
+ overflow: hidden;
+ background-size: cover;
+ background-position: center;
+
+ > .banner {
+ height: 100%;
+ background-color: #4c5e6d;
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
+ will-change: background-position;
+ }
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 78px;
+ background: linear-gradient(transparent, rgba(#000, 0.7));
+ }
+
+ > .followed {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ padding: 4px 8px;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.7);
+ font-size: 0.7em;
+ border-radius: 6px;
+ }
+
+ > .actions {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ background: rgba(0, 0, 0, 0.2);
+ padding: 8px;
+ border-radius: 24px;
+
+ > .menu {
+ vertical-align: bottom;
+ height: 31px;
+ width: 31px;
+ color: #fff;
+ text-shadow: 0 0 8px #000;
+ font-size: 16px;
+ }
+
+ > .koudoku {
+ margin-left: 4px;
+ vertical-align: bottom;
+ }
+ }
+
+ > .title {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 0 0 8px 154px;
+ box-sizing: border-box;
+ color: #fff;
+
+ > .name {
+ display: block;
+ margin: 0;
+ line-height: 32px;
+ font-weight: bold;
+ font-size: 1.8em;
+ text-shadow: 0 0 8px #000;
+ }
+
+ > .bottom {
+ > * {
+ display: inline-block;
+ margin-right: 16px;
+ line-height: 20px;
+ opacity: 0.8;
+
+ &.username {
+ font-weight: bold;
+ }
+ }
+ }
+ }
+ }
+
+ > .title {
+ display: none;
+ text-align: center;
+ padding: 50px 8px 16px 8px;
+ font-weight: bold;
+ border-bottom: solid 0.5px var(--divider);
+
+ > .bottom {
+ > * {
+ display: inline-block;
+ margin-right: 8px;
+ opacity: 0.8;
+ }
+ }
+ }
+
+ > .avatar {
+ display: block;
+ position: absolute;
+ top: 170px;
+ left: 16px;
+ z-index: 2;
+ width: 120px;
+ height: 120px;
+ box-shadow: 1px 1px 3px rgba(#000, 0.2);
+ }
+
+ > .description {
+ padding: 24px 24px 24px 154px;
+ font-size: 0.95em;
+
+ > .empty {
+ margin: 0;
+ opacity: 0.5;
+ }
+ }
+
+ > .fields {
+ padding: 24px;
+ font-size: 0.9em;
+ border-top: solid 0.5px var(--divider);
+
+ > .field {
+ display: flex;
+ padding: 0;
+ margin: 0;
+ align-items: center;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > .name {
+ width: 30%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-weight: bold;
+ text-align: center;
+ }
+
+ > .value {
+ width: 70%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0;
+ }
+ }
+
+ &.system > .field > .name {
+ }
+ }
+
+ > .status {
+ display: flex;
+ padding: 24px;
+ border-top: solid 0.5px var(--divider);
+
+ > a {
+ flex: 1;
+ text-align: center;
+
+ &.active {
+ color: var(--accent);
+ }
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ > b {
+ display: block;
+ line-height: 16px;
+ }
+
+ > span {
+ font-size: 70%;
+ }
+ }
+ }
+ }
+ }
+
+ > .contents {
+ > .content {
+ margin-bottom: var(--margin);
+ }
+ }
+ }
+
+ &.max-width_500px {
+ > .main {
+ > .profile > .main {
+ > .banner-container {
+ height: 140px;
+
+ > .fade {
+ display: none;
+ }
+
+ > .title {
+ display: none;
+ }
+ }
+
+ > .title {
+ display: block;
+ }
+
+ > .avatar {
+ top: 90px;
+ left: 0;
+ right: 0;
+ width: 92px;
+ height: 92px;
+ margin: auto;
+ }
+
+ > .description {
+ padding: 16px;
+ text-align: center;
+ }
+
+ > .fields {
+ padding: 16px;
+ }
+
+ > .status {
+ padding: 16px;
+ }
+ }
+
+ > .contents {
+ > .nav {
+ font-size: 80%;
+ }
+ }
+ }
+ }
+
+ &.wide {
+ display: flex;
+ width: 100%;
+
+ > .main {
+ width: 100%;
+ min-width: 0;
+ }
+
+ > .sub {
+ max-width: 350px;
+ min-width: 350px;
+ margin-left: var(--margin);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue
index aecd25d6b0..8a7a86e0f1 100644
--- a/packages/client/src/pages/user/index.activity.vue
+++ b/packages/client/src/pages/user/index.activity.vue
@@ -1,6 +1,6 @@
<template>
<MkContainer>
- <template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
+ <template #header><i class="fas fa-chart-simple" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
<template #func>
<button class="_button" @click="showMenu">
<i class="fas fa-ellipsis-h"></i>
@@ -36,8 +36,8 @@ function showMenu(ev: MouseEvent) {
active: true,
action: () => {
chartSrc = 'per-user-notes';
- }
- }/*, {
+ },
+ },/*, {
text: i18n.ts.following,
action: () => {
chartSrc = 'per-user-following';
diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue
index 79dd1726e1..cedb0e05f3 100644
--- a/packages/client/src/pages/user/index.photos.vue
+++ b/packages/client/src/pages/user/index.photos.vue
@@ -90,7 +90,7 @@ export default defineComponent({
> .img {
height: 128px;
border-radius: 6px;
- overflow: clip;
+ overflow: hidden; overflow: clip;
}
}
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
index a024dd28bc..99c3413882 100644
--- a/packages/client/src/pages/user/index.vue
+++ b/packages/client/src/pages/user/index.vue
@@ -1,297 +1,109 @@
<template>
-<div>
- <transition name="fade" mode="out-in">
- <MkSpacer v-if="user" :content-max="narrow ? 800 : 1100">
- <div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
- <div class="main">
- <!-- TODO -->
- <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
- <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
-
- <div class="profile">
- <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
-
- <div :key="user.id" class="_block main">
- <div class="banner-container" :style="style">
- <div ref="banner" class="banner" :style="style"></div>
- <div class="fade"></div>
- <div class="title">
- <MkUserName class="name" :user="user" :nowrap="true"/>
- <div class="bottom">
- <span class="username"><MkAcct :user="user" :detail="true" /></span>
- <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
- <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
- <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
- <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
- </div>
- </div>
- <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
- <div v-if="$i" class="actions">
- <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
- <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
- </div>
- </div>
- <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
- <div class="title">
- <MkUserName :user="user" :nowrap="false" class="name"/>
- <div class="bottom">
- <span class="username"><MkAcct :user="user" :detail="true" /></span>
- <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
- <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
- <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
- <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
- </div>
- </div>
- <div class="description">
- <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
- <p v-else class="empty">{{ $ts.noAccountDescription }}</p>
- </div>
- <div class="fields system">
- <dl v-if="user.location" class="field">
- <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
- <dd class="value">{{ user.location }}</dd>
- </dl>
- <dl v-if="user.birthday" class="field">
- <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
- <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
- </dl>
- <dl class="field">
- <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
- <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
- </dl>
- </div>
- <div v-if="user.fields.length > 0" class="fields">
- <dl v-for="(field, i) in user.fields" :key="i" class="field">
- <dt class="name">
- <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
- </dt>
- <dd class="value">
- <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
- </dd>
- </dl>
- </div>
- <div class="status">
- <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
- <b>{{ number(user.notesCount) }}</b>
- <span>{{ $ts.notes }}</span>
- </MkA>
- <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
- <b>{{ number(user.followingCount) }}</b>
- <span>{{ $ts.following }}</span>
- </MkA>
- <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
- <b>{{ number(user.followersCount) }}</b>
- <span>{{ $ts.followers }}</span>
- </MkA>
- </div>
- </div>
- </div>
-
- <div class="contents">
- <template v-if="page === 'index'">
- <div>
- <div v-if="user.pinnedNotes.length > 0" class="_gap">
- <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
- </div>
- <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
- <template v-if="narrow">
- <XPhotos :key="user.id" :user="user"/>
- <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
- </template>
- </div>
- <div>
- <XUserTimeline :user="user"/>
- </div>
- </template>
- <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
- <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
- <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
- <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
- <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
- <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
- </div>
- </div>
- <div v-if="!narrow" class="sub">
- <XPhotos :key="user.id" :user="user"/>
- <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
- </div>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <div>
+ <transition name="fade" mode="out-in">
+ <div v-if="user">
+ <XHome v-if="tab === 'home'" :user="user"/>
+ <XReactions v-else-if="tab === 'reactions'" :user="user"/>
+ <XClips v-else-if="tab === 'clips'" :user="user"/>
+ <XPages v-else-if="tab === 'pages'" :user="user"/>
+ <XGallery v-else-if="tab === 'gallery'" :user="user"/>
</div>
- </MkSpacer>
- <MkError v-else-if="error" @retry="fetch()"/>
- <MkLoading v-else/>
- </transition>
-</div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+ </div>
+</MkStickyContainer>
</template>
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
-import age from 's-age';
-import XUserTimeline from './index.timeline.vue';
-import XNote from '@/components/note.vue';
-import MkFollowButton from '@/components/follow-button.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkFolder from '@/components/ui/folder.vue';
-import MkRemoteCaution from '@/components/remote-caution.vue';
-import MkTab from '@/components/tab.vue';
-import MkInfo from '@/components/ui/info.vue';
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import calcAge from 's-age';
import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
import { getScrollPosition } from '@/scripts/scroll';
import { getUserMenu } from '@/scripts/get-user-menu';
import number from '@/filters/number';
import { userPage, acct as getAcct } from '@/filters/user';
import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { MisskeyNavigator } from '@/scripts/navigate';
-
-export default defineComponent({
- components: {
- XUserTimeline,
- XNote,
- MkFollowButton,
- MkContainer,
- MkRemoteCaution,
- MkFolder,
- MkTab,
- MkInfo,
- XFollowList: defineAsyncComponent(() => import('./follow-list.vue')),
- XReactions: defineAsyncComponent(() => import('./reactions.vue')),
- XClips: defineAsyncComponent(() => import('./clips.vue')),
- XPages: defineAsyncComponent(() => import('./pages.vue')),
- XGallery: defineAsyncComponent(() => import('./gallery.vue')),
- XPhotos: defineAsyncComponent(() => import('./index.photos.vue')),
- XActivity: defineAsyncComponent(() => import('./index.activity.vue')),
- },
-
- props: {
- acct: {
- type: String,
- required: true
- },
- page: {
- type: String,
- required: false,
- default: 'index'
- }
- },
-
- data() {
- return {
- [symbols.PAGE_INFO]: computed(() => this.user ? {
- icon: 'fas fa-user',
- title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`,
- subtitle: `@${getAcct(this.user)}`,
- userName: this.user,
- avatar: this.user,
- path: `/@${this.user.username}`,
- share: {
- title: this.user.name,
- },
- bg: 'var(--bg)',
- tabs: [{
- active: this.page === 'index',
- title: this.$ts.overview,
- icon: 'fas fa-home',
- onClick: () => { this.mkNav.push('/@' + getAcct(this.user)); },
- }, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{
- active: this.page === 'reactions',
- title: this.$ts.reaction,
- icon: 'fas fa-laugh',
- onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/reactions'); },
- }] : [], {
- active: this.page === 'clips',
- title: this.$ts.clips,
- icon: 'fas fa-paperclip',
- onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/clips'); },
- }, {
- active: this.page === 'pages',
- title: this.$ts.pages,
- icon: 'fas fa-file-alt',
- onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/pages'); },
- }, {
- active: this.page === 'gallery',
- title: this.$ts.gallery,
- icon: 'fas fa-icons',
- onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/gallery'); },
- }],
- } : null),
- user: null,
- error: null,
- parallaxAnimationId: null,
- narrow: null,
- mkNav: new MisskeyNavigator(),
- };
- },
-
- computed: {
- style(): any {
- if (this.user.bannerUrl == null) return {};
- return {
- backgroundImage: `url(${ this.user.bannerUrl })`
- };
- },
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
- age(): number {
- return age(this.user.birthday);
- }
- },
-
- watch: {
- acct: 'fetch'
- },
-
- created() {
- this.fetch();
- },
-
- mounted() {
- window.requestAnimationFrame(this.parallaxLoop);
- this.narrow = this.$el.clientWidth < 1000;
- },
-
- beforeUnmount() {
- window.cancelAnimationFrame(this.parallaxAnimationId);
- },
+const XHome = defineAsyncComponent(() => import('./home.vue'));
+const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
+const XClips = defineAsyncComponent(() => import('./clips.vue'));
+const XPages = defineAsyncComponent(() => import('./pages.vue'));
+const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
- methods: {
- getAcct,
-
- fetch() {
- if (this.acct == null) return;
- this.user = null;
- os.api('users/show', Acct.parse(this.acct)).then(user => {
- this.user = user;
- }).catch(err => {
- this.error = err;
- });
- },
+const props = withDefaults(defineProps<{
+ acct: string;
+ page?: string;
+}>(), {
+ page: 'home',
+});
- menu(ev) {
- os.popupMenu(getUserMenu(this.user), ev.currentTarget ?? ev.target);
- },
+const router = useRouter();
- parallaxLoop() {
- this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop);
- this.parallax();
- },
+let tab = $ref(props.page);
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
- parallax() {
- const banner = this.$refs.banner as any;
- if (banner == null) return;
+function fetchUser(): void {
+ if (props.acct == null) return;
+ user = null;
+ os.api('users/show', Acct.parse(props.acct)).then(u => {
+ user = u;
+ }).catch(err => {
+ error = err;
+ });
+}
- const top = getScrollPosition(this.$el);
+watch(() => props.acct, fetchUser, {
+ immediate: true,
+});
- if (top < 0) return;
+function menu(ev) {
+ os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target);
+}
- const z = 1.75; // 奥行き(小さいほど奥)
- const pos = -(top / z);
- banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
- },
+const headerActions = $computed(() => []);
- number,
+const headerTabs = $computed(() => user ? [{
+ key: 'home',
+ title: i18n.ts.overview,
+ icon: 'fas fa-home',
+}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{
+ key: 'reactions',
+ title: i18n.ts.reaction,
+ icon: 'fas fa-laugh',
+}] : [], {
+ key: 'clips',
+ title: i18n.ts.clips,
+ icon: 'fas fa-paperclip',
+}, {
+ key: 'pages',
+ title: i18n.ts.pages,
+ icon: 'fas fa-file-alt',
+}, {
+ key: 'gallery',
+ title: i18n.ts.gallery,
+ icon: 'fas fa-icons',
+}] : null);
- userPage
- }
-});
+definePageMetadata(computed(() => user ? {
+ icon: 'fas fa-user',
+ title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+ subtitle: `@${getAcct(user)}`,
+ userName: user,
+ avatar: user,
+ path: `/@${user.username}`,
+ share: {
+ title: user.name,
+ },
+} : null));
</script>
<style lang="scss" scoped>
@@ -303,291 +115,4 @@ export default defineComponent({
.fade-leave-to {
opacity: 0;
}
-
-.ftskorzw {
-
- > .main {
-
- > .punished {
- font-size: 0.8em;
- padding: 16px;
- }
-
- > .profile {
-
- > .main {
- position: relative;
- overflow: hidden;
-
- > .banner-container {
- position: relative;
- height: 250px;
- overflow: hidden;
- background-size: cover;
- background-position: center;
-
- > .banner {
- height: 100%;
- background-color: #4c5e6d;
- background-size: cover;
- background-position: center;
- box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
- will-change: background-position;
- }
-
- > .fade {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 78px;
- background: linear-gradient(transparent, rgba(#000, 0.7));
- }
-
- > .followed {
- position: absolute;
- top: 12px;
- left: 12px;
- padding: 4px 8px;
- color: #fff;
- background: rgba(0, 0, 0, 0.7);
- font-size: 0.7em;
- border-radius: 6px;
- }
-
- > .actions {
- position: absolute;
- top: 12px;
- right: 12px;
- -webkit-backdrop-filter: var(--blur, blur(8px));
- backdrop-filter: var(--blur, blur(8px));
- background: rgba(0, 0, 0, 0.2);
- padding: 8px;
- border-radius: 24px;
-
- > .menu {
- vertical-align: bottom;
- height: 31px;
- width: 31px;
- color: #fff;
- text-shadow: 0 0 8px #000;
- font-size: 16px;
- }
-
- > .koudoku {
- margin-left: 4px;
- vertical-align: bottom;
- }
- }
-
- > .title {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- padding: 0 0 8px 154px;
- box-sizing: border-box;
- color: #fff;
-
- > .name {
- display: block;
- margin: 0;
- line-height: 32px;
- font-weight: bold;
- font-size: 1.8em;
- text-shadow: 0 0 8px #000;
- }
-
- > .bottom {
- > * {
- display: inline-block;
- margin-right: 16px;
- line-height: 20px;
- opacity: 0.8;
-
- &.username {
- font-weight: bold;
- }
- }
- }
- }
- }
-
- > .title {
- display: none;
- text-align: center;
- padding: 50px 8px 16px 8px;
- font-weight: bold;
- border-bottom: solid 0.5px var(--divider);
-
- > .bottom {
- > * {
- display: inline-block;
- margin-right: 8px;
- opacity: 0.8;
- }
- }
- }
-
- > .avatar {
- display: block;
- position: absolute;
- top: 170px;
- left: 16px;
- z-index: 2;
- width: 120px;
- height: 120px;
- box-shadow: 1px 1px 3px rgba(#000, 0.2);
- }
-
- > .description {
- padding: 24px 24px 24px 154px;
- font-size: 0.95em;
-
- > .empty {
- margin: 0;
- opacity: 0.5;
- }
- }
-
- > .fields {
- padding: 24px;
- font-size: 0.9em;
- border-top: solid 0.5px var(--divider);
-
- > .field {
- display: flex;
- padding: 0;
- margin: 0;
- align-items: center;
-
- &:not(:last-child) {
- margin-bottom: 8px;
- }
-
- > .name {
- width: 30%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- font-weight: bold;
- text-align: center;
- }
-
- > .value {
- width: 70%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- margin: 0;
- }
- }
-
- &.system > .field > .name {
- }
- }
-
- > .status {
- display: flex;
- padding: 24px;
- border-top: solid 0.5px var(--divider);
-
- > a {
- flex: 1;
- text-align: center;
-
- &.active {
- color: var(--accent);
- }
-
- &:hover {
- text-decoration: none;
- }
-
- > b {
- display: block;
- line-height: 16px;
- }
-
- > span {
- font-size: 70%;
- }
- }
- }
- }
- }
-
- > .contents {
- > .content {
- margin-bottom: var(--margin);
- }
- }
- }
-
- &.max-width_500px {
- > .main {
- > .profile > .main {
- > .banner-container {
- height: 140px;
-
- > .fade {
- display: none;
- }
-
- > .title {
- display: none;
- }
- }
-
- > .title {
- display: block;
- }
-
- > .avatar {
- top: 90px;
- left: 0;
- right: 0;
- width: 92px;
- height: 92px;
- margin: auto;
- }
-
- > .description {
- padding: 16px;
- text-align: center;
- }
-
- > .fields {
- padding: 16px;
- }
-
- > .status {
- padding: 16px;
- }
- }
-
- > .contents {
- > .nav {
- font-size: 80%;
- }
- }
- }
- }
-
- &.wide {
- display: flex;
- width: 100%;
-
- > .main {
- width: 100%;
- min-width: 0;
- }
-
- > .sub {
- max-width: 350px;
- min-width: 350px;
- margin-left: var(--margin);
- }
- }
-}
</style>
diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue
index 47e1f12342..f9d5852212 100644
--- a/packages/client/src/pages/welcome.entrance.a.vue
+++ b/packages/client/src/pages/welcome.entrance.a.vue
@@ -13,10 +13,9 @@
<MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
<MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
</div>
- <div class="main _panel">
- <div class="bg">
- <div class="fade"></div>
- </div>
+ <div class="main">
+ <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+ <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button>
<div class="fg">
<h1>
<!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
@@ -24,123 +23,108 @@
<span class="text">{{ instanceName }}</span>
</h1>
<div class="about">
- <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div>
</div>
<div class="action">
- <MkButton inline gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ $ts.signup }}</MkButton>
- <MkButton inline data-cy-signin @click="signin()">{{ $ts.login }}</MkButton>
- </div>
- <div v-if="onlineUsersCount && stats" class="status">
- <div>
- <I18n :src="$ts.nUsers" text-tag="span" class="users">
- <template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
- </I18n>
- <I18n :src="$ts.nNotes" text-tag="span" class="notes">
- <template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
- </I18n>
- </div>
- <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
- <template #n><b>{{ onlineUsersCount }}</b></template>
- </I18n>
+ <MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton>
+ <MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton>
</div>
- <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button>
</div>
</div>
+ <div v-if="instances" class="federation">
+ <MarqueeText :duration="40">
+ <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window">
+ <!--<MkInstanceCardMini :instance="instance"/>-->
+ <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/>
+ <span class="name _monospace">{{ instance.host }}</span>
+ </MkA>
+ </MarqueeText>
+ </div>
</div>
</div>
</template>
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
import { toUnicode } from 'punycode/';
+import XTimeline from './welcome.timeline.vue';
+import MarqueeText from '@/components/marquee.vue';
import XSigninDialog from '@/components/signin-dialog.vue';
import XSignupDialog from '@/components/signup-dialog.vue';
import MkButton from '@/components/ui/button.vue';
import XNote from '@/components/note.vue';
import MkFeaturedPhotos from '@/components/featured-photos.vue';
-import XTimeline from './welcome.timeline.vue';
import { host, instanceName } from '@/config';
import * as os from '@/os';
import number from '@/filters/number';
+import { i18n } from '@/i18n';
-export default defineComponent({
- components: {
- MkButton,
- XNote,
- MkFeaturedPhotos,
- XTimeline,
- },
+let meta = $ref();
+let stats = $ref();
+let tags = $ref();
+let onlineUsersCount = $ref();
+let instances = $ref();
- data() {
- return {
- host: toUnicode(host),
- instanceName,
- meta: null,
- stats: null,
- tags: [],
- onlineUsersCount: null,
- };
- },
+os.api('meta', { detail: true }).then(_meta => {
+ meta = _meta;
+});
- created() {
- os.api('meta', { detail: true }).then(meta => {
- this.meta = meta;
- });
+os.api('stats').then(_stats => {
+ stats = _stats;
+});
- os.api('stats').then(stats => {
- this.stats = stats;
- });
+os.api('get-online-users-count').then(res => {
+ onlineUsersCount = res.count;
+});
- os.api('get-online-users-count').then(res => {
- this.onlineUsersCount = res.count;
- });
+os.api('hashtags/list', {
+ sort: '+mentionedLocalUsers',
+ limit: 8,
+}).then(_tags => {
+ tags = _tags;
+});
- os.api('hashtags/list', {
- sort: '+mentionedLocalUsers',
- limit: 8
- }).then(tags => {
- this.tags = tags;
- });
- },
+os.api('federation/instances', {
+ sort: '+pubSub',
+ limit: 20,
+}).then(_instances => {
+ instances = _instances;
+});
- methods: {
- signin() {
- os.popup(XSigninDialog, {
- autoSet: true
- }, {}, 'closed');
- },
+function signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true,
+ }, {}, 'closed');
+}
- signup() {
- os.popup(XSignupDialog, {
- autoSet: true
- }, {}, 'closed');
- },
+function signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true,
+ }, {}, 'closed');
+}
- showMenu(ev) {
- os.popupMenu([{
- text: this.$t('aboutX', { x: instanceName }),
- icon: 'fas fa-info-circle',
- action: () => {
- os.pageWindow('/about');
- }
- }, {
- text: this.$ts.aboutMisskey,
- icon: 'fas fa-info-circle',
- action: () => {
- os.pageWindow('/about-misskey');
- }
- }, null, {
- text: this.$ts.help,
- icon: 'fas fa-question-circle',
- action: () => {
- window.open(`https://misskey-hub.net/help.md`, '_blank');
- }
- }], ev.currentTarget ?? ev.target);
+function showMenu(ev) {
+ os.popupMenu([{
+ text: i18n.ts.instanceInfo,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about');
},
-
- number
- }
-});
+ }, {
+ text: i18n.ts.aboutMisskey,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ },
+ }, null, {
+ text: i18n.ts.help,
+ icon: 'fas fa-question-circle',
+ action: () => {
+ window.open('https://misskey-hub.net/help.md', '_blank');
+ },
+ }], ev.currentTarget ?? ev.target);
+}
</script>
<style lang="scss" scoped>
@@ -201,7 +185,7 @@ export default defineComponent({
position: absolute;
top: 42px;
left: 42px;
- width: 160px;
+ width: 140px;
@media (max-width: 450px) {
width: 130px;
@@ -226,30 +210,29 @@ export default defineComponent({
position: relative;
width: min(480px, 100%);
margin: auto auto auto 128px;
+ background: var(--panel);
+ border-radius: var(--radius);
box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
@media (max-width: 1200px) {
margin: auto;
}
- > .bg {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 128px;
- background-position: center;
- background-size: cover;
- opacity: 0.75;
+ > .icon {
+ width: 85px;
+ margin-top: -47px;
+ border-radius: 100%;
+ vertical-align: bottom;
+ }
- > .fade {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 128px;
- background: linear-gradient(0deg, var(--panel), var(--X15));
- }
+ > .menu {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ font-size: 18px;
}
> .fg {
@@ -259,8 +242,8 @@ export default defineComponent({
> h1 {
display: block;
margin: 0;
- padding: 32px 32px 24px 32px;
- font-size: 1.5em;
+ padding: 16px 32px 24px 32px;
+ font-size: 1.4em;
> .logo {
vertical-align: bottom;
@@ -280,41 +263,47 @@ export default defineComponent({
line-height: 28px;
}
}
+ }
+ }
- > .status {
- border-top: solid 0.5px var(--divider);
- padding: 32px;
- font-size: 90%;
-
- > div {
- > span:not(:last-child) {
- padding-right: 1em;
- margin-right: 1em;
- border-right: solid 0.5px var(--divider);
- }
- }
-
- > .online {
- ::v-deep(b) {
- color: #41b781;
- }
-
- ::v-deep(span) {
- opacity: 0.7;
- }
- }
- }
+ > .federation {
+ position: absolute;
+ bottom: 16px;
+ left: 0;
+ right: 0;
+ margin: auto;
+ background: var(--acrylicPanel);
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-radius: 999px;
+ overflow: hidden; overflow: clip;
+ width: 800px;
+ padding: 8px 0;
- > .menu {
- position: absolute;
- top: 16px;
- right: 16px;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- }
+ @media (max-width: 900px) {
+ display: none;
}
}
}
}
</style>
+
+<style lang="scss" module>
+.federationInstance {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: bottom;
+ padding: 6px 12px 6px 6px;
+ margin: 0 10px 0 0;
+ background: var(--panel);
+ border-radius: 999px;
+
+ > :global(.icon) {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ margin-right: 5px;
+ border-radius: 999px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue
index 053087fda0..344dc9aed9 100644
--- a/packages/client/src/pages/welcome.entrance.b.vue
+++ b/packages/client/src/pages/welcome.entrance.b.vue
@@ -9,6 +9,7 @@
<img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
</h1>
<div class="about">
+ <!-- eslint-disable-next-line vue/no-v-html -->
<div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
</div>
<div class="action">
diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue
index 6bf487e16e..d583c5df35 100644
--- a/packages/client/src/pages/welcome.entrance.c.vue
+++ b/packages/client/src/pages/welcome.entrance.c.vue
@@ -21,6 +21,7 @@
<img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
</h1>
<div class="about">
+ <!-- eslint-disable-next-line vue/no-v-html -->
<div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
</div>
<div class="action">
diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue
index 1a2f460283..4892ab6ea2 100644
--- a/packages/client/src/pages/welcome.setup.vue
+++ b/packages/client/src/pages/welcome.setup.vue
@@ -3,7 +3,7 @@
<h1>Welcome to Misskey!</h1>
<div class="_formRoot">
<p>{{ $ts.intro }}</p>
- <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-admin-username class="_formBlock">
+ <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock">
<template #label>{{ $ts.username }}</template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue
index 98808229da..a1c3fc2abb 100644
--- a/packages/client/src/pages/welcome.vue
+++ b/packages/client/src/pages/welcome.vue
@@ -11,7 +11,7 @@ import XSetup from './welcome.setup.vue';
import XEntrance from './welcome.entrance.a.vue';
import { instanceName } from '@/config';
import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
let meta = $ref(null);
@@ -19,10 +19,12 @@ os.api('meta', { detail: true }).then(res => {
meta = res;
});
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: instanceName,
- icon: null,
- })),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: instanceName,
+ icon: null,
+})));
</script>