mirror of
https://github.com/MisskeyIO/misskey
synced 2024-11-27 06:18:40 +09:00
feat(frontend): アカウント初期設定ウィザード (#10799)
* wip * 🎨 * 🎨 * wip * wip * 🎨 * Update CHANGELOG.md * wip * Update MkUserSetupDialog.vue * add stories Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com> * update stories * Update MkUserSetupDialog.Follow.stories.impl.ts * test: load mock user account * ✌️ * ✌️ * test: reset on each render * test: use id to identify --------- Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
This commit is contained in:
parent
d5e92c3822
commit
85a4c8dbb1
@ -39,6 +39,7 @@
|
||||
- Fix: フォローリクエストの通知が残る問題を修正
|
||||
|
||||
### Client
|
||||
- アカウント作成時に初期設定ウィザードを表示するように
|
||||
- チャンネル内検索ができるように
|
||||
- チャンネル検索ですべてのチャンネルの取得/表示ができるように
|
||||
- 通知の表示をカスタマイズできるように
|
||||
|
@ -1036,6 +1036,20 @@ channelArchiveConfirmTitle: "{name}をアーカイブしますか?"
|
||||
channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。"
|
||||
thisChannelArchived: "このチャンネルはアーカイブされています。"
|
||||
displayOfNote: "ノートの表示"
|
||||
initialAccountSetting: "初期設定"
|
||||
youFollowing: "フォロー中"
|
||||
|
||||
_initialAccountSetting:
|
||||
accountCreated: "アカウントの作成が完了しました!"
|
||||
letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。"
|
||||
profileSetting: "プロフィール設定"
|
||||
theseSettingsCanEditLater: "これらの設定は後から変更できます。"
|
||||
youCanEditMoreSettingsInSettingsPageLater: "この他にも様々な設定を「設定」ページから行えます。ぜひ後で確認してみてください。"
|
||||
followUsers: "タイムラインを構築するため、気になるユーザーをフォローしてみましょう。"
|
||||
pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。"
|
||||
initialAccountSettingCompleted: "初期設定が完了しました!"
|
||||
haveFun: "{name}をお楽しみください!"
|
||||
ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。"
|
||||
|
||||
_serverRules:
|
||||
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
|
||||
@ -1615,32 +1629,16 @@ _time:
|
||||
hour: "時間"
|
||||
day: "日"
|
||||
|
||||
_tutorial:
|
||||
_timelineTutorial:
|
||||
title: "Misskeyの使い方"
|
||||
step1_1: "ようこそ。"
|
||||
step1_2: "この画面は「タイムライン」と呼ばれ、あなたや、あなたが「フォロー」する人の「ノート」が時系列で表示されます。"
|
||||
step1_3: "あなたはまだ何もノートを投稿しておらず、誰もフォローしていないので、タイムラインには何も表示されていないはずです。"
|
||||
step2_1: "ノートを作成したり誰かをフォローしたりする前に、まずあなたのプロフィールを完成させましょう。"
|
||||
step2_2: "あなたがどんな人かわかると、多くの人にノートを見てもらえたり、フォローしてもらいやすくなります。"
|
||||
step3_1: "プロフィール設定はうまくできましたか?"
|
||||
step3_2: "では試しに、何かノートを投稿してみてください。画面上にある鉛筆マークのボタンを押すとフォームが開きます。"
|
||||
step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できます。"
|
||||
step3_4: "内容が思いつかない?「Misskey始めました」というのはいかがでしょう。"
|
||||
step4_1: "投稿できましたか?"
|
||||
step4_2: "あなたのノートがタイムラインに表示されていれば成功です。"
|
||||
step5_1: "次は、他の人をフォローしてタイムラインを賑やかにしたいところです。"
|
||||
step5_2: "{featured}で人気のノートが見れるので、その中から気になった人を選んでフォローしたり、{explore}で人気のユーザーを探すこともできます。"
|
||||
step5_3: "ユーザーをフォローするには、ユーザーのアイコンをクリックしてユーザーページを表示し、「フォロー」ボタンを押します。"
|
||||
step5_4: "ユーザーによっては、フォローが承認されるまで時間がかかる場合があります。"
|
||||
step6_1: "タイムラインに他のユーザーのノートが表示されていれば成功です。"
|
||||
step6_2: "他の人のノートには、「リアクション」を付けることができ、簡単にあなたの反応を伝えられます。"
|
||||
step6_3: "リアクションを付けるには、ノートの「+」マークをクリックして、好きなリアクションを選択します。"
|
||||
step7_1: "これで、Misskeyの基本的な使い方の説明は終わりました。お疲れ様でした。"
|
||||
step7_2: "もっとMisskeyについて知りたいときは、{help}を見てみてください。"
|
||||
step7_3: "では、Misskeyをお楽しみください🚀"
|
||||
step8_1: "最後に、プッシュ通知を有効化してみませんか?"
|
||||
step8_2: "プッシュ通知を受け取ることで、Misskeyを開いていない時にもリアクションやフォロー、メンションなどに気づけます。"
|
||||
step8_3: "通知の設定は後から変更できます。"
|
||||
step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。"
|
||||
step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。"
|
||||
step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。"
|
||||
step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。"
|
||||
step3_1: "投稿できましたか?"
|
||||
step3_2: "あなたのノートがタイムラインに表示されていれば成功です。"
|
||||
step4_1: "ノートには、「リアクション」を付けることができます。"
|
||||
step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。"
|
||||
|
||||
_2fa:
|
||||
alreadyRegistered: "既に設定は完了しています。"
|
||||
@ -1822,7 +1820,7 @@ _profile:
|
||||
metadataDescription: "プロフィールに表として追加情報を表示することができます。"
|
||||
metadataLabel: "ラベル"
|
||||
metadataContent: "内容"
|
||||
changeAvatar: "アバター画像を変更"
|
||||
changeAvatar: "アイコン画像を変更"
|
||||
changeBanner: "バナー画像を変更"
|
||||
|
||||
_exportOrImport:
|
||||
|
@ -399,6 +399,8 @@ Promise.all([
|
||||
glob('src/components/Mk{A,B}*.vue'),
|
||||
glob('src/components/MkGalleryPostPreview.vue'),
|
||||
glob('src/components/MkSignupServerRules.vue'),
|
||||
glob('src/components/MkUserSetupDialog.vue'),
|
||||
glob('src/components/MkUserSetupDialog.*.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
])
|
||||
.then((globs) => globs.flat())
|
||||
|
@ -3,6 +3,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
|
||||
import { type Preview, setup } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { initialize, mswDecorator } from 'msw-storybook-addon';
|
||||
import { userDetailed } from './fakes';
|
||||
import locale from './locale';
|
||||
import { commonHandlers, onUnhandledRequest } from './mocks';
|
||||
import themes from './themes';
|
||||
@ -10,6 +11,7 @@ import '../src/style.scss';
|
||||
|
||||
const appInitialized = Symbol();
|
||||
|
||||
let lastStory = null;
|
||||
let moduleInitialized = false;
|
||||
let unobserve = () => {};
|
||||
let misskeyOS = null;
|
||||
@ -42,10 +44,16 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
|
||||
unobserve = () => observer.disconnect();
|
||||
}
|
||||
|
||||
function initLocalStorage() {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('account', JSON.stringify(userDetailed()));
|
||||
localStorage.setItem('locale', JSON.stringify(locale));
|
||||
}
|
||||
|
||||
initialize({
|
||||
onUnhandledRequest,
|
||||
});
|
||||
localStorage.setItem("locale", JSON.stringify(locale));
|
||||
initLocalStorage();
|
||||
queueMicrotask(() => {
|
||||
Promise.all([
|
||||
import('../src/components'),
|
||||
@ -76,6 +84,27 @@ queueMicrotask(() => {
|
||||
const preview = {
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
if (lastStory === context.id) {
|
||||
lastStory = null;
|
||||
} else {
|
||||
lastStory = context.id;
|
||||
const channel = addons.getChannel();
|
||||
const resetIndexedDBPromise = globalThis.indexedDB?.databases
|
||||
? indexedDB.databases().then((r) => {
|
||||
for (var i = 0; i < r.length; i++) {
|
||||
indexedDB.deleteDatabase(r[i].name!);
|
||||
}
|
||||
}).catch(() => {})
|
||||
: Promise.resolve();
|
||||
const resetDefaultStorePromise = import('../src/store').then(({ defaultStore }) => {
|
||||
// @ts-expect-error
|
||||
defaultStore.init();
|
||||
}).catch(() => {});
|
||||
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {
|
||||
initLocalStorage();
|
||||
channel.emit(FORCE_REMOUNT, { storyId: context.id });
|
||||
});
|
||||
}
|
||||
const story = Story();
|
||||
if (!moduleInitialized) {
|
||||
const channel = addons.getChannel();
|
||||
|
@ -89,7 +89,6 @@ defineExpose({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
contain: content;
|
||||
container-type: inline-size;
|
||||
border-radius: var(--radius);
|
||||
|
||||
--root-margin: 24px;
|
||||
@ -142,6 +141,7 @@ defineExpose({
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--panel);
|
||||
container-type: size;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -3,7 +3,7 @@ import { expect } from '@storybook/jest';
|
||||
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
import MkSignupServerRules from './MkSignupDialog,rules.vue';
|
||||
import MkSignupServerRules from './MkSignupDialog.rules.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
export const Empty = {
|
||||
|
@ -0,0 +1,51 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks';
|
||||
import { userDetailed } from '../../.storybook/fakes';
|
||||
import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUserSetupDialog_Follow,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUserSetupDialog_Follow v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
}),
|
||||
rest.post('/api/pinned-users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserSetupDialog_Follow>;
|
@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<div style="text-align: center;">{{ i18n.ts._initialAccountSetting.followUsers }}</div>
|
||||
|
||||
<MkFolder :default-open="true">
|
||||
<template #label>{{ i18n.ts.recommended }}</template>
|
||||
|
||||
<MkPagination :pagination="pinnedUsers">
|
||||
<template #default="{ items }">
|
||||
<div :class="$style.users">
|
||||
<XUser v-for="item in items" :key="item.id" :user="item"/>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :default-open="true">
|
||||
<template #label>{{ i18n.ts.popularUsers }}</template>
|
||||
|
||||
<MkPagination :pagination="popularUsers">
|
||||
<template #default="{ items }">
|
||||
<div :class="$style.users">
|
||||
<XUser v-for="item in items" :key="item.id" :user="item"/>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { instance } from '@/instance';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import XUser from '@/components/MkUserSetupDialog.User.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done'): void;
|
||||
}>();
|
||||
|
||||
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
|
||||
|
||||
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
|
||||
state: 'alive',
|
||||
origin: 'local',
|
||||
sort: '+follower',
|
||||
} };
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.users {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
grid-gap: var(--margin);
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,31 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUserSetupDialog_Profile,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUserSetupDialog_Profile v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserSetupDialog_Profile>;
|
101
packages/frontend/src/components/MkUserSetupDialog.Profile.vue
Normal file
101
packages/frontend/src/components/MkUserSetupDialog.Profile.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<MkInfo>{{ i18n.ts._initialAccountSetting.theseSettingsCanEditLater }}</MkInfo>
|
||||
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts.avatar }}</template>
|
||||
<div v-adaptive-bg :class="$style.avatarSection" class="_panel">
|
||||
<MkAvatar :class="$style.avatar" :user="$i" @click="setAvatar"/>
|
||||
<div style="margin-top: 16px;">
|
||||
<MkButton primary rounded inline @click="setAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</FormSlot>
|
||||
|
||||
<MkInput v-model="name" :max="30" manual-save>
|
||||
<template #label>{{ i18n.ts._profile.name }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="description" :max="500" tall manual-save>
|
||||
<template #label>{{ i18n.ts._profile.description }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkInfo>{{ i18n.ts._initialAccountSetting.youCanEditMoreSettingsInSettingsPageLater }}</MkInfo>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { instance } from '@/instance';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { chooseFileFromPc } from '@/scripts/select-file';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done'): void;
|
||||
}>();
|
||||
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
|
||||
watch(name, () => {
|
||||
os.apiWithDialog('i/update', {
|
||||
// 空文字列をnullにしたいので??は使うな
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
name: name.value || null,
|
||||
});
|
||||
});
|
||||
|
||||
watch(description, () => {
|
||||
os.apiWithDialog('i/update', {
|
||||
// 空文字列をnullにしたいので??は使うな
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
description: description.value || null,
|
||||
});
|
||||
});
|
||||
|
||||
function setAvatar(ev) {
|
||||
chooseFileFromPc(false).then(async (files) => {
|
||||
const file = files[0];
|
||||
|
||||
let originalOrCropped = file;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.t('cropImageAsk'),
|
||||
okText: i18n.ts.cropYes,
|
||||
cancelText: i18n.ts.cropNo,
|
||||
});
|
||||
|
||||
if (!canceled) {
|
||||
originalOrCropped = await os.cropImage(file, {
|
||||
aspectRatio: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const i = await os.apiWithDialog('i/update', {
|
||||
avatarId: originalOrCropped.id,
|
||||
});
|
||||
$i.avatarId = i.avatarId;
|
||||
$i.avatarUrl = i.avatarUrl;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.avatarSection {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,32 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userDetailed } from '../../.storybook/fakes';
|
||||
import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUserSetupDialog_User,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUserSetupDialog_User v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
user: userDetailed(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserSetupDialog_User>;
|
101
packages/frontend/src/components/MkUserSetupDialog.User.vue
Normal file
101
packages/frontend/src/components/MkUserSetupDialog.User.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div v-adaptive-bg class="_panel" style="position: relative;">
|
||||
<div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
<div :class="$style.title">
|
||||
<div :class="$style.name"><MkUserName :user="user" :nowrap="false"/></div>
|
||||
<p :class="$style.username"><MkAcct :user="user"/></p>
|
||||
</div>
|
||||
<div :class="$style.description">
|
||||
<div v-if="user.description" :class="$style.mfm">
|
||||
<Mfm :text="user.description" :author="user" :i="$i"/>
|
||||
</div>
|
||||
<span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span>
|
||||
</div>
|
||||
<div :class="$style.footer">
|
||||
<MkButton v-if="!isFollowing" primary gradate rounded full @click="follow"><i class="ti ti-plus"></i> {{ i18n.ts.follow }}</MkButton>
|
||||
<div v-else style="opacity: 0.7; text-align: center;">{{ i18n.ts.youFollowing }} <i class="ti ti-check"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as misskey from 'misskey-js';
|
||||
import { ref } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
user: misskey.entities.UserDetailed;
|
||||
}>();
|
||||
|
||||
const isFollowing = ref(false);
|
||||
|
||||
async function follow() {
|
||||
isFollowing.value = true;
|
||||
os.api('following/create', {
|
||||
userId: props.user.id,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.banner {
|
||||
height: 60px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 13px;
|
||||
z-index: 2;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: solid 4px var(--panel);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
padding: 10px 0 10px 88px;
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: block;
|
||||
margin: 0;
|
||||
line-height: 16px;
|
||||
font-size: 0.8em;
|
||||
color: var(--fg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding: 0 16px 16px 88px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mfm {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,51 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks';
|
||||
import { userDetailed } from '../../.storybook/fakes';
|
||||
import MkUserSetupDialog from './MkUserSetupDialog.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkUserSetupDialog,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkUserSetupDialog v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
}),
|
||||
rest.post('/api/pinned-users', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
userDetailed('44'),
|
||||
userDetailed('49'),
|
||||
]));
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkUserSetupDialog>;
|
135
packages/frontend/src/components/MkUserSetupDialog.vue
Normal file
135
packages/frontend/src/components/MkUserSetupDialog.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="500"
|
||||
:height="550"
|
||||
@close="close"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.initialAccountSetting }}</template>
|
||||
|
||||
<div style="overflow-x: clip;">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
:enter-active-class="$style.transition_x_enterActive"
|
||||
:leave-active-class="$style.transition_x_leaveActive"
|
||||
:enter-from-class="$style.transition_x_enterFrom"
|
||||
:leave-to-class="$style.transition_x_leaveTo"
|
||||
>
|
||||
<template v-if="page === 0">
|
||||
<div :class="$style.centerPage">
|
||||
<MkSpacer :margin-min="20" :margin-max="28">
|
||||
<div class="_gaps" style="text-align: center;">
|
||||
<i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
|
||||
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div>
|
||||
<div>{{ i18n.ts._initialAccountSetting.letsFillYourProfile }}</div>
|
||||
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 1">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<MkSpacer :margin-min="20" :margin-max="28">
|
||||
<XProfile/>
|
||||
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 2">
|
||||
<div style="height: 100cqh; overflow: auto;">
|
||||
<MkSpacer :margin-min="20" :margin-max="28">
|
||||
<XFollow/>
|
||||
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 3">
|
||||
<div :class="$style.centerPage">
|
||||
<MkSpacer :margin-min="20" :margin-max="28">
|
||||
<div class="_gaps" style="text-align: center;">
|
||||
<i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
|
||||
<div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div>
|
||||
<div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div>
|
||||
<MkPushNotificationAllowButton primary show-only-to-register style="margin: 0 auto;"/>
|
||||
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="page === 4">
|
||||
<div :class="$style.centerPage">
|
||||
<MkSpacer :margin-min="20" :margin-max="28">
|
||||
<div class="_gaps" style="text-align: center;">
|
||||
<i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i>
|
||||
<div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div>
|
||||
<I18n :src="i18n.ts._initialAccountSetting.ifYouNeedLearnMore" tag="div" style="padding: 0 16px;">
|
||||
<template #name>{{ instance.name ?? host }}</template>
|
||||
<template #link>
|
||||
<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
|
||||
</template>
|
||||
</I18n>
|
||||
<div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div>
|
||||
<MkButton primary rounded gradate style="margin: 16px auto 0 auto;" @click="close">{{ i18n.ts.close }}</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
</Transition>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import XProfile from '@/components/MkUserSetupDialog.Profile.vue';
|
||||
import XFollow from '@/components/MkUserSetupDialog.Follow.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
import { host } from '@/config';
|
||||
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
const page = ref(defaultStore.state.accountSetupWizard);
|
||||
|
||||
watch(page, () => {
|
||||
defaultStore.set('accountSetupWizard', page.value);
|
||||
});
|
||||
|
||||
function close() {
|
||||
dialog.value.close();
|
||||
defaultStore.set('accountSetupWizard', -1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_x_enterActive,
|
||||
.transition_x_leaveActive {
|
||||
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
|
||||
}
|
||||
.transition_x_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
.transition_x_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
|
||||
.centerPage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100cqh;
|
||||
padding-bottom: 30px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
@ -343,6 +343,10 @@ if ($i) {
|
||||
// only add post shortcuts if logged in
|
||||
hotkeys['p|n'] = post;
|
||||
|
||||
if (defaultStore.state.accountSetupWizard !== -1) {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
|
||||
}
|
||||
|
||||
if ($i.isDeleted) {
|
||||
alert({
|
||||
type: 'warning',
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.title">
|
||||
<div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div>
|
||||
<div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._timelineTutorial.title }}</div>
|
||||
<div :class="$style.step">
|
||||
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
|
||||
<i class="ti ti-chevron-left"></i>
|
||||
@ -12,66 +12,30 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tutorial === 0" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step1_1 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step1_2 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step1_3 }}</div>
|
||||
<div>{{ i18n.t('_timelineTutorial.step1_1', { name: instance.name ?? host }) }}</div>
|
||||
<div>{{ i18n.t('_timelineTutorial.step1_2', { name: instance.name ?? host }) }}</div>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 1" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step2_1 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step2_2 }}</div>
|
||||
<MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA>
|
||||
<div>{{ i18n.ts._timelineTutorial.step2_1 }}</div>
|
||||
<div>{{ i18n.t('_timelineTutorial.step2_2', { name: instance.name ?? host }) }}</div>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 2" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step3_1 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step3_2 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step3_3 }}</div>
|
||||
<small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small>
|
||||
<div>{{ i18n.ts._timelineTutorial.step3_1 }}</div>
|
||||
<div>{{ i18n.ts._timelineTutorial.step3_2 }}</div>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 3" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step4_1 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step4_2 }}</div>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 4" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step5_1 }}</div>
|
||||
<I18n :src="i18n.ts._tutorial.step5_2" tag="div">
|
||||
<template #featured>
|
||||
<MkA class="_link" to="/explore">{{ i18n.ts.featured }}</MkA>
|
||||
</template>
|
||||
<template #explore>
|
||||
<MkA class="_link" to="/explore#users">{{ i18n.ts.explore }}</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<div>{{ i18n.ts._tutorial.step5_3 }}</div>
|
||||
<small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 5" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step6_1 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step6_2 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step6_3 }}</div>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 6" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step7_1 }}</div>
|
||||
<I18n :src="i18n.ts._tutorial.step7_2" tag="div">
|
||||
<template #help>
|
||||
<a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a>
|
||||
</template>
|
||||
</I18n>
|
||||
<div>{{ i18n.ts._tutorial.step7_3 }}</div>
|
||||
</div>
|
||||
<div v-else-if="tutorial === 7" :class="$style.body">
|
||||
<div>{{ i18n.ts._tutorial.step8_1 }}</div>
|
||||
<div>{{ i18n.ts._tutorial.step8_2 }}</div>
|
||||
<small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small>
|
||||
<div>{{ i18n.ts._timelineTutorial.step4_1 }}</div>
|
||||
<div>{{ i18n.ts._timelineTutorial.step4_2 }}</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.footer">
|
||||
<template v-if="tutorial === tutorialsNumber - 1">
|
||||
<MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1"/>
|
||||
<MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton>
|
||||
<MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial = -1">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton>
|
||||
<MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial++">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@ -80,15 +44,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
import { host } from '@/config';
|
||||
|
||||
const tutorialsNumber = 8;
|
||||
const tutorialsNumber = 4;
|
||||
|
||||
const tutorial = computed({
|
||||
get() { return defaultStore.reactiveState.tutorial.value || 0; },
|
||||
set(value) { defaultStore.set('tutorial', value); },
|
||||
get() { return defaultStore.reactiveState.timelineTutorial.value || 0; },
|
||||
set(value) { defaultStore.set('timelineTutorial', value); },
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<div ref="rootEl" v-hotkey.global="keymap">
|
||||
<XTutorial v-if="$i && defaultStore.reactiveState.tutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/>
|
||||
<XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/>
|
||||
<MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
|
||||
|
||||
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||
|
@ -38,7 +38,11 @@ export const pageViewInterruptors: PageViewInterruptor[] = [];
|
||||
// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
|
||||
// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
|
||||
export const defaultStore = markRaw(new Storage('base', {
|
||||
tutorial: {
|
||||
accountSetupWizard: {
|
||||
where: 'account',
|
||||
default: 0,
|
||||
},
|
||||
timelineTutorial: {
|
||||
where: 'account',
|
||||
default: 0,
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user