diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2024-10-09 05:17:29 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-09 05:17:29 +0000 |
| commit | 2518cf36d0e1ae594a527087704ee8bce51013bb (patch) | |
| tree | 957d293824733660cad00bd8f3f5745d744179c3 /packages/frontend | |
| parent | Merge pull request #14580 from misskey-dev/develop (diff) | |
| parent | Release: 2024.10.0 (diff) | |
| download | misskey-2518cf36d0e1ae594a527087704ee8bce51013bb.tar.gz misskey-2518cf36d0e1ae594a527087704ee8bce51013bb.tar.bz2 misskey-2518cf36d0e1ae594a527087704ee8bce51013bb.zip | |
Merge pull request #14675 from misskey-dev/develop
Release: 2024.10.0
Diffstat (limited to 'packages/frontend')
40 files changed, 1630 insertions, 679 deletions
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 42d1a10f0a..f2bdc631d2 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -397,7 +397,18 @@ function toStories(component: string): Promise<string> { const globs = await Promise.all([ glob('src/components/global/Mk*.vue'), glob('src/components/global/RouterView.vue'), - glob('src/components/Mk[A-E]*.vue'), + glob('src/components/MkAbuseReportWindow.vue'), + glob('src/components/MkAccountMoved.vue'), + glob('src/components/MkAchievements.vue'), + glob('src/components/MkAnalogClock.vue'), + glob('src/components/MkAnimBg.vue'), + glob('src/components/MkAnnouncementDialog.vue'), + glob('src/components/MkAntennaEditor.vue'), + glob('src/components/MkAntennaEditorDialog.vue'), + glob('src/components/MkAsUi.vue'), + glob('src/components/MkAutocomplete.vue'), + glob('src/components/MkAvatars.vue'), + glob('src/components/Mk[B-E]*.vue'), glob('src/components/MkFlashPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkSignupServerRules.vue'), diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d3909babfd..3226a554a9 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -28,7 +28,7 @@ "@tabler/icons-webfont": "3.3.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.1.4", - "@vue/compiler-sfc": "3.5.10", + "@vue/compiler-sfc": "3.5.11", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11", "astring": "1.9.0", "broadcast-channel": "7.0.0", @@ -39,12 +39,13 @@ "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "11.10.4", + "chromatic": "11.11.0", "compare-versions": "6.1.1", "cropperjs": "2.0.0-rc.2", "date-fns": "2.30.0", "estree-walker": "3.0.3", "eventemitter3": "5.0.1", + "frontend-shared": "workspace:*", "idb-keyval": "6.2.1", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", @@ -54,13 +55,12 @@ "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", - "frontend-shared": "workspace:*", "photoswipe": "5.4.4", "punycode": "2.3.1", "rollup": "4.22.5", - "sanitize-html": "2.13.0", + "sanitize-html": "2.13.1", "sass": "1.79.3", - "shiki": "1.12.0", + "shiki": "1.21.0", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", "three": "0.169.0", @@ -72,30 +72,31 @@ "uuid": "10.0.0", "v-code-diff": "1.13.1", "vite": "5.4.8", - "vue": "3.5.10", + "vue": "3.5.11", "vuedraggable": "next" }, "devDependencies": { "@misskey-dev/summaly": "5.1.0", - "@storybook/addon-actions": "8.3.3", - "@storybook/addon-essentials": "8.3.3", - "@storybook/addon-interactions": "8.3.3", - "@storybook/addon-links": "8.3.3", - "@storybook/addon-mdx-gfm": "8.3.3", - "@storybook/addon-storysource": "8.3.3", - "@storybook/blocks": "8.3.3", - "@storybook/components": "8.3.3", - "@storybook/core-events": "8.3.3", - "@storybook/manager-api": "8.3.3", - "@storybook/preview-api": "8.3.3", - "@storybook/react": "8.3.3", - "@storybook/react-vite": "8.3.3", - "@storybook/test": "8.3.3", - "@storybook/theming": "8.3.3", - "@storybook/types": "8.3.3", - "@storybook/vue3": "8.3.3", - "@storybook/vue3-vite": "8.3.3", + "@storybook/addon-actions": "8.3.4", + "@storybook/addon-essentials": "8.3.4", + "@storybook/addon-interactions": "8.3.4", + "@storybook/addon-links": "8.3.4", + "@storybook/addon-mdx-gfm": "8.3.4", + "@storybook/addon-storysource": "8.3.4", + "@storybook/blocks": "8.3.4", + "@storybook/components": "8.3.4", + "@storybook/core-events": "8.3.4", + "@storybook/manager-api": "8.3.4", + "@storybook/preview-api": "8.3.4", + "@storybook/react": "8.3.4", + "@storybook/react-vite": "8.3.4", + "@storybook/test": "8.3.4", + "@storybook/theming": "8.3.4", + "@storybook/types": "8.3.4", + "@storybook/vue3": "8.3.4", + "@storybook/vue3-vite": "8.3.4", "@testing-library/vue": "8.1.0", + "@types/canvas-confetti": "^1.6.4", "@types/estree": "1.0.6", "@types/matter-js": "0.19.7", "@types/micromatch": "4.0.9", @@ -110,11 +111,11 @@ "@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/parser": "7.17.0", "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.5.10", + "@vue/runtime-core": "3.5.11", "acorn": "8.12.1", "cross-env": "7.0.3", "cypress": "13.15.0", - "eslint-plugin-import": "2.30.0", + "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "9.28.0", "fast-glob": "3.3.2", "happy-dom": "10.0.3", @@ -128,7 +129,7 @@ "react-dom": "18.3.1", "seedrandom": "3.0.5", "start-server-and-test": "2.0.8", - "storybook": "8.3.3", + "storybook": "8.3.4", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", "vitest": "1.6.0", diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index ddd47ca448..76459ab330 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -230,19 +230,25 @@ export async function mainBoot() { claimAchievement('collectAchievements30'); } - window.setInterval(() => { - if (Math.floor(Math.random() * 20000) === 0) { - claimAchievement('justPlainLucky'); - } - }, 1000 * 10); + if (!claimedAchievements.includes('justPlainLucky')) { + window.setInterval(() => { + if (Math.floor(Math.random() * 20000) === 0) { + claimAchievement('justPlainLucky'); + } + }, 1000 * 10); + } - window.setTimeout(() => { - claimAchievement('client30min'); - }, 1000 * 60 * 30); + if (!claimedAchievements.includes('client30min')) { + window.setTimeout(() => { + claimAchievement('client30min'); + }, 1000 * 60 * 30); + } - window.setTimeout(() => { - claimAchievement('client60min'); - }, 1000 * 60 * 60); + if (!claimedAchievements.includes('client60min')) { + window.setTimeout(() => { + claimAchievement('client60min'); + }, 1000 * 60 * 60); + } // 邪魔 //const lastUsed = miLocalStorage.getItem('lastUsed'); diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts deleted file mode 100644 index cf09c96fd4..0000000000 --- a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; -import { HttpResponse, http } from 'msw'; -import { abuseUserReport } from '../../.storybook/fakes.js'; -import { commonHandlers } from '../../.storybook/mocks.js'; -import MkAbuseReport from './MkAbuseReport.vue'; -export const Default = { - render(args) { - return { - components: { - MkAbuseReport, - }, - setup() { - return { - args, - }; - }, - computed: { - props() { - return { - ...this.args, - }; - }, - events() { - return { - resolved: action('resolved'), - }; - }, - }, - template: '<MkAbuseReport v-bind="props" v-on="events" />', - }; - }, - args: { - report: abuseUserReport(), - }, - parameters: { - layout: 'fullscreen', - msw: { - handlers: [ - ...commonHandlers, - http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => { - action('POST /api/admin/resolve-abuse-user-report')(await request.json()); - return HttpResponse.json({}); - }), - ], - }, - }, -} satisfies StoryObj<typeof MkAbuseReport>; diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index a28e7c2559..0278cb30f0 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -4,112 +4,153 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="bcekxzvu _margin _panel"> - <div class="target"> - <MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'"> - <MkAvatar class="avatar" :user="report.targetUser" indicator/> - <div class="names"> - <MkUserName class="name" :user="report.targetUser"/> - <MkAcct class="acct" :user="report.targetUser" style="display: block;"/> - </div> - </MkA> - <MkKeyValue> - <template #key>{{ i18n.ts.registeredDate }}</template> - <template #value>{{ dateString(report.targetUser.createdAt) }} (<MkTime :time="report.targetUser.createdAt"/>)</template> - </MkKeyValue> - </div> - <div class="detail"> - <div> - <Mfm :text="report.comment" :linkNavigationBehavior="'window'"/> +<MkFolder> + <template #icon> + <i v-if="report.resolved && report.resolvedAs === 'accept'" class="ti ti-check" style="color: var(--success)"></i> + <i v-else-if="report.resolved && report.resolvedAs === 'reject'" class="ti ti-x" style="color: var(--error)"></i> + <i v-else-if="report.resolved" class="ti ti-slash"></i> + <i v-else class="ti ti-exclamation-circle" style="color: var(--warn)"></i> + </template> + <template #label><MkAcct :user="report.targetUser"/> (by <MkAcct :user="report.reporter"/>)</template> + <template #caption>{{ report.comment }}</template> + <template #suffix><MkTime :time="report.createdAt"/></template> + <template #footer> + <div class="_buttons"> + <template v-if="!report.resolved"> + <MkButton @click="resolve('accept')"><i class="ti ti-check" style="color: var(--success)"></i> {{ i18n.ts._abuseUserReport.resolve }} ({{ i18n.ts._abuseUserReport.accept }})</MkButton> + <MkButton @click="resolve('reject')"><i class="ti ti-x" style="color: var(--error)"></i> {{ i18n.ts._abuseUserReport.resolve }} ({{ i18n.ts._abuseUserReport.reject }})</MkButton> + <MkButton @click="resolve(null)"><i class="ti ti-slash"></i> {{ i18n.ts._abuseUserReport.resolve }} ({{ i18n.ts.other }})</MkButton> + </template> + <template v-if="report.targetUser.host != null"> + <MkButton :disabled="report.forwarded" primary @click="forward"><i class="ti ti-corner-up-right"></i> {{ i18n.ts._abuseUserReport.forward }}</MkButton> + <div v-tooltip:dialog="i18n.ts._abuseUserReport.forwardDescription" class="_button _help"><i class="ti ti-help-circle"></i></div> + </template> + <button class="_button" style="margin-left: auto; width: 34px;" @click="showMenu"><i class="ti ti-dots"></i></button> </div> - <hr/> - <div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div> + </template> + + <div :class="$style.root" class="_gaps_s"> + <MkFolder :withSpacer="false"> + <template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template> + <template #label>{{ i18n.ts.target }}: <MkAcct :user="report.targetUser"/></template> + <template #suffix>#{{ report.targetUserId.toUpperCase() }}</template> + + <div style="container-type: inline-size;"> + <RouterView :router="targetRouter"/> + </div> + </MkFolder> + + <MkFolder :defaultOpen="true"> + <template #icon><i class="ti ti-message-2"></i></template> + <template #label>{{ i18n.ts.details }}</template> + <div class="_gaps_s"> + <Mfm :text="report.comment" :linkNavigationBehavior="'window'"/> + </div> + </MkFolder> + + <MkFolder :withSpacer="false"> + <template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template> + <template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template> + <template #suffix>#{{ report.reporterId.toUpperCase() }}</template> + + <div style="container-type: inline-size;"> + <RouterView :router="reporterRouter"/> + </div> + </MkFolder> + + <MkFolder :defaultOpen="false"> + <template #icon><i class="ti ti-message-2"></i></template> + <template #label>{{ i18n.ts.moderationNote }}</template> + <template #suffix>{{ moderationNote.length > 0 ? '...' : i18n.ts.none }}</template> + <div class="_gaps_s"> + <MkTextarea v-model="moderationNote" manualSave> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> + </MkTextarea> + </div> + </MkFolder> + <div v-if="report.assignee"> {{ i18n.ts.moderator }}: <MkAcct :user="report.assignee"/> </div> - <div><MkTime :time="report.createdAt"/></div> - <div class="action"> - <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> - {{ i18n.ts.forwardReport }} - <template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template> - </MkSwitch> - <MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton> - </div> </div> -</div> +</MkFolder> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { provide, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { dateString } from '@/filters/date.js'; +import MkFolder from '@/components/MkFolder.vue'; +import RouterView from '@/components/global/RouterView.vue'; +import { useRouterFactory } from '@/router/supplier'; +import MkTextarea from '@/components/MkTextarea.vue'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; const props = defineProps<{ - report: any; + report: Misskey.entities.AdminAbuseUserReportsResponse[number]; }>(); const emit = defineEmits<{ (ev: 'resolved', reportId: string): void; }>(); -const forward = ref(props.report.forwarded); +const routerFactory = useRouterFactory(); +const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`); +targetRouter.init(); +const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`); +reporterRouter.init(); + +const moderationNote = ref(props.report.moderationNote ?? ''); -function resolve() { +watch(moderationNote, async () => { + os.apiWithDialog('admin/update-abuse-user-report', { + reportId: props.report.id, + moderationNote: moderationNote.value, + }).then(() => { + }); +}); + +function resolve(resolvedAs) { os.apiWithDialog('admin/resolve-abuse-user-report', { - forward: forward.value, reportId: props.report.id, + resolvedAs, }).then(() => { emit('resolved', props.report.id); }); } -</script> - -<style lang="scss" scoped> -.bcekxzvu { - display: flex; - - > .target { - width: 35%; - box-sizing: border-box; - text-align: left; - padding: 24px; - border-right: solid 1px var(--divider); - > .info { - display: flex; - box-sizing: border-box; - align-items: center; - padding: 14px; - border-radius: 8px; - --c: rgb(255 196 0 / 15%); - background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); - background-size: 16px 16px; - - > .avatar { - width: 42px; - height: 42px; - } +function forward() { + os.apiWithDialog('admin/forward-abuse-user-report', { + reportId: props.report.id, + }).then(() => { - > .names { - margin-left: 0.3em; - padding: 0 8px; - flex: 1; + }); +} - > .name { - font-weight: bold; - } - } - } - } +function showMenu(ev: MouseEvent) { + os.popupMenu([{ + icon: 'ti ti-id', + text: 'Copy ID', + action: () => { + copyToClipboard(props.report.id); + }, + }, { + icon: 'ti ti-json', + text: 'Copy JSON', + action: () => { + copyToClipboard(JSON.stringify(props.report, null, '\t')); + }, + }], ev.currentTarget ?? ev.target); +} +</script> - > .detail { - flex: 1; - padding: 24px; - } +<style lang="scss" module> +.root { } </style> diff --git a/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts b/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts new file mode 100644 index 0000000000..6763f7c546 --- /dev/null +++ b/packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import MkExtensionInstaller from './MkExtensionInstaller.vue'; +import lightTheme from '@@/themes/_light.json5'; + +export const Plugin = { + render(args) { + return { + components: { + MkExtensionInstaller, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkExtensionInstaller v-bind="props" />', + }; + }, + args: { + extension: { + type: 'plugin', + raw: '"do nothing"', + meta: { + name: 'do nothing plugin', + version: '1.0', + author: 'syuilo and misskey-project', + description: 'a plugin that does nothing', + permissions: ['read:account'], + config: { + 'doNothing': true, + }, + }, + }, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkExtensionInstaller>; + +export const Theme = { + render(args) { + return { + components: { + MkExtensionInstaller, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkExtensionInstaller v-bind="props" />', + }; + }, + args: { + extension: { + type: 'theme', + raw: JSON.stringify(lightTheme), + meta: lightTheme, + }, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkExtensionInstaller>; diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue new file mode 100644 index 0000000000..0f7acd69e7 --- /dev/null +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -0,0 +1,146 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps_m" :class="$style.extInstallerRoot"> + <div :class="$style.extInstallerIconWrapper"> + <i v-if="isPlugin" class="ti ti-plug"></i> + <i v-else-if="isTheme" class="ti ti-palette"></i> + <!-- 拡張用? --> + <i v-else class="ti ti-download"></i> + </div> + <h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].title }}</h2> + <div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div> + <MkInfo v-if="isPlugin" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo> + <FormSection> + <template #label>{{ i18n.ts._externalResourceInstaller[`_${extension.type}`].metaTitle }}</template> + <div class="_gaps_s"> + <FormSplit> + <MkKeyValue> + <template #key>{{ i18n.ts.name }}</template> + <template #value>{{ extension.meta.name }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ extension.meta.author }}</template> + </MkKeyValue> + </FormSplit> + <MkKeyValue v-if="isPlugin"> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ extension.meta.description ?? i18n.ts.none }}</template> + </MkKeyValue> + <MkKeyValue v-if="isPlugin"> + <template #key>{{ i18n.ts.version }}</template> + <template #value>{{ extension.meta.version }}</template> + </MkKeyValue> + <MkKeyValue v-if="isPlugin"> + <template #key>{{ i18n.ts.permission }}</template> + <template #value> + <ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList"> + <li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> + </ul> + <template v-else>{{ i18n.ts.none }}</template> + </template> + </MkKeyValue> + <MkKeyValue v-if="isTheme"> + <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> + <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template> + </MkKeyValue> + <MkFolder> + <template #icon><i class="ti ti-code"></i></template> + <template #label>{{ i18n.ts._plugin.viewSource }}</template> + + <MkCode :code="extension.raw"/> + </MkFolder> + </div> + </FormSection> + <slot name="additionalInfo"/> + <div class="_buttonsCenter"> + <MkButton primary @click="emits('confirm')"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +export type Extension = { + type: 'plugin'; + raw: string; + meta: { + name: string; + version: string; + author: string; + description?: string; + permissions?: string[]; + config?: Record<string, any>; + }; +} | { + type: 'theme'; + raw: string; + meta: { + name: string; + author: string; + base?: 'light' | 'dark'; + }; +}; +</script> +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSplit from '@/components/form/split.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import { i18n } from '@/i18n.js'; + +const isPlugin = computed(() => props.extension.type === 'plugin'); +const isTheme = computed(() => props.extension.type === 'theme'); + +const props = defineProps<{ + extension: Extension; +}>(); + +const emits = defineEmits<{ + (ev: 'confirm'): void; +}>(); +</script> + +<style lang="scss" module> +.extInstallerRoot { + border-radius: var(--radius); + background: var(--panel); + padding: 1.5rem; +} + +.extInstallerIconWrapper { + width: 48px; + height: 48px; + font-size: 24px; + line-height: 48px; + text-align: center; + border-radius: 50%; + margin-left: auto; + margin-right: auto; + + background-color: var(--accentedBg); + color: var(--accent); +} + +.extInstallerTitle { + font-size: 1.2rem; + text-align: center; + margin: 0; +} + +.extInstallerNormDesc { + text-align: center; +} + +.extInstallerKVList { + margin-top: 0; + margin-bottom: 0; +} +</style> diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index a5f3069d45..8262ae5d0c 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -38,9 +38,12 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkSpacer :marginMin="14" :marginMax="22"> + <MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22"> <slot></slot> </MkSpacer> + <div v-else> + <slot></slot> + </div> <div v-if="$slots.footer" :class="$style.footer"> <slot name="footer"></slot> </div> @@ -59,9 +62,11 @@ import { defaultStore } from '@/store.js'; const props = withDefaults(defineProps<{ defaultOpen?: boolean; maxHeight?: number | null; + withSpacer?: boolean; }>(), { defaultOpen: false, maxHeight: null, + withSpacer: true, }); const getBgColor = (el: HTMLElement) => { diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue new file mode 100644 index 0000000000..09825487bf --- /dev/null +++ b/packages/frontend/src/components/MkFukidashi.vue @@ -0,0 +1,100 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="[ + $style.root, + tail === 'left' ? $style.left : $style.right, + negativeMargin === true && $style.negativeMargin, + shadow === true && $style.shadow, + ]" +> + <div :class="$style.bg"> + <svg v-if="tail !== 'none'" :class="$style.tail" version="1.1" viewBox="0 0 14.597 14.58" xmlns="http://www.w3.org/2000/svg"> + <g transform="translate(-173.71 -87.184)"> + <path d="m188.19 87.657c-1.469 2.3218-3.9315 3.8312-6.667 4.0865-2.2309-1.7379-4.9781-2.6816-7.8061-2.6815h-5.1e-4v12.702h12.702v-5.1e-4c2e-5 -1.9998-0.47213-3.9713-1.378-5.754 2.0709-1.6834 3.2732-4.2102 3.273-6.8791-6e-5 -0.49375-0.0413-0.98662-0.1235-1.4735z" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-width=".33225" style="paint-order:stroke fill markers"/> + </g> + </svg> + <div :class="$style.content"> + <slot></slot> + </div> + </div> +</div> +</template> + +<script setup lang="ts"> +withDefaults(defineProps<{ + tail?: 'left' | 'right' | 'none'; + negativeMargin?: boolean; + shadow?: boolean; +}>(), { + tail: 'right', + negativeMargin: false, + shadow: false, +}); +</script> + +<style module lang="scss"> +.root { + --fukidashi-radius: var(--radius); + --fukidashi-bg: var(--panel); + + position: relative; + display: inline-block; + min-height: calc(var(--fukidashi-radius) * 2); + padding-top: calc(var(--fukidashi-radius) * .13); + + &.shadow { + filter: drop-shadow(0 4px 32px var(--shadow)); + } + + &.left { + padding-left: calc(var(--fukidashi-radius) * .13); + + &.negativeMargin { + margin-left: calc(calc(var(--fukidashi-radius) * .13) * -1); + } + } + + &.right { + padding-right: calc(var(--fukidashi-radius) * .13); + + &.negativeMargin { + margin-right: calc(calc(var(--fukidashi-radius) * .13) * -1); + } + } +} + +.bg { + width: 100%; + height: 100%; + background: var(--fukidashi-bg); + border-radius: var(--fukidashi-radius); +} + +.content { + position: relative; + padding: 8px 12px; +} + +.tail { + position: absolute; + top: 0; + display: block; + width: calc(var(--fukidashi-radius) * 1.13); + height: auto; + fill: var(--fukidashi-bg); +} + +.left .tail { + left: 0; + transform: rotateY(180deg); +} + +.right .tail { + right: 0; +} +</style> diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue index 9d9661e816..71bd5addfb 100644 --- a/packages/frontend/src/components/MkMention.vue +++ b/packages/frontend/src/components/MkMention.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }" :behavior="navigationBehavior"> +<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :behavior="navigationBehavior"> <img :class="$style.icon" :src="avatarUrl" alt=""> <span> <span>@{{ username }}</span> @@ -16,7 +16,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { toUnicode } from 'punycode'; import { computed } from 'vue'; -import tinycolor from 'tinycolor2'; import { host as localHost } from '@@/js/config.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; @@ -37,11 +36,7 @@ const isMe = $i && ( `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() ); -const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); -bg.setAlpha(0.1); -const bgCss = bg.toRgbString(); - -const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages +const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar ? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`) : `/avatar/@${props.username}@${props.host}`, ); @@ -53,9 +48,11 @@ const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages padding: 4px 8px 4px 4px; border-radius: 999px; color: var(--mention); + background: color(from var(--mention) srgb r g b / 0.1); &.isMe { color: var(--mentionMe); + background: color(from var(--mentionMe) srgb r g b / 0.1); } } diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 890b99fcc2..14f6bdcc34 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -437,9 +437,11 @@ onBeforeUnmount(() => { &.big:not(.asDrawer) { > .menu { + min-width: 230px; + > .item { padding: 6px 20px; - font-size: 1em; + font-size: 0.95em; line-height: 24px; } } diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 888c570571..a75b9ddd10 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -5,18 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <header :class="$style.root"> - <component :is="defaultStore.state.enableCondensedLine ? 'MkCondensedLine' : 'div'" :minScale="0.5" style="min-width: 0;"> - <div style="display: flex; white-space: nowrap; align-items: baseline;"> - <div v-if="mock" :class="$style.name"> - <MkUserName :user="note.user"/> - </div> - <MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)"> - <MkUserName :user="note.user"/> - </MkA> - <div v-if="note.user.isBot" :class="$style.isBot">bot</div> - <div :class="$style.username"><MkAcct :user="note.user"/></div> - </div> - </component> + <div v-if="mock" :class="$style.name"> + <MkUserName :user="note.user"/> + </div> + <MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)"> + <MkUserName :user="note.user"/> + </MkA> + <div v-if="note.user.isBot" :class="$style.isBot">bot</div> + <div :class="$style.username"><MkAcct :user="note.user"/></div> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> </div> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 12c2974de4..b27d883b85 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,13 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <div :class="$style.head"> <MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/> - <MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> + <MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div> <img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/> <MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/> - <MkAvatar v-else-if="notification.type === 'exportCompleted'" :class="$style.icon" :user="$i" link preview/> <img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/> <div :class="[$style.subIcon, { @@ -27,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_achievementEarned]: notification.type === 'achievementEarned', [$style.t_exportCompleted]: notification.type === 'exportCompleted', + [$style.t_login]: notification.type === 'login', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, }]" > @@ -40,6 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i> <i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i> + <i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i> <template v-else-if="notification.type === 'roleAssigned'"> <img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/> <i v-else class="ti ti-badges"></i> @@ -59,6 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> + <span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span> <MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> @@ -225,6 +227,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) --eventReactionHeart: var(--love); --eventReaction: #e99a0b; --eventAchievement: #cb9a11; + --eventLogin: #007aff; --eventOther: #88a6b7; } @@ -346,6 +349,12 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) pointer-events: none; } +.t_login { + padding: 3px; + background: var(--eventLogin); + pointer-events: none; +} + .tail { flex: 1; min-width: 0; diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index 80b75a0875..42322fec3d 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -7,7 +7,14 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-show="props.modelValue.length != 0" :class="$style.root"> <Sortable :modelValue="props.modelValue" :class="$style.files" itemKey="id" :animation="150" :delay="100" :delayOnTouchOnly="true" @update:modelValue="v => emit('update:modelValue', v)"> <template #item="{element}"> - <div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> + <div + :class="$style.file" + role="button" + tabindex="0" + @click="showFileMenu(element, $event)" + @keydown.space.enter="showFileMenu(element, $event)" + @contextmenu.prevent="showFileMenu(element, $event)" + > <MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/> <div v-if="element.isSensitive" :class="$style.sensitive"> <i class="ti ti-eye-exclamation" style="margin: auto;"></i> @@ -133,7 +140,7 @@ async function crop(file: Misskey.entities.DriveFile): Promise<void> { emit('replaceFile', file, newFile); } -function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void { +function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void { if (menuShowing) return; const isImage = file.type.startsWith('image/'); @@ -199,6 +206,10 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent): void { border-radius: 4px; overflow: hidden; cursor: move; + + &:focus-visible { + outline-offset: 4px; + } } .thumbnail { diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue new file mode 100644 index 0000000000..6336b78c80 --- /dev/null +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -0,0 +1,206 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.wrapper" data-cy-signin-page-input> + <div :class="$style.root"> + <div :class="$style.avatar"> + <i class="ti ti-user"></i> + </div> + + <!-- ログイン画面メッセージ --> + <MkInfo v-if="message"> + {{ message }} + </MkInfo> + + <!-- 外部サーバーへの転送 --> + <div v-if="openOnRemote" class="_gaps_m"> + <div class="_gaps_s"> + <MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)"> + {{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i> + </MkButton> + <button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)"> + {{ i18n.ts.specifyServerHost }} + </button> + </div> + <div :class="$style.orHr"> + <p :class="$style.orMsg">{{ i18n.ts.or }}</p> + </div> + </div> + + <!-- username入力 --> + <form class="_gaps_s" @submit.prevent="emit('usernameSubmitted', username)"> + <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkButton type="submit" large primary rounded style="margin: 0 auto;" data-cy-signin-page-input-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </form> + + <!-- パスワードレスログイン --> + <div :class="$style.orHr"> + <p :class="$style.orMsg">{{ i18n.ts.or }}</p> + </div> + <div> + <MkButton type="submit" style="margin: auto auto;" large rounded primary gradate @click="emit('passkeyClick', $event)"> + <i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }} + </MkButton> + </div> + </div> +</div> +</template> + +<script setup lang="ts"> +import { ref } from 'vue'; +import { toUnicode } from 'punycode/'; + +import { query, extractDomain } from '@@/js/url.js'; +import { host as configHost } from '@@/js/config.js'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkInfo from '@/components/MkInfo.vue'; + +const props = withDefaults(defineProps<{ + message?: string, + openOnRemote?: OpenOnRemoteOptions, +}>(), { + message: '', + openOnRemote: undefined, +}); + +const emit = defineEmits<{ + (ev: 'usernameSubmitted', v: string): void; + (ev: 'passkeyClick', v: MouseEvent): void; +}>(); + +const host = toUnicode(configHost); + +const username = ref(''); + +//#region Open on remote +function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { + switch (options.type) { + case 'web': + case 'lookup': { + let _path: string; + + if (options.type === 'lookup') { + // TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼ + // _path = `/lookup?uri=${encodeURIComponent(_path)}`; + _path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`; + } else { + _path = options.path; + } + + if (targetHost) { + window.open(`https://${targetHost}${_path}`, '_blank', 'noopener'); + } else { + window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener'); + } + break; + } + case 'share': { + const params = query(options.params); + if (targetHost) { + window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener'); + } else { + window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener'); + } + break; + } + } +} + +async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> { + const { canceled, result: hostTemp } = await os.inputText({ + title: i18n.ts.inputHostName, + placeholder: 'misskey.example.com', + }); + + if (canceled) return; + + let targetHost: string | null = hostTemp; + + // ドメイン部分だけを取り出す + targetHost = extractDomain(targetHost ?? ''); + if (targetHost == null) { + os.alert({ + type: 'error', + title: i18n.ts.invalidValue, + text: i18n.ts.tryAgain, + }); + return; + } + openRemote(options, targetHost); +} +//#endregion +</script> + +<style lang="scss" module> +.root { + display: flex; + flex-direction: column; + gap: 20px; +} + +.wrapper { + display: flex; + align-items: center; + width: 100%; + min-height: 336px; + + > .root { + width: 100%; + } +} + +.avatar { + margin: 0 auto; + background-color: color-mix(in srgb, var(--fg), transparent 85%); + color: color-mix(in srgb, var(--fg), transparent 25%); + text-align: center; + height: 64px; + width: 64px; + font-size: 24px; + line-height: 64px; + border-radius: 50%; +} + +.instanceManualSelectButton { + display: block; + text-align: center; + opacity: .7; + font-size: .8em; + + &:hover { + text-decoration: underline; + } +} + +.orHr { + position: relative; + margin: .4em auto; + width: 100%; + height: 1px; + background: var(--divider); +} + +.orMsg { + position: absolute; + top: -.6em; + display: inline-block; + padding: 0 1em; + background: var(--panel); + font-size: 0.8em; + color: var(--fgOnPanel); + margin: 0; + left: 50%; + transform: translateX(-50%); +} +</style> diff --git a/packages/frontend/src/components/MkSignin.passkey.vue b/packages/frontend/src/components/MkSignin.passkey.vue new file mode 100644 index 0000000000..0d68955fab --- /dev/null +++ b/packages/frontend/src/components/MkSignin.passkey.vue @@ -0,0 +1,92 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.wrapper"> + <div class="_gaps" :class="$style.root"> + <div class="_gaps_s"> + <div :class="$style.passkeyIcon"> + <i class="ti ti-fingerprint"></i> + </div> + <div :class="$style.passkeyDescription">{{ i18n.ts.useSecurityKey }}</div> + </div> + + <MkButton large primary rounded :disabled="queryingKey" style="margin: 0 auto;" @click="queryKey">{{ i18n.ts.retry }}</MkButton> + + <MkButton v-if="isPerformingPasswordlessLogin !== true" transparent rounded :disabled="queryingKey" style="margin: 0 auto;" @click="emit('useTotp')">{{ i18n.ts.useTotp }}</MkButton> + </div> +</div> +</template> + +<script setup lang="ts"> +import { ref, onMounted } from 'vue'; +import { get as webAuthnRequest } from '@github/webauthn-json/browser-ponyfill'; + +import { i18n } from '@/i18n.js'; + +import MkButton from '@/components/MkButton.vue'; + +import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; + +const props = defineProps<{ + credentialRequest: CredentialRequestOptions; + isPerformingPasswordlessLogin?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'done', credential: AuthenticationPublicKeyCredential): void; + (ev: 'useTotp'): void; +}>(); + +const queryingKey = ref(true); + +async function queryKey() { + queryingKey.value = true; + await webAuthnRequest(props.credentialRequest) + .catch(() => { + return Promise.reject(null); + }) + .then((credential) => { + emit('done', credential); + }) + .finally(() => { + queryingKey.value = false; + }); +} + +onMounted(() => { + queryKey(); +}); +</script> + +<style lang="scss" module> +.wrapper { + display: flex; + align-items: center; + width: 100%; + min-height: 336px; + + > .root { + width: 100%; + } +} + +.passkeyIcon { + margin: 0 auto; + background-color: var(--accentedBg); + color: var(--accent); + text-align: center; + height: 64px; + width: 64px; + font-size: 24px; + line-height: 64px; + border-radius: 50%; +} + +.passkeyDescription { + text-align: center; + font-size: 1.1em; +} +</style> diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue new file mode 100644 index 0000000000..2d79e2aeb1 --- /dev/null +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -0,0 +1,181 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.wrapper" data-cy-signin-page-password> + <div class="_gaps" :class="$style.root"> + <div :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined }"></div> + <div :class="$style.welcomeBackMessage"> + <I18n :src="i18n.ts.welcomeBackWithName" tag="span"> + <template #name><Mfm :text="user.name ?? user.username" :plain="true"/></template> + </I18n> + </div> + + <!-- password入力 --> + <form class="_gaps_s" @submit.prevent="onSubmit"> + <!-- ブラウザ オートコンプリート用 --> + <input type="hidden" name="username" autocomplete="username" :value="user.username"> + + <MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required autofocus data-cy-signin-password> + <template #prefix><i class="ti ti-lock"></i></template> + <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> + </MkInput> + + <div v-if="needCaptcha"> + <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> + <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> + </div> + + <MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </form> + </div> +</div> +</template> + +<script lang="ts"> +export type PwResponse = { + password: string; + captcha: { + hCaptchaResponse: string | null; + mCaptchaResponse: string | null; + reCaptchaResponse: string | null; + turnstileResponse: string | null; + }; +}; +</script> + +<script setup lang="ts"> +import { ref, computed, useTemplateRef, defineAsyncComponent } from 'vue'; +import * as Misskey from 'misskey-js'; + +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; + +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkCaptcha from '@/components/MkCaptcha.vue'; + +const props = defineProps<{ + user: Misskey.entities.UserDetailed; + needCaptcha: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'passwordSubmitted', v: PwResponse): void; +}>(); + +const password = ref(''); + +const hCaptcha = useTemplateRef('hcaptcha'); +const mCaptcha = useTemplateRef('mcaptcha'); +const reCaptcha = useTemplateRef('recaptcha'); +const turnstile = useTemplateRef('turnstile'); + +const hCaptchaResponse = ref<string | null>(null); +const mCaptchaResponse = ref<string | null>(null); +const reCaptchaResponse = ref<string | null>(null); +const turnstileResponse = ref<string | null>(null); + +const captchaFailed = computed((): boolean => { + return ( + (instance.enableHcaptcha && !hCaptchaResponse.value) || + (instance.enableMcaptcha && !mCaptchaResponse.value) || + (instance.enableRecaptcha && !reCaptchaResponse.value) || + (instance.enableTurnstile && !turnstileResponse.value) + ); +}); + +function resetPassword(): void { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { + closed: () => dispose(), + }); +} + +function onSubmit() { + emit('passwordSubmitted', { + password: password.value, + captcha: { + hCaptchaResponse: hCaptchaResponse.value, + mCaptchaResponse: mCaptchaResponse.value, + reCaptchaResponse: reCaptchaResponse.value, + turnstileResponse: turnstileResponse.value, + }, + }); +} + +function resetCaptcha() { + hCaptcha.value?.reset(); + mCaptcha.value?.reset(); + reCaptcha.value?.reset(); + turnstile.value?.reset(); +} + +defineExpose({ + resetCaptcha, +}); +</script> + +<style lang="scss" module> +.wrapper { + display: flex; + align-items: center; + width: 100%; + min-height: 336px; + + > .root { + width: 100%; + } +} + +.avatar { + margin: 0 auto 0 auto; + width: 64px; + height: 64px; + background: #ddd; + background-position: center; + background-size: cover; + border-radius: 100%; +} + +.welcomeBackMessage { + text-align: center; + font-size: 1.1em; +} + +.instanceManualSelectButton { + display: block; + text-align: center; + opacity: .7; + font-size: .8em; + + &:hover { + text-decoration: underline; + } +} + +.orHr { + position: relative; + margin: .4em auto; + width: 100%; + height: 1px; + background: var(--divider); +} + +.orMsg { + position: absolute; + top: -.6em; + display: inline-block; + padding: 0 1em; + background: var(--panel); + font-size: 0.8em; + color: var(--fgOnPanel); + margin: 0; + left: 50%; + transform: translateX(-50%); +} +</style> diff --git a/packages/frontend/src/components/MkSignin.totp.vue b/packages/frontend/src/components/MkSignin.totp.vue new file mode 100644 index 0000000000..880c08315e --- /dev/null +++ b/packages/frontend/src/components/MkSignin.totp.vue @@ -0,0 +1,74 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.wrapper"> + <div class="_gaps" :class="$style.root"> + <div class="_gaps_s"> + <div :class="$style.totpIcon"> + <i class="ti ti-key"></i> + </div> + <div :class="$style.totpDescription">{{ i18n.ts['2fa'] }}</div> + </div> + + <!-- totp入力 --> + <form class="_gaps_s" @submit.prevent="emit('totpSubmitted', token)"> + <MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required autofocus :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'"> + <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template> + <template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template> + <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template> + </MkInput> + + <MkButton type="submit" large primary rounded style="margin: 0 auto;">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </form> + </div> +</div> +</template> + +<script setup lang="ts"> +import { ref } from 'vue'; + +import { i18n } from '@/i18n.js'; + +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; + +const emit = defineEmits<{ + (ev: 'totpSubmitted', token: string): void; +}>(); + +const token = ref(''); +const isBackupCode = ref(false); +</script> + +<style lang="scss" module> +.wrapper { + display: flex; + align-items: center; + width: 100%; + min-height: 336px; + + > .root { + width: 100%; + } +} + +.totpIcon { + margin: 0 auto; + background-color: var(--accentedBg); + color: var(--accent); + text-align: center; + height: 64px; + width: 64px; + font-size: 24px; + line-height: 64px; + border-radius: 50%; +} + +.totpDescription { + text-align: center; + font-size: 1.1em; +} +</style> diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 7942a84d66..26e1ac516c 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -4,239 +4,288 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> - <div class="_gaps_m"> - <div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div> - <MkInfo v-if="message"> - {{ message }} - </MkInfo> - <div v-if="openOnRemote" class="_gaps_m"> - <div class="_gaps_s"> - <MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)"> - {{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i> - </MkButton> - <button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)"> - {{ i18n.ts.specifyServerHost }} - </button> - </div> - <div :class="$style.orHr"> - <p :class="$style.orMsg">{{ i18n.ts.or }}</p> - </div> - </div> - <div v-if="!totpLogin" class="normal-signin _gaps_m"> - <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> - <template #prefix>@</template> - <template #suffix>@{{ host }}</template> - </MkInput> - <MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required data-cy-signin-password> - <template #prefix><i class="ti ti-lock"></i></template> - <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> - </MkInput> - <MkButton type="submit" large primary rounded :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> - </div> - <div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> - <div v-if="user && user.securityKeys" class="twofa-group tap-group"> - <p>{{ i18n.ts.useSecurityKey }}</p> - <MkButton v-if="!queryingKey" @click="query2FaKey"> - {{ i18n.ts.retry }} - </MkButton> - </div> - <div v-if="user && user.securityKeys" :class="$style.orHr"> - <p :class="$style.orMsg">{{ i18n.ts.or }}</p> - </div> - <div class="twofa-group totp-group _gaps"> - <MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'"> - <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template> - <template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template> - <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template> - </MkInput> - <MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> - </div> - </div> - <div v-if="!totpLogin && usePasswordLessLogin" :class="$style.orHr"> - <p :class="$style.orMsg">{{ i18n.ts.or }}</p> - </div> - <div v-if="!totpLogin && usePasswordLessLogin" class="twofa-group tap-group"> - <MkButton v-if="!queryingKey" type="submit" :disabled="signing" style="margin: auto auto;" rounded large primary @click="onPasskeyLogin"> - <i class="ti ti-device-usb" style="font-size: medium;"></i> - {{ signing ? i18n.ts.loggingIn : i18n.ts.signinWithPasskey }} - </MkButton> - <p v-if="queryingKey">{{ i18n.ts.useSecurityKey }}</p> - </div> +<div :class="$style.signinRoot"> + <Transition + mode="out-in" + :enterActiveClass="$style.transition_enterActive" + :leaveActiveClass="$style.transition_leaveActive" + :enterFromClass="$style.transition_enterFrom" + :leaveToClass="$style.transition_leaveTo" + + :inert="waiting" + > + <!-- 1. 外部サーバーへの転送・username入力・パスキー --> + <XInput + v-if="page === 'input'" + key="input" + :message="message" + :openOnRemote="openOnRemote" + + @usernameSubmitted="onUsernameSubmitted" + @passkeyClick="onPasskeyLogin" + /> + + <!-- 2. パスワード入力 --> + <XPassword + v-else-if="page === 'password'" + key="password" + ref="passwordPageEl" + + :user="userInfo!" + :needCaptcha="needCaptcha" + + @passwordSubmitted="onPasswordSubmitted" + /> + + <!-- 3. ワンタイムパスワード --> + <XTotp + v-else-if="page === 'totp'" + key="totp" + + @totpSubmitted="onTotpSubmitted" + /> + + <!-- 4. パスキー --> + <XPasskey + v-else-if="page === 'passkey'" + key="passkey" + + :credentialRequest="credentialRequest!" + :isPerformingPasswordlessLogin="doingPasskeyFromInputPage" + + @done="onPasskeyDone" + @useTotp="onUseTotp" + /> + </Transition> + <div v-if="waiting" :class="$style.waitingRoot"> + <MkLoading/> </div> -</form> +</div> </template> -<script lang="ts" setup> -import { defineAsyncComponent, ref } from 'vue'; -import { toUnicode } from 'punycode/'; +<script setup lang="ts"> +import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; -import { SigninWithPasskeyResponse } from 'misskey-js/entities.js'; -import { query, extractDomain } from '@@/js/url.js'; -import { host as configHost } from '@@/js/config.js'; -import MkDivider from './MkDivider.vue'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; -import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; -import MkButton from '@/components/MkButton.vue'; -import MkInput from '@/components/MkInput.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import * as os from '@/os.js'; +import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; + import { misskeyApi } from '@/scripts/misskey-api.js'; +import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; -const signing = ref(false); -const user = ref<Misskey.entities.UserDetailed | null>(null); -const usePasswordLessLogin = ref<Misskey.entities.UserDetailed['usePasswordLessLogin']>(true); -const username = ref(''); -const password = ref(''); -const token = ref(''); -const host = ref(toUnicode(configHost)); -const totpLogin = ref(false); -const isBackupCode = ref(false); -const queryingKey = ref(false); -let credentialRequest: CredentialRequestOptions | null = null; -const passkey_context = ref(''); +import XInput from '@/components/MkSignin.input.vue'; +import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue'; +import XTotp from '@/components/MkSignin.totp.vue'; +import XPasskey from '@/components/MkSignin.passkey.vue'; + +import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; const emit = defineEmits<{ - (ev: 'login', v: any): void; + (ev: 'login', v: Misskey.entities.SigninFlowResponse): void; }>(); const props = withDefaults(defineProps<{ - withAvatar?: boolean; autoSet?: boolean; message?: string, openOnRemote?: OpenOnRemoteOptions, }>(), { - withAvatar: true, autoSet: false, message: '', openOnRemote: undefined, }); -function onUsernameChange(): void { - misskeyApi('users/show', { - username: username.value, - }).then(userResponse => { - user.value = userResponse; - usePasswordLessLogin.value = userResponse.usePasswordLessLogin; - }, () => { - user.value = null; - usePasswordLessLogin.value = true; - }); -} +const page = ref<'input' | 'password' | 'totp' | 'passkey'>('input'); +const waiting = ref(false); -function onLogin(res: any): Promise<void> | void { - if (props.autoSet) { - return login(res.i); - } -} +const passwordPageEl = useTemplateRef('passwordPageEl'); +const needCaptcha = ref(false); -async function query2FaKey(): Promise<void> { - if (credentialRequest == null) return; - queryingKey.value = true; - await webAuthnRequest(credentialRequest) - .catch(() => { - queryingKey.value = false; - return Promise.reject(null); - }).then(credential => { - credentialRequest = null; - queryingKey.value = false; - signing.value = true; - return misskeyApi('signin', { - username: username.value, - password: password.value, - credential: credential.toJSON(), - }); - }).then(res => { - emit('login', res); - return onLogin(res); - }).catch(err => { - if (err === null) return; - os.alert({ - type: 'error', - text: i18n.ts.signinFailed, - }); - signing.value = false; - }); -} +const userInfo = ref<null | Misskey.entities.UserDetailed>(null); +const password = ref(''); + +//#region Passkey Passwordless +const credentialRequest = shallowRef<CredentialRequestOptions | null>(null); +const passkeyContext = ref(''); +const doingPasskeyFromInputPage = ref(false); function onPasskeyLogin(): void { - signing.value = true; if (webAuthnSupported()) { + doingPasskeyFromInputPage.value = true; + waiting.value = true; misskeyApi('signin-with-passkey', {}) - .then((res: SigninWithPasskeyResponse) => { - totpLogin.value = false; - signing.value = false; - queryingKey.value = true; - passkey_context.value = res.context ?? ''; - credentialRequest = parseRequestOptionsFromJSON({ + .then((res) => { + passkeyContext.value = res.context ?? ''; + credentialRequest.value = parseRequestOptionsFromJSON({ publicKey: res.option, }); + + page.value = 'passkey'; + waiting.value = false; }) - .then(() => queryPasskey()) - .catch(loginFailed); + .catch(onSigninApiError); } } -async function queryPasskey(): Promise<void> { - if (credentialRequest == null) return; - queryingKey.value = true; - console.log('Waiting passkey auth...'); - await webAuthnRequest(credentialRequest) - .catch((err) => { - console.warn('Passkey Auth fail!: ', err); - queryingKey.value = false; - return Promise.reject(null); - }).then(credential => { - credentialRequest = null; - queryingKey.value = false; - signing.value = true; - return misskeyApi('signin-with-passkey', { - credential: credential.toJSON(), - context: passkey_context.value, - }); - }).then((res: SigninWithPasskeyResponse) => { +function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void { + waiting.value = true; + + if (doingPasskeyFromInputPage.value) { + misskeyApi('signin-with-passkey', { + credential: credential.toJSON(), + context: passkeyContext.value, + }).then((res) => { + if (res.signinResponse == null) { + onSigninApiError(); + return; + } emit('login', res.signinResponse); - return onLogin(res.signinResponse); + }).catch(onSigninApiError); + } else if (userInfo.value != null) { + tryLogin({ + username: userInfo.value.username, + password: password.value, + credential: credential.toJSON(), }); + } } -function onSubmit(): void { - signing.value = true; - if (!totpLogin.value && user.value && user.value.twoFactorEnabled) { - if (webAuthnSupported() && user.value.securityKeys) { - misskeyApi('signin', { - username: username.value, - password: password.value, - }).then(res => { - totpLogin.value = true; - signing.value = false; - credentialRequest = parseRequestOptionsFromJSON({ - publicKey: res, - }); - }) - .then(() => query2FaKey()) - .catch(loginFailed); - } else { - totpLogin.value = true; - signing.value = false; - } +function onUseTotp(): void { + page.value = 'totp'; +} +//#endregion + +async function onUsernameSubmitted(username: string) { + waiting.value = true; + + userInfo.value = await misskeyApi('users/show', { + username, + }).catch(() => null); + + await tryLogin({ + username, + }); +} + +async function onPasswordSubmitted(pw: PwResponse) { + waiting.value = true; + password.value = pw.password; + + if (userInfo.value == null) { + await os.alert({ + type: 'error', + title: i18n.ts.noSuchUser, + text: i18n.ts.signinFailed, + }); + waiting.value = false; + return; + } else { + await tryLogin({ + username: userInfo.value.username, + password: pw.password, + 'hcaptcha-response': pw.captcha.hCaptchaResponse, + 'm-captcha-response': pw.captcha.mCaptchaResponse, + 'g-recaptcha-response': pw.captcha.reCaptchaResponse, + 'turnstile-response': pw.captcha.turnstileResponse, + }); + } +} + +async function onTotpSubmitted(token: string) { + waiting.value = true; + + if (userInfo.value == null) { + await os.alert({ + type: 'error', + title: i18n.ts.noSuchUser, + text: i18n.ts.signinFailed, + }); + waiting.value = false; + return; } else { - misskeyApi('signin', { - username: username.value, + await tryLogin({ + username: userInfo.value.username, password: password.value, - token: user.value?.twoFactorEnabled ? token.value : undefined, - }).then(res => { + token, + }); + } +} + +async function tryLogin(req: Partial<Misskey.entities.SigninFlowRequest>): Promise<Misskey.entities.SigninFlowResponse> { + const _req = { + username: req.username ?? userInfo.value?.username, + ...req, + }; + + function assertIsSigninFlowRequest(x: Partial<Misskey.entities.SigninFlowRequest>): x is Misskey.entities.SigninFlowRequest { + return x.username != null; + } + + if (!assertIsSigninFlowRequest(_req)) { + throw new Error('Invalid request'); + } + + return await misskeyApi('signin-flow', _req).then(async (res) => { + if (res.finished) { emit('login', res); - onLogin(res); - }).catch(loginFailed); + await onLoginSucceeded(res); + } else { + switch (res.next) { + case 'captcha': { + needCaptcha.value = true; + page.value = 'password'; + break; + } + case 'password': { + needCaptcha.value = false; + page.value = 'password'; + break; + } + case 'totp': { + page.value = 'totp'; + break; + } + case 'passkey': { + if (webAuthnSupported()) { + credentialRequest.value = parseRequestOptionsFromJSON({ + publicKey: res.authRequest, + }); + page.value = 'passkey'; + } else { + page.value = 'totp'; + } + break; + } + } + + if (doingPasskeyFromInputPage.value === true) { + doingPasskeyFromInputPage.value = false; + page.value = 'input'; + password.value = ''; + } + passwordPageEl.value?.resetCaptcha(); + nextTick(() => { + waiting.value = false; + }); + } + return res; + }).catch((err) => { + onSigninApiError(err); + return Promise.reject(err); + }); +} + +async function onLoginSucceeded(res: Misskey.entities.SigninFlowResponse & { finished: true; }) { + if (props.autoSet) { + await login(res.i); } } -function loginFailed(err: any): void { - switch (err.id) { +function onSigninApiError(err?: any): void { + const id = err?.id ?? null; + + switch (id) { case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { os.alert({ type: 'error', @@ -265,6 +314,14 @@ function loginFailed(err: any): void { }); break; } + case 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.incorrectTotp, + }); + break; + } case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': { os.alert({ type: 'error', @@ -273,6 +330,14 @@ function loginFailed(err: any): void { }); break; } + case '93b86c4b-72f9-40eb-9815-798928603d1e': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.passkeyVerificationFailed, + }); + break; + } case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': { os.alert({ type: 'error', @@ -299,113 +364,55 @@ function loginFailed(err: any): void { } } - totpLogin.value = false; - signing.value = false; -} - -function resetPassword(): void { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { - closed: () => dispose(), - }); -} - -function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { - switch (options.type) { - case 'web': - case 'lookup': { - let _path: string; - - if (options.type === 'lookup') { - // TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼ - // _path = `/lookup?uri=${encodeURIComponent(_path)}`; - _path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`; - } else { - _path = options.path; - } - - if (targetHost) { - window.open(`https://${targetHost}${_path}`, '_blank', 'noopener'); - } else { - window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener'); - } - break; - } - case 'share': { - const params = query(options.params); - if (targetHost) { - window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener'); - } else { - window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener'); - } - break; - } + if (doingPasskeyFromInputPage.value === true) { + doingPasskeyFromInputPage.value = false; + page.value = 'input'; + password.value = ''; } -} - -async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> { - const { canceled, result: hostTemp } = await os.inputText({ - title: i18n.ts.inputHostName, - placeholder: 'misskey.example.com', + passwordPageEl.value?.resetCaptcha(); + nextTick(() => { + waiting.value = false; }); - - if (canceled) return; - - let targetHost: string | null = hostTemp; - - // ドメイン部分だけを取り出す - targetHost = extractDomain(targetHost); - if (targetHost == null) { - os.alert({ - type: 'error', - title: i18n.ts.invalidValue, - text: i18n.ts.tryAgain, - }); - return; - } - openRemote(options, targetHost); } + +onBeforeUnmount(() => { + password.value = ''; + needCaptcha.value = false; + userInfo.value = null; +}); </script> <style lang="scss" module> -.avatar { - margin: 0 auto 0 auto; - width: 64px; - height: 64px; - background: #ddd; - background-position: center; - background-size: cover; - border-radius: 100%; +.transition_enterActive, +.transition_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); } - -.instanceManualSelectButton { - display: block; - text-align: center; - opacity: .7; - font-size: .8em; - - &:hover { - text-decoration: underline; - } +.transition_enterFrom { + opacity: 0; + transform: translateX(50px); } +.transition_leaveTo { + opacity: 0; + transform: translateX(-50px); +} + +.signinRoot { + overflow-x: hidden; + overflow-x: clip; -.orHr { position: relative; - margin: .4em auto; - width: 100%; - height: 1px; - background: var(--divider); } -.orMsg { +.waitingRoot { position: absolute; - top: -.6em; - display: inline-block; - padding: 0 1em; - background: var(--panel); - font-size: 0.8em; - color: var(--fgOnPanel); - margin: 0; - left: 50%; - transform: translateX(-50%); + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: color-mix(in srgb, var(--panel), transparent 50%); + display: flex; + justify-content: center; + align-items: center; + z-index: 1; } </style> diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index d48780e9de..8351d7d5e0 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -4,26 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModalWindow - ref="dialog" - :width="400" - :height="450" - @close="onClose" +<MkModal + ref="modal" + :preferType="'dialog'" + @click="onClose" @closed="emit('closed')" > - <template #header>{{ i18n.ts.login }}</template> - - <MkSpacer :marginMin="20" :marginMax="28"> - <MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/> - </MkSpacer> -</MkModalWindow> + <div :class="$style.root"> + <div :class="$style.header"> + <div :class="$style.headerText"><i class="ti ti-login-2"></i> {{ i18n.ts.login }}</div> + <button :class="$style.closeButton" class="_button" @click="onClose"><i class="ti ti-x"></i></button> + </div> + <div :class="$style.content"> + <MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/> + </div> + </div> +</MkModal> </template> <script lang="ts" setup> import { shallowRef } from 'vue'; import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import MkSignin from '@/components/MkSignin.vue'; -import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; withDefaults(defineProps<{ @@ -42,15 +45,62 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const modal = shallowRef<InstanceType<typeof MkModal>>(); function onClose() { emit('cancelled'); - if (dialog.value) dialog.value.close(); + if (modal.value) modal.value.close(); } function onLogin(res) { emit('done', res); - if (dialog.value) dialog.value.close(); + if (modal.value) modal.value.close(); } </script> + +<style lang="scss" module> +.root { + overflow: auto; + margin: auto; + position: relative; + width: 100%; + max-width: 400px; + height: 100%; + max-height: 450px; + box-sizing: border-box; + background: var(--panel); + border-radius: var(--radius); +} + +.header { + position: sticky; + top: 0; + left: 0; + width: 100%; + height: 50px; + box-sizing: border-box; + display: flex; + align-items: center; + font-weight: bold; + backdrop-filter: var(--blur, blur(15px)); + background: var(--acrylicBg); + z-index: 1; +} + +.headerText { + padding: 0 20px; + box-sizing: border-box; +} + +.closeButton { + margin-left: auto; + padding: 16px; + font-size: 16px; + line-height: 16px; +} + +.content { + padding: 32px; + box-sizing: border-box; +} +</style> diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 4ab4380ad5..ff096dc729 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -81,10 +81,10 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import { toUnicode } from 'punycode/'; import * as Misskey from 'misskey-js'; +import * as config from '@@/js/config.js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; -import * as config from '@@/js/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; @@ -98,13 +98,14 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'signup', user: Misskey.entities.SigninResponse): void; + (ev: 'signup', user: Misskey.entities.SigninFlowResponse): void; (ev: 'signupEmailPending'): void; }>(); const host = toUnicode(config.host); const hcaptcha = ref<Captcha | undefined>(); +const mcaptcha = ref<Captcha | undefined>(); const recaptcha = ref<Captcha | undefined>(); const turnstile = ref<Captcha | undefined>(); @@ -268,19 +269,25 @@ async function onSubmit(): Promise<void> { }); emit('signupEmailPending'); } else { - const res = await misskeyApi('signin', { + const res = await misskeyApi('signin-flow', { username: username.value, password: password.value, }); emit('signup', res); - if (props.autoSet) { + if (props.autoSet && res.finished) { return login(res.i); + } else { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); } } } catch { submitting.value = false; hcaptcha.value?.reset?.(); + mcaptcha.value?.reset?.(); recaptcha.value?.reset?.(); turnstile.value?.reset?.(); diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 97310d32a6..4cccd99492 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -47,7 +47,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', res: Misskey.entities.SigninResponse): void; + (ev: 'done', res: Misskey.entities.SigninFlowResponse): void; (ev: 'closed'): void; }>(); @@ -55,7 +55,7 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const isAcceptedServerRule = ref(false); -function onSignup(res: Misskey.entities.SigninResponse) { +function onSignup(res: Misskey.entities.SigninFlowResponse) { emit('done', res); dialog.value?.close(); } diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index d914492231..1beb8874e0 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -6,6 +6,7 @@ import { VNode, h, SetupContext, provide } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; +import { host } from '@@/js/config.js'; import MkUrl from '@/components/global/MkUrl.vue'; import MkTime from '@/components/global/MkTime.vue'; import MkLink from '@/components/MkLink.vue'; @@ -17,7 +18,6 @@ import MkCodeInline from '@/components/MkCodeInline.vue'; import MkGoogle from '@/components/MkGoogle.vue'; import MkSparkle from '@/components/MkSparkle.vue'; import MkA, { MkABehavior } from '@/components/global/MkA.vue'; -import { host } from '@@/js/config.js'; import { defaultStore } from '@/store.js'; function safeParseFloat(str: unknown): number | null { @@ -57,7 +57,8 @@ type MfmEvents = { // eslint-disable-next-line import/no-default-export export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { - provide('linkNavigationBehavior', props.linkNavigationBehavior); + // こうしたいところだけど functional component 内では provide は使えない + //provide('linkNavigationBehavior', props.linkNavigationBehavior); const isNote = props.isNote ?? true; const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false; @@ -350,6 +351,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven key: Math.random(), url: token.props.url, rel: 'nofollow noopener', + navigationBehavior: props.linkNavigationBehavior, })]; } @@ -358,6 +360,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven key: Math.random(), url: token.props.url, rel: 'nofollow noopener', + navigationBehavior: props.linkNavigationBehavior, }, genEl(token.children, scale, true))]; } @@ -366,6 +369,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven key: Math.random(), host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host, username: token.props.username, + navigationBehavior: props.linkNavigationBehavior, })]; } @@ -374,6 +378,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven key: Math.random(), to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--hashtag);', + behavior: props.linkNavigationBehavior, }, `#${token.props.hashtag}`)]; } diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 19bd794a5d..38bdfc52d4 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -27,6 +27,7 @@ import MkLoadingPage from '@/pages/_loading_.vue'; const props = defineProps<{ router?: IRouter; + nested?: boolean; }>(); const router = props.router ?? inject('router'); @@ -39,6 +40,8 @@ const currentDepth = inject('routerCurrentDepth', 0); provide('routerCurrentDepth', currentDepth + 1); function resolveNested(current: Resolved, d = 0): Resolved | null { + if (!props.nested) return current; + if (d === currentDepth) { return current; } else { diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index d40d1eee58..033634396e 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -53,6 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkTextarea v-model="moderationNote" manualSave> <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> </MkTextarea> <!-- @@ -205,6 +206,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, defineAsyncComponent, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -220,7 +222,6 @@ import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { url } from '@@/js/config.js'; import { acct } from '@/filters/user.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 0b9847fed3..22173bb888 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -12,6 +12,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton> </div> + <MkInfo v-if="!defaultStore.reactiveState.abusesTutorial.value" closable @close="closeTutorial()"> + {{ i18n.ts._abuseUserReport.resolveTutorial }} + </MkInfo> + <div :class="$style.inputs" class="_gaps"> <MkSelect v-model="state" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.state }}</template> @@ -44,8 +48,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> --> - <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> - <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> + <MkPagination v-slot="{items}" ref="reports" :pagination="pagination"> + <div class="_gaps"> + <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> + </div> </MkPagination> </div> </MkSpacer> @@ -54,7 +60,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, shallowRef, ref } from 'vue'; - import XHeader from './_header_.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; @@ -62,6 +67,8 @@ import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { defaultStore } from '@/store.js'; const reports = shallowRef<InstanceType<typeof MkPagination>>(); @@ -85,6 +92,10 @@ function resolved(reportId) { reports.value?.removeItem(reportId); } +function closeTutorial() { + defaultStore.set('abusesTutorial', false); +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index db87bd996d..61745e0ff3 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> - <RouterView/> + <RouterView nested/> </div> </div> </template> diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 64d7f25845..6cf95e936e 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -165,6 +165,11 @@ SPDX-License-Identifier: AGPL-3.0-only <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 === 'updateAbuseReportNote'"> + <div :class="$style.diff"> + <CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/> + </div> + </template> <details> <summary>raw</summary> diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index 8590ee1651..38610e7e92 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -20,9 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--margin);"> - <div class="_gaps_s"> - <XModLog v-for="item in items" :key="item.id" :log="item"/> - </div> + <MkDateSeparatedList v-slot="{ item }" :items="items" :noGap="false" style="--margin: 8px;"> + <XModLog :key="item.id" :log="item"/> + </MkDateSeparatedList> </MkPagination> </div> </MkSpacer> @@ -39,6 +39,7 @@ import MkInput from '@/components/MkInput.vue'; import MkPagination from '@/components/MkPagination.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; const logs = shallowRef<InstanceType<typeof MkPagination>>(); diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue index 4e767fba16..124790338c 100644 --- a/packages/frontend/src/pages/admin/system-webhook.item.vue +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkFolder> <template #label>{{ entity.name || entity.url }}</template> + <template v-if="entity.name != null && entity.name != ''" #caption>{{ entity.url }}</template> <template #icon> <i v-if="!entity.isActive" class="ti ti-player-pause"/> <i v-else-if="entity.latestStatus === null" class="ti ti-circle"/> diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index f63a799365..2b85489706 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -55,7 +55,8 @@ const tab = ref('featured'); const featuredFlashsPagination = { endpoint: 'flash/featured' as const, - noPaging: true, + limit: 5, + offsetMode: true, }; const myFlashsPagination = { endpoint: 'flash/my' as const, diff --git a/packages/frontend/src/pages/install-extensions.vue b/packages/frontend/src/pages/install-extensions.vue index 4bee437f65..83f16fce68 100644 --- a/packages/frontend/src/pages/install-extensions.vue +++ b/packages/frontend/src/pages/install-extensions.vue @@ -8,76 +8,26 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="500"> <MkLoading v-if="uiPhase === 'fetching'"/> - <div v-else-if="uiPhase === 'confirm' && data" class="_gaps_m" :class="$style.extInstallerRoot"> - <div :class="$style.extInstallerIconWrapper"> - <i v-if="data.type === 'plugin'" class="ti ti-plug"></i> - <i v-else-if="data.type === 'theme'" class="ti ti-palette"></i> - <i v-else class="ti ti-download"></i> - </div> - <h2 :class="$style.extInstallerTitle">{{ i18n.ts._externalResourceInstaller[`_${data.type}`].title }}</h2> - <div :class="$style.extInstallerNormDesc">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</div> - <MkInfo v-if="data.type === 'plugin'" :warn="true">{{ i18n.ts._plugin.installWarn }}</MkInfo> - <FormSection> - <template #label>{{ i18n.ts._externalResourceInstaller[`_${data.type}`].metaTitle }}</template> - <div class="_gaps_s"> - <FormSplit> + <MkExtensionInstaller v-else-if="uiPhase === 'confirm' && data" :extension="data" @confirm="install()"> + <template #additionalInfo> + <FormSection> + <template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template> + <div class="_gaps_s"> <MkKeyValue> - <template #key>{{ i18n.ts.name }}</template> - <template #value>{{ data.meta?.name }}</template> + <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template> + <template #value><MkUrl :url="url" :showUrlPreview="false"></MkUrl></template> </MkKeyValue> <MkKeyValue> - <template #key>{{ i18n.ts.author }}</template> - <template #value>{{ data.meta?.author }}</template> + <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.hashVerify }}</template> + <template #value> + <!-- この画面が出ている時点でハッシュの検証には成功している --> + <i class="ti ti-check" style="color: var(--accent)"></i> + </template> </MkKeyValue> - </FormSplit> - <MkKeyValue v-if="data.type === 'plugin'"> - <template #key>{{ i18n.ts.description }}</template> - <template #value>{{ data.meta?.description }}</template> - </MkKeyValue> - <MkKeyValue v-if="data.type === 'plugin'"> - <template #key>{{ i18n.ts.version }}</template> - <template #value>{{ data.meta?.version }}</template> - </MkKeyValue> - <MkKeyValue v-if="data.type === 'plugin'"> - <template #key>{{ i18n.ts.permission }}</template> - <template #value> - <ul :class="$style.extInstallerKVList"> - <li v-for="permission in data.meta?.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li> - </ul> - </template> - </MkKeyValue> - <MkKeyValue v-if="data.type === 'theme' && data.meta?.base"> - <template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template> - <template #value>{{ i18n.ts[data.meta.base] }}</template> - </MkKeyValue> - <MkFolder> - <template #icon><i class="ti ti-code"></i></template> - <template #label>{{ i18n.ts._plugin.viewSource }}</template> - - <MkCode :code="data.raw ?? ''"/> - </MkFolder> - </div> - </FormSection> - <FormSection> - <template #label>{{ i18n.ts._externalResourceInstaller._vendorInfo.title }}</template> - <div class="_gaps_s"> - <MkKeyValue> - <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.endpoint }}</template> - <template #value><MkUrl :url="url ?? ''" :showUrlPreview="false"></MkUrl></template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ i18n.ts._externalResourceInstaller._vendorInfo.hashVerify }}</template> - <template #value> - <!--この画面が出ている時点でハッシュの検証には成功している--> - <i class="ti ti-check" style="color: var(--accent)"></i> - </template> - </MkKeyValue> - </div> - </FormSection> - <div class="_buttonsCenter"> - <MkButton primary @click="install()"><i class="ti ti-check"></i> {{ i18n.ts.install }}</MkButton> - </div> - </div> + </div> + </FormSection> + </template> + </MkExtensionInstaller> <div v-else-if="uiPhase === 'error'" class="_gaps_m" :class="[$style.extInstallerRoot, $style.error]"> <div :class="$style.extInstallerIconWrapper"> <i class="ti ti-circle-x"></i> @@ -96,14 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue'; import MkLoading from '@/components/global/MkLoading.vue'; +import MkExtensionInstaller, { type Extension } from '@/components/MkExtensionInstaller.vue'; import MkButton from '@/components/MkButton.vue'; -import FormSection from '@/components/form/section.vue'; -import FormSplit from '@/components/form/split.vue'; -import MkCode from '@/components/MkCode.vue'; -import MkUrl from '@/components/global/MkUrl.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkUrl from '@/components/global/MkUrl.vue'; +import FormSection from '@/components/form/section.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { AiScriptPluginMeta, parsePluginMeta, installPlugin } from '@/scripts/install-plugin.js'; @@ -124,24 +71,7 @@ const errorKV = ref<{ const url = ref<string | null>(null); const hash = ref<string | null>(null); -const data = ref<{ - type: 'plugin' | 'theme'; - raw: string; - meta?: { - // Plugin & Theme Common - name: string; - author: string; - - // Plugin - description?: string; - version?: string; - permissions?: string[]; - config?: Record<string, any>; - - // Theme - base?: 'light' | 'dark'; - }; -} | null>(null); +const data = ref<Extension | null>(null); function goBack(): void { history.back(); @@ -227,7 +157,7 @@ async function fetch() { data.value = { type: 'theme', meta: { - description, + // description, // 使用されていない ...meta, }, raw: res.data, @@ -353,9 +283,4 @@ definePageMetadata(() => ({ .extInstallerNormDesc { text-align: center; } - -.extInstallerKVList { - margin-top: 0; - margin-bottom: 0; -} </style> diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index c69530b343..6cec3f9d45 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -51,6 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton> <MkTextarea v-model="moderationNote" manualSave> <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> </MkTextarea> </div> </FormSection> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue index 0e0c1f4c0c..68e36ef1bb 100644 --- a/packages/frontend/src/pages/settings/apps.vue +++ b/packages/frontend/src/pages/settings/apps.vue @@ -14,30 +14,39 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <template #default="{items}"> <div class="_gaps"> - <div v-for="token in items" :key="token.id" class="_panel" :class="$style.app"> - <img v-if="token.iconUrl" :class="$style.appIcon" :src="token.iconUrl" alt=""/> - <div :class="$style.appBody"> - <div :class="$style.appName">{{ token.name }}</div> - <div>{{ token.description }}</div> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.installedDate }}</template> - <template #value><MkTime :time="token.createdAt"/></template> - </MkKeyValue> - <MkKeyValue oneline> - <template #key>{{ i18n.ts.lastUsedDate }}</template> - <template #value><MkTime :time="token.lastUsedAt"/></template> - </MkKeyValue> - <details> - <summary>{{ i18n.ts.details }}</summary> + <MkFolder v-for="token in items" :key="token.id" :defaultOpen="true"> + <template #icon> + <img v-if="token.iconUrl" :class="$style.appIcon" :src="token.iconUrl" alt=""/> + <i v-else class="ti ti-plug"/> + </template> + <template #label>{{ token.name }}</template> + <template #caption>{{ token.description }}</template> + <template #suffix><MkTime :time="token.lastUsedAt"/></template> + <template #footer> + <MkButton danger @click="revoke(token)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </template> + + <div class="_gaps_s"> + <div v-if="token.description">{{ token.description }}</div> + <div> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.installedDate }}</template> + <template #value><MkTime :time="token.createdAt" :mode="'detail'"/></template> + </MkKeyValue> + <MkKeyValue oneline> + <template #key>{{ i18n.ts.lastUsedDate }}</template> + <template #value><MkTime :time="token.lastUsedAt" :mode="'detail'"/></template> + </MkKeyValue> + </div> + <MkFolder> + <template #label>{{ i18n.ts.permission }}</template> + <template #suffix>{{ Object.keys(token.permission).length === 0 ? i18n.ts.none : Object.keys(token.permission).length }}</template> <ul> <li v-for="p in token.permission" :key="p">{{ i18n.ts._permissions[p] }}</li> </ul> - </details> - <div> - <MkButton inline danger @click="revoke(token)"><i class="ti ti-trash"></i></MkButton> - </div> + </MkFolder> </div> - </div> + </MkFolder> </div> </template> </FormPagination> @@ -52,6 +61,7 @@ import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; import { infoImageUrl } from '@/instance.js'; const list = ref<InstanceType<typeof FormPagination>>(); @@ -82,26 +92,9 @@ definePageMetadata(() => ({ </script> <style lang="scss" module> -.app { - display: flex; - padding: 16px; -} - .appIcon { - display: block; - flex-shrink: 0; - margin: 0 12px 0 0; - width: 50px; - height: 50px; - border-radius: 8px; -} - -.appBody { - width: calc(100% - 62px); - position: relative; -} - -.appName { - font-weight: bold; + width: 20px; + height: 20px; + border-radius: 4px; } </style> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 7d16740a3e..96a95f1635 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> <div class="bkzroven" style="container-type: inline-size;"> - <RouterView/> + <RouterView nested/> </div> </div> </div> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 9e6cd04365..19c5d892de 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -46,14 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFolder> <template #icon><i class="ti ti-list"></i></template> <template #label>{{ i18n.ts._profile.metadataEdit }}</template> - - <div :class="$style.metadataRoot"> - <div :class="$style.metadataMargin"> - <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" inline danger style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> - <MkButton v-else inline style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton> - <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <template #footer> + <div class="_buttons"> + <MkButton primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton :disabled="fields.length >= 16" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" danger @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkButton v-else @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton> </div> + </template> + + <div :class="$style.metadataRoot" class="_gaps_s"> + <MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo> <Sortable v-model="fields" @@ -65,24 +68,20 @@ SPDX-License-Identifier: AGPL-3.0-only @end="e => e.item.classList.remove('active')" > <template #item="{element, index}"> - <div :class="$style.fieldDragItem"> + <div v-panel :class="$style.fieldDragItem"> <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button> <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button> <div :class="$style.dragItemForm"> <FormSplit :minWidth="200"> - <MkInput v-model="element.name" small> - <template #label>{{ i18n.ts._profile.metadataLabel }}</template> + <MkInput v-model="element.name" small :placeholder="i18n.ts._profile.metadataLabel"> </MkInput> - <MkInput v-model="element.value" small> - <template #label>{{ i18n.ts._profile.metadataContent }}</template> + <MkInput v-model="element.value" small :placeholder="i18n.ts._profile.metadataContent"> </MkInput> </FormSplit> </div> </div> </template> </Sortable> - - <MkInfo>{{ i18n.ts._profile.verifiedLinkDescription }}</MkInfo> </div> </MkFolder> <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> @@ -310,19 +309,11 @@ definePageMetadata(() => ({ container-type: inline-size; } -.metadataMargin { - margin-bottom: 1.5em; -} - .fieldDragItem { display: flex; - padding-bottom: .75em; + padding: 10px; align-items: flex-end; - border-bottom: solid 0.5px var(--divider); - - &:last-child { - border-bottom: 0; - } + border-radius: 6px; /* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */ @container (max-width: 452px) { diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index ae8ac88361..111df41127 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -48,9 +48,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> <div v-if="user.followedMessage != null" class="followedMessage"> - <div style="border: solid 1px var(--love); border-radius: 6px; background: color-mix(in srgb, var(--love), transparent 90%); padding: 6px 8px;"> - <Mfm :text="user.followedMessage" :author="user"/> - </div> + <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin shadow> + <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> + <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user"/></MkSparkle></div> + </MkFukidashi> </div> <div v-if="user.roles.length > 0" class="roles"> <span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }"> @@ -63,6 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="iAmModerator" class="moderationNote"> <MkTextarea v-if="editModerationNote || (moderationNote != null && moderationNote !== '')" v-model="moderationNote" manualSave> <template #label>{{ i18n.ts.moderationNote }}</template> + <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> </MkTextarea> <div v-else> <MkButton small @click="editModerationNote = true">{{ i18n.ts.addModerationNote }}</MkButton> @@ -158,15 +160,16 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { getScrollPosition } from '@@/js/scroll.js'; import MkNote from '@/components/MkNote.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import MkAccountMoved from '@/components/MkAccountMoved.vue'; +import MkFukidashi from '@/components/MkFukidashi.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkOmit from '@/components/MkOmit.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; -import { getScrollPosition } from '@@/js/scroll.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; @@ -180,6 +183,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; import { useRouter } from '@/router/supplier.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; +import MkSparkle from '@/components/MkSparkle.vue'; function calcAge(birthdate: string): number { const date = new Date(birthdate); @@ -467,7 +471,18 @@ onUnmounted(() => { > .followedMessage { padding: 24px 24px 0 154px; - font-size: 0.9em; + + > .fukidashi { + display: block; + --fukidashi-bg: color-mix(in srgb, var(--accent), var(--panel) 85%); + --fukidashi-radius: 16px; + font-size: 0.9em; + + .messageHeader { + opacity: 0.7; + font-size: 0.85em; + } + } } > .roles { diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index a227c7c4bc..dd258aad98 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -14,6 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="_gaps_m" style="padding: 32px;"> <div>{{ i18n.ts.intro }}</div> + <MkInput v-model="setupPassword" type="password" data-cy-admin-initial-password> + <template #label>{{ i18n.ts.initialPasswordForSetup }} <div v-tooltip:dialog="i18n.ts.initialPasswordForSetupDescription" class="_button _help"><i class="ti ti-help-circle"></i></div></template> + <template #prefix><i class="ti ti-lock"></i></template> + </MkInput> <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> @@ -36,9 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import { host, version } from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; -import { host, version } from '@@/js/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; @@ -47,6 +51,7 @@ import MkAnimBg from '@/components/MkAnimBg.vue'; const username = ref(''); const password = ref(''); +const setupPassword = ref(''); const submitting = ref(false); function submit() { @@ -56,14 +61,27 @@ function submit() { misskeyApi('admin/accounts/create', { username: username.value, password: password.value, + setupPassword: setupPassword.value === '' ? null : setupPassword.value, }).then(res => { return login(res.token); - }).catch(() => { + }).catch((err) => { submitting.value = false; + let title = i18n.ts.somethingHappened; + let text = err.message + '\n' + err.id; + + if (err.code === 'ACCESS_DENIED') { + title = i18n.ts.permissionDeniedError; + text = i18n.ts.operationForbidden; + } else if (err.code === 'INCORRECT_INITIAL_PASSWORD') { + title = i18n.ts.permissionDeniedError; + text = i18n.ts.incorrectPassword; + } + os.alert({ type: 'error', - text: i18n.ts.somethingHappened, + title, + text, }); }); } @@ -74,8 +92,8 @@ function submit() { min-height: 100svh; padding: 32px 32px 64px 32px; box-sizing: border-box; -display: grid; -place-content: center; + display: grid; + place-content: center; } .form { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 1ddcca5afe..cb52938980 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -78,6 +78,10 @@ export const defaultStore = markRaw(new Storage('base', { global: false, }, }, + abusesTutorial: { + where: 'account', + default: false, + }, keepCw: { where: 'account', default: true, @@ -222,7 +226,7 @@ export const defaultStore = markRaw(new Storage('base', { }, animatedMfm: { where: 'device', - default: false, + default: !window.matchMedia('(prefers-reduced-motion)').matches, }, advancedMfm: { where: 'device', diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index e982df8ffd..504562a91e 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -109,6 +109,11 @@ export function getConfig(): UserConfig { } }, }, + preprocessorOptions: { + scss: { + api: 'modern-compiler', + }, + }, }, define: { |