🌏

Python、Django でブログ実装(こつこつ④検索、ページネーション実装、JavaScript で便利な機能)

に公開

今回は、検索機能やページネーションを実装し、それから JavaScript を使って、少し便利な機能を実装しよーと思います!
それでは、いきまっしょい♪

前回までの記事

前回
https://zenn.dev/animalz/articles/3ef5e6ad182da5
2回前
https://zenn.dev/animalz/articles/71771ba84fc3bd
3回前
https://zenn.dev/animalz/articles/30f1cd844a57bf

前回までのプロジェクト(コード)

GitHub: https://github.com/Animalyzm/mikoto_project
今回のプロジェクトは、django/blog です。
Git のコミット・メッセージ
前回: django_blog_3_comment
2回前: django_blog_2_bugfix
3回前: django_blog_1_markdown


今回やること

検索機能とページネーションを実装し、JavaScript でトップ・スクロール・ボタンと、記事のもくじリンクを実装します。


環境

Windows10、PyCharm、Python v3.13.2、Django v5.1.7


ディレクトリ構成

※これまでに変更・作成したファイルのみになります。

ディレクトリ構成
blog
├ media(以下、省略)
├ static(以下、省略)
├ templates(以下、省略)
├ blog
| ├ settings.py
| └ urls.py
└ app
  ├ templatetags/mytag.py
  ├ admin.py
  ├ context_processors.py.py
  ├ models.py
  ├ urls.py
  └ views.py

検索機能と検索結果にも機能するページネーションを実装する

ページネーションは、Udemy のレクチャーを参考にさせてもらいました。(後述)
views.py、templatetags/mytag.py、HTML の順に作成していきます。
ページ毎の記事件数は、paginate_by で設定し、検索用メソッド、カテゴリ・リンク用のメソッドを実装します。

app/views.py
...
class IndexView(generic.ListView):
    model = Post
    paginate_by = 5
    
    def get_queryset(self):
        queryset = Post.objects.order_by('-created_at')  # 降順
        keyword = self.request.GET.get('keyword')   # 検索入力キーワード
        if keyword:
            queryset = queryset.filter(
                Q(title__icontains=keyword) | Q(content__icontains=keyword)
            )  # icontains: 部分一致、大小文字区別なし
        return queryset
...
class CategoryView(generic.ListView):
    model = Post
    paginate_by = 5

    def get_queryset(self):
        category_pk = self.kwargs['category_pk']
        queryset = Post.objects.order_by('-created_at').filter(category__pk=category_pk)
        return queryset
...

テンプレート・タグを使い、検索時などでもページネーションが機能するように、URL を書き換える関数を実装します。
simple-tags: https://docs.djangoproject.com/ja/5.2/howto/custom-template-tags/#simple-tags

app/templatetags/mytag.py
from django import template

register = template.Library()  # タグやフィルター登録の初期化処理

@register.simple_tag
def url_replace(request, key, value):
    url_dict = request.GET.copy()
    print(url_dict, key, value)
    url_dict[key] = value
    return url_dict.urlencode()

ページネーションのレイアウトは、ブートストラップを使います。

snippets/page.html
{% load mytag %}
<nav aria-label="Page navigation">
  <ul class="pagination fw-bold">
    {% if page_obj.has_previous %}
      <li class="page-item">
        <a class="page-link bg-dark text-secondary page_border" href="?{% url_replace request 'page' page_obj.previous_page_number %}" aria-label="Previous">
          <span aria-hidden="true">«</span>
        </a>
      </li>
    {% endif %}

    {% if page_obj.number > 3 %}
      <li class="page-item">
        <a class="page-link bg-dark text-secondary page_border" href="?page=1" aria-label="First">
          1
        </a>
      </li>
    {% endif %}
    {% if page_obj.number > 4 %}
      <li class="page-item">
        <span class="page-link bg-dark text-secondary page_border"  aria-hidden="true">
          ...
        </span>
      </li>
    {% endif %}

    {% for link_page in page_obj.paginator.page_range %}
      {% if link_page == page_obj.number %}
        <li class="page-item">
          <a class="page-link bg-dark text-light page_border" href="?{% url_replace request 'page' link_page %}">
            {{ link_page }}
          </a>
        </li>
      {% elif link_page < page_obj.number|add:3 and link_page > page_obj.number|add:-3 %}
        <li class="page-item">
          <a class="page-link bg-dark text-secondary page_border" href="?{% url_replace request 'page' link_page %}">
            {{ link_page }}
          </a>
        </li>
      {% endif %}
    {% endfor %}

    {% if page_obj.number < page_obj.paginator.num_pages|add:-3 %}
      <li class="page-item">
        <span class="page-link bg-dark text-secondary page_border" aria-hidden="true">
          ...
        </span>
      </li>
    {% endif %}
    {% if page_obj.number < page_obj.paginator.num_pages|add:-2 %}
      <li class="page-item">
        <a class="page-link bg-dark text-secondary page_border" href="?page={{ page_obj.paginator.num_pages }}" aria-label="Last">
          {{ page_obj.paginator.num_pages}}
        </a>
      </li>
    {% endif%}

    {% if page_obj.has_next %}
      <li class="page-item">
        <a class="page-link bg-dark text-secondary page_border" href="?{% url_replace request 'page' page_obj.next_page_number %}" aria-label="Next">
          <span aria-hidden="true">»</span>
        </a>
      </li>
    {% endif %}
  </ul>
</nav>

リストページに、スニペットをはります。

app/post_list.html
...
      {% endfor %}
      <!-- ページネーション -->
      <span class="d-flex justify-content-center">
        {% include 'snippets/page.html' %}
      </span>
    </div>
    <!-- 768ピクセル以上 -->
...

検索ボックスのレイアウトにも、ブートストラップを使います。

app/base.html
...
              {% endif %}
            </ul>
            <!-- 検索 -->
            <form class="d-flex" method="GET" action="{% url 'app:index' %}">
              <input class="form-control fw-bold ms-3" type="search" placeholder="Search" aria-label="Search" name="keyword" style="background-color:#111; color: #ccc;">
              <button class="btn btn-outline-success mx-2 fw-bold" type="submit">Search</button>
            </form>
          </div>
        </div>
      </nav>
...

CSS でページネーションの表示を微調整します。

static/app/style.css
...
.page_border {
  border: 1px solid #555;
}
...

動作確認します。
とりあえず、カテゴリ別、検索ワード別の記事をたくさん書きます。
ページネーションの確認をするので、1ページあたりの表示件数を2に減らします。
まず、通常のホーム画面からのページネーションを確認します。
検索ボックスと、ページネーションの表示は、問題なく表示されています。

中間は省略しますが、5ページ目まで無事に表示されました。

カテゴリ・リンクをテストします。
それぞれのカテゴリを全ページ表示させた結果、ちゃんとカテゴリ別でページネーションが機能しました。
(画像はさぶ・カテゴリの最終ページです)

次は、検索でページネーションを試します。
「あにまる」を検索した結果、ちゃんと、ページネーションが機能しました。
(画像は検索結果の最終ページです)


トップ・スクロール・ボタンを実装する(JavaScript)

HTML(JavaScript)、CSS の順にファイルを作ります。

app/base.html
...
    </main>

    <!-- トップへ戻るボタン -->
    <div id="js-scroll-top" class="scroll-top">⇧</div>
    
    <!-- フッター -->
...
    <script> 
      // スクロールボタン
      const PageTopBtn = document.getElementById('js-scroll-top');
      PageTopBtn.addEventListener('click', () =>{
        window.scrollTo({
          top: 0,
          behavior: 'smooth'
        });
      });
      //スクロール時のイベントを追加
      window.addEventListener('scroll' , scroll_event );
      function scroll_event(){
        if(window.pageYOffset > 50){
          PageTopBtn.style.opacity = '0.5';
          // ホバー時設定
          PageTopBtn.addEventListener('mouseover', function() {
            PageTopBtn.style.opacity = 1;
            PageTopBtn.style.cursor = "pointer";
          });
          PageTopBtn.addEventListener('mouseleave', function() {
            PageTopBtn.style.opacity = 0.5;
          });
        }else	if(window.pageYOffset < 50){
          PageTopBtn.style.opacity = '0';
          // ホバー時設定
          PageTopBtn.addEventListener('mouseover', function() {
            PageTopBtn.style.opacity = 0;
            PageTopBtn.style.cursor = "default";
          });
          PageTopBtn.addEventListener('mouseleave', function() {
            PageTopBtn.style.opacity = 0;
          });
        }
      };
    </script>

スクロール・ボタンの表示を CSS で調整します。

static/app/style.css
...
/* スクロールボタン */
.scroll-top {
    position: fixed;
    right: 15px;
    bottom: 25px;
    z-index: 100;
    background-color: #666;
    width: 50px;
    height: 50px;
    border-radius: 20%;
    color: #fff;
    line-height: 50px;
    text-align: center;
    font-size: 30px;
    opacity: 0;
    transition-duration: 0.5s;
  }
/* 自作 */

動作確認します。
ページネーションの記事数を5に戻して、ホーム画面(http://localhost:8080/app/)にアクセスします。
初期画面では矢印は表示されてません。

下方の画面をスクロールさせると、右下にトップ・スクロール・ボタンが表示されます。

トップ・スクロール・ボタンをクリックすると、トップまで戻ってボタンは消えます。


スマホでも使える TOC もくじリンクを作る(JavaScript)

すでにマークダウンを使えるようにしてあり、extensions で TOC も使えるようになっています。
まず、もくじつきの記事を書いていきます。
とりあえず、もくじは最上部に表示させ、それをナビバーへと移動させます。
もくじを表示させるには、記事の表示させたい場所に [TOC] と書くだけです。
長めの記事にするため、改行を多めに入れています。

詳細ページにアクセスすると、こんな感じの表示になります。

最初にもくじリンクがあるので、「アルファベット」をクリックすると、アルファベットのところまでスクロールします。

このままだと、最上部にいるときにしか使えないので、ナビバーに移し、どこでも、スマホでもリンクを使えるようにします。
HTML(JavaScript)、CSS の順にファイルを作ります。
詳細記事ページでのみ、もくじリンクが表示されるようにします。

app/base.html
...
              <!-- 管理者のみ表示 -->
              {% if user.is_superuser %}
                <li class="nav-item ms-3">
                  <a class="nav-link" href="{% url 'admin:index' %}">Admin</a>
                </li>
              {% endif %}
              <!-- TOC -->
              {% if 'detail' in request.resolver_match.url_name %}
                <li class="nav-item ms-3 toc-display" style="border-bottom: none;">
                  <span class="toc-display">
                    <a class="nav-link active fw-bold">もくじ</a>
                    <span id="toc" class="card"></span>
                  </span>
                </li>
              {% endif %}
            </ul>
...
<script> 
      // TOC の位置を貼りかえる(本文からナビバーへ)
      const toc = document.querySelector('div.card-body > div.toc > ul');
      console.log(toc);
      if (toc !== null) {
        const copyToc = toc.cloneNode(true);
        document.querySelector('span#toc').appendChild(copyToc);
        document.querySelector('div.card-body > div.toc').removeChild(toc);
      }

      // スクロールボタン
...

ホバー時に、もくじリンクが表示されるように、CSS で調整します。

static/app/style.css
...
/* TOC: ホバーで目次表示 */
#toc {
  border: none;
}
#toc ul {
  display: none;
  position: absolute;
  width: 300px;
  list-style: none;
  background-color: #000;
  padding: 1rem;
  border-radius: 0.5em;
  font-weight: bold;
}
.toc-display {
  position: relative;
  display: inline-block;
}
.toc-display:hover #toc ul {
  display: block;
  opacity: 0.8;
  border-bottom: none;
}
/* スクロールボタン */
...

動作確認します。
詳細ページにアクセスすると、ナビバーに「もくじ」が表示されます。

マウスをもくじのところへ持っていくと、もくじリンクが表示されます。

「アルファベット」をクリックすると、ちゃんとスクロールし、もくじリンクもナビバーに表示されます。

若干見にくいですが、スマホ表示に変更しても、目次リンクは表示されます。


今回のプロジェクト(コード)

GitHub: https://github.com/Animalyzm/mikoto_project
今回のプロジェクトは、django/blog です。
Git のコミット・メッセージは、django_blog_4_search_pagination です。
データベースなどは削除してるので、使用するにはマイグレートが必要になります。

コマンド・プロンプト
python manage.py makemigrations
python manage.py migrate

参考にさせてもらったレクチャー

滝澤成人(最終更新月2022年7月)、プログラミング初心者でも安心、Python/Django入門講座、Udemy レクチャー


4回にわたり記事を書きましたが、やっとブログが完成しました!
なかなか、使いやすいブログになったのではないかと思います。
今回は以上になります、ありがとうございましたー♪

Discussion