1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【ハンズオン資料】MonacaとNCMBで単語帳アプリを作ってみよう

Posted at

Monacaを使って何かアプリを作ってみたいと思っても、いざとなるとアイデアが出てこないかもしれません。また、最初から大きなアプリを作ろうと思うと、何から手を付けて良いのか分からないことでしょう。

そこで、この記事では手順を踏んで簡単なアプリを開発してみます。最初の一歩として、ぜひチャレンジしてみてください。

今回はNCMB(ニフクラmobile backend)と組み合わせて、単語帳アプリを開発します。

記事

こちらのハンズオン資料は下記のMonacaプレス記事をベースにしています。

ベースコード

下記のURLをMonaca IDEにてインポートしてください。

https://github.com/NCMBMania/Monaca_WordList_App/archive/refs/heads/main.zip

利用している技術、ライブラリ

このアプリで利用している技術やライブラリは次の通りです。

仕様

このアプリの画面は次の通りです。

目的

単語帳アプリは英語に限らず、新しい言語を覚えるのによく使われる単語帳をアプリで再現しています。単語帳は複数作成可能で、その中に単語を登録します。トレーニングモードでは、最初は単語しか表示されません。タップで回答(日本語)を表示します。単語には画像も紐付けられます。画像を使うことで、単語だけでなくイメージと結びつけた記憶を促せる仕組みです。

単語帳一覧画面

作成済みの単語帳を一覧表示する画面です。

単語帳作成画面

新しい単語帳を作成します。作成すると単語帳一覧画面に戻ります。

単語一覧画面

単語帳に登録されている単語を一覧表示します。単語をタップすると、単語編集画面に遷移します。単語を右にスワイプすると、単語の削除用アクションボタンが表示されます。

単語登録画面

新しい単語を登録します。単語はテキストエリアで、一行ごとに元の単語と意味の二つをカンマ区切りで入力することでまとめて一気に登録できます。

単語編集画面

登録した単語を編集する画面です。ここでは単語に紐付けて画像をアップロードできます。アップロードした画像はトレーニング画面で表示されます。

学習画面

単語帳を覚える画面です。最初は元の言葉しか表示されません。タップで回答が表示されます。スワイプまたは左右をタップすることで次の単語に移動します。記憶したと思ったら、記憶したをタップします。これでデータは次回以降は表示されなくなります。

答えを表示した画面は次のようになります。

ユーザーデータの権限

今回は個人用を想定し、認証は設けていません。データはオープン(誰でも読み書き可能)として保存しています。

ニフクラ mobile backendのキーを取得

ニフクラ mobile backendにユーザー登録、またはログインしてアプリを作成します。その結果として、次の2つのキーが取得できます。

  • アプリケーションキー
  • クライアントキー

ライブラリのインストール

外部ライブラリとして、ncmbを追加します。JavaScriptライブラリですので、JS/CSSコンポーネントの追加と削除から行ってください。

読み込むファイルとしてncmb.min.jsをチェックするのを忘れないでください。

単語一覧をエクスポートする機能を実装するためにFileSaver.jsを利用しています。これもNCMB同様に file-saver を検索して追加してください。

NCMBの初期化

www/js/app.js を開いて、下記のようにニフクラmobile backendを初期化する処理を追加します。上記で紹介したアプリケーションキーとクライアントキーを適用してください。

const applicationKey = 'YOUR_APPLICATION_KEY';
const clientKey = 'YOUR_CLIENT_KEY';
const ncmb = new NCMB(applicationKey, clientKey);

ルーティングについて

どのURLへ擬似的にアクセスした際にどの画面を出すかは js/routes.js で定義しています。

const routes = [
  // 最初に読み込まれるHTML
  {
    path: '/',
    url: './index.html',
  },
  // 最初の画面(単語帳一覧)
  {
    path: '/home/',
    componentUrl: './pages/home.html',
  },
  // 単語帳作成画面
  {
    path: '/word_books/new',
    componentUrl: './pages/word_books/new.html',
  },
  // 単語帳詳細(単語一覧)
  {
    path: '/word_books/:objectId',
    componentUrl: './pages/word_books/show.html',
  },
  // 学習画面
  {
    path: '/word_books/:objectId/training',
    componentUrl: './pages/word_books/training.html',
  },
  // 単語登録画面
  {
    path: '/word_books/:objectId/words/new',
    componentUrl: './pages/words/new.html',
  },
  // 単語編集画面
  {
    path: '/word_books/:objectId/words/:wordId/edit',
    componentUrl: './pages/words/edit.html',
  },
];

index.html の紹介

Monacaアプリで一番初めに読み込まれる www/index.html では画面の実装はせず、home.htmlを読み込む指定だけしています。

<div id="app">
  <div class="views safe-areas">
    <div id="view-home" class="view view-main view-init" data-name="home" data-url="/home/">
    </div>
  </div>
</div>

単語帳一覧画面

単語帳一覧画面 home.html はNCMBから取得した単語帳の一覧を表示します。また、単語帳作成画面へのリンクも用意しておきます。

<div class="row text-align-center">
  <div class="col-auto">
    <div class="block-title">学習する単語帳を選択してください</div>
  </div>
</div>
<div class="row">
  <div class="col text-align-center" id="no_word_book">
    <div>まだ単語帳がありません</div>
    <a href="/word_books/new" class="item-link list-button">
      <div class="item-inner">
        <div class="item-title">単語帳を作成する</div>
      </div>
    </a>
  </div>
  <div id="exist_word_book" class="col-auto">
    <div class="list">
      <ul>
      </ul>
    </div>
  </div>
</div>

単語帳の取得

単語帳の一覧を取得する処理は画面を表示する度に行いたいので pageBeforeIn イベントで実行します。取得した単語帳が0件だった場合と、あった場合で表示するDOMを変えています。

// 記述済み
pageBeforeIn: async function(e, page) {
  // 登録されている単語帳を取得
  this.wordBooks = await this.getWordBooks();
  // 単語帳の有無で画面の表示を切り分け
  if (this.wordBooks.length === 0) {
    $$('#exist_word_book').hide();
    $$('#no_word_book').show();
  } else {
    $$('#no_word_book').hide();
    $$('#exist_word_book').show();
    // 単語帳がある場合は画面に表示
    this.showWordBooks(this.wordBooks);
    // 表示した単語帳に、単語帳を選択した際のイベントを設定
    $$('.show-word-book').on('click', this.showWordBook);
  }
},

getWordBooks メソッドは次のようになります。

記述してください

getWordBooks: async function() {
  const { ncmb } = this.$app.data;
  // データストアを準備
  const WordBook = ncmb.DataStore('WordBook');
  // データを取得
  return await WordBook.fetchAll();
}

そしてこの取得した単語帳を showWordBooks メソッドで表示します。これは単純にHTMLを構築しているだけです。

// 記述済み:単語帳一覧を表示
showWordBooks: function(wordBooks) {
  $$('#exist_word_book ul').html(wordBooks.map(wb => {
    return `<li><a href="#" data-object-id="${wb.objectId}" class="show-word-book">
      <div class="item-content">
          <div class="item-media">
            <i class="f7-icons">book</i>
          </div>
          <div class="item-inner">
              <div class="item-title">
                ${wb.name}
              </div>
              <div class="item-after">
                ${wb.words_count || 0 }単語
              </div>
          </div>
      </div>
    </a></li>`;
  }).join(''));        
},

画面に表示した後、 $$('.show-word-book') に対してタップイベント showWordBook を設定しています。これは単語帳詳細(単語一覧)画面へ遷移する処理です。

// 記述済み:選択された単語帳に移動する処理
showWordBook: function(e) {
  // 選択された単語帳のobjectIdを取得
  const objectId = $$(e.target).parents('a').data('object-id');
  if (!objectId) return;
  // objectIdで対象の単語帳を特定
  const wordBook = this.wordBooks.filter(w => w.objectId === objectId)[0];
  // 単語帳データを次の画面に引き継いで移動
  this.$router.navigate(
    `/word_books/show`,
    {
      context: { wordBook }
    }
  )
},

単語帳登録画面

では次に単語帳登録画面 pages/word_books/new.html について説明します。ここでは単語帳の名前を入力する画面を表示します。

<div class="page-content">
  <div class="row">
    <div class="col-auto text-align-center">
      <div class="block-title">単語帳の名前を決めてください</div>
    </div>
    <div class="col-auto">
      <div class="list">
        <ul>
          <li class="item-content item-input">
            <div class="item-inner">
              <div class="item-title item-label">単語帳の名前</div>
              <div class="item-input-wrap">
                <input type="text" name="name">
                <span class="input-clear-button"></span>
              </div>
            </div>
          </li>
        </ul>
      </div>
      <a href="#" class="item-link list-button" @click="saveWordBook">
        <div class="item-inner">
          <div class="item-title">単語帳を作成する</div>
        </div>
      </a>
    </div>
  </div>
</div>

この時 @click を使ってボタンタップ時に saveWordBook メソッドを呼び出す指定をしています。

saveWordBook メソッドについて

saveWordBookは入力された単語帳名を、NCMBのデータストアに保存します。クラス名(データベースのテーブル相当)はWordBookとしています。単語帳を作成した時点では単語はないので words_count は0として初期化しています。保存後はFramework7のルーティング機能を使って前の画面に戻っています。

記述してください

// 単語帳を作成する処理
saveWordBook: async function(e) {
  const { ncmb } = this.$app.data;
  // 単語帳のインスタンスを作成
  const wordBook = new (ncmb.DataStore('WordBook'));
  // 必要なデータをセットして保存
  await wordBook
    .set('name', $$('[name="name"]').val())
    .set('words_count', 0)
    .save();
  // 前の画面に戻る
  this.$router.back();
},

これで単語帳の作成処理が完成です。

単語登録画面について

単語登録画面 words/new.html は指定した単語帳に対して単語を登録します。1単語ずつ登録するのは大変なので、改行毎に区切ってまとめて登録できるようにしています。各行で覚えたい単語、そして日本語(または答え)をカンマ区切りでつないで入力します。

<div class="page-content">
  <div class="row">
    <div class="col-auto text-align-center">
      <div class="block-title">単語とその訳をカンマ区切りで入力</div>
      <div class="block">
        <p>単語は改行を使って複数登録できます</p>
      </div>
    </div>
    <div class="col-auto">
      <div class="list">
        <ul>
          <li class="item-content item-input">
            <textarea id="words" class="resizable" placeholder="Apple,リンゴ"></textarea>
          </li>
        </ul>
      </div>
      <a href="#" class="item-link list-button" @click="saveWord">
        <div class="item-inner">
          <div class="item-title">単語を登録する</div>
        </div>
      </a>
    </div>
  </div>
</div>

単語を登録するボタンをタップした際の処理は saveWord を呼び出す指定をしています。ちょっと長いですが、行っていることは入力された内容を改行毎に区切って、その行毎に単語データ(Wordクラス)として作成しています。そして単語データはNCMBのリレーション機能に入れています。このリレーション機能はNCMBのデータストアで各データの繋がり(リレーション)を表現する機能です。

このリレーションによって、単語帳と単語とが1対Nの関係で保存されます。

記述してください

saveWord: async function(e) {
  const { ncmb } = this.$app.data;
  const ary = $$('#words').val()
    .split(/\r\n|\n|\r/)
    .filter(s => s !== '');
  // エラーチェック
  if (ary.length === 0) {
    alert('単語がありません');
    return;
  }
  const relation = new ncmb.Relation;
  const Word = ncmb.DataStore('Word');
  // 行毎に処理
  ary.forEach((str, i) => {
    // エラーチェック
    const words = str.split(',');
    if (words.length !== 2) {
      alert(`内容が不正です (${i + 1}行目。${str})`);
      return;
    }
    // 単語データの作成
    const word = new Word;
    word
      .set('remember', false)
      .set('original', words[0])
      .set('japanese', words[1]);
    // リレーションに追加
    relation.add(word);
  });
  // 単語一覧のデータを紐付けで単語帳を保存
  await this.wordBook
    .set('words', relation)
    .update();
  // 単語数をカウントする共通関数
  await this.$app.methods.updateWordCount(this.wordBook);
  // 前の画面に戻る
  this.$router.back();
},

this.$app.methods.updateWordCountjs/app.js に定義しているメソッドで、単語の数を数えて単語帳データに反映しています。これは単語数が変わる処理で随時実行しています。

記述してください

updateWordCount: async function(wordBook) {
  // NCMBのWordクラス(DBでいうテーブル相当)を用意
  const Word = ncmb.DataStore('Word');
  // 該当する単語帳内で、rememberフラグが立っていないデータを検索
  const words = await Word
    .relatedTo(wordBook, 'words')
    .equalTo('remember', false)
    .count()                       // 結果行数を取得する
    .fetchAll();
  await wordBook
    .set('words_count', words.count) // 結果行数を反映
    .update();
},

単語一覧画面について

単語一覧画面 pages/word_books/show.html について紹介します。この画面では前の画面(単語帳一覧画面)から送られてきた単語帳データを使って、単語一覧を取得、表示します。

<div class="page-content">
  <div class="row">
    <div class="col text-align-center" id="no_word">
      <div>まだ単語がありません</div>
      <a href="#" class="item-link list-button new-word">
        <div class="item-inner">
          <div class="item-title">単語を登録する</div>
        </div>
      </a>
    </div>
    <div id="exist_word" class="col-auto">
      <div class="row justify-content-center">
        <p>
          <button class="button button-fill" @click="startTraining">
            トレーニングを開始する
          </button>
        </p>
      </div>
      <div class="list">
        <ul>
        </ul>
      </div>
      <p>
        <button class="button button-fill" @click="export">
          エクスポート
        </button>
      </p>
    </div>
  </div>
</div>

単語一覧の取得

単語一覧の取得法は次のようになります。保存した際に説明した通り、これはNCMBのリレーションを使っています。データを取得する際には relatedTo メソッドを使います。この処理はほかの画面でも使うので、共通関数として js/app.js にて定義しています。

記述してください

getWords: async function(wordBook) {
  // NCMBのWordクラス(DBでいうテーブル相当)を準備
  const Word = ncmb.DataStore('Word');
  // 該当する単語帳内で、rememberフラグが立っていないデータを検索
  // 最大1000件
  return await Word
    .relatedTo(wordBook, 'words')
    .equalTo('remember', false)
    .limit(1000)
    .fetchAll();
}

呼び出す際には this.$app.methods.getWords(this.wordBook) のようにします。

単語一覧の表示

取得した単語一覧を表示するのは showWords メソッドです。この処理は単純にHTMLを作成して表示しているだけです。

// 記述済み:単語一覧を表示する
showWords: function(words) {
  $$('#exist_word ul').html(words.map(w => {
    return `<li class="swipeout">
      <div class="swipeout-content">
        <a href="#" data-object-id="${w.objectId}" class="show_word">
          <div class="item-content">
              <div class="item-media">
                <i class="f7-icons">textformat</i>
              </div>
              <div class="item-inner">
                  <div class="item-title">
                    ${w.original}
                  </div>
                  <div class="item-after">
                    ${w.japanese }
                  </div>
              </div>
          </div>
        </a>
      </div>
      <div class="swipeout-actions-left">
        <a href="#" class="swipeout-delete" data-object-id="${w.objectId}">Delete</a>
      </div>
    </li>`;
  }).join(''));
}

この一覧で行っているのは次の2つの処理です。

  • 左にスワイプした時に削除用ラベルを表示
  • 単語をタップした時に単語詳細画面に遷移

HTML表示後に各イベントを設定しています。

$$('.swipeout-delete').on('click', this.deleteWord);
$$('.show_word').on('click', this.showWord);

左にスワイプした時に削除用ラベルを表示

スワイプの機能はFramework7の機能として提供されています。ここでは削除用ラベルをタップした際の処理 deleteWord を紹介します。各リストにユニークなID(objectId)を出力しておくことで、処理対象になる単語データを取得しています。そしてデータストアの delete メソッドを使ってデータを削除しています。

記述してください

// 単語を削除する処理
deleteWord: function(e) {
  // 削除対象のobjectIdを取得
  const objectId = $$(e.target).data('object-id');
  // 削除対象の単語データを取得
  const word = this.words.filter(w => w.objectId === objectId)[0];
  // 単語があれば削除実行
  if (word) word.delete();
  // 単語数をカウントする共通関数を実行
  this.$app.methods.updateWordCount(this.wordBook);
},

単語をタップした時に単語編集画面に遷移

この処理も削除処理と同じで、対象になる単語データを取得した上で画面遷移しています。

// 記述済み:単語画面に移動する処理
showWord: function(e) {
  // 対象になる単語データを取得
  const objectId = $$(e.target).parents('a').data('object-id');
  const word = this.words.filter(w => w.objectId === objectId)[0];
  // 単語がなければ終了
  if (!word) return;
  // 単語があれば移動
  this.$router.navigate(
    `/word_books/${this.wordBook.objectId}/words/${word.objectId}/edit`,
    {
      context: {
        wordBook: this.wordBook,
        word
      }
    }
  );
},

単語編集画面について

単語帳詳細(単語一覧)画面から単語編集画面 pages/words/edit.html に遷移した時には、まず単語情報を表示しています。

<div class="page-content">
  <div class="row">
    <div class="col-auto text-align-center">
      <div class="block-title">単語を編集してください</div>
      <div class="block">
        <p>単語の追加情報として画像も登録できます</p>
      </div>
    </div>
    <div class="col-auto">
      <div class="list no-hairlines-md">
        <ul>
          <li class="item-content item-input">
            <div class="item-inner">
              <div class="item-input-wrap">
                <input type="text" name="original" value="{{word.original}}">
              </div>
            </div>
          </li>
          <li class="item-content item-input">
            <div class="item-inner">
              <div class="item-input-wrap">
                <input type="text" name="japanese" value="{{word.japanese}}">
              </div>
            </div>
          </li>
          <li class="item-content item-input">
            <div class="item-inner">
              <div class="item-input-wrap">
                <input type="file" accept="image/*" name="upload" @change="uploadImage" />
              </div>
            </div>
          </li>
          <li class="item-content show-image">
            <img src="{{ word.image }}" class="image" />
            <input type="hidden" name="image" value="{{word.image}}" />
          </li>
        </ul>
      </div>
      <a href="#" class="item-link list-button" @click="updateWord">
        <div class="item-inner">
          <div class="item-title">単語を更新する</div>
        </div>
      </a>
    </div>
  </div>
</div>

画像を紐付ける

ファイルアップロードには画像を指定した際のイベント(@change)として uploadImage メソッドを指定しています。この処理ではNCMBのファイルストアを使って画像をアップロードしています。アップロード後、画像の内容を読み込んで、画面に表示しています。

記述してください

// 写真をアップロードして、そのファイル名をhiddenに適用する処理
uploadImage: async function(e) {
  // NCMBで写真アップロード
  const {ncmb} = this.$app.data;
  const d = new Date();
  const file = e.target.files[0];
  // 写真名に重複が発生しないように現在時刻をファイル名に追加
  const name = `${d.getTime()}_${file.name}`;
  // ファイルアップロード
  await ncmb.File.upload(name, file);
  // 画像データを取得(ローカルファイルのもの)
  const image = await this.$app.methods.loadImage(file);
  // 取得した画像データを表示
  $$('.image').attr('src', image);
  $$('[name="image"]').val(name);
  $$('.show-image').show();
}

画像データを読み込む this.$app.methods.loadImage は他の画面でも使うので js/app.js に定義しています。これはFileReaderを使ってデータを読み込む処理をPromise化しているだけです。

// 記述済み
loadImage: async function(file) {
  return new Promise((res, rej) => {
    const fr = new FileReader;
    fr.onload = (result) => {
      res(fr.result);
    }
    fr.readAsDataURL(file);
  });
},

単語情報を更新する

単語情報を更新する処理はボタンをタップした際のイベントとして updateWord を指定しています。この処理は対象になるNCMBの単語データを入力内容に合わせて更新しているだけです。

記述してください

// 単語データを更新する処理
updateWord: async function(e) {
  await this.word
    .set('original', $$('[name="original"]').val())
    .set('japanese', $$('[name="japanese"]').val())
    .set('image', $$('[name="image"]').val())
    .update();
  // 前の画面に戻る
  this.$router.back();
},

最初に画像を表示する

単語編集画面を表示した際の処理として、画像がある場合は表示しています。ファイルのダウンロードはNCMBのdownloadメソッドを使っています。バイナリデータなのでblobを指定します。この結果はファイルアップロード時と同じように loadImage でdataURI形式に変換できます。その内容をimgタグに反映しています。

画像をダウンロードの下に記述してください。他は記述済み

pageBeforeIn: async function(e, page) {
  // 最初にimgタグを隠す
  $$('.show-image').hide();
  // NCMBから画像データをBlobで取得
  const { ncmb } = this.$app.data;
  if (!this.word.image) return;

  // 画像をダウンロード
  const file = await ncmb.File.download(this.word.image, 'blob');

  // BlobをdataURIに変換
  const image = await this.$app.methods.loadImage(file);
  // 変換結果をimgタグに反映
  $$('.image').attr('src', image);
  // imgタグを表示
  $$('.show-image').show();
}

学習画面について

最後に学習画面についてです。これは pages/word_books/training.html になります。

<div class="page-content">
  <div class="row">
    <div class="col-auto text-align-center">
      <div class="block-title">タップで回答が表示されます</div>
      <div class="block">
        <p>スワイプで次の単語を表示します</p>
      </div>
    </div>
  </div>
  <div class="row">
    <div class="col-auto">
      <span data-progress="0" class="progressbar" id="inline-progressbar"></span>
    </div>
  </div>
  <div class="row">
    <div class="col-auto">
      <div class="swiper-container swiper-init">
        <div class="swiper-wrapper">
        </div>
      </div>
    </div>
  </div>
</div>

この画面では単語の一覧を読み込み、スワイプできるUIに適用しています。スワイプ機能はFramework7標準のものを使っているので、ここでは詳細は解説しません。

// 記述済み
async mounted() {
  // 学習する単語一覧を取得
  this.words = await this.$app.methods.getWords(this.wordBook);
  // スワイプ用ライブラリを準備
  this.swiper = this.$app.swiper.create('.swiper-container', {});
  // 単語を表示
  await this.showWords();
  // 前に戻る処理
  $$('.previous').on('click', this.movePrevious);
  // 次に進む処理
  $$('.next').on('click', this.moveNext);
  // 回答を表示する処理
  $$('.open').on('click', this.showAnswer);
  // 記憶したボタンを押した際の処理
  $$('.remember').on('click', this.changeToRemember);
},

筆者環境ではスワイプがうまく動かなかったため、単語の両端をタップして次の単語、または前の単語に移動するようにしました。また、答えをチェックというボタンをタップした際の処理 showAnswer は単純にDOM操作しているだけです。この3つの処理は次のようになります。

// 記述済み:前に戻る処理
movePrevious: function(e) {
  // 最初なら何もしない
  if (this.swiper.realIndex === 0) return;
  // 前のスライドに移動
  this.swiper.slidePrev();
  // プログレスバーを更新
  this.updateProgress(this.swiper.realIndex - 1, swiper.slides.length);
},
// 次に進む処理
moveNext: function(e) {
  // 最後なら何もしない
  if (this.swiper.realIndex === this.words.length) return;
  // 次のスライドに移動
  this.swiper.slideNext();
  // プログレスバーを更新
  this.updateProgress(this.swiper.realIndex + 1, this.swiper.slides.length);
},
// 回答を表示する処理
showAnswer: function(e) {
  $$(e.target).hide();
  $$(e.target).parents('.content').find('.answer').show();
},

答えを表示して、記憶したボタンをタップした際には単語データの remember フィールドを true にしています。こうすることで、学習済みであるとして次回以降出ないようにしています。記憶した単語は一覧から削除します。

記憶完了のフラグを立てるの下に記述してください。他は記述済み

// 記憶したボタンを押した際の処理
changeToRemember: async function(e) {
  // 対象になる単語データを取得
  const objectId = $$(e.target).parents('a').data('object-id');
  const word = this.words.filter(w => w.objectId === objectId)[0];
  // 記憶完了のフラグを立てる
  await word.set('remember', true).update();
  // 単語数を更新
  this.$app.methods.updateWordCount(this.wordBook);
  // スライドを削除
  this.swiper.removeSlide(this.swiper.activeIndex);
}

ここまでで学習画面の処理が完了となります。

まとめ

今回はFramework7を使って単語学習アプリを作成しました。NCMBはデータストアのCRUD(作成、取得、更新、削除)とファイルストアのアップロードとダウンロード機能を使っています。他にも認証やプッシュ通知といった機能がありますので、ぜひ使ってみてください。

Framework7とMonacaを組み合わせることで、多彩な機能を持ったUIのアプリを開発できるようになります。ぜひトライしてみてください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?