summaryrefslogtreecommitdiff
path: root/packages/frontend
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2024-10-09 05:17:29 +0000
committerGitHub <noreply@github.com>2024-10-09 05:17:29 +0000
commit2518cf36d0e1ae594a527087704ee8bce51013bb (patch)
tree957d293824733660cad00bd8f3f5745d744179c3 /packages/frontend
parentMerge pull request #14580 from misskey-dev/develop (diff)
parentRelease: 2024.10.0 (diff)
downloadmisskey-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')
-rw-r--r--packages/frontend/.storybook/generate.tsx13
-rw-r--r--packages/frontend/package.json55
-rw-r--r--packages/frontend/src/boot/main-boot.ts28
-rw-r--r--packages/frontend/src/components/MkAbuseReport.stories.impl.ts54
-rw-r--r--packages/frontend/src/components/MkAbuseReport.vue187
-rw-r--r--packages/frontend/src/components/MkExtensionInstaller.stories.impl.ts83
-rw-r--r--packages/frontend/src/components/MkExtensionInstaller.vue146
-rw-r--r--packages/frontend/src/components/MkFolder.vue7
-rw-r--r--packages/frontend/src/components/MkFukidashi.vue100
-rw-r--r--packages/frontend/src/components/MkMention.vue11
-rw-r--r--packages/frontend/src/components/MkMenu.vue4
-rw-r--r--packages/frontend/src/components/MkNoteHeader.vue20
-rw-r--r--packages/frontend/src/components/MkNotification.vue13
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue15
-rw-r--r--packages/frontend/src/components/MkSignin.input.vue206
-rw-r--r--packages/frontend/src/components/MkSignin.passkey.vue92
-rw-r--r--packages/frontend/src/components/MkSignin.password.vue181
-rw-r--r--packages/frontend/src/components/MkSignin.totp.vue74
-rw-r--r--packages/frontend/src/components/MkSignin.vue575
-rw-r--r--packages/frontend/src/components/MkSigninDialog.vue80
-rw-r--r--packages/frontend/src/components/MkSignupDialog.form.vue15
-rw-r--r--packages/frontend/src/components/MkSignupDialog.vue4
-rw-r--r--packages/frontend/src/components/global/MkMfm.ts9
-rw-r--r--packages/frontend/src/components/global/RouterView.vue3
-rw-r--r--packages/frontend/src/pages/admin-user.vue3
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue17
-rw-r--r--packages/frontend/src/pages/admin/index.vue2
-rw-r--r--packages/frontend/src/pages/admin/modlog.ModLog.vue5
-rw-r--r--packages/frontend/src/pages/admin/modlog.vue7
-rw-r--r--packages/frontend/src/pages/admin/system-webhook.item.vue1
-rw-r--r--packages/frontend/src/pages/flash/flash-index.vue3
-rw-r--r--packages/frontend/src/pages/install-extensions.vue117
-rw-r--r--packages/frontend/src/pages/instance-info.vue1
-rw-r--r--packages/frontend/src/pages/settings/apps.vue73
-rw-r--r--packages/frontend/src/pages/settings/index.vue2
-rw-r--r--packages/frontend/src/pages/settings/profile.vue39
-rw-r--r--packages/frontend/src/pages/user/home.vue25
-rw-r--r--packages/frontend/src/pages/welcome.setup.vue28
-rw-r--r--packages/frontend/src/store.ts6
-rw-r--r--packages/frontend/vite.config.ts5
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: {