T-CREATOR

Remix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成

Remix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成

管理画面を開発する際、データの一覧表示、フィルタリング、CSV エクスポートといった機能は必須の要件となることがほとんどです。Remix を使えば、これらの機能を効率的に実装できるだけでなく、優れた UX と SEO にも配慮した設計が可能になります。

本記事では、Remix の特性を最大限に活かした管理画面テンプレートの構築方法を、実装コード付きで詳しく解説していきます。初めて Remix で管理画面を作る方でも、この記事を読めばすぐに実践できる構成をご紹介いたします。

背景

管理画面に求められる機能要件

企業向けシステムや SaaS アプリケーションの管理画面には、共通して求められる機能があります。 それは「大量のデータを効率的に閲覧・検索し、必要に応じてエクスポートできること」です。

管理画面の典型的なユースケースとして、以下が挙げられるでしょう:

  • ユーザー情報の一覧表示と検索
  • 注文履歴のフィルタリングと抽出
  • ログデータの期間指定とダウンロード

Remix が管理画面開発に適している理由

Remix は Next.js とは異なるアプローチで、サーバーとクライアントのデータフローを設計しています。 特に管理画面開発において、以下の特徴が大きなメリットとなるのです:

#特徴メリット
1Loader による SSR データ取得初期表示が高速で SEO にも有利
2Form と Action の統合フィルタリング処理がシンプルに実装可能
3URL 連動の自然な状態管理ブックマークや共有が容易
4Resource RoutesCSV エクスポートなどの非 HTML レスポンスに最適

以下の図は、Remix における管理画面のデータフローを示したものです。

mermaidflowchart TB
  user["管理者"] -->|"ページアクセス"| loader["Loader 関数"]
  loader -->|"DB クエリ"| db[("データベース")]
  db -->|"データ返却"| loader
  loader -->|"SSR"| page["管理画面ページ"]
  page -->|"表示"| user

  user -->|"フィルタ送信"| form["Form"]
  form -->|"URL パラメータ"| loader

  user -->|"CSV 出力"| resource["Resource Route"]
  resource -->|"クエリ"| db
  db -->|"データ"| resource
  resource -->|"CSV ファイル"| user

この図から分かるように、Remix では Loader がデータ取得の中心となり、Form による操作が自然に URL パラメータへ反映される設計になっています。

図で理解できる要点:

  • Loader 関数がサーバーサイドでデータを取得し SSR で配信
  • Form 送信が URL パラメータとして Loader へ自動的に渡される
  • Resource Route により CSV などの非 HTML レスポンスも簡単に実装可能

課題

従来の管理画面実装における問題点

管理画面を実装する際、多くの開発者が直面する課題があります。 それは「状態管理の複雑化」と「パフォーマンスの劣化」です。

従来の SPA アプローチでは、以下のような問題が発生しがちでした:

#課題影響
1クライアント側の状態管理が複雑フィルタ条件、ページネーション、ソート順などを Redux や Context で管理する必要がある
2初期ロードの遅延JavaScript バンドルのダウンロード後にデータフェッチが始まる
3URL との同期が困難ブックマークや共有時に状態が再現されない
4エクスポート処理の実装コストクライアント側で大量データを処理するか、別途 API を用意する必要がある

データ量増加に伴うパフォーマンス問題

管理画面のデータ量が増えるにつれ、以下のパフォーマンス問題が顕在化します:

  • 数千件のレコードをクライアント側で保持すると、メモリ使用量が増大する
  • フィルタリングやソート処理がブラウザ上で実行されるため、UI がフリーズしやすくなる
  • CSV エクスポート時にブラウザのメモリ制限に達してしまう

これらの課題を解決するには、サーバー側でのデータ処理URL ベースの状態管理が鍵となるのです。

以下の図は、従来の SPA 方式と Remix 方式の処理フローの違いを示しています。

mermaidflowchart LR
  subgraph spa["従来の SPA 方式"]
    direction TB
    spa_user["ユーザー"] -->|"アクセス"| spa_load["JS バンドル<br/>ダウンロード"]
    spa_load -->|"実行"| spa_fetch["API フェッチ"]
    spa_fetch -->|"データ取得"| spa_render["クライアント<br/>レンダリング"]
    spa_render -->|"表示"| spa_user
  end

  subgraph remix["Remix 方式"]
    direction TB
    remix_user["ユーザー"] -->|"アクセス"| remix_loader["Loader で<br/>データ取得"]
    remix_loader -->|"SSR"| remix_render["HTML 配信"]
    remix_render -->|"即座に表示"| remix_user
  end

図で理解できる要点:

  • SPA 方式では JS ダウンロード → 実行 → API フェッチの順で処理されるため初期表示が遅い
  • Remix 方式では Loader が事前にデータを取得し SSR で配信するため即座に表示可能
  • Remix は URL パラメータで状態を管理するため、ブックマークや共有が容易

解決策

Remix の Loader と Form を活用した設計

Remix では、Loader 関数でサーバー側のデータ取得を行い、Form コンポーネントで URL パラメータを自動管理できます。 この仕組みを活用することで、状態管理のコードをほとんど書かずに、フィルタリング機能を実装できるのです。

基本的な設計方針は以下の通りです:

#機能実装方法
1データ一覧表示Loader 関数で DB からデータ取得、useLoaderData でコンポーネントに渡す
2フィルタリングForm の送信で URL パラメータを更新、Loader が自動的に再実行される
3CSV エクスポートResource Route で CSV ファイルを生成し、ダウンロード用エンドポイントを提供

Resource Route による CSV エクスポート

Remix の Resource Route は、HTML 以外のレスポンスを返すための機能です。 CSV エクスポート機能を実装する際に、この Resource Route を活用すると非常にシンプルなコードで実現できます。

通常の Route では JSX を返しますが、Resource Route では任意の Response オブジェクトを返せるため、CSV ファイルのダウンロードに最適なのです。

以下の図は、Remix 管理画面の全体アーキテクチャを示しています。

mermaidflowchart TB
  route["routes/admin/users.tsx"]
  resource["routes/admin/users/export.csv.tsx"]

  route -->|"Loader"| db1[("DB")]
  route -->|"useLoaderData"| table["テーブル<br/>コンポーネント"]
  route -->|"Form"| filter["フィルタ<br/>コンポーネント"]

  filter -->|"submit"| url["URL パラメータ<br/>更新"]
  url -->|"再実行"| route

  resource -->|"Loader"| db2[("DB")]
  resource -->|"Response"| csv["CSV ファイル"]

  table -->|"エクスポート<br/>ボタン"| resource

図で理解できる要点:

  • 通常の Route で一覧表示とフィルタリングを担当
  • Resource Route で CSV エクスポート専用のエンドポイントを提供
  • Form 送信により URL パラメータが更新され、Loader が自動的に再実行される

具体例

プロジェクトのセットアップ

まず、Remix プロジェクトを作成し、必要なパッケージをインストールします。

bash# Remix プロジェクトの作成
npx create-remix@latest my-admin-panel

# プロジェクトディレクトリへ移動
cd my-admin-panel

# 必要なパッケージのインストール
yarn add date-fns prisma @prisma/client
yarn add -D @types/node

次に、Prisma を初期化してデータベーススキーマを定義しましょう。

bash# Prisma の初期化
npx prisma init

データベーススキーマの定義

データベーススキーマを定義します。 今回はユーザー管理を例に、シンプルな構成で実装していきます。

prisma// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

User モデルを定義します。

prisma// prisma/schema.prisma (続き)

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  role      String   // "admin" | "user" | "guest"
  status    String   // "active" | "inactive"
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

スキーマ定義後、マイグレーションを実行します。

bash# マイグレーションの実行
npx prisma migrate dev --name init

# Prisma Client の生成
npx prisma generate

型定義とユーティリティ関数

型定義ファイルを作成し、フィルタ条件やユーザーデータの型を定義します。

typescript// app/types/user.ts

export type UserRole = 'admin' | 'user' | 'guest';
export type UserStatus = 'active' | 'inactive';

フィルタ条件の型を定義します。

typescript// app/types/user.ts (続き)

export interface UserFilter {
  keyword?: string; // 名前やメールで検索
  role?: UserRole; // ロールでフィルタ
  status?: UserStatus; // ステータスでフィルタ
  page?: number; // ページ番号
  limit?: number; // 1ページあたりの件数
}

Loader 関数の実装

管理画面のメインとなる Route ファイルを作成し、Loader 関数を実装します。 Loader 関数では、URL パラメータからフィルタ条件を取得し、データベースへクエリを実行します。

typescript// app/routes/admin.users.tsx

import {
  json,
  type LoaderFunctionArgs,
} from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';
import { PrismaClient } from '@prisma/client';
import type { UserFilter } from '~/types/user';

const prisma = new PrismaClient();

URL パラメータからフィルタ条件を抽出する関数を実装します。

typescript// app/routes/admin.users.tsx (続き)

/**
 * URL パラメータからフィルタ条件を抽出
 */
function getFilterFromSearchParams(
  searchParams: URLSearchParams
): UserFilter {
  return {
    keyword: searchParams.get('keyword') || undefined,
    role:
      (searchParams.get('role') as UserFilter['role']) ||
      undefined,
    status:
      (searchParams.get(
        'status'
      ) as UserFilter['status']) || undefined,
    page: Number(searchParams.get('page')) || 1,
    limit: Number(searchParams.get('limit')) || 20,
  };
}

Loader 関数を実装します。この関数がサーバー側でデータを取得し、SSR で配信します。

typescript// app/routes/admin.users.tsx (続き)

export async function loader({
  request,
}: LoaderFunctionArgs) {
  // URL パラメータを取得
  const url = new URL(request.url);
  const filter = getFilterFromSearchParams(
    url.searchParams
  );

  // ページネーション用の offset を計算
  const offset =
    ((filter.page || 1) - 1) * (filter.limit || 20);

  // WHERE 条件を構築
  const where: any = {};

  // キーワード検索: 名前またはメールに部分一致
  if (filter.keyword) {
    where.OR = [
      { name: { contains: filter.keyword } },
      { email: { contains: filter.keyword } },
    ];
  }

  // ロールでフィルタ
  if (filter.role) {
    where.role = filter.role;
  }

  // ステータスでフィルタ
  if (filter.status) {
    where.status = filter.status;
  }

  // データ取得と総件数の取得を並行実行
  const [users, total] = await Promise.all([
    prisma.user.findMany({
      where,
      skip: offset,
      take: filter.limit,
      orderBy: { createdAt: 'desc' },
    }),
    prisma.user.count({ where }),
  ]);

  return json({ users, total, filter });
}

フィルタコンポーネントの実装

フィルタ用のコンポーネントを作成します。 Remix の Form コンポーネントを使用することで、送信時に自動的に URL パラメータが更新されます。

typescript// app/routes/admin.users.tsx (続き)

/**
 * フィルタフォームコンポーネント
 * Form コンポーネントを使うことで、送信時に URL パラメータが自動更新される
 */
function UserFilter({ filter }: { filter: UserFilter }) {
  return (
    <Form method='get' className='filter-form'>
      <div className='filter-row'>
        {/* キーワード検索 */}
        <label>
          <span>キーワード検索</span>
          <input
            type='text'
            name='keyword'
            defaultValue={filter.keyword}
            placeholder='名前またはメール'
          />
        </label>

        {/* ロールでフィルタ */}
        <label>
          <span>ロール</span>
          <select name='role' defaultValue={filter.role}>
            <option value=''>すべて</option>
            <option value='admin'>管理者</option>
            <option value='user'>一般ユーザー</option>
            <option value='guest'>ゲスト</option>
          </select>
        </label>

        {/* ステータスでフィルタ */}
        <label>
          <span>ステータス</span>
          <select
            name='status'
            defaultValue={filter.status}
          >
            <option value=''>すべて</option>
            <option value='active'>有効</option>
            <option value='inactive'>無効</option>
          </select>
        </label>
      </div>

      {/* 検索ボタン */}
      <div className='filter-actions'>
        <button type='submit'>検索</button>
        <a href='/admin/users'>リセット</a>
      </div>
    </Form>
  );
}

テーブルコンポーネントの実装

データ一覧を表示するテーブルコンポーネントを実装します。

typescript// app/routes/admin.users.tsx (続き)

/**
 * ユーザー一覧テーブルコンポーネント
 */
function UserTable({ users }: { users: any[] }) {
  return (
    <table className='data-table'>
      <thead>
        <tr>
          <th>ID</th>
          <th>名前</th>
          <th>メール</th>
          <th>ロール</th>
          <th>ステータス</th>
          <th>登録日</th>
        </tr>
      </thead>
      <tbody>
        {users.length === 0 ? (
          <tr>
            <td colSpan={6} className='no-data'>
              データがありません
            </td>
          </tr>
        ) : (
          users.map((user) => (
            <tr key={user.id}>
              <td>{user.id}</td>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.role}</td>
              <td>{user.status}</td>
              <td>
                {new Date(
                  user.createdAt
                ).toLocaleDateString()}
              </td>
            </tr>
          ))
        )}
      </tbody>
    </table>
  );
}

ページネーションコンポーネントの実装

ページネーション機能を実装します。 URL パラメータの page を更新することで、Loader が自動的に再実行されます。

typescript// app/routes/admin.users.tsx (続き)

/**
 * ページネーションコンポーネント
 */
function Pagination({
  total,
  filter,
}: {
  total: number;
  filter: UserFilter;
}) {
  const currentPage = filter.page || 1;
  const limit = filter.limit || 20;
  const totalPages = Math.ceil(total / limit);

  // 現在のフィルタ条件を保持したまま、page パラメータだけを変更する関数
  const buildPageUrl = (page: number) => {
    const params = new URLSearchParams();
    if (filter.keyword)
      params.set('keyword', filter.keyword);
    if (filter.role) params.set('role', filter.role);
    if (filter.status) params.set('status', filter.status);
    params.set('page', String(page));
    params.set('limit', String(limit));
    return `?${params.toString()}`;
  };

  return (
    <div className='pagination'>
      <span>
        全 {total} 件中 {(currentPage - 1) * limit + 1} -{' '}
        {Math.min(currentPage * limit, total)} 件を表示
      </span>

      <div className='pagination-buttons'>
        {/* 前へボタン */}
        {currentPage > 1 && (
          <a href={buildPageUrl(currentPage - 1)}>前へ</a>
        )}

        {/* ページ番号 */}
        {Array.from(
          { length: totalPages },
          (_, i) => i + 1
        ).map((page) => (
          <a
            key={page}
            href={buildPageUrl(page)}
            className={page === currentPage ? 'active' : ''}
          >
            {page}
          </a>
        ))}

        {/* 次へボタン */}
        {currentPage < totalPages && (
          <a href={buildPageUrl(currentPage + 1)}>次へ</a>
        )}
      </div>
    </div>
  );
}

メインコンポーネントの実装

最後に、すべてのコンポーネントを組み合わせたメインコンポーネントを実装します。

typescript// app/routes/admin.users.tsx (続き)

/**
 * メインコンポーネント
 */
export default function AdminUsers() {
  // Loader から取得したデータを useLoaderData で受け取る
  const { users, total, filter } =
    useLoaderData<typeof loader>();

  return (
    <div className='admin-users'>
      <h1>ユーザー管理</h1>

      {/* フィルタフォーム */}
      <UserFilter filter={filter} />

      {/* CSV エクスポートボタン */}
      <div className='export-actions'>
        <a
          href='/admin/users/export.csv'
          download='users.csv'
          className='export-button'
        >
          CSV エクスポート
        </a>
      </div>

      {/* データテーブル */}
      <UserTable users={users} />

      {/* ページネーション */}
      <Pagination total={total} filter={filter} />
    </div>
  );
}

CSV エクスポート用 Resource Route の実装

CSV エクスポート機能を Resource Route として実装します。 Resource Route は HTML ではなく、CSV ファイルを返すためのエンドポイントです。

typescript// app/routes/admin.users.export.csv.tsx

import type { LoaderFunctionArgs } from '@remix-run/node';
import { PrismaClient } from '@prisma/client';
import { format } from 'date-fns';
import type { UserFilter } from '~/types/user';

const prisma = new PrismaClient();

URL パラメータからフィルタ条件を抽出する関数を再利用します。

typescript// app/routes/admin.users.export.csv.tsx (続き)

/**
 * URL パラメータからフィルタ条件を抽出
 */
function getFilterFromSearchParams(
  searchParams: URLSearchParams
): UserFilter {
  return {
    keyword: searchParams.get('keyword') || undefined,
    role:
      (searchParams.get('role') as UserFilter['role']) ||
      undefined,
    status:
      (searchParams.get(
        'status'
      ) as UserFilter['status']) || undefined,
  };
}

CSV 文字列を生成する関数を実装します。

typescript// app/routes/admin.users.export.csv.tsx (続き)

/**
 * ユーザーデータを CSV 形式に変換
 */
function convertToCSV(users: any[]): string {
  // CSV ヘッダー行
  const headers = [
    'ID',
    '名前',
    'メール',
    'ロール',
    'ステータス',
    '登録日',
  ];

  // データ行を作成
  const rows = users.map((user) => [
    user.id,
    user.name,
    user.email,
    user.role,
    user.status,
    format(new Date(user.createdAt), 'yyyy-MM-dd HH:mm:ss'),
  ]);

  // ヘッダーとデータを結合
  const allRows = [headers, ...rows];

  // CSV 文字列に変換(各フィールドをダブルクォートで囲む)
  return allRows
    .map((row) => row.map((cell) => `"${cell}"`).join(','))
    .join('\n');
}

Loader 関数を実装し、CSV ファイルをレスポンスとして返します。

typescript// app/routes/admin.users.export.csv.tsx (続き)

/**
 * CSV エクスポート用の Loader 関数
 * Response オブジェクトを返すことで、CSV ファイルをダウンロードさせる
 */
export async function loader({
  request,
}: LoaderFunctionArgs) {
  // URL パラメータを取得
  const url = new URL(request.url);
  const filter = getFilterFromSearchParams(
    url.searchParams
  );

  // WHERE 条件を構築(一覧画面と同じロジック)
  const where: any = {};

  if (filter.keyword) {
    where.OR = [
      { name: { contains: filter.keyword } },
      { email: { contains: filter.keyword } },
    ];
  }

  if (filter.role) {
    where.role = filter.role;
  }

  if (filter.status) {
    where.status = filter.status;
  }

  // データを取得(エクスポートでは全件取得)
  const users = await prisma.user.findMany({
    where,
    orderBy: { createdAt: 'desc' },
  });

  // CSV に変換
  const csv = convertToCSV(users);

  // BOM 付き UTF-8 で CSV を返す(Excel で文字化けしないため)
  const bom = '\uFEFF';
  const content = bom + csv;

  // Response オブジェクトを返す
  return new Response(content, {
    status: 200,
    headers: {
      'Content-Type': 'text/csv; charset=utf-8',
      'Content-Disposition': `attachment; filename="users_${format(
        new Date(),
        'yyyyMMdd_HHmmss'
      )}.csv"`,
    },
  });
}

スタイリングの追加

管理画面らしい見た目にするため、基本的なスタイルを追加します。

css/* app/styles/admin.css */

.admin-users {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.admin-users h1 {
  font-size: 24px;
  margin-bottom: 20px;
}

フィルタフォームのスタイルを定義します。

css/* app/styles/admin.css (続き) */

.filter-form {
  background: #f5f5f5;
  padding: 20px;
  border-radius: 8px;
  margin-bottom: 20px;
}

.filter-row {
  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(200px, 1fr)
  );
  gap: 15px;
  margin-bottom: 15px;
}

.filter-row label {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.filter-row label span {
  font-weight: bold;
  font-size: 14px;
}

.filter-row input,
.filter-row select {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

アクションボタンとテーブルのスタイルを定義します。

css/* app/styles/admin.css (続き) */

.filter-actions {
  display: flex;
  gap: 10px;
}

.filter-actions button,
.export-button {
  padding: 10px 20px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  text-decoration: none;
  display: inline-block;
}

.filter-actions a {
  padding: 10px 20px;
  background: #6c757d;
  color: white;
  text-decoration: none;
  border-radius: 4px;
}

.export-actions {
  margin-bottom: 20px;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
  background: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.data-table th,
.data-table td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #ddd;
}

.data-table th {
  background: #f8f9fa;
  font-weight: bold;
}

.data-table tbody tr:hover {
  background: #f5f5f5;
}

.no-data {
  text-align: center;
  color: #999;
  padding: 40px !important;
}

ページネーションのスタイルを定義します。

css/* app/styles/admin.css (続き) */

.pagination {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 20px;
}

.pagination-buttons {
  display: flex;
  gap: 5px;
}

.pagination-buttons a {
  padding: 8px 12px;
  background: white;
  border: 1px solid #ddd;
  text-decoration: none;
  color: #333;
  border-radius: 4px;
}

.pagination-buttons a.active {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

.pagination-buttons a:hover:not(.active) {
  background: #f5f5f5;
}

動作確認とテスト

実装が完了したら、開発サーバーを起動して動作を確認しましょう。

bash# 開発サーバーの起動
yarn dev

ブラウザで http:​/​​/​localhost:3000​/​admin​/​users にアクセスし、以下の機能が正しく動作することを確認します:

#確認項目期待される動作
1初期表示ユーザー一覧が表示される
2キーワード検索名前またはメールで部分一致検索できる
3ロールフィルタロールでフィルタリングできる
4ステータスフィルタステータスでフィルタリングできる
5ページネーションページを切り替えられる
6CSV エクスポートフィルタ条件を反映した CSV がダウンロードされる
7URL 連動フィルタ条件が URL に反映され、リロードしても状態が保持される

以下の図は、実装した管理画面の状態遷移を示しています。

mermaidstateDiagram-v2
  [*] --> initial_load: ページアクセス
  initial_load --> display_data: Loader 実行

  display_data --> filter_submit: フィルタ送信
  filter_submit --> url_update: URL パラメータ更新
  url_update --> display_data: Loader 再実行

  display_data --> page_change: ページ変更
  page_change --> url_update

  display_data --> csv_export: CSV 出力
  csv_export --> download: ファイル DL
  download --> display_data

  display_data --> [*]

図で理解できる要点:

  • ページアクセス時に Loader が実行されデータが表示される
  • フィルタ送信やページ変更により URL パラメータが更新され、Loader が自動的に再実行される
  • CSV エクスポートは Resource Route により独立して処理される

まとめ

Remix を使った管理画面テンプレートの実装方法をご紹介しました。

本記事で解説した内容を振り返ると、以下のポイントが重要です:

実装のポイント:

  • Loader 関数で SSR によるデータ取得を行い、初期表示を高速化する
  • Form コンポーネントで URL パラメータを自動管理し、状態管理コードを削減する
  • Resource Route で CSV エクスポートなどの非 HTML レスポンスをシンプルに実装する
  • URL ベースの状態管理により、ブックマークや共有が容易になる

得られるメリット:

#メリット効果
1コード量の削減状態管理ライブラリが不要になり、実装コードが大幅に削減される
2パフォーマンス向上SSR により初期表示が高速化し、大量データも効率的に処理できる
3保守性の向上URL と状態が連動するため、デバッグやテストが容易になる
4UX の改善ブラウザの戻る・進むボタンが正しく動作し、URL の共有が可能になる

この実装パターンは、ユーザー管理に限らず、注文履歴、ログ閲覧、商品管理など、あらゆる管理画面に応用できます。 Remix の特性を理解し、適切に活用することで、効率的かつ保守性の高い管理画面を構築できるでしょう。

ぜひこのテンプレートをベースに、プロジェクトに合わせてカスタマイズしてみてください。

関連リンク