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:
syuilo 2023-05-08 17:29:19 +09:00 committed by GitHub
parent d5e92c3822
commit 85a4c8dbb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 651 additions and 83 deletions

View File

@ -39,6 +39,7 @@
- Fix: フォローリクエストの通知が残る問題を修正 - Fix: フォローリクエストの通知が残る問題を修正
### Client ### Client
- アカウント作成時に初期設定ウィザードを表示するように
- チャンネル内検索ができるように - チャンネル内検索ができるように
- チャンネル検索ですべてのチャンネルの取得/表示ができるように - チャンネル検索ですべてのチャンネルの取得/表示ができるように
- 通知の表示をカスタマイズできるように - 通知の表示をカスタマイズできるように

View File

@ -1036,6 +1036,20 @@ channelArchiveConfirmTitle: "{name}をアーカイブしますか?"
channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。" channelArchiveConfirmDescription: "アーカイブすると、チャンネル一覧や検索結果に表示されなくなり、新たな書き込みもできなくなります。"
thisChannelArchived: "このチャンネルはアーカイブされています。" thisChannelArchived: "このチャンネルはアーカイブされています。"
displayOfNote: "ノートの表示" displayOfNote: "ノートの表示"
initialAccountSetting: "初期設定"
youFollowing: "フォロー中"
_initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!"
letsFillYourProfile: "まずはあなたのプロフィールを設定しましょう。"
profileSetting: "プロフィール設定"
theseSettingsCanEditLater: "これらの設定は後から変更できます。"
youCanEditMoreSettingsInSettingsPageLater: "この他にも様々な設定を「設定」ページから行えます。ぜひ後で確認してみてください。"
followUsers: "タイムラインを構築するため、気になるユーザーをフォローしてみましょう。"
pushNotificationDescription: "プッシュ通知を有効にすると{name}の通知をお使いのデバイスで受け取ることができます。"
initialAccountSettingCompleted: "初期設定が完了しました!"
haveFun: "{name}をお楽しみください!"
ifYouNeedLearnMore: "{name}(Misskey)の使い方などを詳しく知るには{link}をご覧ください。"
_serverRules: _serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
@ -1615,32 +1629,16 @@ _time:
hour: "時間" hour: "時間"
day: "日" day: "日"
_tutorial: _timelineTutorial:
title: "Misskeyの使い方" title: "Misskeyの使い方"
step1_1: "ようこそ。" step1_1: "この画面は「タイムライン」です。{name}に投稿された「ノート」が時系列で表示されます。"
step1_2: "この画面は「タイムライン」と呼ばれ、あなたや、あなたが「フォロー」する人の「ノート」が時系列で表示されます。" step1_2: "タイムラインにはいくつか種類があり、例えば「ホームタイムライン」にはあなたがフォローしている人のノートが流れ、「ローカルタイムライン」には{name}全体のノートが流れます。"
step1_3: "あなたはまだ何もノートを投稿しておらず、誰もフォローしていないので、タイムラインには何も表示されていないはずです。" step2_1: "試しに、何かノートを投稿してみましょう。画面上にある鉛筆マークのボタンを押すとフォームが開きます。"
step2_1: "ノートを作成したり誰かをフォローしたりする前に、まずあなたのプロフィールを完成させましょう。" step2_2: "初めてのノートの内容は、あなたの自己紹介や「{name}始めました」などがおすすめです。"
step2_2: "あなたがどんな人かわかると、多くの人にノートを見てもらえたり、フォローしてもらいやすくなります。" step3_1: "投稿できましたか?"
step3_1: "プロフィール設定はうまくできましたか?" step3_2: "あなたのノートがタイムラインに表示されていれば成功です。"
step3_2: "では試しに、何かノートを投稿してみてください。画面上にある鉛筆マークのボタンを押すとフォームが開きます。" step4_1: "ノートには、「リアクション」を付けることができます。"
step3_3: "内容を書いたら、フォーム右上のボタンを押すと投稿できます。" step4_2: "リアクションを付けるには、ノートの「+」マークをクリックして、好きな絵文字を選択します。"
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: "通知の設定は後から変更できます。"
_2fa: _2fa:
alreadyRegistered: "既に設定は完了しています。" alreadyRegistered: "既に設定は完了しています。"
@ -1822,7 +1820,7 @@ _profile:
metadataDescription: "プロフィールに表として追加情報を表示することができます。" metadataDescription: "プロフィールに表として追加情報を表示することができます。"
metadataLabel: "ラベル" metadataLabel: "ラベル"
metadataContent: "内容" metadataContent: "内容"
changeAvatar: "アバター画像を変更" changeAvatar: "アイコン画像を変更"
changeBanner: "バナー画像を変更" changeBanner: "バナー画像を変更"
_exportOrImport: _exportOrImport:

View File

@ -399,6 +399,8 @@ Promise.all([
glob('src/components/Mk{A,B}*.vue'), glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/pages/user/home.vue'), glob('src/pages/user/home.vue'),
]) ])
.then((globs) => globs.flat()) .then((globs) => globs.flat())

View File

@ -3,6 +3,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
import { type Preview, setup } from '@storybook/vue3'; import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic'; import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon'; import { initialize, mswDecorator } from 'msw-storybook-addon';
import { userDetailed } from './fakes';
import locale from './locale'; import locale from './locale';
import { commonHandlers, onUnhandledRequest } from './mocks'; import { commonHandlers, onUnhandledRequest } from './mocks';
import themes from './themes'; import themes from './themes';
@ -10,6 +11,7 @@ import '../src/style.scss';
const appInitialized = Symbol(); const appInitialized = Symbol();
let lastStory = null;
let moduleInitialized = false; let moduleInitialized = false;
let unobserve = () => {}; let unobserve = () => {};
let misskeyOS = null; let misskeyOS = null;
@ -42,10 +44,16 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme
unobserve = () => observer.disconnect(); unobserve = () => observer.disconnect();
} }
function initLocalStorage() {
localStorage.clear();
localStorage.setItem('account', JSON.stringify(userDetailed()));
localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({ initialize({
onUnhandledRequest, onUnhandledRequest,
}); });
localStorage.setItem("locale", JSON.stringify(locale)); initLocalStorage();
queueMicrotask(() => { queueMicrotask(() => {
Promise.all([ Promise.all([
import('../src/components'), import('../src/components'),
@ -76,6 +84,27 @@ queueMicrotask(() => {
const preview = { const preview = {
decorators: [ decorators: [
(Story, context) => { (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(); const story = Story();
if (!moduleInitialized) { if (!moduleInitialized) {
const channel = addons.getChannel(); const channel = addons.getChannel();

View File

@ -89,7 +89,6 @@ defineExpose({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
contain: content; contain: content;
container-type: inline-size;
border-radius: var(--radius); border-radius: var(--radius);
--root-margin: 24px; --root-margin: 24px;
@ -142,6 +141,7 @@ defineExpose({
flex: 1; flex: 1;
overflow: auto; overflow: auto;
background: var(--panel); background: var(--panel);
container-type: size;
} }
} }
</style> </style>

View File

@ -3,7 +3,7 @@ import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library'; import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3'; import { StoryObj } from '@storybook/vue3';
import { onBeforeUnmount } from 'vue'; import { onBeforeUnmount } from 'vue';
import MkSignupServerRules from './MkSignupDialog,rules.vue'; import MkSignupServerRules from './MkSignupDialog.rules.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance'; import { instance } from '@/instance';
export const Empty = { export const Empty = {

View File

@ -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>;

View File

@ -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>

View File

@ -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>;

View 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>

View File

@ -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>;

View 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>

View File

@ -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>;

View 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>

View File

@ -343,6 +343,10 @@ if ($i) {
// only add post shortcuts if logged in // only add post shortcuts if logged in
hotkeys['p|n'] = post; hotkeys['p|n'] = post;
if (defaultStore.state.accountSetupWizard !== -1) {
popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed');
}
if ($i.isDeleted) { if ($i.isDeleted) {
alert({ alert({
type: 'warning', type: 'warning',

View File

@ -1,7 +1,7 @@
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<div :class="$style.title"> <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"> <div :class="$style.step">
<button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--"> <button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--">
<i class="ti ti-chevron-left"></i> <i class="ti ti-chevron-left"></i>
@ -12,66 +12,30 @@
</button> </button>
</div> </div>
</div> </div>
<div v-if="tutorial === 0" :class="$style.body"> <div v-if="tutorial === 0" :class="$style.body">
<div>{{ i18n.ts._tutorial.step1_1 }}</div> <div>{{ i18n.t('_timelineTutorial.step1_1', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.ts._tutorial.step1_2 }}</div> <div>{{ i18n.t('_timelineTutorial.step1_2', { name: instance.name ?? host }) }}</div>
<div>{{ i18n.ts._tutorial.step1_3 }}</div>
</div> </div>
<div v-else-if="tutorial === 1" :class="$style.body"> <div v-else-if="tutorial === 1" :class="$style.body">
<div>{{ i18n.ts._tutorial.step2_1 }}</div> <div>{{ i18n.ts._timelineTutorial.step2_1 }}</div>
<div>{{ i18n.ts._tutorial.step2_2 }}</div> <div>{{ i18n.t('_timelineTutorial.step2_2', { name: instance.name ?? host }) }}</div>
<MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA>
</div> </div>
<div v-else-if="tutorial === 2" :class="$style.body"> <div v-else-if="tutorial === 2" :class="$style.body">
<div>{{ i18n.ts._tutorial.step3_1 }}</div> <div>{{ i18n.ts._timelineTutorial.step3_1 }}</div>
<div>{{ i18n.ts._tutorial.step3_2 }}</div> <div>{{ i18n.ts._timelineTutorial.step3_2 }}</div>
<div>{{ i18n.ts._tutorial.step3_3 }}</div>
<small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small>
</div> </div>
<div v-else-if="tutorial === 3" :class="$style.body"> <div v-else-if="tutorial === 3" :class="$style.body">
<div>{{ i18n.ts._tutorial.step4_1 }}</div> <div>{{ i18n.ts._timelineTutorial.step4_1 }}</div>
<div>{{ i18n.ts._tutorial.step4_2 }}</div> <div>{{ i18n.ts._timelineTutorial.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> </div>
<div :class="$style.footer"> <div :class="$style.footer">
<template v-if="tutorial === tutorialsNumber - 1"> <template v-if="tutorial === tutorialsNumber - 1">
<MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1"/> <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial = -1">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton>
<MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton>
</template> </template>
<template v-else> <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> </template>
</div> </div>
</div> </div>
@ -80,15 +44,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { host } from '@/config';
const tutorialsNumber = 8; const tutorialsNumber = 4;
const tutorial = computed({ const tutorial = computed({
get() { return defaultStore.reactiveState.tutorial.value || 0; }, get() { return defaultStore.reactiveState.timelineTutorial.value || 0; },
set(value) { defaultStore.set('tutorial', value); }, set(value) { defaultStore.set('timelineTutorial', value); },
}); });
</script> </script>

View File

@ -3,7 +3,7 @@
<template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template> <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template>
<MkSpacer :content-max="800"> <MkSpacer :content-max="800">
<div ref="rootEl" v-hotkey.global="keymap"> <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);"/> <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> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>

View File

@ -38,7 +38,11 @@ export const pageViewInterruptors: PageViewInterruptor[] = [];
// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) // TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない // あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
export const defaultStore = markRaw(new Storage('base', { export const defaultStore = markRaw(new Storage('base', {
tutorial: { accountSetupWizard: {
where: 'account',
default: 0,
},
timelineTutorial: {
where: 'account', where: 'account',
default: 0, default: 0,
}, },