【第3回】画面の構成とパスワード表示機能の実装

パスワード管理アプリ作成

こんにちは。今回は、前回までに構築した開発環境とSQLiteのデータベースをもとに、実際の画面構成や表示機能を実装していきます。

【第1回】なぜ自分でパスワード管理アプリを作ったのか?〜40代エンジニアの試行錯誤〜
【第2回】Next.js × SQLite で始める個人アプリ開発入門

共通レイアウトの定義(app/layout.tsx)

まず、既存の layout.tsx を以下のように書き直して、アプリ全体の共通レイアウトを整えます。

    
import './globals.css';
import React from "react";
import Link from 'next/link';

const RootLayout = ({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) => {
  return (
    <html lang="ja">
      <body className="font-sans bg-gray-100 text-gray-800 flex flex-col min-h-screen">
        <header className="bg-blue-500 text-white py-4 px-6 fixed top-0 w-full z-10">
          <h1 className="text-lg font-bold">
            <Link href="/" className="block focus:outline-none focus:ring">
              プライベートデスク
              <span className="sr-only"> - このアプリの共通レイアウト</span>
            </Link>
          </h1>
        </header>

        <main className="flex-grow p-6 overflow-auto mt-16 mb-16">
          {children}
        </main>

        <footer className="bg-gray-700 text-white py-4 px-6 fixed bottom-0 w-full z-10">
          <p className="text-center text-sm">© 2025 Private Desk App</p>
        </footer>
      </body>
    </html>
  );
};

export default RootLayout;

    
  

また、app/page.tsx は削除し、メイン画面は app/(main)/ 以下で管理します。

メインレイアウトとパスワード一覧画面

app/(main)/layout.tsx

    

import React from "react";

const MainLayout = ({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) => {
  return <>{children}</>; 
};

export default MainLayout;

    
  

app/(main)/page.tsx では、パスワードの一覧表示と「パスワード登録」ページへのリンクを提供します。

    

'use client';

import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import PasswordList from '../components/PasswordList';

type Password = {
  id: number;
  site_name: string;
  site_url: string;
  login_id: string | null;
  password: string;
  email: string | null;
  category: string | null;
};

const MainPage = () => {
  const [passwords, setPasswords] = useState<Password[]>([]);
  const [loading, setLoading] = useState(true);
  const [errors, setErrors] = useState<{ diaries?: string; wikis?: string; passwords?: string }>({});

  const fetchData = useCallback(async <T,>(
    url: string,
    setter: (data: T) => void,
    key: keyof typeof errors
  ) => {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`${key}の取得に失敗しました。`);
      const data: T = await response.json();
      setter(data);
    } catch (err) {
      console.error(`Error fetching ${key}:`, err);
      setErrors((prev) => ({ ...prev, [key]: (err as Error).message }));
    }
  }, []);

  useEffect(() => {
    const loadData = async () => {
      await Promise.all([
        fetchData<Password[]>('/api/passwords', setPasswords, 'passwords'),
      ]);
      setLoading(false);
    };
    loadData();
  }, [fetchData]);

  if (loading) {
    return <div>読み込み中...</div>;
  }

  return (
    <div className="p-3">
      <header className="flex justify-between items-center">
        <h1 className="text-2xl font-bold">パスワード管理</h1>
        <div className="flex space-x-4">
          <Link
            href="/passwords/new"
            className="bg-green-500 text-white font-bold py-2 px-4 rounded-lg shadow-md hover:bg-green-600 active:scale-95 transition-transform"
          >
            パスワード登録
          </Link>
        </div>
      </header>
      <section className="my-6">
        <h2 className="text-xl font-semibold">パスワード一覧</h2>
        {errors.passwords ? (
          <p className="text-red-500">{errors.passwords}</p>
        ) : passwords.length > 0 ? (
          <PasswordList passwords={passwords} />
        ) : (
          <p className="text-gray-500">登録されたパスワードがありません。</p>
        )}
      </section>
    </div>
  );
};

export default MainPage;
    
  

パスワード一覧コンポーネント

app/components/PasswordList.tsx にて、取得したパスワード情報を表形式で表示し、パスワードコピーや編集画面への遷移ボタンを実装しています。

    
'use client';

import React, { useState } from "react";
import { useRouter } from "next/navigation";

type Password = {
    id: number;
    site_name: string;
    site_url: string;
    login_id: string | null;
    password: string;
    email: string | null;
    category: string | null;
};

type PasswordListProps = {
    passwords: Password[];
};

const PasswordList: React.FC<PasswordListProps> = ({ passwords }) => {
    const router = useRouter();
    const [visiblePasswordId, setVisiblePasswordId] = useState<number | null>(null);

    const handleUpdate = (id: number) => {
        router.push(`/passwords/edit/${id}`);
    };

    const handlePasswordClick = (password: string, id: number) => {
        // 表示切り替え
        setVisiblePasswordId(visiblePasswordId === id ? null : id);

        // 一時的なテキストエリアを作成してパスワードをコピー
        const textArea = document.createElement('textarea');
        textArea.value = password;
        document.body.appendChild(textArea);
        textArea.select();
        try {
            document.execCommand('copy');
            alert('パスワードがクリップボードにコピーされました');
        } catch (err) {
            console.error('クリップボードへのコピーに失敗しました', err);
        }
        document.body.removeChild(textArea);
    };

    const renderLink = (url: string) => (
        <a
            href={url}
            target="_blank"
            rel="noopener noreferrer"
            className="text-blue-600 hover:underline"
            aria-label={`Open ${url} in a new tab`}
        >
            {url}
        </a>
    );

    const TableRow: React.FC<{ password: Password }> = ({ password }) => (
        <tr key={password.id} className="hover:bg-gray-50">
            <td className="py-4 px-2 border-b border-gray-300">{password.site_name}</td>
            <td className="py-4 px-2 border-b border-gray-300">{renderLink(password.site_url)}</td>
            <td className="py-4 px-2 border-b border-gray-300">{password.login_id ?? "N/A"}</td>
            <td
                className="py-4 px-2 border-b border-gray-300 cursor-pointer text-center"
                onClick={() => handlePasswordClick(password.password, password.id)}
                aria-label={`Click to copy password for ${password.site_name}`}
                title="クリックでコピー"
            >
                **********
            </td>
            <td className="py-4 px-2 border-b border-gray-300">{password.email ?? "N/A"}</td>
            <td className="py-4 px-2 border-b border-gray-300 text-center">
                <button
                    onClick={() => handleUpdate(password.id)}
                    className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 focus:outline-none"
                    aria-label={`Update details for ${password.site_name}`}
                >
                    更新
                </button>
            </td>
        </tr>
    );

    return (
        <table className="w-full bg-white border border-gray-300">
            <thead>
                <tr className="bg-gray-100">
                    <th className="py-4 px-2 border-b border-gray-300 w-1/6 text-left">サイト名</th>
                    <th className="py-4 px-2 border-b border-gray-300 w-1/6 text-left">サイトURL</th>
                    <th className="py-4 px-2 border-b border-gray-300 w-1/6 text-left">ログインID</th>
                    <th className="py-4 px-2 border-b border-gray-300 w-1/6 text-left">パスワード</th>
                    <th className="py-4 px-2 border-b border-gray-300 w-1/6 text-left">メールアドレス</th>
                    <th className="py-4 px-2 border-b border-gray-300 w-1/6">操作</th>
                </tr>
            </thead>
            <tbody>
                {passwords.map((password) => (
                    <TableRow key={password.id} password={password} />
                ))}
            </tbody>
        </table>
    );
};

export default PasswordList;

    
  

APIエンドポイントの作成

以下のように、src/app/api/passwords/route.ts に GET メソッド用の API エンドポイントを実装しました。次回以降で POST・PUT なども追加していきます。

    
import { NextResponse } from 'next/server';
import { runSelect } from '@/lib/db';
import type { Password } from '../../types/password';

export async function GET() {
  try {
    const results = runSelect<Password>('SELECT * FROM password_manager ORDER BY category, site_name');
    return NextResponse.json(results);
  } catch (error) {
    return NextResponse.json({ error: 'DB取得失敗' }, { status: 500 });
  }
}

    
  

次回予告:「パスワードの登録・更新機能について」

次回は、「パスワードの登録・更新機能」を実装します。

まとめ・感想

ここまでで、アプリの共通レイアウト・メインページ・パスワード一覧表示までを構築しました。

今回新たに「パスワード一覧コンポーネント」を追加したことで、UIとデータの結びつきが明確になり、ユーザー視点でも使いやすい画面になってきたと感じます。

自分の手で一つ一つの機能を作り込んでいくことで、技術的な理解だけでなく、自分自身の思考や設計に対する整理力も磨かれていく感覚があります。

コードを書きながら「もっと使いやすくできないか」「見た目をどう整えるか」を考えることが、アプリ開発の楽しさそのものです。

コメント

0 件のコメント:

コメントを投稿

コメントをお待ちしています。