feat(docs): ステップバイステップガイド (#145)
* feat(docs): ステップバイステップガイドを追加 * fix * fix * docs * add docs
@ -1,32 +1,47 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Scrollspy allows you to watch visible headings in a specific page.
|
||||
* Useful for table of contents live style updates.
|
||||
*/
|
||||
export const useScrollspy = () => {
|
||||
const observer = ref() as Ref<IntersectionObserver>
|
||||
const visibleHeadings = ref([]) as Ref<string[]>
|
||||
const activeHeadings = ref([]) as Ref<string[]>
|
||||
export const useScrollspy = (config?: IntersectionObserverInit) => {
|
||||
let observingElements: Element[] = [];
|
||||
|
||||
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) => {
|
||||
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[]) =>
|
||||
headings.forEach((heading) => {
|
||||
observer.value.observe(heading)
|
||||
})
|
||||
mainHeading.value = entries.reduce((max, entry) => (entry.intersectionRatio > max.intersectionRatio ? entry : max), entries[0]).target.id;
|
||||
}
|
||||
|
||||
function updateHeadings(headings: Element[]) {
|
||||
observingElements.forEach((el) => {
|
||||
observer.value?.unobserve(el);
|
||||
});
|
||||
|
||||
observingElements = headings;
|
||||
|
||||
observingElements.forEach((heading) => {
|
||||
observer.value?.observe(heading);
|
||||
});
|
||||
}
|
||||
|
||||
watch(visibleHeadings, (val, oldVal) => {
|
||||
if (val.length === 0) { activeHeadings.value = oldVal } else { activeHeadings.value = val }
|
||||
}, { deep: true })
|
||||
|
||||
// Create intersection observer
|
||||
onBeforeMount(() => (observer.value = new IntersectionObserver(observerCallback)))
|
||||
onBeforeMount(() => (observer.value = new IntersectionObserver(observerCallback, config)))
|
||||
|
||||
// Destroy it
|
||||
onBeforeUnmount(() => observer.value?.disconnect())
|
||||
@ -34,6 +49,7 @@ export const useScrollspy = () => {
|
||||
return {
|
||||
visibleHeadings,
|
||||
activeHeadings,
|
||||
mainHeading,
|
||||
updateHeadings
|
||||
}
|
||||
}
|
11
content/ja/docs/2.for-users/5.stepped-guides/1.index.md
Normal file
@ -0,0 +1,11 @@
|
||||
# ステップバイステップガイド
|
||||
|
||||
このセクションでは、Misskeyを利用する中で見られる複雑な操作を、一歩ずつ丁寧に解説しています。
|
||||
|
||||
:::warning
|
||||
|
||||
このセクションはベータ版です。内容が不完全である可能性があります。
|
||||
|
||||
:::
|
||||
|
||||
<MkIndex />
|
2
content/ja/docs/2.for-users/5.stepped-guides/_dir.yml
Normal file
@ -0,0 +1,2 @@
|
||||
title: "ステップバイステップガイド"
|
||||
description: "Misskeyの操作を一歩ずつ丁寧に解説しています。操作方法がわからなくなったらここをチェック!"
|
@ -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はアプリモードで起動します。
|
@ -172,6 +172,8 @@ _docs:
|
||||
_toc:
|
||||
title: "このページの内容"
|
||||
toPageTop: "ページ上部に戻る"
|
||||
_steppedGuide:
|
||||
selectCourse: "ガイドを選ぶ"
|
||||
|
||||
_blog:
|
||||
title: "ブログ"
|
||||
|
@ -1,7 +1,12 @@
|
||||
<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">
|
||||
<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">
|
||||
{{ $t('_docs._toc.title') }}
|
||||
</summary>
|
||||
@ -13,15 +18,75 @@
|
||||
<AsideNavIco class="block w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="pt-6 p-0 sm:p-12 lg:p-6 w-full overflow-x-hidden">
|
||||
<template v-if="data?.body">
|
||||
<Tip v-if="locale !== 'ja'" class="mb-6" :label="$t('_i18n._missing.title')">
|
||||
<I18nT scope="global" keypath="_i18n._missing.description" tag="p">
|
||||
<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>
|
||||
</template>
|
||||
</I18nT>
|
||||
<div class="pt-6 p-0 sm:p-12 lg:p-6 w-full">
|
||||
<Tip v-if="locale !== 'ja'" class="mb-6" :label="$t('_i18n._missing.title')">
|
||||
<I18nT scope="global" keypath="_i18n._missing.description" tag="p">
|
||||
<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>
|
||||
</template>
|
||||
</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>
|
||||
<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>
|
||||
<div class="mt-8 mb-4 flex flex-wrap justify-end gap-3">
|
||||
@ -37,7 +102,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</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">
|
||||
<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" />
|
||||
@ -76,6 +141,41 @@ if (!data.value) {
|
||||
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)) {
|
||||
route.meta.__isDocsIndexPage = true;
|
||||
}
|
||||
@ -86,14 +186,29 @@ if (data.value.description) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.docs-main {
|
||||
<style module>
|
||||
.docsLayoutWithAsideToc,
|
||||
.steppedGuideRoot {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.steppedGuideImage_enterActive,
|
||||
.steppedGuideImage_leaveActive {
|
||||
transition: opacity 300ms;
|
||||
}
|
||||
|
||||
.steppedGuideImage_enterFrom,
|
||||
.steppedGuideImage_leaveTo {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
.docs-main {
|
||||
.docsLayoutWithAsideToc {
|
||||
grid-template-columns: 1fr 14rem;
|
||||
}
|
||||
|
||||
.steppedGuideRoot {
|
||||
grid-template-columns: 1fr 20rem;
|
||||
}
|
||||
}
|
||||
</style>
|
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 81 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 62 KiB |
@ -1,18 +1,41 @@
|
||||
// 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 の型定義
|
||||
*
|
||||
* `/content/<lang>/docs/` のフロントマターはこの形式で入力してください
|
||||
*/
|
||||
export interface MiDocsParsedContent extends MarkdownParsedContent {
|
||||
interface MiDocsParsedContentMd extends MarkdownParsedContent {
|
||||
_TYPE_: undefined;
|
||||
|
||||
/** もくじの見出しをさかのぼる限度 */
|
||||
maxTocDepth?: number;
|
||||
|
||||
/** 前へ・次へボタンの階層考慮を無視 */
|
||||
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 の型定義
|
||||
|