feat(docs): ステップバイステップガイド (#145)

* feat(docs): ステップバイステップガイドを追加

* fix

* fix

* docs

* add docs
This commit is contained in:
かっこかり 2024-04-06 17:06:58 +09:00 committed by GitHub
parent 9590c508e0
commit 89f16c36b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 283 additions and 32 deletions

View File

@ -1,32 +1,47 @@
import type { Ref } from 'vue'
/** /**
* Scrollspy allows you to watch visible headings in a specific page. * Scrollspy allows you to watch visible headings in a specific page.
* Useful for table of contents live style updates. * Useful for table of contents live style updates.
*/ */
export const useScrollspy = () => { export const useScrollspy = (config?: IntersectionObserverInit) => {
const observer = ref() as Ref<IntersectionObserver> let observingElements: Element[] = [];
const visibleHeadings = ref([]) as Ref<string[]>
const activeHeadings = ref([]) as Ref<string[]>
const observerCallback = (entries: IntersectionObserverEntry[]) => const observer = ref<IntersectionObserver>();
const visibleHeadings = ref<string[]>([]);
const activeHeadings = ref<string[]>([]);
const mainHeading = ref<string | null>(null);
function observerCallback(entries: IntersectionObserverEntry[]) {
entries.forEach((entry) => { entries.forEach((entry) => {
const id = entry.target.id const id = entry.target.id;
if (entry.isIntersecting) { visibleHeadings.value.push(id) } else { visibleHeadings.value = visibleHeadings.value.filter(t => t !== id) } if (entry.isIntersecting) {
}) visibleHeadings.value.push(id);
} else {
visibleHeadings.value = visibleHeadings.value.filter((t) => t !== id);
}
});
const updateHeadings = (headings: Element[]) => mainHeading.value = entries.reduce((max, entry) => (entry.intersectionRatio > max.intersectionRatio ? entry : max), entries[0]).target.id;
headings.forEach((heading) => { }
observer.value.observe(heading)
}) function updateHeadings(headings: Element[]) {
observingElements.forEach((el) => {
observer.value?.unobserve(el);
});
observingElements = headings;
observingElements.forEach((heading) => {
observer.value?.observe(heading);
});
}
watch(visibleHeadings, (val, oldVal) => { watch(visibleHeadings, (val, oldVal) => {
if (val.length === 0) { activeHeadings.value = oldVal } else { activeHeadings.value = val } if (val.length === 0) { activeHeadings.value = oldVal } else { activeHeadings.value = val }
}, { deep: true }) }, { deep: true })
// Create intersection observer // Create intersection observer
onBeforeMount(() => (observer.value = new IntersectionObserver(observerCallback))) onBeforeMount(() => (observer.value = new IntersectionObserver(observerCallback, config)))
// Destroy it // Destroy it
onBeforeUnmount(() => observer.value?.disconnect()) onBeforeUnmount(() => observer.value?.disconnect())
@ -34,6 +49,7 @@ export const useScrollspy = () => {
return { return {
visibleHeadings, visibleHeadings,
activeHeadings, activeHeadings,
mainHeading,
updateHeadings updateHeadings
} }
} }

View File

@ -0,0 +1,11 @@
# ステップバイステップガイド
このセクションでは、Misskeyを利用する中で見られる複雑な操作を、一歩ずつ丁寧に解説しています。
:::warning
このセクションはベータ版です。内容が不完全である可能性があります。
:::
<MkIndex />

View File

@ -0,0 +1,2 @@
title: "ステップバイステップガイド"
description: "Misskeyの操作を一歩ずつ丁寧に解説しています。操作方法がわからなくなったらここをチェック"

View File

@ -0,0 +1,82 @@
title: "Misskey Webをアプリ化して使う方法"
description: "Misskey WebをPWAアプリ化する方法をご紹介します。スマートフォンでMisskeyを利用する際のおすすめの方法です。"
# Do not translate any keys that includes underscore
_TYPE_: "STEPPED_GUIDE"
body: |
ここでは、Misskey WebをPWAアプリ化する方法をご紹介します。スマートフォンでMisskeyを利用する際のおすすめの方法です。
## PWAとは
PWAは、Progressive Web Appの略で、ウェブページをアプリのように扱うことができる技術です。Misskeyは、標準でPWAに対応しています。
ここでは、PWAの設定方法を端末のOS別に紹介しています。以下からお使いのOSを選んで進んでください
guides:
- _AUTOSELECT_TYPE_: "OS_ANDROID"
_LAYOUT_TYPE_: "IMAGE_PORTRAIT_FIXED"
title: "Android (Google Chrome)"
description: |
AndroidでPWAをお使いになる際は、Google Chromeがおすすめです。
ここでは、Google Chromeを使用した設定方法について解説します。
steps:
- title: "PWAにしたいMisskeyサーバーを開く"
image: "android/sp_0.png"
description: |
PWAとして端末にインストールしたいMisskeyサーバーにアクセスし、ログインしてください。
- title: "詳細設定メニューを開く"
image: "android/sp_1.png"
description: |
Misskeyサーバーの任意のページを開いたまま、右上にある「︙」ボタンをタップします。
- title: "「ホーム画面に追加」または「アプリをインストール」をタップ"
image: "android/sp_2.png"
description: |
メニュー内にある「ホーム画面に追加」もしくは「アプリをインストール」の項目をタップし、出てきたダイアログの指示に従ってください。
- title: "ホーム画面を確認する"
image: "android/sp_3.png"
description: |
しばらくすると、ホーム画面に、サーバーのアイコンもしくはMisskeyのアイコンが追加されます。
このアイコンをタップすることで、Misskeyはアプリモードで起動します。
- _AUTOSELECT_TYPE_: "OS_IOS"
_LAYOUT_TYPE_: "IMAGE_PORTRAIT_FIXED"
title: "iOS / iPadOS"
description: |
iOS または iPadOS でPWAをお使いになる際は、Safariをご利用ください。
開始する前に、お使いの端末に搭載されているOSが最新のものかどうかを確認してください。最新ではない場合は、[こちら](https://support.apple.com/ja-jp/ios/update)を参考にしてアップデートしてください。
steps:
- title: "PWAにしたいMisskeyサーバーを開く"
image: "ios/sp_0.png"
description: |
PWAとして端末にインストールしたいMisskeyサーバーにアクセスし、ログインしてください。
- title: "共有ボタンをタップして開く"
image: "ios/sp_1.png"
description: |
Misskeyサーバーの任意のページを開いたまま、メニューバーの共有ボタンをタップします。
- title: "「ホーム画面に追加」をタップ"
image: "ios/sp_2.png"
description: |
オプションのリストを下にスクロールしてから、「ホーム画面に追加」をタップします。
この後、通知の送信許可を求められることがありますので、画面の指示に従い、必要に応じて許可してください。
「ホーム画面に追加」が表示されない場合は、設定項目が非表示になっている可能性があります。表示するには、リストの一番下までスクロールし、「アクションを編集」から「ホーム画面に追加」の項目を追加してください。詳しくは[Appleのサポートページ](https://support.apple.com/ja-jp/guide/iphone/iph42ab2f3a7/ios)をご覧ください。
- title: "ホーム画面を確認する"
image: "ios/sp_3.png"
description: |
しばらくすると、ホーム画面に、サーバーのアイコンもしくはMisskeyのアイコンが追加されます。
このアイコンをタップすることで、Misskeyはアプリモードで起動します。

View File

@ -172,6 +172,8 @@ _docs:
_toc: _toc:
title: "このページの内容" title: "このページの内容"
toPageTop: "ページ上部に戻る" toPageTop: "ページ上部に戻る"
_steppedGuide:
selectCourse: "ガイドを選ぶ"
_blog: _blog:
title: "ブログ" title: "ブログ"

View File

@ -1,7 +1,12 @@
<template> <template>
<div class="grid docs-main"> <div
class="grid"
:class="{
[$style.docsLayoutWithAsideToc]: shouldShowToc,
}"
>
<div class="lg:hidden sticky top-16 -mx-6 -mt-6 overflow-y-auto bg-slate-50 dark:bg-slate-900 z-[9890] border-b dark:border-slate-700 text-sm flex items-start"> <div class="lg:hidden sticky top-16 -mx-6 -mt-6 overflow-y-auto bg-slate-50 dark:bg-slate-900 z-[9890] border-b dark:border-slate-700 text-sm flex items-start">
<details v-if="data?.body && (data.body.toc?.links ?? []).length > 0" class="peer order-2 flex-grow flex-shrink-0" :open="openState"> <details v-if="shouldShowToc && data?.body && (data.body.toc?.links ?? []).length > 0" class="peer order-2 flex-grow flex-shrink-0" :open="openState">
<summary class="py-4 cursor-pointer"> <summary class="py-4 cursor-pointer">
{{ $t('_docs._toc.title') }} {{ $t('_docs._toc.title') }}
</summary> </summary>
@ -13,15 +18,75 @@
<AsideNavIco class="block w-5 h-5" /> <AsideNavIco class="block w-5 h-5" />
</button> </button>
</div> </div>
<div class="pt-6 p-0 sm:p-12 lg:p-6 w-full overflow-x-hidden"> <div class="pt-6 p-0 sm:p-12 lg:p-6 w-full">
<template v-if="data?.body"> <Tip v-if="locale !== 'ja'" class="mb-6" :label="$t('_i18n._missing.title')">
<Tip v-if="locale !== 'ja'" class="mb-6" :label="$t('_i18n._missing.title')"> <I18nT scope="global" keypath="_i18n._missing.description" tag="p">
<I18nT scope="global" keypath="_i18n._missing.description" tag="p"> <template #link>
<template #link> <GNuxtLink class="font-bold hover:underline underline-offset-2" to="https://crowdin.com/project/misskey-hub" target="_blank">{{ $t('_i18n._missing.linkLabel') }}</GNuxtLink>
<GNuxtLink class="font-bold hover:underline underline-offset-2" to="https://crowdin.com/project/misskey-hub" target="_blank">{{ $t('_i18n._missing.linkLabel') }}</GNuxtLink> </template>
</template> </I18nT>
</I18nT> </Tip>
<div v-if="data?._TYPE_ === 'STEPPED_GUIDE'" class="grid" :class="$style.steppedGuideRoot">
<div class="markdown-body w-full lg:col-span-2 mb-6">
<h1>{{ data.title }}</h1>
<MDC :value="data?.body" />
<div>
<label for="guideSelector" class="block">{{ $t('_docs._steppedGuide.selectCourse') }}</label>
<select id="guideSelector" class="form-select" :disabled="data.guides.length <= 1" v-model="guideIndex">
<option v-for="guide, i in data.guides" :value="i">{{ guide.title }}</option>
</select>
</div>
</div>
<Tip v-if="data.guides[guideIndex]?.description" class="mb-6 lg:col-span-2 markdown-body">
<MDC :value="data.guides[guideIndex].description" />
</Tip> </Tip>
<div>
<ol class="relative before:absolute before:left-[13px] before:top-3.5 before:w-0.5 before:h-[calc(100%-.875rem)] before:rounded-full before:bg-gray-300 space-y-8">
<li
v-for="(step, i) in data.guides[guideIndex].steps"
:key="i"
:id="`steppedGuideSection_${guideIndex}_${i}`"
class="ml-7 relative flex items-center"
:class="{
'lg:min-h-[calc(100vh-4rem)] steppedGuideSection': (data.guides[guideIndex]._LAYOUT_TYPE_ === 'IMAGE_PORTRAIT_FIXED'),
}"
>
<div>
<div class="flex items-center space-x-4 mb-4">
<div class="w-7 h-7 rounded-full flex-shrink-0 -ml-7 font-bold leading-7 text-center text-white bg-accent-600 ring-4 ring-white">{{ i + 1 }}</div>
<h3 class="font-bold text-lg">{{ step.title }}</h3>
</div>
<div class="ml-4">
<img
v-if="step?.image"
:src="`/img/docs/${slugs.join('/')}/${step.image}`"
class="w-auto h-full mx-auto max-h-96 mb-4"
:class="{
'lg:hidden': (data.guides[guideIndex]._LAYOUT_TYPE_ === 'IMAGE_PORTRAIT_FIXED'),
}"
/>
<MDC v-if="step.description" :value="step.description" class="markdown-body" />
</div>
</div>
</li>
</ol>
</div>
<div class="hidden lg:block">
<div class="sticky top-16 h-[calc(100vh-4rem)] p-6">
<div v-if="data.guides[guideIndex]._LAYOUT_TYPE_ === 'IMAGE_PORTRAIT_FIXED'" class="relative h-full">
<Transition
:enterActiveClass="$style.steppedGuideImage_enterActive"
:leaveActiveClass="$style.steppedGuideImage_leaveActive"
:enterFromClass="$style.steppedGuideImage_enterFrom"
:leaveToClass="$style.steppedGuideImage_leaveTo"
>
<img v-if="currentStep?.image" :src="`/img/docs/${slugs.join('/')}/${currentStep.image}`" :key="`steppedGuideSection_${currentStep.image}`" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-auto" />
</Transition>
</div>
</div>
</div>
</div>
<template v-else-if="data?.body">
<ContentRenderer v-if="data.body.children.length > 0" :value="data" class="markdown-body w-full mb-6"> <ContentRenderer v-if="data.body.children.length > 0" :value="data" class="markdown-body w-full mb-6">
</ContentRenderer> </ContentRenderer>
<div class="mt-8 mb-4 flex flex-wrap justify-end gap-3"> <div class="mt-8 mb-4 flex flex-wrap justify-end gap-3">
@ -37,7 +102,7 @@
</div> </div>
</template> </template>
</div> </div>
<div class="hidden lg:block text-sm"> <div v-if="shouldShowToc" class="hidden lg:block text-sm">
<div class="sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto py-6 pl-6"> <div class="sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto py-6 pl-6">
<h3 class="font-bold mb-6">{{ $t('_docs._toc.title') }}</h3> <h3 class="font-bold mb-6">{{ $t('_docs._toc.title') }}</h3>
<DocsTocLinks v-if="data?.body" :links="data?.body.toc?.links" :max-depth="data?.maxTocDepth ?? undefined" class="break-words" /> <DocsTocLinks v-if="data?.body" :links="data?.body.toc?.links" :max-depth="data?.maxTocDepth ?? undefined" class="break-words" />
@ -76,6 +141,41 @@ if (!data.value) {
throw createError({ statusCode: 404, statusMessage: 'page not found', fatal: true }); throw createError({ statusCode: 404, statusMessage: 'page not found', fatal: true });
} }
const shouldShowToc = computed(() => data.value?._TYPE_ !== 'STEPPED_GUIDE');
const guideIndex = ref<number>(0);
const { mainHeading, updateHeadings } = useScrollspy({
rootMargin: '-64px 0px 0px 0px',
threshold: 0.5,
});
const currentStep = computed(() => {
if (!mainHeading.value || data.value?._TYPE_ !== 'STEPPED_GUIDE') return null;
const [currentGuideIndex, currentStepIndex] = mainHeading.value.split('_').slice(1).map((v) => parseInt(v, 10));
return data.value.guides[currentGuideIndex].steps[currentStepIndex];
});
onMounted(() => {
if (data.value?._TYPE_ === 'STEPPED_GUIDE') {
// User Agent
if (data.value.guides.some((g) => g._AUTOSELECT_TYPE_)) {
const ua = navigator.userAgent.toLowerCase();
if (ua.includes('iphone') || ua.includes('ipad')) {
guideIndex.value = data.value.guides.findIndex((g) => g._AUTOSELECT_TYPE_ === 'OS_IOS');
} else if (ua.includes('android')) {
guideIndex.value = data.value.guides.findIndex((g) => g._AUTOSELECT_TYPE_ === 'OS_ANDROID');
}
}
nextTick(() => {
watch(guideIndex, () => {
updateHeadings([
...document.querySelectorAll('.steppedGuideSection'),
]);
}, { immediate: true });
})
}
});
if (data.value._file && /index\.[a-z]+$/.test(data.value._file)) { if (data.value._file && /index\.[a-z]+$/.test(data.value._file)) {
route.meta.__isDocsIndexPage = true; route.meta.__isDocsIndexPage = true;
} }
@ -86,14 +186,29 @@ if (data.value.description) {
} }
</script> </script>
<style scoped> <style module>
.docs-main { .docsLayoutWithAsideToc,
.steppedGuideRoot {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.steppedGuideImage_enterActive,
.steppedGuideImage_leaveActive {
transition: opacity 300ms;
}
.steppedGuideImage_enterFrom,
.steppedGuideImage_leaveTo {
opacity: 0;
}
@screen lg { @screen lg {
.docs-main { .docsLayoutWithAsideToc {
grid-template-columns: 1fr 14rem; grid-template-columns: 1fr 14rem;
} }
.steppedGuideRoot {
grid-template-columns: 1fr 20rem;
}
} }
</style> </style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -1,18 +1,41 @@
// Misskey Docs Frontmatter Types // Misskey Docs Frontmatter Types
import type { ParsedContent, MarkdownParsedContent } from '@nuxt/content/dist/runtime/types'; import type { ParsedContent, MarkdownParsedContent, MarkdownRoot } from '@nuxt/content/dist/runtime/types';
/** /**
* Docs Frontmatter * Docs Frontmatter
* *
* `/content/<lang>/docs/` * `/content/<lang>/docs/`
*/ */
export interface MiDocsParsedContent extends MarkdownParsedContent { interface MiDocsParsedContentMd extends MarkdownParsedContent {
_TYPE_: undefined;
/** もくじの見出しをさかのぼる限度 */ /** もくじの見出しをさかのぼる限度 */
maxTocDepth?: number; maxTocDepth?: number;
/** 前へ・次へボタンの階層考慮を無視 */ /** 前へ・次へボタンの階層考慮を無視 */
ignoreDirBasedNav?: boolean; ignoreDirBasedNav?: boolean;
} };
/**
*
*/
interface MiDocsParsedContentSteppedGuide extends ParsedContent {
_TYPE_: 'STEPPED_GUIDE';
guides: {
_AUTOSELECT_TYPE_?: 'OS_ANDROID' | 'OS_IOS';
_LAYOUT_TYPE_?: 'IMAGE_PORTRAIT_FIXED';
title: string;
description?: string | MarkdownRoot;
steps: {
title: string;
description: string | MarkdownRoot;
image?: string;
}[];
}[];
};
export type MiDocsParsedContent = MiDocsParsedContentMd | MiDocsParsedContentSteppedGuide;
/** /**
* Blog Frontmatter * Blog Frontmatter