diff options
Diffstat (limited to 'packages/frontend/src/pages')
71 files changed, 2239 insertions, 810 deletions
diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index ac5d601d6d..71353c7dfa 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -74,9 +74,9 @@ const pagination = { sort: sort.value, host: host.value !== '' ? host.value : null, ...( - state.value === 'federating' ? { federating: true } : - state.value === 'subscribing' ? { subscribing: true } : - state.value === 'publishing' ? { publishing: true } : + state.value === 'federating' ? { federating: true, suspended: false, blocked: false } : + state.value === 'subscribing' ? { subscribing: true, suspended: false, blocked: false } : + state.value === 'publishing' ? { publishing: true, suspended: false, blocked: false } : state.value === 'suspended' ? { suspended: true } : state.value === 'blocked' ? { blocked: true } : state.value === 'silenced' ? { silenced: true } : diff --git a/packages/frontend/src/pages/about.overview.vue b/packages/frontend/src/pages/about.overview.vue new file mode 100644 index 0000000000..c378c0a0b8 --- /dev/null +++ b/packages/frontend/src/pages/about.overview.vue @@ -0,0 +1,210 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps_m"> + <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> + <div style="overflow: clip;"> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> + <div :class="$style.bannerName"> + <b>{{ instance.name ?? host }}</b> + </div> + </div> + </div> + + <MkKeyValue> + <template #key>{{ i18n.ts.description }}</template> + <template #value><div v-html="sanitizeHtml(instance.description)"></div></template> + </MkKeyValue> + + <FormSection> + <div class="_gaps_m"> + <MkKeyValue :copy="version"> + <template #key>Sharkey</template> + <template #value>{{ version }}</template> + </MkKeyValue> + <div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })"> + </div> + <FormLink to="/about-sharkey"> + <template #icon><i class="ti ti-info-circle"></i></template> + {{ i18n.ts.aboutMisskey }} + </FormLink> + <FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/sharkey-${version}.tar.gz`" external> + <template #icon><i class="ti ti-code"></i></template> + {{ i18n.ts.sourceCode }} + </FormLink> + <MkInfo v-else warn> + {{ i18n.ts.sourceCodeIsNotYetProvided }} + </MkInfo> + </div> + </FormSection> + + <FormSection> + <div class="_gaps_m"> + <FormSplit> + <MkKeyValue :copy="instance.maintainerName"> + <template #key>{{ i18n.ts.administrator }}</template> + <template #value> + <template v-if="instance.maintainerName">{{ instance.maintainerName }}</template> + <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> + </template> + </MkKeyValue> + <MkKeyValue :copy="instance.maintainerEmail"> + <template #key>{{ i18n.ts.contact }}</template> + <template #value> + <template v-if="instance.maintainerEmail">{{ instance.maintainerEmail }}</template> + <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> + </template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.inquiry }}</template> + <template #value> + <MkLink v-if="instance.inquiryUrl" :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink> + <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> + </template> + </MkKeyValue> + </FormSplit> + <FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external> + <template #icon><i class="ti ti-user-shield"></i></template> + {{ i18n.ts.impressum }} + </FormLink> + <div class="_gaps_s"> + <FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external> + <template #icon><i class="ti ti-user-shield"></i></template> + <template #default>{{ i18n.ts.impressum }}</template> + </FormLink> + <MkFolder v-if="instance.serverRules.length > 0"> + <template #icon><i class="ti ti-checkup-list"></i></template> + <template #label>{{ i18n.ts.serverRules }}</template> + <ol class="_gaps_s" :class="$style.rules"> + <li v-for="item in instance.serverRules" :key="item" :class="$style.rule"> + <div :class="$style.ruleText" v-html="sanitizeHtml(item)"></div> + </li> + </ol> + </MkFolder> + <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external> + <template #icon><i class="ti ti-license"></i></template> + <template #default>{{ i18n.ts.termsOfService }}</template> + </FormLink> + <FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external> + <template #icon><i class="ti ti-shield-lock"></i></template> + <template #default>{{ i18n.ts.privacyPolicy }}</template> + </FormLink> + <FormLink v-if="instance.feedbackUrl" :to="instance.feedbackUrl" external> + <template #icon><i class="ti ti-message"></i></template> + <template #default>{{ i18n.ts.feedback }}</template> + </FormLink> + </div> + </div> + </FormSection> + + <FormSuspense v-slot="{ result: stats }" :p="initStats"> + <FormSection> + <template #label>{{ i18n.ts.statistics }}</template> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.users }}</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.notes }}</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + </FormSuspense> + + <FormSection> + <template #label>Well-known resources</template> + <div class="_gaps_s"> + <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> +</template> + +<script lang="ts" setup> +import sanitizeHtml from '@/scripts/sanitize-html.js'; +import { host, version } from '@/config.js'; +import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; +import number from '@/filters/number.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkLink from '@/components/MkLink.vue'; + +const initStats = () => misskeyApi('stats', {}); +</script> + +<style lang="scss" module> +.banner { + text-align: center; + border-radius: var(--radius); + overflow: clip; + background-color: var(--panel); + background-size: cover; + background-position: center center; +} + +.bannerIcon { + display: block; + margin: 16px auto 0 auto; + height: 64px; + border-radius: var(--radius-sm);; +} + +.bannerName { + display: block; + padding: 16px; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); +} + +.rules { + counter-reset: item; + list-style: none; + padding: 0; + margin: 0; +} + +.rule { + display: flex; + gap: 8px; + word-break: break-word; + + &::before { + flex-shrink: 0; + display: flex; + position: sticky; + top: calc(var(--stickyTop, 0px) + 8px); + counter-increment: item; + content: counter(item); + width: 32px; + height: 32px; + line-height: 32px; + background-color: var(--accentedBg); + color: var(--accent); + font-size: 13px; + font-weight: bold; + align-items: center; + justify-content: center; + border-radius: var(--radius-ellipse); + } +} + +.ruleText { + padding-top: 6px; +} +</style> diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 11823cc799..f35cbe8d5a 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -8,113 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> - <div class="_gaps_m"> - <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> - <div style="overflow: clip;"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> - <div :class="$style.bannerName"> - <b>{{ instance.name ?? host }}</b> - </div> - </div> - </div> - - <MkKeyValue> - <template #key>{{ i18n.ts.description }}</template> - <template #value><div v-html="sanitizeHtml(instance.description)"></div></template> - </MkKeyValue> - - <FormSection> - <div class="_gaps_m"> - <MkKeyValue :copy="version"> - <template #key>Sharkey</template> - <template #value>{{ version }}</template> - </MkKeyValue> - <div v-html="i18n.tsx.poweredByMisskeyDescription({ name: instance.name ?? host })"> - </div> - <FormLink to="/about-sharkey"> - <template #icon><i class="ti ti-info-circle"></i></template> - {{ i18n.ts.aboutMisskey }} - </FormLink> - <FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/sharkey-${version}.tar.gz`" external> - <template #icon><i class="ti ti-code"></i></template> - {{ i18n.ts.sourceCode }} - </FormLink> - <MkInfo v-else warn> - {{ i18n.ts.sourceCodeIsNotYetProvided }} - </MkInfo> - </div> - </FormSection> - - <FormSection> - <div class="_gaps_m"> - <FormSplit> - <MkKeyValue> - <template #key>{{ i18n.ts.administrator }}</template> - <template #value>{{ instance.maintainerName }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.contact }}</template> - <template #value>{{ instance.maintainerEmail }}</template> - </MkKeyValue> - </FormSplit> - <FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external> - <template #icon><i class="ti ti-user-shield"></i></template> - {{ i18n.ts.impressum }} - </FormLink> - <div class="_gaps_s"> - <MkFolder v-if="instance.serverRules.length > 0"> - <template #label> - <i class="ti ti-checkup-list"></i> - {{ i18n.ts.serverRules }} - </template> - - <ol class="_gaps_s" :class="$style.rules"> - <li v-for="(item, index) in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="sanitizeHtml(item)"></div></li> - </ol> - </MkFolder> - <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external> - <template #icon><i class="ti ti-license"></i></template> - {{ i18n.ts.termsOfService }} - </FormLink> - <FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external> - <template #icon><i class="ti ti-shield-lock"></i></template> - {{ i18n.ts.privacyPolicy }} - </FormLink> - <FormLink v-if="instance.feedbackUrl" :to="instance.feedbackUrl" external> - <template #icon><i class="ti ti-message"></i></template> - {{ i18n.ts.feedback }} - </FormLink> - </div> - </div> - </FormSection> - - <FormSuspense :p="initStats"> - <FormSection> - <template #label>{{ i18n.ts.statistics }}</template> - <FormSplit> - <MkKeyValue> - <template #key>{{ i18n.ts.users }}</template> - <template #value>{{ number(stats.originalUsersCount) }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts.notes }}</template> - <template #value>{{ number(stats.originalNotesCount) }}</template> - </MkKeyValue> - </FormSplit> - </FormSection> - </FormSuspense> - - <FormSection> - <template #label>Well-known resources</template> - <div class="_gaps_s"> - <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> + <XOverview/> </MkSpacer> <MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20"> <XEmojis/> @@ -130,27 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import sanitizeHtml from '@/scripts/sanitize-html.js'; -import { computed, watch, ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import XEmojis from './about.emojis.vue'; -import XFederation from './about.federation.vue'; -import { version, host } from '@/config.js'; -import FormLink from '@/components/form/link.vue'; -import FormSection from '@/components/form/section.vue'; -import FormSuspense from '@/components/form/suspense.vue'; -import FormSplit from '@/components/form/split.vue'; -import MkFolder from '@/components/MkFolder.vue'; -import MkKeyValue from '@/components/MkKeyValue.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import MkInstanceStats from '@/components/MkInstanceStats.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import number from '@/filters/number.js'; +import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import { instance } from '@/instance.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; + +const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue')); +const XEmojis = defineAsyncComponent(() => import('@/pages/about.emojis.vue')); +const XFederation = defineAsyncComponent(() => import('@/pages/about.federation.vue')); +const MkInstanceStats = defineAsyncComponent(() => import('@/components/MkInstanceStats.vue')); const props = withDefaults(defineProps<{ initialTab?: string; @@ -158,7 +41,6 @@ const props = withDefaults(defineProps<{ initialTab: 'overview', }); -const stats = ref<Misskey.entities.StatsResponse | null>(null); const tab = ref(props.initialTab); watch(tab, () => { @@ -167,11 +49,6 @@ watch(tab, () => { } }); -const initStats = () => misskeyApi('stats', { -}).then((res) => { - stats.value = res; -}); - const headerActions = computed(() => []); const headerTabs = computed(() => [{ @@ -196,64 +73,3 @@ definePageMetadata(() => ({ icon: 'ti ti-info-circle', })); </script> - -<style lang="scss" module> -.banner { - text-align: center; - border-radius: var(--radius); - overflow: clip; - background-size: cover; - background-position: center center; -} - -.bannerIcon { - display: block; - margin: 16px auto 0 auto; - height: 64px; - border-radius: var(--radius-sm); -} - -.bannerName { - display: block; - padding: 16px; - color: #fff; - text-shadow: 0 0 8px #000; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); -} - -.rules { - counter-reset: item; - list-style: none; - padding: 0; - margin: 0; -} - -.rule { - display: flex; - gap: 8px; - word-break: break-word; - - &::before { - flex-shrink: 0; - display: flex; - position: sticky; - top: calc(var(--stickyTop, 0px) + 8px); - counter-increment: item; - content: counter(item); - width: 32px; - height: 32px; - line-height: 32px; - background-color: var(--accentedBg); - color: var(--accent); - font-size: 13px; - font-weight: bold; - align-items: center; - justify-content: center; - border-radius: var(--radius-ellipse); - } -} - -.ruleText { - padding-top: 6px; -} -</style> diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index ef5388969b..af98967fe2 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -482,16 +482,20 @@ function toggleRoleItem(role) { } function createAnnouncement() { - os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { user: user.value, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); } function editAnnouncement(announcement) { - os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { user: user.value, announcement, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); } watch(() => props.userId, () => { diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index 5c9c32c964..61dc4f8549 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only <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.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + <MkButton v-if="action.asFullButton" class="fullButton" primary :disabled="action.disabled" @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" :disabled="action.disabled" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> </template> </template> </div> @@ -56,6 +56,7 @@ const props = defineProps<{ text: string; icon: string; asFullButton?: boolean; + disabled?: boolean; handler: (ev: MouseEvent) => void; }[]; thin?: boolean; diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue new file mode 100644 index 0000000000..827e22e8ae --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -0,0 +1,321 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialogEl" + :width="400" + :height="490" + :withOkButton="false" + :okButtonDisabled="false" + @close="onCancelClicked" + @closed="emit('closed')" +> + <template #header> + {{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }} + </template> + <div v-if="loading === 0" style="display: flex; flex-direction: column; min-height: 100%;"> + <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> + <div :class="$style.root" class="_gaps_m"> + <MkInput v-model="title"> + <template #label>{{ i18n.ts.title }}</template> + </MkInput> + <MkSelect v-model="method"> + <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> + <option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option> + <option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option> + <template #caption> + {{ methodCaption }} + </template> + </MkSelect> + <div> + <MkSelect v-if="method === 'email'" v-model="userId"> + <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template> + <option v-for="user in moderators" :key="user.id" :value="user.id"> + {{ user.name ? `${user.name}(${user.username})` : user.username }} + </option> + </MkSelect> + <div v-else-if="method === 'webhook'" :class="$style.systemWebhook"> + <MkSelect v-model="systemWebhookId" style="flex: 1"> + <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template> + <option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id"> + {{ webhook.name }} + </option> + </MkSelect> + <MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked"> + <span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/> + <span v-else class="ti ti-settings" style="line-height: normal"/> + </MkButton> + </div> + </div> + + <MkDivider/> + + <MkSwitch v-model="isActive"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </div> + </MkSpacer> + + <div :class="$style.footer" class="_buttonsCenter"> + <MkButton primary rounded :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton> + <MkButton rounded @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> + </div> + </div> + <div v-else> + <MkLoading/> + </div> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, shallowRef, toRefs } from 'vue'; +import { entities } from 'misskey-js'; +import MkButton from '@/components/MkButton.vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n.js'; +import MkInput from '@/components/MkInput.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import MkSelect from '@/components/MkSelect.vue'; +import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkDivider from '@/components/MkDivider.vue'; +import * as os from '@/os.js'; + +type NotificationRecipientMethod = 'email' | 'webhook'; + +const emit = defineEmits<{ + (ev: 'submitted'): void; + (ev: 'canceled'): void; + (ev: 'closed'): void; +}>(); + +const props = defineProps<{ + mode: 'create' | 'edit'; + id?: string; +}>(); + +const { mode, id } = toRefs(props); + +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); + +const loading = ref<number>(0); + +const title = ref<string>(''); +const method = ref<NotificationRecipientMethod>('email'); +const userId = ref<string | null>(null); +const systemWebhookId = ref<string | null>(null); +const isActive = ref<boolean>(true); + +const moderators = ref<entities.User[]>([]); +const systemWebhooks = ref<(entities.SystemWebhook | { id: null, name: string })[]>([]); + +const methodCaption = computed(() => { + switch (method.value) { + case 'email': { + return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.mail; + } + case 'webhook': { + return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.webhook; + } + default: { + return ''; + } + } +}); + +const disableSubmitButton = computed(() => { + if (!title.value) { + return true; + } + + switch (method.value) { + case 'email': { + return userId.value === null; + } + case 'webhook': { + return systemWebhookId.value === null; + } + default: { + return true; + } + } +}); + +async function onSubmitClicked() { + await loadingScope(async () => { + const _userId = (method.value === 'email') ? userId.value : null; + const _systemWebhookId = (method.value === 'webhook') ? systemWebhookId.value : null; + const params = { + isActive: isActive.value, + name: title.value, + method: method.value, + userId: _userId ?? undefined, + systemWebhookId: _systemWebhookId ?? undefined, + }; + + try { + switch (mode.value) { + case 'create': { + await misskeyApi('admin/abuse-report/notification-recipient/create', params); + break; + } + case 'edit': { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await misskeyApi('admin/abuse-report/notification-recipient/update', { id: id.value!, ...params }); + break; + } + } + + dialogEl.value?.close(); + emit('submitted'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (ex: any) { + const msg = ex.message ?? i18n.ts.internalServerErrorDescription; + await os.alert({ type: 'error', title: i18n.ts.error, text: msg }); + dialogEl.value?.close(); + emit('canceled'); + } + }); +} + +function onCancelClicked() { + dialogEl.value?.close(); + emit('canceled'); +} + +async function onEditSystemWebhookClicked() { + let result: MkSystemWebhookResult | null; + if (systemWebhookId.value === null) { + result = await showSystemWebhookEditorDialog({ + mode: 'create', + }); + } else { + result = await showSystemWebhookEditorDialog({ + mode: 'edit', + id: systemWebhookId.value, + }); + } + if (!result) { + return; + } + + await fetchSystemWebhooks(); + systemWebhookId.value = result.id ?? null; +} + +async function fetchSystemWebhooks() { + await loadingScope(async () => { + systemWebhooks.value = [ + { id: null, name: i18n.ts.createNew }, + ...await misskeyApi('admin/system-webhook/list', { }), + ]; + }); +} + +async function fetchModerators() { + await loadingScope(async () => { + const users = Array.of<entities.User>(); + for (; ;) { + const res = await misskeyApi('admin/show-users', { + limit: 100, + state: 'adminOrModerator', + origin: 'local', + offset: users.length, + }); + + if (res.length === 0) { + break; + } + + users.push(...res); + } + + moderators.value = users; + }); +} + +async function loadingScope<T>(fn: () => Promise<T>): Promise<T> { + loading.value++; + try { + return await fn(); + } finally { + loading.value--; + } +} + +onMounted(async () => { + await loadingScope(async () => { + await fetchModerators(); + await fetchSystemWebhooks(); + + if (mode.value === 'edit') { + if (!id.value) { + throw new Error('id is required'); + } + + try { + const res = await misskeyApi('admin/abuse-report/notification-recipient/show', { id: id.value }); + + title.value = res.name; + method.value = res.method; + userId.value = res.userId ?? null; + systemWebhookId.value = res.systemWebhookId ?? null; + isActive.value = res.isActive; + // eslint-disable-next-line + } catch (ex: any) { + const msg = ex.message ?? i18n.ts.internalServerErrorDescription; + await os.alert({ type: 'error', title: i18n.ts.error, text: msg }); + dialogEl.value?.close(); + emit('canceled'); + } + } else { + userId.value = moderators.value[0]?.id ?? null; + systemWebhookId.value = systemWebhooks.value[0]?.id ?? null; + } + }); +}); + +</script> + +<style lang="scss" module> +.root { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; +} + +.footer { + position: sticky; + z-index: 10000; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--divider); + background: var(--acrylicBg); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} + +.systemWebhook { + display: flex; + flex-direction: row; + justify-content: stretch; + align-items: flex-end; + gap: 8px; +} + +.systemWebhookEditButton { + min-width: 0; + min-height: 0; + width: 34px; + height: 34px; + flex-shrink: 0; + box-sizing: border-box; + margin: 1px 0; + padding: 6px; +} +</style> diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue new file mode 100644 index 0000000000..0b86808faf --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue @@ -0,0 +1,114 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_panel _gaps_s"> + <div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"/> {{ methodName }}</div> + <div :class="$style.rightDivider" style="flex: 0.5">{{ entity.name }}</div> + <div :class="$style.rightDivider" style="flex: 1"> + <div v-if="method === 'email' && user"> + {{ + `${i18n.ts._abuseReport._notificationRecipient.notifiedUser}: ` + ((user.name) ? `${user.name}(${user.username})` : user.username) + }} + </div> + <div v-if="method === 'webhook' && systemWebhook"> + {{ `${i18n.ts._abuseReport._notificationRecipient.notifiedWebhook}: ` + systemWebhook.name }} + </div> + </div> + <div :class="$style.recipientButtons" style="margin-left: auto"> + <button :class="$style.recipientButton" @click="onEditButtonClicked()"> + <span class="ti ti-settings"/> + </button> + <button :class="$style.recipientButton" @click="onDeleteButtonClicked()"> + <span class="ti ti-trash"/> + </button> + </div> +</div> +</template> + +<script setup lang="ts"> +import { entities } from 'misskey-js'; +import { computed, toRefs } from 'vue'; +import { i18n } from '@/i18n.js'; + +const emit = defineEmits<{ + (ev: 'edit', id: entities.AbuseReportNotificationRecipient['id']): void; + (ev: 'delete', id: entities.AbuseReportNotificationRecipient['id']): void; +}>(); + +const props = defineProps<{ + entity: entities.AbuseReportNotificationRecipient; +}>(); + +const { entity } = toRefs(props); + +const method = computed(() => entity.value.method); +const user = computed(() => entity.value.user); +const systemWebhook = computed(() => entity.value.systemWebhook); +const methodIcon = computed(() => { + switch (entity.value.method) { + case 'email': + return 'ti-mail'; + case 'webhook': + return 'ti-webhook'; + default: + return 'ti-help'; + } +}); +const methodName = computed(() => { + switch (entity.value.method) { + case 'email': + return i18n.ts._abuseReport._notificationRecipient._recipientType.mail; + case 'webhook': + return i18n.ts._abuseReport._notificationRecipient._recipientType.webhook; + default: + return '不明'; + } +}); + +function onEditButtonClicked() { + emit('edit', entity.value.id); +} + +function onDeleteButtonClicked() { + emit('delete', entity.value.id); +} +</script> + +<style module lang="scss"> +.root { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 4px 8px; +} + +.rightDivider { + border-right: 0.5px solid var(--divider); +} + +.recipientButtons { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + margin-right: -4; +} + +.recipientButton { + background-color: transparent; + border: none; + border-radius: 9999px; + box-sizing: border-box; + margin-top: -2px; + margin-bottom: -2px; + padding: 8px; + + &:hover { + background-color: var(--buttonBg); + } +} +</style> diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue new file mode 100644 index 0000000000..f5249261be --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -0,0 +1,177 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header> + <XHeader :actions="headerActions" :tabs="headerTabs"/> + </template> + + <MkSpacer :contentMax="900"> + <div :class="$style.root" class="_gaps_m"> + <div :class="$style.addButton"> + <MkButton primary @click="onAddButtonClicked"> + <span class="ti ti-plus"/> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }} + </MkButton> + </div> + <div :class="$style.subMenus" class="_gaps_s"> + <MkSelect v-model="filterMethod" style="flex: 1"> + <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> + <option :value="null">-</option> + <option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option> + <option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option> + </MkSelect> + <MkInput v-model="filterText" type="search" style="flex: 1"> + <template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template> + </MkInput> + </div> + + <MkDivider/> + + <div :class="$style.recipients" class="_gaps_s"> + <XRecipient + v-for="r in filteredRecipients" + :key="r.id" + :entity="r" + @edit="onEditButtonClicked" + @delete="onDeleteButtonClicked" + /> + </div> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script setup lang="ts"> +import { entities } from 'misskey-js'; +import { computed, defineAsyncComponent, onMounted, ref } from 'vue'; +import XRecipient from './notification-recipient.item.vue'; +import XHeader from '@/pages/admin/_header_.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os.js'; +import MkDivider from '@/components/MkDivider.vue'; +import { i18n } from '@/i18n.js'; + +const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]); + +const filterMethod = ref<string | null>(null); +const filterText = ref<string>(''); + +const filteredRecipients = computed(() => { + const method = filterMethod.value; + const text = filterText.value.trim().length === 0 ? null : filterText.value; + + return recipients.value.filter(it => { + if (method ?? text) { + if (text) { + const keywords = [it.name, it.systemWebhook?.name, it.user?.name, it.user?.username]; + if (keywords.filter(k => k?.includes(text)).length !== 0) { + return true; + } + } + + if (method) { + return it.method.includes(method); + } + + return false; + } + + return true; + }); +}); +const headerActions = computed(() => []); +const headerTabs = computed(() => []); + +async function onAddButtonClicked() { + await showEditor('create'); +} + +async function onEditButtonClicked(id: string) { + await showEditor('edit', id); +} + +async function onDeleteButtonClicked(id: string) { + const res = await os.confirm({ + type: 'warning', + title: i18n.ts._abuseReport._notificationRecipient.deleteConfirm, + }); + if (!res.canceled) { + await misskeyApi('admin/abuse-report/notification-recipient/delete', { id: id }); + await fetchRecipients(); + } +} + +async function showEditor(mode: 'create' | 'edit', id?: string) { + const { needLoad } = await new Promise<{ needLoad: boolean }>(async resolve => { + const { dispose } = os.popup( + defineAsyncComponent(() => import('./notification-recipient.editor.vue')), + { + mode, + id, + }, + { + submitted: () => { + resolve({ needLoad: true }); + }, + canceled: () => { + resolve({ needLoad: false }); + }, + closed: () => { + dispose(); + }, + }, + ); + }); + + if (needLoad) { + await fetchRecipients(); + } +} + +async function fetchRecipients() { + const result = await misskeyApi('admin/abuse-report/notification-recipient/list', { + method: ['email', 'webhook'], + }); + + recipients.value = result.sort((a, b) => (a.method + a.id).localeCompare(b.method + b.id)); +} + +onMounted(async () => { + await fetchRecipients(); +}); +</script> + +<style module lang="scss"> +.root { + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; +} + +.addButton { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.subMenus { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-end; +} + +.recipients { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; +} +</style> diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 39267b1345..fdc014a46e 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -7,30 +7,33 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="900"> - <div> - <div class="reports"> - <div class=""> - <div class="inputs" style="display: flex;"> - <MkSelect v-model="state" style="margin: 0; flex: 1;"> - <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="unresolved">{{ i18n.ts.unresolved }}</option> - <option value="resolved">{{ i18n.ts.resolved }}</option> - </MkSelect> - <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> - <template #label>{{ i18n.ts.reporteeOrigin }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkSelect> - <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> - <template #label>{{ i18n.ts.reporterOrigin }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkSelect> - </div> - <!-- TODO + <div :class="$style.root" class="_gaps"> + <div :class="$style.subMenus" class="_gaps"> + <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton> + </div> + + <div :class="$style.inputs" class="_gaps"> + <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="unresolved">{{ i18n.ts.unresolved }}</option> + <option value="resolved">{{ i18n.ts.resolved }}</option> + </MkSelect> + <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporteeOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ i18n.ts.reporterOrigin }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.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"> <span>{{ i18n.ts.username }}</span> @@ -41,11 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> --> - <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50" style="margin-top: var(--margin);"> - <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> - </MkPagination> - </div> - </div> + <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50" style="margin-top: var(--margin);"> + <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> + </MkPagination> </div> </MkSpacer> </MkStickyContainer> @@ -60,6 +61,7 @@ import MkPagination from '@/components/MkPagination.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkButton from '@/components/MkButton.vue'; const reports = shallowRef<InstanceType<typeof MkPagination>>(); @@ -80,7 +82,7 @@ const pagination = { }; function resolved(reportId) { - reports.value.removeItem(reportId); + reports.value?.removeItem(reportId); } const headerActions = computed(() => []); @@ -92,3 +94,26 @@ definePageMetadata(() => ({ icon: 'ti ti-exclamation-circle', })); </script> + +<style module lang="scss"> +.root { + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; +} + +.subMenus { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; +} + +.inputs { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} +</style> diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index e7fb62ec1d..b9e09c8d03 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -11,70 +11,83 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo> <MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> - <MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null"> - <template #label>{{ announcement.title }}</template> - <template #icon> - <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> - <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> - <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> - <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> - </template> - <template #caption>{{ announcement.text }}</template> + <MkSelect v-model="announcementsStatus"> + <template #label>{{ i18n.ts.filter }}</template> + <option value="active">{{ i18n.ts.active }}</option> + <option value="archived">{{ i18n.ts.archived }}</option> + </MkSelect> - <div class="_gaps_m"> - <MkInput v-model="announcement.title"> - <template #label>{{ i18n.ts.title }}</template> - </MkInput> - <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true"> - <template #label>{{ i18n.ts.text }}</template> - </MkTextarea> - <MkInput v-model="announcement.imageUrl" type="url"> - <template #label>{{ i18n.ts.imageUrl }}</template> - </MkInput> - <MkRadios v-model="announcement.icon"> - <template #label>{{ i18n.ts.icon }}</template> - <option value="info"><i class="ti ti-info-circle"></i></option> - <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> - <option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> - <option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> - </MkRadios> - <MkRadios v-model="announcement.display"> - <template #label>{{ i18n.ts.display }}</template> - <option value="normal">{{ i18n.ts.normal }}</option> - <option value="banner">{{ i18n.ts.banner }}</option> - <option value="dialog">{{ i18n.ts.dialog }}</option> - </MkRadios> - <MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo> - <MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription"> - {{ i18n.ts._announcement.forExistingUsers }} - </MkSwitch> - <MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription"> - {{ i18n.ts._announcement.silence }} - </MkSwitch> - <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> - {{ i18n.ts._announcement.needConfirmationToRead }} - </MkSwitch> - <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p> - <div class="buttons _buttons"> - <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> - <MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> - <MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkLoading v-if="loading"/> + + <template v-else> + <MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null"> + <template #label>{{ announcement.title }}</template> + <template #icon> + <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> + <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> + <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> + <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> + </template> + <template #caption>{{ announcement.text }}</template> + + <div class="_gaps_m"> + <MkInput v-model="announcement.title"> + <template #label>{{ i18n.ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true"> + <template #label>{{ i18n.ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl" type="url"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <MkRadios v-model="announcement.icon"> + <template #label>{{ i18n.ts.icon }}</template> + <option value="info"><i class="ti ti-info-circle"></i></option> + <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> + <option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> + <option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> + </MkRadios> + <MkRadios v-model="announcement.display"> + <template #label>{{ i18n.ts.display }}</template> + <option value="normal">{{ i18n.ts.normal }}</option> + <option value="banner">{{ i18n.ts.banner }}</option> + <option value="dialog">{{ i18n.ts.dialog }}</option> + </MkRadios> + <MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo> + <MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription"> + {{ i18n.ts._announcement.forExistingUsers }} + </MkSwitch> + <MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription"> + {{ i18n.ts._announcement.silence }} + </MkSwitch> + <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> + {{ i18n.ts._announcement.needConfirmationToRead }} + </MkSwitch> + <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p> + <div class="buttons _buttons"> + <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="announcement.id != null && announcement.isActive" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> + <MkButton v-if="announcement.id != null && !announcement.isActive" class="button" inline @click="unarchive(announcement)"><i class="ti ti-restore"></i> {{ i18n.ts.unarchive }}</MkButton> + <MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> </div> - </div> - </MkFolder> - <MkButton class="button" @click="more()"> - <i class="ti ti-reload"></i>{{ i18n.ts.more }} - </MkButton> + </MkFolder> + <MkLoading v-if="loadingMore"/> + <MkButton class="button" @click="more()"> + <i class="ti ti-reload"></i>{{ i18n.ts.more }} + </MkButton> + </template> </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -85,11 +98,22 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkFolder from '@/components/MkFolder.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +const announcementsStatus = ref<'active' | 'archived'>('active'); + +const loading = ref(true); +const loadingMore = ref(false); + const announcements = ref<any[]>([]); -misskeyApi('admin/announcements/list').then(announcementResponse => { - announcements.value = announcementResponse; -}); +watch(announcementsStatus, (to) => { + loading.value = true; + misskeyApi('admin/announcements/list', { + status: to, + }).then(announcementResponse => { + announcements.value = announcementResponse; + loading.value = false; + }); +}, { immediate: true }); function add() { announcements.value.unshift({ @@ -125,6 +149,14 @@ async function archive(announcement) { refresh(); } +async function unarchive(announcement) { + await os.apiWithDialog('admin/announcements/update', { + ...announcement, + isActive: true, + }); + refresh(); +} + async function save(announcement) { if (announcement.id == null) { await os.apiWithDialog('admin/announcements/create', announcement); @@ -135,24 +167,32 @@ async function save(announcement) { } function more() { - misskeyApi('admin/announcements/list', { untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id }).then(announcementResponse => { + loadingMore.value = true; + misskeyApi('admin/announcements/list', { + status: announcementsStatus.value, + untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id + }).then(announcementResponse => { announcements.value = announcements.value.concat(announcementResponse); + loadingMore.value = false; }); } function refresh() { - misskeyApi('admin/announcements/list').then(announcementResponse => { + loading.value = true; + misskeyApi('admin/announcements/list', { + status: announcementsStatus.value, + }).then(announcementResponse => { announcements.value = announcementResponse; + loading.value = false; }); } -refresh(); - const headerActions = computed(() => [{ asFullButton: true, icon: 'ti ti-plus', text: i18n.ts.add, handler: add, + disabled: announcementsStatus.value === 'archived', }]); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 141de0be34..1902c97724 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -81,9 +81,9 @@ const pagination = { sort: sort.value, host: host.value !== '' ? host.value : null, ...( - state.value === 'federating' ? { federating: true } : - state.value === 'subscribing' ? { subscribing: true } : - state.value === 'publishing' ? { publishing: true } : + state.value === 'federating' ? { federating: true, suspended: false, blocked: false } : + state.value === 'subscribing' ? { subscribing: true, suspended: false, blocked: false } : + state.value === 'publishing' ? { publishing: true, suspended: false, blocked: false } : state.value === 'suspended' ? { suspended: true } : state.value === 'blocked' ? { blocked: true } : state.value === 'silenced' ? { silenced: true } : diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index c5bb2766dc..794669d6b5 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -230,6 +230,11 @@ const menuDef = computed(() => [{ to: '/admin/external-services', active: currentPage.value?.route.name === 'external-services', }, { + icon: 'ti ti-webhook', + text: 'Webhook', + to: '/admin/system-webhook', + active: currentPage.value?.route.name === 'system-webhook', + }, { icon: 'ti ti-adjustments', text: i18n.ts.other, to: '/admin/other-settings', diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue index 6b14bd42c2..e090616b26 100644 --- a/packages/frontend/src/pages/admin/instance-block.vue +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -8,14 +8,22 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> - <MkTextarea v-if="tab === 'block'" v-model="blockedHosts"> - <span>{{ i18n.ts.blockedInstances }}</span> - <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> - </MkTextarea> - <MkTextarea v-else-if="tab === 'silence'" v-model="silencedHosts" class="_formBlock"> - <span>{{ i18n.ts.silencedInstances }}</span> - <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template> - </MkTextarea> + <template v-if="tab === 'block'"> + <MkTextarea v-model="blockedHosts"> + <span>{{ i18n.ts.blockedInstances }}</span> + <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> + </MkTextarea> + </template> + <template v-else-if="tab === 'silence'"> + <MkTextarea v-model="silencedHosts" class="_formBlock"> + <span>{{ i18n.ts.silencedInstances }}</span> + <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template> + </MkTextarea> + <MkTextarea v-model="mediaSilencedHosts" class="_formBlock"> + <span>{{ i18n.ts.mediaSilencedInstances }}</span> + <template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template> + </MkTextarea> + </template> <MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </FormSuspense> </MkSpacer> @@ -36,18 +44,21 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; const blockedHosts = ref<string>(''); const silencedHosts = ref<string>(''); +const mediaSilencedHosts = ref<string>(''); const tab = ref('block'); async function init() { const meta = await misskeyApi('admin/meta'); blockedHosts.value = meta.blockedHosts.join('\n'); silencedHosts.value = meta.silencedHosts.join('\n'); + mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n'); } function save() { os.apiWithDialog('admin/update-meta', { blockedHosts: blockedHosts.value.split('\n') || [], silencedHosts: silencedHosts.value.split('\n') || [], + mediaSilencedHosts: mediaSilencedHosts.value.split('\n') || [], }).then(() => { fetchInstance(true); diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 9f172140bc..c4f2c292e0 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local"> <template #label>{{ i18n.ts.expirationDate }}</template> </MkInput> - <MkInput v-model="createCount" type="number"> + <MkInput v-model="createCount" type="number" min="1"> <template #label>{{ i18n.ts.createCount }}</template> </MkInput> <MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton> diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index c1e633df23..4cddca3a7d 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -8,9 +8,36 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label> <b :class="{ - [$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type), - [$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type), - [$style.logRed]: ['suspend', 'approve', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type) + [$style.logGreen]: [ + 'createRole', + 'addCustomEmoji', + 'createGlobalAnnouncement', + 'createUserAnnouncement', + 'createAd', + 'createInvitation', + 'createAvatarDecoration', + 'createSystemWebhook', + 'createAbuseReportNotificationRecipient', + ].includes(log.type), + [$style.logYellow]: [ + 'markSensitiveDriveFile', + 'resetPassword' + ].includes(log.type), + [$style.logRed]: [ + 'suspend', + 'approve', + 'deleteRole', + 'suspendRemoteInstance', + 'deleteGlobalAnnouncement', + 'deleteUserAnnouncement', + 'deleteCustomEmoji', + 'deleteNote', + 'deleteDriveFile', + 'deleteAd', + 'deleteAvatarDecoration', + 'deleteSystemWebhook', + 'deleteAbuseReportNotificationRecipient', + ].includes(log.type) }" >{{ i18n.ts._moderationLogTypes[log.type] }}</b> <span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> @@ -41,6 +68,12 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span> <span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span> <span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span> + <span v-else-if="log.type === 'createSystemWebhook'">: {{ log.info.webhook.name }}</span> + <span v-else-if="log.type === 'updateSystemWebhook'">: {{ log.info.before.name }}</span> + <span v-else-if="log.type === 'deleteSystemWebhook'">: {{ log.info.webhook.name }}</span> + <span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span> + <span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span> + <span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span> </template> <template #icon> <MkAvatar :user="log.user" :class="$style.avatar"/> @@ -120,6 +153,16 @@ SPDX-License-Identifier: AGPL-3.0-only <CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/> </div> </template> + <template v-else-if="log.type === 'updateSystemWebhook'"> + <div :class="$style.diff"> + <CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/> + </div> + </template> + <template v-else-if="log.type === 'updateAbuseReportNotificationRecipient'"> + <div :class="$style.diff"> + <CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/> + </div> + </template> <details> <summary>raw</summary> diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 0bff6e39aa..8200244cd7 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -418,6 +418,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> + <template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> + <template #suffix> + <span v-if="role.policies.canUpdateBioMedia.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canUpdateBioMedia.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateBioMedia)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canUpdateBioMedia.value" :disabled="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canUpdateBioMedia.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> <template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index ffd7577689..0a8bd0e898 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -153,6 +153,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> + <template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> + <template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canUpdateBioMedia"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> <template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #suffix>{{ policies.pinLimit }}</template> @@ -263,7 +271,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { instance } from '@/instance.js'; +import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { ROLE_POLICIES } from '@/const.js'; import { useRouter } from '@/router/supplier.js'; @@ -287,6 +295,7 @@ async function updateBaseRole() { await os.apiWithDialog('admin/roles/update-default-policies', { policies, }); + fetchInstance(true); } function create() { diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue new file mode 100644 index 0000000000..0c07122af3 --- /dev/null +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -0,0 +1,117 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.main"> + <span :class="$style.icon"> + <i v-if="!entity.isActive" class="ti ti-player-pause"/> + <i v-else-if="entity.latestStatus === null" class="ti ti-circle"/> + <i + v-else-if="[200, 201, 204].includes(entity.latestStatus)" + class="ti ti-check" + :style="{ color: 'var(--success)' }" + /> + <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"/> + </span> + <span :class="$style.text">{{ entity.name || entity.url }}</span> + <span :class="$style.suffix"> + <MkTime v-if="entity.latestSentAt" :time="entity.latestSentAt" style="margin-right: 8px"/> + <button :class="$style.suffixButton" @click="onEditClick"> + <i class="ti ti-settings"></i> + </button> + <button :class="$style.suffixButton" @click="onDeleteClick"> + <i class="ti ti-trash"></i> + </button> + </span> +</div> +</template> + +<script lang="ts" setup> +import { entities } from 'misskey-js'; +import { toRefs } from 'vue'; + +const emit = defineEmits<{ + (ev: 'edit', value: entities.SystemWebhook): void; + (ev: 'delete', value: entities.SystemWebhook): void; +}>(); + +const props = defineProps<{ + entity: entities.SystemWebhook; +}>(); + +const { entity } = toRefs(props); + +function onEditClick() { + emit('edit', entity.value); +} + +function onDeleteClick() { + emit('delete', entity.value); +} + +</script> + +<style module lang="scss"> +.main { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 14px; + background: var(--buttonBg); + border: none; + border-radius: 6px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--buttonHoverBg); + } + + &.active { + color: var(--accent); + background: var(--buttonHoverBg); + } +} + +.icon { + margin-right: 0.75em; + flex-shrink: 0; + text-align: center; + color: var(--fgTransparentWeak); +} + +.text { + flex-shrink: 1; + white-space: normal; + padding-right: 12px; + text-align: center; +} + +.suffix { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gaps: 4px; + margin-left: auto; + margin-right: -8px; + opacity: 0.7; + white-space: nowrap; +} + +.suffixButton { + background: transparent; + border: none; + border-radius: 9999px; + margin-top: -8px; + margin-bottom: -8px; + padding: 8px; + + &:hover { + background: var(--buttonBg); + } +} +</style> diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue new file mode 100644 index 0000000000..7a40eec944 --- /dev/null +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -0,0 +1,96 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header> + <XHeader :actions="headerActions" :tabs="headerTabs"/> + </template> + + <MkSpacer :contentMax="900"> + <div class="_gaps_m"> + <MkButton :class="$style.linkButton" full @click="onCreateWebhookClicked"> + {{ i18n.ts._webhookSettings.createWebhook }} + </MkButton> + + <FormSection> + <div class="_gaps"> + <XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/> + </div> + </FormSection> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref } from 'vue'; +import { entities } from 'misskey-js'; +import XItem from './system-webhook.item.vue'; +import FormSection from '@/components/form/section.vue'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { i18n } from '@/i18n.js'; +import XHeader from '@/pages/admin/_header_.vue'; +import MkButton from '@/components/MkButton.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; +import * as os from '@/os.js'; + +const webhooks = ref<entities.SystemWebhook[]>([]); + +const headerActions = computed(() => []); +const headerTabs = computed(() => []); + +async function onCreateWebhookClicked() { + await showSystemWebhookEditorDialog({ + mode: 'create', + }); + + await fetchWebhooks(); +} + +async function onEditButtonClicked(webhook: entities.SystemWebhook) { + await showSystemWebhookEditorDialog({ + mode: 'edit', + id: webhook.id, + }); + + await fetchWebhooks(); +} + +async function onDeleteButtonClicked(webhook: entities.SystemWebhook) { + const result = await os.confirm({ + type: 'warning', + title: i18n.ts._webhookSettings.deleteConfirm, + }); + if (!result.canceled) { + await misskeyApi('admin/system-webhook/delete', { + id: webhook.id, + }); + await fetchWebhooks(); + } +} + +async function fetchWebhooks() { + const result = await misskeyApi('admin/system-webhook/list', {}); + webhooks.value = result.sort((a, b) => a.id.localeCompare(b.id)); +} + +onMounted(async () => { + await fetchWebhooks(); +}); + +definePageMetadata(() => ({ + title: 'SystemWebhook', + icon: 'ti ti-webhook', +})); +</script> + +<style module lang="scss"> +.linkButton { + text-align: left; + padding: 10px 18px; +} +</style> diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue index 85ae9062d4..802a6bf399 100644 --- a/packages/frontend/src/pages/announcement.vue +++ b/packages/frontend/src/pages/announcement.vue @@ -109,6 +109,15 @@ definePageMetadata(() => ({ </script> <style lang="scss" module> +.fadeEnterActive, +.fadeLeaveActive { + transition: opacity 0.125s ease; +} +.fadeEnterFrom, +.fadeLeaveTo { + opacity: 0; +} + .announcement { padding: 16px; } diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 22e8fe8071..e947ec9ba5 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="800"> - <div ref="rootEl" v-hotkey.global="keymap"> + <div ref="rootEl"> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> <div :class="$style.tl"> <MkTimeline @@ -44,9 +44,6 @@ const antenna = ref<Misskey.entities.Antenna | null>(null); const queue = ref(0); const rootEl = shallowRef<HTMLElement>(); const tlEl = shallowRef<InstanceType<typeof MkTimeline>>(); -const keymap = computed(() => ({ - 't': focus, -})); function queueUpdated(q) { queue.value = q; diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 76c36fda86..e922599642 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -93,7 +93,7 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import { PageHeaderItem } from '@/types/page-header.js'; import { isSupportShare } from '@/scripts/navigator.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { miLocalStorage } from '@/local-storage.js'; import { useRouter } from '@/router/supplier.js'; import { deepMerge } from '@/scripts/merge.js'; diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index 428d60c4d9..506d906683 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -43,7 +43,7 @@ import { url } from '@/config.js'; import MkButton from '@/components/MkButton.vue'; import { clipsCache } from '@/cache.js'; import { isSupportShare } from '@/scripts/navigator.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; const props = defineProps<{ clipId: string, diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue index bcdcf43275..1f2bee5a77 100644 --- a/packages/frontend/src/pages/contact.vue +++ b/packages/frontend/src/pages/contact.vue @@ -7,18 +7,26 @@ SPDX-License-Identifier: AGPL-3.0-only <MkStickyContainer> <template #header><MkPageHeader/></template> <MkSpacer :contentMax="600" :marginMin="20"> - <div class="_gaps"> - <MkKeyValue> - <template #key>{{ i18n.ts.inquiry }}</template> + <div class="_gaps_m"> + <MkKeyValue :copy="instance.maintainerName"> + <template #key>{{ i18n.ts.administrator }}</template> <template #value> - <MkLink :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink> + <template v-if="instance.maintainerName">{{ instance.maintainerName }}</template> + <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> </template> </MkKeyValue> - - <MkKeyValue> - <template #key>{{ i18n.ts.email }}</template> + <MkKeyValue :copy="instance.maintainerEmail"> + <template #key>{{ i18n.ts.contact }}</template> + <template #value> + <template v-if="instance.maintainerEmail">{{ instance.maintainerEmail }}</template> + <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> + </template> + </MkKeyValue> + <MkKeyValue :copy="instance.inquiryUrl"> + <template #key>{{ i18n.ts.inquiry }}</template> <template #value> - <div>{{ instance.maintainerEmail }}</div> + <MkLink v-if="instance.inquiryUrl" :url="instance.inquiryUrl" target="_blank">{{ instance.inquiryUrl }}</MkLink> + <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> </template> </MkKeyValue> </div> @@ -28,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { i18n } from '@/i18n.js'; -import { definePageMetadata } from '@/scripts/page-metadata.js'; import { instance } from '@/instance.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkLink from '@/components/MkLink.vue'; diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 82d03231f7..8904096875 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -132,18 +132,19 @@ const toggleSelect = (emoji) => { }; const add = async (ev: MouseEvent) => { - os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { }, { done: result => { if (result.created) { emojisPaginationComponent.value.prepend(result.created); } }, - }, 'closed'); + closed: () => dispose(), + }); }; const edit = (emoji) => { - os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { emoji: emoji, }, { done: result => { @@ -156,7 +157,8 @@ const edit = (emoji) => { emojisPaginationComponent.value.removeItem(emoji.id); } }, - }, 'closed'); + closed: () => dispose(), + }); }; const importEmoji = (emoji) => { diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 4749d7c981..4ed2a67678 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -37,11 +37,17 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> </div> - <div> - <button class="_button" :class="$style.fileAltEditBtn" @click="describe()"> + <div class="_gaps_s"> + <button class="_button" :class="$style.kvEditBtn" @click="move()"> + <MkKeyValue> + <template #key>{{ i18n.ts.folder }}</template> + <template #value>{{ folderHierarchy.join(' > ') }}<i class="ti ti-pencil" :class="$style.kvEditIcon"></i></template> + </MkKeyValue> + </button> + <button class="_button" :class="$style.kvEditBtn" @click="describe()"> <MkKeyValue> <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.fileAltEditIcon"></i></template> + <template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.kvEditIcon"></i></template> </MkKeyValue> </button> <MkKeyValue :class="$style.fileMetaDataChildren"> @@ -90,6 +96,18 @@ const props = defineProps<{ const fetching = ref(true); const file = ref<Misskey.entities.DriveFile>(); +const folderHierarchy = computed(() => { + if (!file.value) return [i18n.ts.drive]; + const folderNames = [i18n.ts.drive]; + + function get(folder: Misskey.entities.DriveFolder) { + if (folder.parent) get(folder.parent); + folderNames.push(folder.name); + } + + if (file.value.folder) get(file.value.folder); + return folderNames; +}); const isImage = computed(() => file.value?.type.startsWith('image/')); async function fetch() { @@ -122,6 +140,19 @@ function crop() { }); } +function move() { + if (!file.value) return; + + os.selectDriveFolder(false).then(folder => { + misskeyApi('drive/files/update', { + fileId: file.value.id, + folderId: folder[0] ? folder[0].id : null, + }).then(async () => { + await fetch(); + }); + }); +} + function toggleSensitive() { if (!file.value) return; @@ -160,7 +191,7 @@ function rename() { function describe() { if (!file.value) return; - os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { default: file.value.comment ?? '', file: file.value, }, { @@ -172,7 +203,8 @@ function describe() { await fetch(); }); }, - }, 'closed'); + closed: () => dispose(), + }); } async function deleteFile() { @@ -233,6 +265,7 @@ onMounted(async () => { background-color: var(--accentedBg); color: var(--accent); text-decoration: none; + outline: none; } &.danger { @@ -280,14 +313,14 @@ onMounted(async () => { padding: .5rem 1rem; } -.fileAltEditBtn { +.kvEditBtn { text-align: start; display: block; width: 100%; padding: .5rem 1rem; border-radius: var(--radius); - .fileAltEditIcon { + .kvEditIcon { display: inline-block; color: transparent; visibility: hidden; @@ -298,7 +331,7 @@ onMounted(async () => { color: var(--accent); background-color: var(--accentedBg); - .fileAltEditIcon { + .kvEditIcon { color: var(--accent); visibility: visible; } diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue index eba5b92154..0f0b7e1ea8 100644 --- a/packages/frontend/src/pages/drop-and-fusion.game.vue +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -210,7 +210,7 @@ import { apiUrl } from '@/config.js'; import { $i } from '@/account.js'; import * as sound from '@/scripts/sound.js'; import MkRange from '@/components/MkRange.vue'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; type FrontendMonoDefinition = { id: string; @@ -1008,8 +1008,18 @@ function attachGameEvents() { const domX = rect.left + (x * viewScale); const domY = rect.top + (y * viewScale); const scoreUnit = getScoreUnit(props.gameMode); - os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end'); - os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta + (scoreUnit === 'pt' ? '' : scoreUnit) }, {}, 'end'); + + { + const { dispose } = os.popup(MkRippleEffect, { x: domX, y: domY }, { + end: () => dispose(), + }); + } + + { + const { dispose } = os.popup(MkPlusOneEffect, { x: domX, y: domY, value: scoreDelta + (scoreUnit === 'pt' ? '' : scoreUnit) }, { + end: () => dispose(), + }); + } if (nextMono) { const def = monoDefinitions.value.find(x => x.id === nextMono.id)!; diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 5805fb4589..c5f0dde878 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -15,8 +15,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="emoji" #header>:{{ emoji.name }}:</template> <template v-else #header>New emoji</template> - <div> - <MkSpacer :marginMin="20" :marginMax="28"> + <div style="display: flex; flex-direction: column; min-height: 100%;"> + <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> <div class="_gaps_m"> <div v-if="imgUrl != null" :class="$style.imgs"> <div style="background: #000;" :class="$style.imgContainer"> @@ -239,10 +239,12 @@ async function del() { .footer { position: sticky; + z-index: 10000; bottom: 0; left: 0; padding: 12px; border-top: solid 0.5px var(--divider); + background: var(--acrylicBg); -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); } diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index 503d627d53..03a3b8f1c0 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -14,10 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import * as os from '@/os.js'; import * as Misskey from 'misskey-js'; +import * as os from '@/os.js'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; @@ -40,12 +40,12 @@ function menu(ev) { text: i18n.ts.info, icon: 'ti ti-info-circle', action: async () => { - os.popup(MkCustomEmojiDetailedDialog, { + const { dispose } = os.popup(MkCustomEmojiDetailedDialog, { emoji: await misskeyApiGet('emoji', { name: props.emoji.name, - }) + }), }, { - anchor: ev.target, + closed: () => dispose(), }); }, }], ev.currentTarget ?? ev.target); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 3445da26a2..0b9f4dfe58 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -37,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { AISCRIPT_VERSION } from '@syuilo/aiscript'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; @@ -48,7 +49,7 @@ import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import { useRouter } from '@/router/supplier.js'; -const PRESET_DEFAULT = `/// @ 0.18.0 +const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION} var name = "" @@ -66,7 +67,7 @@ Ui:render([ ]) `; -const PRESET_OMIKUJI = `/// @ 0.18.0 +const PRESET_OMIKUJI = `/// @ ${AISCRIPT_VERSION} // ユーザーごとに日替わりのおみくじのプリセット // 選択肢 @@ -109,7 +110,7 @@ Ui:render([ ]) `; -const PRESET_SHUFFLE = `/// @ 0.18.0 +const PRESET_SHUFFLE = `/// @ ${AISCRIPT_VERSION} // 巻き戻し可能な文字シャッフルのプリセット let string = "ペペロンチーノ" @@ -188,7 +189,7 @@ var cursor = 0 do() `; -const PRESET_QUIZ = `/// @ 0.18.0 +const PRESET_QUIZ = `/// @ ${AISCRIPT_VERSION} let title = '地理クイズ' let qas = [{ @@ -301,7 +302,7 @@ qaEls.push(Ui:C:container({ Ui:render(qaEls) `; -const PRESET_TIMELINE = `/// @ 0.18.0 +const PRESET_TIMELINE = `/// @ ${AISCRIPT_VERSION} // APIリクエストを行いローカルタイムラインを表示するプリセット @fetch() { diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 78bbb60f2a..b6d6b318c3 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -78,7 +78,8 @@ import MkCode from '@/components/MkCode.vue'; import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { pleaseLogin } from '@/scripts/please-login.js'; const props = defineProps<{ id: string; @@ -143,6 +144,7 @@ function shareWithNote() { function like() { if (!flash.value) return; + pleaseLogin(); os.apiWithDialog('flash/like', { flashId: flash.value.id, @@ -154,6 +156,7 @@ function like() { async function unlike() { if (!flash.value) return; + pleaseLogin(); const confirm = await os.confirm({ type: 'warning', diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue deleted file mode 100644 index 247b0ac639..0000000000 --- a/packages/frontend/src/pages/follow.vue +++ /dev/null @@ -1,71 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div> -</div> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import * as Misskey from 'misskey-js'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { i18n } from '@/i18n.js'; -import { defaultStore } from '@/store.js'; -import { mainRouter } from '@/router/main.js'; - -async function follow(user): Promise<void> { - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.tsx.followConfirm({ name: user.name || user.username }), - }); - - if (canceled) { - window.close(); - return; - } - - os.apiWithDialog('following/create', { - userId: user.id, - withReplies: defaultStore.state.defaultWithReplies, - }); - user.withReplies = defaultStore.state.defaultWithReplies; -} - -const acct = new URL(location.href).searchParams.get('acct'); -if (acct == null) { - throw new Error('acct required'); -} - -let promise; - -if (acct.startsWith('https://')) { - promise = misskeyApi('ap/show', { - uri: acct, - }); - promise.then(res => { - if (res.type === 'User') { - follow(res.object); - } else if (res.type === 'Note') { - mainRouter.push(`/notes/${res.object.id}`); - } else { - os.alert({ - type: 'error', - text: 'Not a user', - }).then(() => { - window.close(); - }); - } - }); -} else { - promise = misskeyApi('users/show', Misskey.acct.parse(acct)); - promise.then(user => { - follow(user); - }); -} - -os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); -</script> diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index ab0305ca8d..6d5a7d5ac4 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -77,7 +77,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { useRouter } from '@/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index afd6df1ad9..b52f4decaa 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -8,12 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><MkPageHeader/></template> <MkSpacer :contentMax="800"> <div class="_gaps"> - <div class="_panel"> + <div class="_panel" :class="$style.link"> <MkA to="/bubble-game"> <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> </MkA> </div> - <div class="_panel"> + <div class="_panel" :class="$style.link"> <MkA to="/reversi"> <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> </MkA> @@ -32,3 +32,10 @@ definePageMetadata(() => ({ icon: 'ti ti-device-gamepad', })); </script> + +<style module> +.link:focus-within { + outline: 2px solid var(--focus); + outline-offset: -2px; +} +</style> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index a6bc3e7138..4ff26197d8 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -48,6 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch> <MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch> <MkSwitch v-model="isNSFW" :disabled="!instance" @update:modelValue="toggleNSFW">Mark as NSFW</MkSwitch> + <MkSwitch v-model="isMediaSilenced" :disabled="!meta || !instance" @update:modelValue="toggleMediaSilenced">{{ i18n.ts.mediaSilenceThisInstance }}</MkSwitch> <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> <MkTextarea v-model="moderationNote" manualSave> <template #label>{{ i18n.ts.moderationNote }}</template> @@ -169,6 +170,7 @@ const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'au const isBlocked = ref(false); const isSilenced = ref(false); const isNSFW = ref(false); +const isMediaSilenced = ref(false); const faviconUrl = ref<string | null>(null); const moderationNote = ref(''); @@ -198,8 +200,9 @@ async function fetch(): Promise<void> { isBlocked.value = instance.value?.isBlocked ?? false; isSilenced.value = instance.value?.isSilenced ?? false; isNSFW.value = instance.value?.isNSFW ?? false; + isMediaSilenced.value = instance.value?.isMediaSilenced ?? false; faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview'); - moderationNote.value = instance.value?.moderationNote; + moderationNote.value = instance.value?.moderationNote ?? ''; } async function toggleBlock(): Promise<void> { @@ -221,6 +224,16 @@ async function toggleSilenced(): Promise<void> { }); } +async function toggleMediaSilenced(): Promise<void> { + if (!meta.value) throw new Error('No meta?'); + if (!instance.value) throw new Error('No instance?'); + const { host } = instance.value; + const mediaSilencedHosts = meta.value.mediaSilencedHosts ?? []; + await misskeyApi('admin/update-meta', { + mediaSilencedHosts: isMediaSilenced.value ? mediaSilencedHosts.concat([host]) : mediaSilencedHosts.filter(x => x !== host), + }); +} + async function stopDelivery(): Promise<void> { if (!instance.value) throw new Error('No instance?'); suspensionState.value = 'manuallySuspended'; diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue new file mode 100644 index 0000000000..3233953942 --- /dev/null +++ b/packages/frontend/src/pages/lookup.vue @@ -0,0 +1,97 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :contentMax="800"> + <div v-if="state === 'done'" class="_buttonsCenter"> + <MkButton @click="close">{{ i18n.ts.close }}</MkButton> + <MkButton @click="goToMisskey">{{ i18n.ts.goToMisskey }}</MkButton> + </div> + <div v-else class="_fullInfo"> + <MkLoading/> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { mainRouter } from '@/router/main.js'; +import MkButton from '@/components/MkButton.vue'; + +const state = ref<'fetching' | 'done'>('fetching'); + +function fetch() { + const params = new URL(location.href).searchParams; + + // acctのほうはdeprecated + let uri = params.get('uri') ?? params.get('acct'); + if (uri == null) { + state.value = 'done'; + return; + } + + let promise: Promise<any>; + + if (uri.startsWith('https://')) { + promise = misskeyApi('ap/show', { + uri, + }); + promise.then(res => { + if (res.type === 'User') { + mainRouter.replace(res.object.host ? `/@${res.object.username}@${res.object.host}` : `/@${res.object.username}`); + } else if (res.type === 'Note') { + mainRouter.replace(`/notes/${res.object.id}`); + } else { + os.alert({ + type: 'error', + text: 'Not a user', + }); + } + }); + } else { + if (uri.startsWith('acct:')) { + uri = uri.slice(5); + } + promise = misskeyApi('users/show', Misskey.acct.parse(uri)); + promise.then(user => { + mainRouter.replace(user.host ? `/@${user.username}@${user.host}` : `/@${user.username}`); + }); + } + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); +} + +function close(): void { + window.close(); + + // 閉じなければ100ms後タイムラインに + window.setTimeout(() => { + location.href = '/'; + }, 100); +} + +function goToMisskey(): void { + location.href = '/'; +} + +fetch(); + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePageMetadata({ + title: i18n.ts.lookup, + icon: 'ti ti-world-search', +}); +</script> diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 2d026d2fa9..2b8518747f 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -4,43 +4,33 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <XAntenna :antenna="draft" @created="onAntennaCreated"/> -</div> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + + <MkAntennaEditor @created="onAntennaCreated"/> +</MkStickyContainer> </template> <script lang="ts" setup> -import { ref } from 'vue'; -import XAntenna from './editor.vue'; +import { computed } from 'vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { antennasCache } from '@/cache.js'; import { useRouter } from '@/router/supplier.js'; +import MkAntennaEditor from '@/components/MkAntennaEditor.vue'; const router = useRouter(); -const draft = ref({ - name: '', - src: 'all', - userListId: null, - users: [], - keywords: [], - excludeKeywords: [], - excludeBots: false, - withReplies: false, - caseSensitive: false, - localOnly: false, - withFile: false, - notify: false, -}); - function onAntennaCreated() { antennasCache.delete(); router.push('/my/antennas'); } +const headerActions = computed(() => []); +const headerTabs = computed(() => []); + definePageMetadata(() => ({ - title: i18n.ts.manageAntennas, + title: i18n.ts.createAntenna, icon: 'ti ti-antenna', })); </script> diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index 9471be8575..9f927cd1a0 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -4,15 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class=""> - <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> -</div> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + + <MkAntennaEditor v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> +</MkStickyContainer> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import XAntenna from './editor.vue'; +import MkAntennaEditor from '@/components/MkAntennaEditor.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; @@ -36,8 +38,11 @@ misskeyApi('antennas/show', { antennaId: props.antennaId }).then((antennaRespons antenna.value = antennaResponse; }); +const headerActions = computed(() => []); +const headerTabs = computed(() => []); + definePageMetadata(() => ({ - title: i18n.ts.manageAntennas, + title: i18n.ts.editAntenna, icon: 'ti ti-antenna', })); </script> diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue deleted file mode 100644 index 2949bfc02c..0000000000 --- a/packages/frontend/src/pages/my-antennas/editor.vue +++ /dev/null @@ -1,146 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<MkSpacer :contentMax="700"> - <div> - <div class="_gaps_m"> - <MkInput v-model="name"> - <template #label>{{ i18n.ts.name }}</template> - </MkInput> - <MkSelect v-model="src"> - <template #label>{{ i18n.ts.antennaSource }}</template> - <option value="all">{{ i18n.ts._antennaSources.all }}</option> - <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> - <option value="users">{{ i18n.ts._antennaSources.users }}</option> - <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> - <option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option> - </MkSelect> - <MkSelect v-if="src === 'list'" v-model="userListId"> - <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> - </MkSelect> - <MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users"> - <template #label>{{ i18n.ts.users }}</template> - <template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template> - </MkTextarea> - <MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch> - <MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch> - <MkTextarea v-model="keywords"> - <template #label>{{ i18n.ts.antennaKeywords }}</template> - <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> - </MkTextarea> - <MkTextarea v-model="excludeKeywords"> - <template #label>{{ i18n.ts.antennaExcludeKeywords }}</template> - <template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template> - </MkTextarea> - <MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> - <MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch> - <MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch> - </div> - <div :class="$style.actions"> - <MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> - <MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> - </div> - </div> -</MkSpacer> -</template> - -<script lang="ts" setup> -import { watch, ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkTextarea from '@/components/MkTextarea.vue'; -import MkSelect from '@/components/MkSelect.vue'; -import MkSwitch from '@/components/MkSwitch.vue'; -import * as os from '@/os.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { i18n } from '@/i18n.js'; - -const props = defineProps<{ - antenna: Misskey.entities.Antenna -}>(); - -const emit = defineEmits<{ - (ev: 'created'): void, - (ev: 'updated'): void, - (ev: 'deleted'): void, -}>(); - -const name = ref<string>(props.antenna.name); -const src = ref<Misskey.entities.AntennasCreateRequest['src']>(props.antenna.src); -const userListId = ref<string | null>(props.antenna.userListId); -const users = ref<string>(props.antenna.users.join('\n')); -const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('\n')); -const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); -const caseSensitive = ref<boolean>(props.antenna.caseSensitive); -const localOnly = ref<boolean>(props.antenna.localOnly); -const excludeBots = ref<boolean>(props.antenna.excludeBots); -const withReplies = ref<boolean>(props.antenna.withReplies); -const withFile = ref<boolean>(props.antenna.withFile); -const userLists = ref<Misskey.entities.UserList[] | null>(null); - -watch(() => src.value, async () => { - if (src.value === 'list' && userLists.value === null) { - userLists.value = await misskeyApi('users/lists/list'); - } -}); - -async function saveAntenna() { - const antennaData = { - name: name.value, - src: src.value, - userListId: userListId.value, - excludeBots: excludeBots.value, - withReplies: withReplies.value, - withFile: withFile.value, - caseSensitive: caseSensitive.value, - localOnly: localOnly.value, - users: users.value.trim().split('\n').map(x => x.trim()), - keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')), - excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')), - }; - - if (props.antenna.id == null) { - await os.apiWithDialog('antennas/create', antennaData); - emit('created'); - } else { - await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: props.antenna.id }); - emit('updated'); - } -} - -async function deleteAntenna() { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }), - }); - if (canceled) return; - - await misskeyApi('antennas/delete', { - antennaId: props.antenna.id, - }); - - os.success(); - emit('deleted'); -} - -function addUser() { - os.selectUser({ includeSelf: true }).then(user => { - users.value = users.value.trim(); - users.value += '\n@' + Misskey.acct.toString(user as any); - users.value = users.value.trim(); - }); -} -</script> - -<style lang="scss" module> -.actions { - margin-top: 16px; - padding: 24px 0; - border-top: solid 0.5px var(--divider); -} -</style> diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 1a0d7177fc..ece998a7a5 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkPagination v-slot="{ items }" ref="pagingComponent" :pagination="pagination" class="_gaps"> - <MkClipPreview v-for="item in items" :key="item.id" :clip="item"/> + <MkClipPreview v-for="item in items" :key="item.id" :clip="item" :noUserInfo="true"/> </MkPagination> </div> <div v-else-if="tab === 'favorites'" key="favorites" class="_gaps"> diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 7492b099ea..a2ceb222fe 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -133,22 +133,25 @@ async function removeUser(item, ev) { } async function showMembershipMenu(item, ev) { + const withRepliesRef = ref(item.withReplies); os.popupMenu([{ - text: item.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline, - icon: item.withReplies ? 'ti ti-messages-off' : 'ti ti-messages', - action: async () => { - misskeyApi('users/lists/update-membership', { - listId: list.value.id, - userId: item.userId, - withReplies: !item.withReplies, - }).then(() => { - paginationEl.value.updateItem(item.id, (old) => ({ - ...old, - withReplies: !item.withReplies, - })); - }); - }, + type: 'switch', + text: i18n.ts.showRepliesToOthersInTimeline, + icon: 'ti ti-messages', + ref: withRepliesRef, }], ev.currentTarget ?? ev.target); + watch(withRepliesRef, withReplies => { + misskeyApi('users/lists/update-membership', { + listId: list.value!.id, + userId: item.userId, + withReplies, + }).then(() => { + paginationEl.value!.updateItem(item.id, (old) => ({ + ...old, + withReplies, + })); + }); + }); } async function deleteList() { diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 1e38f42890..7080802a4d 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -125,7 +125,7 @@ import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { instance } from '@/instance.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; const props = defineProps<{ pageName: string; @@ -286,6 +286,7 @@ definePageMetadata(() => ({ background-color: var(--accentedBg); color: var(--accent); text-decoration: none; + outline: none; } } diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue new file mode 100644 index 0000000000..8e07b190aa --- /dev/null +++ b/packages/frontend/src/pages/preview.vue @@ -0,0 +1,26 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <MkSample/> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkSample from '@/components/MkPreview.vue'; +import { i18n } from '@/i18n.js'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; + +const headerActions = computed(() => []); + +const headerTabs = computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.preview, + icon: 'ti ti-eye', +}))); +</script> diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index 6b67a9cc87..6d24029535 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -44,7 +44,9 @@ async function save() { onMounted(() => { if (props.token == null) { - os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {}, 'closed'); + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { + closed: () => dispose(), + }); mainRouter.push('/'); } }); diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 175ea62411..7d9cefa5c9 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -169,7 +169,7 @@ const props = defineProps<{ const showBoardLabels = ref<boolean>(false); const useAvatarAsStone = ref<boolean>(true); const autoplaying = ref<boolean>(false); -// eslint-disable-next-line vue/no-setup-props-destructure +// eslint-disable-next-line vue/no-setup-props-reactivity-loss const game = ref<Misskey.entities.ReversiGameDetailed & { logs: Reversi.Serializer.SerializedLog[] }>(deepClone(props.game)); const logPos = ref<number>(game.value.logs.length); const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({ diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index eadc51881c..97a793753d 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -20,6 +20,7 @@ import { useStream } from '@/stream.js'; import { signinRequired } from '@/account.js'; import { useRouter } from '@/router/supplier.js'; import * as os from '@/os.js'; +import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; import { useInterval } from '@/scripts/use-interval.js'; @@ -44,7 +45,7 @@ function start(_game: Misskey.entities.ReversiGameDetailed) { if (shareWhenStart.value) { misskeyApi('notes/create', { - text: i18n.ts._reversi.iStartedAGame + '\n' + location.href, + text: `${i18n.ts._reversi.iStartedAGame}\n${url}/reversi/g/${props.gameId}`, visibility: 'home', }); } diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index e048b498f9..3eae84ce64 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -6,14 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <div class="_gaps"> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - <MkFolder> - <template #label>{{ i18n.ts.options }}</template> + <MkFoldableSection :expanded="true"> + <template #header>{{ i18n.ts.options }}</template> <div class="_gaps_m"> - <MkSwitch v-model="isLocalOnly">{{ i18n.ts.localOnly }}</MkSwitch> + <MkRadios v-model="hostSelect"> + <template #label>{{ i18n.ts.host }}</template> + <option value="all" default>{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option> + </MkRadios> + <MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search"> + <template #prefix><i class="ti ti-server"></i></template> + </MkInput> <MkSwitch v-model="order">Sort by newest to oldest</MkSwitch> <MkSelect v-model="filetype" small> <template #label>File Type</template> @@ -25,18 +33,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder :defaultOpen="true"> <template #label>{{ i18n.ts.specifyUser }}</template> - <template v-if="user" #suffix>@{{ user.username }}</template> + <template v-if="user" #suffix>@{{ user.username }}{{ user.host ? `@${user.host}` : "" }}</template> - <div style="text-align: center;" class="_gaps"> - <div v-if="user">@{{ user.username }}</div> - <div> - <MkButton v-if="user == null" primary rounded inline @click="selectUser">{{ i18n.ts.selectUser }}</MkButton> - <MkButton v-else danger rounded inline @click="user = null">{{ i18n.ts.remove }}</MkButton> + <div class="_gaps"> + <div :class="$style.userItem"> + <MkUserCardMini v-if="user" :class="$style.userCard" :user="user" :withChart="false"/> + <MkButton v-if="user == null && $i != null" transparent :class="$style.addMeButton" @click="selectSelf"><div :class="$style.addUserButtonInner"><span><i class="ti ti-plus"></i><i class="ti ti-user"></i></span><span>{{ i18n.ts.selectSelf }}</span></div></MkButton> + <MkButton v-if="user == null" transparent :class="$style.addUserButton" @click="selectUser"><div :class="$style.addUserButtonInner"><i class="ti ti-plus"></i><span>{{ i18n.ts.selectUser }}</span></div></MkButton> + <button class="_button" :class="$style.remove" :disabled="user == null" @click="removeUser"><i class="ti ti-x"></i></button> </div> </div> </MkFolder> </div> - </MkFolder> + </MkFoldableSection> <div> <MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton> </div> @@ -50,7 +59,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { computed, ref, toRef, watch } from 'vue'; +import type { UserDetailed } from 'misskey-js/entities.js'; +import type { Paging } from '@/components/MkPagination.vue'; import MkNotes from '@/components/MkNotes.vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; @@ -62,45 +73,131 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFolder from '@/components/MkFolder.vue'; import { useRouter } from '@/router/supplier.js'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import MkRadios from '@/components/MkRadios.vue'; +import { $i } from '@/account.js'; +import { instance } from '@/instance.js'; -const router = useRouter(); +const props = withDefaults(defineProps<{ + query?: string; + userId?: string; + username?: string; + host?: string | null; +}>(), { + query: '', + userId: undefined, + username: undefined, + host: '', +}); +const router = useRouter(); const key = ref(0); -const searchQuery = ref(''); -const searchOrigin = ref('combined'); -const notePagination = ref(); -const user = ref<any>(null); -const isLocalOnly = ref(false); const order = ref(false); const filetype = ref(null); +const noteSearchableScope = instance.noteSearchableScope ?? 'local'; + +const hostSelect = ref<'all' | 'local' | 'specified'>('all'); + +const setHostSelectWithInput = (after:string|undefined|null, before:string|undefined|null) => { + if (before === after) return; + if (after === '') hostSelect.value = 'all'; + else hostSelect.value = 'specified'; +}; + +setHostSelectWithInput(hostInput.value, undefined); + +watch(hostInput, setHostSelectWithInput); + +const searchHost = computed(() => { + if (hostSelect.value === 'local') return '.'; + if (hostSelect.value === 'specified') return hostInput.value; + return null; +}); + +if (props.userId != null) { + misskeyApi('users/show', { userId: props.userId }).then(_user => { + user.value = _user; + }); +} else if (props.username != null) { + misskeyApi('users/show', { + username: props.username, + ...(props.host != null && props.host !== '') ? { host: props.host } : {}, + }).then(_user => { + user.value = _user; + }); +} + function selectUser() { - os.selectUser({ includeSelf: true }).then(_user => { + os.selectUser({ includeSelf: true, localOnly: instance.noteSearchableScope === 'local' }).then(_user => { user.value = _user; + hostInput.value = _user.host ?? ''; }); } +function selectSelf() { + user.value = $i as UserDetailed | null; + hostInput.value = null; +} + +function removeUser() { + user.value = null; + hostInput.value = ''; +} + async function search() { const query = searchQuery.value.toString().trim(); if (query == null || query === '') return; - if (query.startsWith('http://') || query.startsWith('https://')) { - const promise = misskeyApi('ap/show', { - uri: query, + //#region AP lookup + if (query.startsWith('http://') || query.startsWith('https://') && !query.includes(' ')) { + const confirm = await os.confirm({ + type: 'info', + text: i18n.ts.lookupConfirm, }); + if (!confirm.canceled) { + const promise = misskeyApi('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + if (res.type === 'User') { + router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + router.push(`/notes/${res.object.id}`); + } - const res = await promise; + return; + } + } + //#endregion - if (res.type === 'User') { - router.push(`/@${res.object.username}@${res.object.host}`); - } else if (res.type === 'Note') { - router.push(`/notes/${res.object.id}`); + if (query.length > 1 && !query.includes(' ')) { + if (query.startsWith('@')) { + const confirm = await os.confirm({ + type: 'info', + text: i18n.ts.lookupConfirm, + }); + if (!confirm.canceled) { + router.push(`/${query}`); + return; + } } - return; + if (query.startsWith('#')) { + const confirm = await os.confirm({ + type: 'info', + text: i18n.ts.openTagPageConfirm, + }); + if (!confirm.canceled) { + router.push(`/tags/${encodeURIComponent(query.substring(1))}`); + return; + } + } } notePagination.value = { @@ -109,13 +206,51 @@ async function search() { params: { query: searchQuery.value, userId: user.value ? user.value.id : null, + ...(searchHost.value ? { host: searchHost.value } : {}), order: order.value ? 'desc' : 'asc', filetype: filetype.value, }, }; - if (isLocalOnly.value) notePagination.value.params.host = '.'; - key.value++; } </script> +<style lang="scss" module> +.userItem { + display: flex; + justify-content: center; +} +.addMeButton { + border: 2px dashed var(--fgTransparent); + padding: 12px; + margin-right: 16px; +} +.addUserButton { + border: 2px dashed var(--fgTransparent); + padding: 12px; + flex-grow: 1; +} +.addUserButtonInner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + min-height: 38px; +} +.userCard { + flex-grow: 1; +} +.remove { + width: 32px; + height: 32px; + align-self: center; + + & > i:before { + color: #ff2a2a; + } + + &:disabled { + opacity: 0; + } +} +</style> diff --git a/packages/frontend/src/pages/search.stories.impl.ts b/packages/frontend/src/pages/search.stories.impl.ts new file mode 100644 index 0000000000..0110a7ab8e --- /dev/null +++ b/packages/frontend/src/pages/search.stories.impl.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import search_ from './search.vue'; +import { userDetailed } from '@/../.storybook/fakes.js'; +import { commonHandlers } from '@/../.storybook/mocks.js'; + +const localUser = userDetailed('someuserid', 'miskist', null, 'Local Misskey User'); + +export const Default = { + render(args) { + return { + components: { + search_, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<search_ v-bind="props" />', + }; + }, + args: { + ignoreNotesSearchAvailable: true, + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/users/show', () => { + return HttpResponse.json(userDetailed()); + }), + http.post('/api/users/search', () => { + return HttpResponse.json([userDetailed(), localUser]); + }), + ], + }, + }, +} satisfies StoryObj<typeof search_>; + +export const NoteSearchDisabled = { + ...Default, + args: {}, +} satisfies StoryObj<typeof search_>; + +export const WithUsernameLocal = { + ...Default, + + args: { + ...Default.args, + username: localUser.username, + host: localUser.host, + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/users/show', () => { + return HttpResponse.json(localUser); + }), + http.post('/api/users/search', () => { + return HttpResponse.json([userDetailed(), localUser]); + }), + ], + }, + }, +} satisfies StoryObj<typeof search_>; + +export const WithUserType = { + ...Default, + args: { + type: 'user', + }, +} satisfies StoryObj<typeof search_>; diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index daf4b64bde..a355c0eeaa 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <div class="_gaps"> - <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> + <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> <MkRadios v-model="searchOrigin" @update:modelValue="search()"> @@ -25,7 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, toRef } from 'vue'; +import type { Endpoints } from 'misskey-js'; +import type { Paging } from '@/components/MkPagination.vue'; import MkUserList from '@/components/MkUserList.vue'; import MkInput from '@/components/MkInput.vue'; import MkRadios from '@/components/MkRadios.vue'; @@ -36,34 +38,74 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { useRouter } from '@/router/supplier.js'; +const props = withDefaults(defineProps<{ + query?: string, + origin?: Endpoints['users/search']['req']['origin'], +}>(), { + query: '', + origin: 'combined', +}); + const router = useRouter(); const key = ref(''); -const searchQuery = ref(''); -const searchOrigin = ref('combined'); -const userPagination = ref(); +const searchQuery = ref(toRef(props, 'query').value); +const searchOrigin = ref(toRef(props, 'origin').value); +const userPagination = ref<Paging>(); async function search() { const query = searchQuery.value.toString().trim(); if (query == null || query === '') return; - if (query.startsWith('http://') || query.startsWith('https://')) { - const promise = misskeyApi('ap/show', { - uri: query, + //#region AP lookup + if (query.startsWith('http://') || query.startsWith('https://') && !query.includes(' ')) { + const confirm = await os.confirm({ + type: 'info', + text: i18n.ts.lookupConfirm, }); + if (!confirm.canceled) { + const promise = misskeyApi('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); - os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + const res = await promise; - const res = await promise; + if (res.type === 'User') { + router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + router.push(`/notes/${res.object.id}`); + } - if (res.type === 'User') { - router.push(`/@${res.object.username}@${res.object.host}`); - } else if (res.type === 'Note') { - router.push(`/notes/${res.object.id}`); + return; } + } + //#endregion - return; + if (query.length > 1 && !query.includes(' ')) { + if (query.startsWith('@')) { + const confirm = await os.confirm({ + type: 'info', + text: i18n.ts.lookupConfirm, + }); + if (!confirm.canceled) { + router.push(`/${query}`); + return; + } + } + + if (query.startsWith('#')) { + const confirm = await os.confirm({ + type: 'info', + text: i18n.ts.openTagPageConfirm, + }); + if (!confirm.canceled) { + router.push(`/user-tags/${encodeURIComponent(query.substring(1))}`); + return; + } + } } if (query.match(/^@[a-z0-9_.-]+@[a-z0-9_.-]+$/i)) { diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index a3dcda77be..38d7548fa8 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -9,8 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="tab === 'note'" key="note" :contentMax="800"> - <div v-if="notesSearchAvailable"> - <XNote/> + <div v-if="notesSearchAvailable || ignoreNotesSearchAvailable"> + <XNote v-bind="props"/> </div> <div v-else> <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> @@ -18,27 +18,43 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> <MkSpacer v-else-if="tab === 'user'" key="user" :contentMax="800"> - <XUser/> + <XUser v-bind="props"/> </MkSpacer> </MkHorizontalSwipe> </MkStickyContainer> </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, ref } from 'vue'; +import { computed, defineAsyncComponent, ref, toRef } from 'vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { $i } from '@/account.js'; -import { instance } from '@/instance.js'; +import { notesSearchAvailable } from '@/scripts/check-permissions.js'; import MkInfo from '@/components/MkInfo.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +const props = withDefaults(defineProps<{ + query?: string, + userId?: string, + username?: string, + host?: string | null, + type?: 'note' | 'user', + origin?: 'combined' | 'local' | 'remote', + // For storybook only + ignoreNotesSearchAvailable?: boolean, +}>(), { + query: '', + userId: undefined, + username: undefined, + host: undefined, + type: 'note', + origin: 'combined', + ignoreNotesSearchAvailable: false, +}); + const XNote = defineAsyncComponent(() => import('./search.note.vue')); const XUser = defineAsyncComponent(() => import('./search.user.vue')); -const tab = ref('note'); - -const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes)); +const tab = ref(toRef(props, 'type').value); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index b7d648c1a4..6a9a1e16e2 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -108,9 +108,11 @@ async function registerTOTP(): Promise<void> { token: auth.result.token, }); - os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), { twoFactorData, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); } async function unregisterTOTP(): Promise<void> { diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 1182346de9..08c9261dcf 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -74,22 +74,24 @@ async function removeAccount(account) { } function addExistingAccount() { - os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: async res => { await addAccounts(res.id, res.i); os.success(); init(); }, - }, 'closed'); + closed: () => dispose(), + }); } function createAccount() { - os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { done: async res => { await addAccounts(res.id, res.i); switchAccountWithToken(res.i); }, - }, 'closed'); + closed: () => dispose(), + }); } async function switchAccount(account: any) { diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue index d9596b4e45..b35d406a98 100644 --- a/packages/frontend/src/pages/settings/api.vue +++ b/packages/frontend/src/pages/settings/api.vue @@ -23,7 +23,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; const isDesktop = ref(window.innerWidth >= 1100); function generateToken() { - os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { done: async result => { const { name, permissions } = result; const { token } = await misskeyApi('miauth/gen-token', { @@ -38,7 +38,8 @@ function generateToken() { text: token, }); }, - }, 'closed'); + closed: () => dispose(), + }); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/avatar-decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.vue index 3cc911c014..77229d3349 100644 --- a/packages/frontend/src/pages/settings/avatar-decoration.vue +++ b/packages/frontend/src/pages/settings/avatar-decoration.vue @@ -67,7 +67,7 @@ misskeyApi('get-avatar-decorations').then(_avatarDecorations => { }); function openDecoration(avatarDecoration, index?: number) { - os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration.dialog.vue')), { decoration: avatarDecoration, usingIndex: index, }, { @@ -108,7 +108,8 @@ function openDecoration(avatarDecoration, index?: number) { }); $i.avatarDecorations = update; }, - }, 'closed'); + closed: () => dispose(), + }); } function detachAllDecorations() { diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 0b0b932f46..3f7db1b779 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> -import { computed, ref, watch } from 'vue'; +import { computed, ref, watch, type StyleValue } from 'vue'; import tinycolor from 'tinycolor2'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; @@ -102,10 +102,10 @@ function fetchDriveInfo(): void { }); } -function genUsageBar(fsize: number): object { +function genUsageBar(fsize: number): StyleValue { return { width: `${fsize / usage.value * 100}%`, - background: tinycolor({ h: 180 - (fsize / usage.value * 180), s: 0.7, l: 0.5 }), + background: tinycolor({ h: 180 - (fsize / usage.value * 180), s: 0.7, l: 0.5 }).toHslString(), }; } diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index bcaa048de2..fa09637844 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -90,7 +90,7 @@ const meterStyle = computed(() => { h: 180 - (usage.value / capacity.value * 180), s: 0.7, l: 0.5, - }), + }).toHslString(), }; }); diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index b48308b96f..47681e6cde 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -234,6 +234,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="disableStreamingTimeline">{{ i18n.ts.disableStreamingTimeline }}</MkSwitch> <MkSwitch v-model="enableHorizontalSwipe">{{ i18n.ts.enableHorizontalSwipe }}</MkSwitch> <MkSwitch v-model="alwaysConfirmFollow">{{ i18n.ts.alwaysConfirmFollow }}</MkSwitch> + <MkSwitch v-model="confirmWhenRevealingSensitiveMedia">{{ i18n.ts.confirmWhenRevealingSensitiveMedia }}</MkSwitch> </div> <MkSelect v-model="serverDisconnectedBehavior"> <template #label>{{ i18n.ts.whenServerDisconnected }}</template> @@ -241,6 +242,12 @@ SPDX-License-Identifier: AGPL-3.0-only <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> <option value="disabled">{{ i18n.ts._serverDisconnectedBehavior.disabled }}</option> </MkSelect> + <MkSelect v-model="contextMenu"> + <template #label>{{ i18n.ts._contextMenu.title }}</template> + <option value="app">{{ i18n.ts._contextMenu.app }}</option> + <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option> + <option value="native">{{ i18n.ts._contextMenu.native }}</option> + </MkSelect> <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing> <template #label>{{ i18n.ts.numberOfPageCache }}</template> <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> @@ -426,6 +433,8 @@ const visibilityOnBoost = computed(defaultStore.makeGetterSetter('visibilityOnBo const enableHorizontalSwipe = computed(defaultStore.makeGetterSetter('enableHorizontalSwipe')); const useNativeUIForVideoAudioPlayer = computed(defaultStore.makeGetterSetter('useNativeUIForVideoAudioPlayer')); const alwaysConfirmFollow = computed(defaultStore.makeGetterSetter('alwaysConfirmFollow')); +const confirmWhenRevealingSensitiveMedia = computed(defaultStore.makeGetterSetter('confirmWhenRevealingSensitiveMedia')); +const contextMenu = computed(defaultStore.makeGetterSetter('contextMenu')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); @@ -485,6 +494,8 @@ watch([ showVisibilitySelectorOnBoost, visibilityOnBoost, alwaysConfirmFollow, + confirmWhenRevealingSensitiveMedia, + contextMenu, ], async () => { await reloadAsk(); }); diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue index 9804454e66..3c3dcfe41e 100644 --- a/packages/frontend/src/pages/settings/plugin.vue +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -82,7 +82,7 @@ import MkCode from '@/components/MkCode.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import * as os from '@/os.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { ColdDeviceStorage } from '@/store.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 00cd64dd9f..f9fd494ce9 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -118,8 +118,6 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'sound_note', 'sound_noteMy', 'sound_notification', - 'sound_antenna', - 'sound_channel', ]; const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ 'lightTheme', diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 4ffa367365..6cc19db127 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -481,6 +481,7 @@ definePageMetadata(() => ({ &:hover, &:focus { opacity: .7; } + &:active { cursor: pointer; } diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 113abd708b..81478fede5 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -9,7 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.sound }}</template> <option v-for="x in soundsTypes" :key="x ?? 'null'" :value="x">{{ getSoundTypeName(x) }}</option> </MkSelect> - <div v-if="type === '_driveFile_'" :class="$style.fileSelectorRoot"> + <div v-if="type === '_driveFile_' && driveFileError === true" :class="$style.fileSelectorRoot"> + <MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton> + <div :class="$style.fileErrorRoot"> + <MkCondensedLine>{{ i18n.ts._soundSettings.driveFileError }}</MkCondensedLine> + </div> + </div> + <div v-else-if="type === '_driveFile_'" :class="$style.fileSelectorRoot"> <MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton> <div :class="['_nowrap', !fileUrl && $style.fileNotSelected]">{{ friendlyFileName }}</div> </div> @@ -19,13 +25,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_buttons"> <MkButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</MkButton> - <MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton inline primary :disabled="!hasChanged || driveFileError" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </div> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import type { SoundType } from '@/scripts/sound.js'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; @@ -51,13 +57,18 @@ const type = ref<SoundType>(props.type); const fileId = ref(props.fileId); const fileUrl = ref(props.fileUrl); const fileName = ref<string>(''); +const driveFileError = ref(false); +const hasChanged = ref(false); const volume = ref(props.volume); if (type.value === '_driveFile_' && fileId.value) { - const apiRes = await misskeyApi('drive/files/show', { + await misskeyApi('drive/files/show', { fileId: fileId.value, + }).then((res) => { + fileName.value = res.name; + }).catch((res) => { + driveFileError.value = true; }); - fileName.value = apiRes.name; } function getSoundTypeName(f: SoundType): string { @@ -107,9 +118,21 @@ function selectSound(ev) { fileUrl.value = file.url; fileName.value = file.name; fileId.value = file.id; + driveFileError.value = false; + hasChanged.value = true; }); } +watch([type, volume], ([typeTo, volumeTo], [typeFrom, volumeFrom]) => { + if (typeFrom !== typeTo && typeTo !== '_driveFile_') { + fileUrl.value = undefined; + fileName.value = ''; + fileId.value = undefined; + driveFileError.value = false; + } + hasChanged.value = true; +}); + function listen() { if (type.value === '_driveFile_' && (!fileUrl.value || !fileId.value)) { os.alert({ @@ -131,6 +154,10 @@ function listen() { } function save() { + if (hasChanged.value === false || driveFileError.value === true) { + return; + } + if (type.value === '_driveFile_' && !fileUrl.value) { os.alert({ type: 'warning', @@ -163,6 +190,13 @@ function save() { gap: 8px; } +.fileErrorRoot { + flex-grow: 1; + min-width: 0; + font-weight: 700; + color: var(--error); +} + .fileSelectorButton { flex-shrink: 0; } diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 090f0cf14c..9fcf564e55 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -21,8 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder v-for="type in operationTypes" :key="type"> <template #label>{{ i18n.ts._sfx[type] }}</template> <template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template> - - <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/> + <Suspense> + <template #default> + <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/> + </template> + <template #fallback> + <MkLoading/> + </template> + </Suspense> </MkFolder> </div> </FormSection> @@ -54,8 +60,6 @@ const sounds = ref<Record<OperationType, Ref<SoundStore>>>({ note: defaultStore.reactiveState.sound_note, noteMy: defaultStore.reactiveState.sound_noteMy, notification: defaultStore.reactiveState.sound_notification, - antenna: defaultStore.reactiveState.sound_antenna, - channel: defaultStore.reactiveState.sound_channel, reaction: defaultStore.reactiveState.sound_reaction, }); diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index 92e389a288..67943524ef 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="statusbar.props.shuffle"> <template #label>{{ i18n.ts.shuffle }}</template> </MkSwitch> - <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" min="1"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> <MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1"> @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </template> <template v-else-if="statusbar.type === 'federation'"> - <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number" min="1"> <template #label>{{ i18n.ts.refreshInterval }}</template> </MkInput> <MkRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1"> diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index 8a94d7388b..579ca6b20b 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -38,7 +38,7 @@ import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import { Theme, getBuiltinThemesRef } from '@/scripts/theme.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import * as os from '@/os.js'; import { getThemes, removeTheme } from '@/theme-store.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index eaa9b5b97e..ad07a6b539 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -213,12 +213,18 @@ definePageMetadata(() => ({ } } + .dn:focus-visible ~ .toggle { + outline: 2px solid var(--focus); + outline-offset: 2px; + } + .toggle { cursor: pointer; display: inline-block; position: relative; width: 90px; height: 50px; + margin: 4px; // focus用のアウトライン background-color: #83D8FF; border-radius: 90px - 6; transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index e9fb1e471e..058ef69c35 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <FormSection> - <template #label>{{ i18n.ts._webhookSettings.events }}</template> + <template #label>{{ i18n.ts._webhookSettings.trigger }}</template> <div class="_gaps_s"> <MkSwitch v-model="event_follow">{{ i18n.ts._webhookSettings._events.follow }}</MkSwitch> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index 5bf85e48f4..d62357caaf 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <FormSection> - <template #label>{{ i18n.ts._webhookSettings.events }}</template> + <template #label>{{ i18n.ts._webhookSettings.trigger }}</template> <div class="_gaps_s"> <MkSwitch v-model="event_follow">{{ i18n.ts._webhookSettings._events.follow }}</MkSwitch> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index a24911e717..d93ceadf56 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -8,8 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :displayMyAvatar="true"/></template> <MkSpacer :contentMax="800"> <MkHorizontalSwipe v-model:tab="src" :tabs="$i ? headerTabs : headerTabsWhenNotLogin"> - <div :key="src" ref="rootEl" v-hotkey.global="keymap"> - <MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()"> + <div :key="src" ref="rootEl"> + <MkInfo v-if="isBasicTimeline(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()"> {{ i18n.ts._timelineDescription[src] }} </MkInfo> <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> @@ -46,7 +46,6 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; import { $i } from '@/account.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js'; @@ -54,21 +53,15 @@ import { deviceKind } from '@/scripts/device-kind.js'; import { deepMerge } from '@/scripts/merge.js'; import { MenuItem } from '@/types/menu.js'; import { miLocalStorage } from '@/local-storage.js'; +import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; provide('shouldOmitHeaderTitle', true); -const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable); -const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable); -const isBubbleTimelineAvailable = ($i == null && instance.policies.btlAvailable) || ($i != null && $i.policies.btlAvailable); -const keymap = { - 't': focus, -}; - const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>(); const rootEl = shallowRef<HTMLElement>(); const queue = ref(0); -const srcWhenNotSignin = ref<'local' | 'global'>(isLocalTimelineAvailable ? 'local' : 'global'); +const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global'); const src = computed<'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`>({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x), @@ -79,7 +72,11 @@ const withRenotes = computed<boolean>({ }); // computed内での無限ループを防ぐためのフラグ -const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>('withReplies'); +const localSocialTLFilterSwitchStore = ref<'withReplies' | 'onlyFiles' | false>( + defaultStore.reactiveState.tl.value.filter.withReplies ? 'withReplies' : + defaultStore.reactiveState.tl.value.filter.onlyFiles ? 'onlyFiles' : + false, +); const withReplies = computed<boolean>({ get: () => { @@ -239,7 +236,7 @@ function focus(): void { } function closeTutorial(): void { - if (!['home', 'local', 'social', 'global'].includes(src.value)) return; + if (!isBasicTimeline(src.value)) return; const before = defaultStore.state.timelineTutorials; before[src.value] = true; defaultStore.set('timelineTutorials', before); @@ -255,7 +252,7 @@ const headerActions = computed(() => { type: 'switch', text: i18n.ts.showRenotes, ref: withRenotes, - }, src.value === 'local' || src.value === 'social' ? { + }, isBasicTimeline(src.value) && hasWithReplies(src.value) ? { type: 'switch', text: i18n.ts.showRepliesToOthersInTimeline, ref: withReplies, @@ -268,7 +265,7 @@ const headerActions = computed(() => { type: 'switch', text: i18n.ts.fileAttachedOnly, ref: onlyFiles, - disabled: src.value === 'local' || src.value === 'social' ? withReplies : false, + disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false, }], ev.currentTarget ?? ev.target); }, }, @@ -290,32 +287,12 @@ const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserList title: l.name, icon: 'ti ti-star', iconOnly: true, -}))), { - key: 'home', - title: i18n.ts._timelines.home, - icon: 'ti ti-home', - iconOnly: true, -}, ...(isLocalTimelineAvailable ? [{ - key: 'local', - title: i18n.ts._timelines.local, - icon: 'ti ti-planet', - iconOnly: true, -}, { - key: 'social', - title: i18n.ts._timelines.social, - icon: 'ti ti-universe', +}))), ...availableBasicTimelines().map(tl => ({ + key: tl, + title: i18n.ts._timelines[tl], + icon: basicTimelineIconClass(tl), iconOnly: true, -}] : []), ...(isBubbleTimelineAvailable ? [{ - key: 'bubble', - title: 'Bubble', - icon: 'ph-drop ph-bold ph-lg', - iconOnly: true, -}] : []), ...(isGlobalTimelineAvailable ? [{ - key: 'global', - title: i18n.ts._timelines.global, - icon: 'ti ti-whirl', - iconOnly: true, -}] : []), { +})), { icon: 'ti ti-list', title: i18n.ts.lists, iconOnly: true, @@ -332,24 +309,16 @@ const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserList onClick: chooseChannel, }] as Tab[]); -const headerTabsWhenNotLogin = computed(() => [ - ...(isLocalTimelineAvailable ? [{ - key: 'local', - title: i18n.ts._timelines.local, - icon: 'ti ti-planet', - iconOnly: true, - }] : []), - ...(isGlobalTimelineAvailable ? [{ - key: 'global', - title: i18n.ts._timelines.global, - icon: 'ti ti-whirl', - iconOnly: true, - }] : []), -] as Tab[]); +const headerTabsWhenNotLogin = computed(() => [...availableBasicTimelines().map(tl => ({ + key: tl, + title: i18n.ts._timelines[tl], + icon: basicTimelineIconClass(tl), + iconOnly: true, +}))] as Tab[]); definePageMetadata(() => ({ title: i18n.ts.timeline, - icon: src.value === 'local' ? 'ti ti-planet' : src.value === 'social' ? 'ti ti-universe' : src.value === 'global' ? 'ti ti-whirl' : src.value === 'bubble' ? 'ph-drop ph-bold ph-lg' : 'ti ti-home', + icon: isBasicTimeline(src.value) ? basicTimelineIconClass(src.value) : 'ti ti-home', })); </script> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 112394170f..2bf388b62a 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -31,9 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> - <div v-if="$i" class="actions"> + <div class="actions"> <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> - <MkFollowButton v-if="$i.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> </div> </div> <MkAvatar class="avatar" :user="user" indicator/> @@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only <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" :colored="false"/> + <Mfm :text="field.name" :author="user" :plain="true" :colored="false"/> </dt> <dd class="value"> <Mfm :text="field.value" :author="user" :colored="false"/> @@ -181,6 +181,7 @@ import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { defaultStore } from '@/store.js'; import { $i, iAmModerator } from '@/account.js'; import { dateString } from '@/filters/date.js'; import { confetti } from '@/scripts/confetti.js'; @@ -492,11 +493,12 @@ onUnmounted(() => { > .name { display: block; - margin: 0; + margin: -10px; + padding: 10px; line-height: 32px; font-weight: bold; font-size: 1.8em; - text-shadow: 0 0 8px #000; + filter: drop-shadow(0 0 4px #000); } > .bottom { diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue new file mode 100644 index 0000000000..252b1a2955 --- /dev/null +++ b/packages/frontend/src/pages/welcome.timeline.note.vue @@ -0,0 +1,109 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :key="note.id" :class="$style.note"> + <div class="_panel _gaps_s" :class="$style.content"> + <div v-if="note.cw != null" :class="$style.richcontent"> + <div><Mfm :text="note.cw" :author="note.user"/></div> + <MkCwButton v-model="showContent" :text="note.text" :renote="note.renote" :files="note.files" :poll="note.poll" style="margin: 4px 0;"/> + <div v-if="showContent"> + <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user"/> + <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + </div> + <div v-else ref="noteTextEl" :class="[$style.text, { [$style.collapsed]: shouldCollapse }]"> + <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user"/> + <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <div v-if="note.files && note.files.length > 0" :class="$style.richcontent"> + <MkMediaList :mediaList="note.files.slice(0, 4)"/> + </div> + <div v-if="note.poll"> + <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> + </div> + <div v-if="note.reactionCount > 0" :class="$style.reactions"> + <MkReactionsViewer :note="note" :maxNumber="16"/> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, shallowRef, onUpdated, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkMediaList from '@/components/MkMediaList.vue'; +import MkPoll from '@/components/MkPoll.vue'; +import MkCwButton from '@/components/MkCwButton.vue'; + +defineProps<{ + note: Misskey.entities.Note; +}>(); + +const noteTextEl = shallowRef<HTMLDivElement>(); +const shouldCollapse = ref(false); +const showContent = ref(false); + +function calcCollapse() { + if (noteTextEl.value) { + const height = noteTextEl.value.scrollHeight; + if (height > 200) { + shouldCollapse.value = true; + } + } +} + +onMounted(() => { + calcCollapse(); +}); + +onUpdated(() => { + calcCollapse(); +}); +</script> + +<style lang="scss" module> +.note { + margin-left: auto; +} + +.text { + position: relative; + max-height: 200px; + overflow: hidden; + + &.collapsed::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } +} + +.content { + padding: 16px; + margin: 0 0 0 auto; + max-width: max-content; + border-radius: var(--radius-md); +} + +.reactions { + box-sizing: border-box; + margin: 8px -16px -8px; + padding: 8px 16px 0; + width: calc(100% + 32px); + border-top: 1px solid var(--divider); +} + +.richcontent { + min-width: 250px; +} +</style> diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 3e519c0910..045f424cda 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -4,24 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> - <div ref="scrollEl" :class="[$style.scrollbox, { [$style.scroll]: isScrolling }]"> - <div v-for="note in notes" :key="note.id" :class="$style.note"> - <div class="_panel" :class="$style.content"> - <div> - <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :isBlock="true" :author="note.user"/> - <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> - </div> - <div v-if="note.files.length > 0" :class="$style.richcontent"> - <MkMediaList :mediaList="note.files"/> - </div> - <div v-if="note.poll"> - <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> - </div> - </div> - <MkReactionsViewer ref="reactionsViewer" :note="note"/> - </div> +<div :class="$style.root" class="_gaps"> + <div + ref="notesMainContainerEl" + class="_gaps" + :class="[$style.scrollBoxMain, { [$style.scrollIntro]: (scrollState === 'intro'), [$style.scrollLoop]: (scrollState === 'loop') }]" + @animationend="changeScrollState" + > + <XNote v-for="note in notes" :key="`${note.id}_1`" :class="$style.note" :note="note"/> + </div> + <div v-if="isScrolling" class="_gaps" :class="[$style.scrollBoxSub, { [$style.scrollIntro]: (scrollState === 'intro'), [$style.scrollLoop]: (scrollState === 'loop') }]"> + <XNote v-for="note in notes" :key="`${note.id}_2`" :class="$style.note" :note="note"/> </div> </div> </template> @@ -29,43 +22,54 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { onUpdated, ref, shallowRef } from 'vue'; -import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; -import MkMediaList from '@/components/MkMediaList.vue'; -import MkPoll from '@/components/MkPoll.vue'; +import XNote from '@/pages/welcome.timeline.note.vue'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { getScrollContainer } from '@/scripts/scroll.js'; const notes = ref<Misskey.entities.Note[]>([]); const isScrolling = ref(false); -const scrollEl = shallowRef<HTMLElement>(); +const scrollState = ref<null | 'intro' | 'loop'>(null); +const notesMainContainerEl = shallowRef<HTMLElement>(); misskeyApiGet('notes/featured').then(_notes => { notes.value = _notes.filter(n => n.cw == null); }); +function changeScrollState() { + if (scrollState.value !== 'loop') { + scrollState.value = 'loop'; + } +} + onUpdated(() => { - if (!scrollEl.value) return; - const container = getScrollContainer(scrollEl.value); + if (!notesMainContainerEl.value) return; + const container = getScrollContainer(notesMainContainerEl.value); const containerHeight = container ? container.clientHeight : window.innerHeight; - if (scrollEl.value.offsetHeight > containerHeight) { + if (notesMainContainerEl.value.offsetHeight > containerHeight) { + if (scrollState.value === null) { + scrollState.value = 'intro'; + } isScrolling.value = true; } }); </script> <style lang="scss" module> -@keyframes scroll { +@keyframes scrollIntro { 0% { transform: translate3d(0, 0, 0); } - 5% { - transform: translate3d(0, 0, 0); + 100% { + transform: translate3d(0, calc(calc(-100% - 128px) - var(--margin)), 0); } - 75% { - transform: translate3d(0, calc(-100% + 90vh), 0); +} + +@keyframes scrollConstant { + 0% { + transform: translate3d(0, -128px, 0); } - 90% { - transform: translate3d(0, calc(-100% + 90vh), 0); + 100% { + transform: translate3d(0, calc(calc(-100% - 128px) - var(--margin)), 0); } } @@ -73,24 +77,26 @@ onUpdated(() => { text-align: right; } -.scrollbox { - &.scroll { - animation: scroll 45s linear infinite; +.scrollBoxMain { + &.scrollIntro { + animation: scrollIntro 30s linear forwards; + } + &.scrollLoop { + animation: scrollConstant 30s linear infinite; } } -.note { - margin: 16px 0 16px auto; -} - -.content { - padding: 16px; - margin: 0 0 0 auto; - max-width: max-content; - border-radius: var(--radius-md); +.scrollBoxSub { + &.scrollIntro { + animation: scrollIntro 30s linear forwards; + } + &.scrollLoop { + animation: scrollConstant 30s linear infinite; + } } -.richcontent { - min-width: 250px; +.root:has(.note:hover) .scrollBoxMain, +.root:has(.note:hover) .scrollBoxSub { + animation-play-state: paused; } </style> |