diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
| commit | 84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b (patch) | |
| tree | a182502a5192992d873e7a7fcbf01662bb0dfca2 /packages/client/src/pages | |
| parent | Merge pull request #8821 from misskey-dev/develop (diff) | |
| parent | 12.112.1 (diff) | |
| download | misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.gz misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.bz2 misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages')
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 }} <-> {{ 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 }} <-> {{ 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 }} <-> {{ 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> |