🚀

【開発ログ】タグで単語を整理!フィルタリング機能を追加

に公開

今回は、タグ別に単語を絞り込んで表示できるフィルタリング機能を実装しました。
単語登録時にタグを設定しておくことで、特定のカテゴリだけを選んで勉強できるようになります。

前回

  • 単語カードを API から取得

今回

  • タグ別検索・フィルタリングの実装

① タグ選択UI

ユーザがタグを選択できるように、検索・フィルタリング用の UI コンポーネントを作成。
UIを分離して共通化 することで、LessonCard・TestPageなど複数のページで再利用できるようにしました。

"use client";
import { useEffect, useState } from "react";

type Props = {
  onSelect: (tag: string | null) => void;
  defaultTag?: string | null;
};

export function TagFilter({ onSelect, defaultTag = null }: Props) {
  const [tags, setTags] = useState<string[]>([]);
  const [selected, setSelected] = useState<string | null>(defaultTag);

  useEffect(() => {
    const fetchTags = async () => {
      try {
        const res = await fetch("/api/tag");
        if (!res.ok) throw new Error("failed to fetch");
        const data = await res.json();
        setTags(data.map((t: { name: string }) => t.name));
      } catch (err) {
        console.error(err);
        setTags([]);
      }
    };
    fetchTags();
  }, []);

  return (
    <div className="flex flex-wrap gap-2">
      <button
        onClick={() => {
          setSelected(null);
          onSelect(null);
        }}
        className={`px-3 py-1 rounded-full border ${
          selected === null ? "bg-blue-500 text-white" : "bg-white text-black"
        }`}
      >
        全て
      </button>

      {tags.map((tag) => (
        <button
          key={tag}
          onClick={() => {
            setSelected(tag);
            onSelect(tag);
          }}
          className={`px-3 py-1 rounded-full border ${
            selected === tag ? "bg-blue-500 text-white" : "bg-white text-black"
          }`}
        >
          {tag}
        </button>
      ))}

      {tags.length === 0 && (
        <p className="text-gray-500 text-sm mt-2">タグが登録されていません</p>
      )}
    </div>
  );
}


② API

searchParamsを使ってURLクエリからキーワードを取得。
該当キーワードがあればcontainsで部分一致検索を行う。

import { getDecodedSessionOrRedirect } from "@/lib/authServer";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  try {
    const decoded = await getDecodedSessionOrRedirect();
    if (!decoded) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    // 🔍 Keyword取得
    const keyword = req.nextUrl.searchParams.get("q");

    const tags = await prisma.tag.findMany({
      where: {
        userId: decoded.id, // authServer変換済み
        ...(keyword ? { name: { contains: keyword } } : {}),
      },
      orderBy: { createdAt: "asc" },
    });

    return NextResponse.json(tags);
  } catch (error) {
    console.error(error);
    return NextResponse.json({ error: "error" }, { status: 500 });
  }
}

LessonCardでの利用例

TagFilterから選択されたタグを受け取り、APIリクエストのURLを切り替えることで、該当タグの単語だけを表示するようにしました。

"use client";

import { LearningCardData } from "@/types/lesson";
import { useEffect, useState } from "react";
import { TagFilter } from "../ui/TagFilter";

export default function LearningCard() {
  const [cards, setCards] = useState<LearningCardData[]>([]);
  const [selectedTag, setSelectedTag] = useState<string | null>(null);

  useEffect(() => {
    const fetchWords = async () => {
      const url = selectedTag
        ? `/api/learn?tag=${encodeURIComponent(selectedTag)}`
        : "/api/learn";

      const res = await fetch(url, { cache: "no-store" });
      if (!res.ok) throw new Error("単語の取得に失敗しました");
      const data = await res.json();
      setCards(data);
    };
    fetchWords();
  }, [selectedTag]);

  return (
    <>
      <TagFilter onSelect={setSelectedTag} />
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4">
        {cards.map((card) => (
          <article key={card.id} className="rounded-2xl border p-4 shadow-sm hover:shadow-md transition">
            <div className="relative aspect-[4/3] mb-3 overflow-hidden rounded-xl bg-gray-100">
              <img src={card.imgUrl} alt={`${card.ja} / ${card.ko}`} className="object-cover" />
            </div>
            <h2 className="text-lg font-semibold">
              {card.ja} <span className="text-gray-500">/ {card.ko}</span>
            </h2>
            <div className="mt-2 flex flex-wrap gap-2">
              {card.tags.map((t) => (
                <span key={t} className="text-xs rounded-full border px-2 py-1">{t}</span>
              ))}
            </div>
          </article>
        ))}
        {cards.length === 0 && (
          <p className="text-gray-500 text-sm mt-4">該当する単語カードがありません。</p>
        )}
      </div>
    </>
  );
}


④ API(/api/learn)の修正

LessonCard側で選択されたタグを反映できるように、 API /api/learn も修正しました。
searchParamsでタグ名を受け取り、該当タグが存在する単語のみを返すように変更。

// /api/learn/route.ts
import { getDecodedSessionOrRedirect } from "@/lib/authServer";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";

export async function GET(req: Request) {
  const decoded = await getDecodedSessionOrRedirect();

  //searchParams 追加
  const { searchParams } = new URL(req.url);
  const tag = searchParams.get("tag");

  const words = await prisma.word.findMany({
    where: {
      userId: decoded.id,
      ...(tag ? { tags: { some: { name: tag } } } : {}), // タグ指定があればフィルタリング
    },
    include: { tags: true, image: true },
    orderBy: { createdAt: "desc" },
  });

  return NextResponse.json(words);
}

実際の表示

動物をフィルターした画面
犬の画像


悩んだこと / 学んだこと

  • Tagテーブルにユーザ情報がなかった
    ユーザごとに単語を管理しているのに、TagテーブルにuserIdが存在しない状態でした。
    そのため、タグが全ユーザ共通になってしまい、他のユーザのタグまで一覧に混ざる問題が発生。
    userId カラムを追加したところ、既存データとの整合性が取れずエラーが発生したため、
    開発環境では思い切って npx prisma migrate reset を実行し、DBをリセットして解決しました。
  • Prismaの複合ユニークキーの扱い

Prismaで複数のUnique Keyを定義している場合、where 句の書き方に少しクセがあります。
例:Tagモデルに以下を定義している場合

@@unique([userId, name])

connectOrCreate の記述は次のよう👇

where: { userId_name: { userId: user.id, name: t } },
create: { userId: user.id, name: t },

次回やること

  • タグを登録し、選択中のタグを外せるようにすること
  • UI デザインの再調整(ボタンの色・一覧性改善)
  • 新しい単語を保存しようとするとセッションエラー発生 → ログイン中でもセッション満了になるケースを調査・解決

Discussion