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?

【AI開発】初心者でもできる!AIエージェントを使った簡単アプリ開発「自分だけの食べログ」

Last updated at Posted at 2025-04-07

はじめに

こういうアプリがほしいなーと思ったら、まずは聞いてみる。AIエージェントにね!
すでに調べものとかでAIエージェント(Microsoft Copilotとか)を利用してますが、個人開発でAIエージェントを使ってみたのでご紹介します。

利用したAIエージェント

  • Microsoft Copolot

  • Cline

なお、自分でコードは一切書いていません!
Microsoft CopilotClineを両方使ってみての所感も最後の方に書いています。

「自分だけの食べログ」の概要

あくまでも個人開発のため、個人で利用できる範囲の制約や考え方となります。

趣味が食べ歩きなので、食べログで色々調べて新しいお店を見つけては食べに行ってます。
色々食べ歩いていると、「あれっ、このお店行ったっけ?」とか、
友人から「この近くでおすすめの場所ない?」とかがあります。
そんなとき自分だけの食べログがあると便利だなー!と思い、作ってみました。

プロンプト一覧

Microsoft Copilotに以下のプロンプトを与えました。
(もちろん他にもこまごまとした指示は与えてますが、ざっくりとは以下の感じです。)
 
:boy:「画面の左側にお店の一覧を表示し、画面の右側に地図を表示してください。」
:robot:「OK!index.htmlstyle.cssscript.jsを作るね!」
 
:boy:お店の追加ボタンを配置してください。お店の追加ボタンでポップアップを表示してください。」
:robot:「OK!ポップアップ画面に登録ボタンキャンセルボタンも追加しとくね!」
 
:boy:「お店の追加ポップアップでは、お店の名前お店の住所星の評価メモを入力項目としてください。」
:robot:「OK!星の評価は数値入力にしとくね!メモは複数行入力できるようにしとくね!」
 
:boy:「お店の住所は地図と連動するようにしてください。」
:robot:「OK!Google Mapと連動するならMaps JavaScript APIが必要だよ!」
 
:boy:「了解。Maps JavaScript API用意しますね。」

:boy:APIキーを直に書くのは嫌だから別ファイルにしてください。」
:robot:「OK!別ファイルを参照するようにしたよ!」

別ファイルにしただけではAPIキーの漏洩は防げません。セキュリティ上よくありません。
今回はあくまでも個人での利用に限定することとしています。

:boy:「お店の一覧に登録したお店の情報を表示してください。」
:robot:「OK!お店の一覧にリスト形式で表示するね!」
 
:boy:「お店の一覧で登録した情報は修正削除できるようにしてください。」
:robot:「OK!修正ボタン削除ボタンを追加しとくね!修正ボタンでお店の登録と同じポップアップを開くよ!」
 
:boy:「修正ボタンは鉛筆マーク、削除ボタンはゴミ箱マークにしてください。」
:robot:「OK!アイコンを使えるようにしとくよ!」
 
:boy:「お店の一覧にフィルタ機能を追加してください。」
:robot:「OK!実装したよ!
:boy:いいね!

:boy:エクスポートボタンを配置してください。エクスポートボタンでお店の情報をcsv形式で出力してください。」
:robot:「OK!実装したよ!
:boy:早っ!
 
:boy:インポートボタンを配置してください。インポートボタンでcsvファイルを読み込んでお店の一覧情報を更新してください。」
:robot:「OK!実装したよ!
:boy:えっ、もう!?

:boy:「お店の一覧をクリックしたら地図にお店の場所をピンで表示してください。」
:robot:「OK!実装したよ!
:boy:すごっ。。。

:boy:「画面をモダナイズしてください。」(抽象的でごめんなさい。)
:robot:「OK!画面の見た目を見やすく、色はオレンジと白を基調に修正したよ!」
:boy:最高かよ。

:boy:「PCでもスマホでも見られるようにレスポンシブルデザインにしてください。」
:robot:「OK!画面のサイズに合わせてボタンの大きさを変更できるようにしたよ!」
:boy:完璧です。


:robot:(AIエージェント)さんが上記のように言ったかは定かではありませんが、
途中から :boy: (自分)は上記のようなリアクションをとってました。

完成しました

そして完成したのがこちら:point_down:
(後で作成したソースは全部載せます。)

index.htmlを開きます。

image.png

「お店の登録」ボタンをクリックすると、お店の登録ポップアップ画面が表示されます。

image.png

住所入力でMaps JavaScript APIと連動して、Place Autocompleteが有効になっています。

image.png

お店情報を登録すると、お店一覧にお店の情報が表示されます。
お店の情報をクリックすると、地図と連動してお店の住所のところにピンを表示してくれます。

image.png

エクスポートボタンをクリックすると、csv形式でお店の情報を出力できます。
もちろん、インポートボタンでcsvファイルを指定してお店の一覧を表示してくれます。

image.png

画面の横幅を小さくするとこんな感じです。

image.png

工夫したところ

  • 必要な機能のみ(お店の追加、修正、削除、地図表示)でシンプルに!
  • 地図と連動! 住所入力をラクに。お店の住所を地図で表示できるように。
  • 登録データはエクスポートすることでDBを使わない! すべてフロントエンドで完結。
  • 見た目(UI)をモダナイズ
  • PCとスマホでもみれるようにレスポンシブルデザインに!

ソースコード

index.html
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>my食べログ</title>
    <link rel="stylesheet" href="styles.css">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
    <script src="loadGoogleMaps.js"></script>
</head>
<body>
    <div class="container">
        <div class="sidebar">
            <h1>お店一覧</h1>
            <div class="action-container">
                <button id="add-store-btn">お店を登録</button>
                <button id="export-btn">エクスポート</button>
                <button id="import-btn">インポート</button>
            </div>
            <div class="filter-container">
                <input type="text" id="filter-input" placeholder="お店の名前を入力してください">
            </div>
            <ul id="store-list">
                <!-- 登録されたお店がここに表示されます -->
            </ul>
        </div>
        <div id="map" class="map">
            <h1>地図</h1>
            <div id="map-container"></div>
        </div>
    </div>

    <div id="popup" class="hidden">
        <div class="popup-content">
            <h2>お店を登録する</h2>
            <form id="store-form">
                <label for="store-name">お店の名前:</label>
                <input type="text" id="store-name" name="store-name" required>

                <label for="store-address">住所:</label>
                <input type="text" id="store-address" name="store-address" required>

                <label for="store-rating">評価:</label>
                <input type="number" id="store-rating" name="store-rating" min="1" max="5" required>

                <label for="store-notes">メモ:</label>
                <textarea id="store-notes" name="store-notes"></textarea>

                <button type="submit">登録</button>
                <button type="button" id="close-popup">キャンセル</button>
            </form>
        </div>
    </div>
    <script src="script.js"></script>
</body>
</html>

script.js
script.js
document.addEventListener('DOMContentLoaded', () => {
    const mapContainer = document.getElementById('map-container');
    mapContainer.innerHTML = '地図を表示する場所 (マッピングライブラリを導入してください)';

    const addStoreBtn = document.getElementById('add-store-btn');
    const filterInput = document.getElementById('filter-input'); // フィルタ入力
    const exportBtn = document.getElementById('export-btn'); // エクスポートボタン
    const importBtn = document.getElementById('import-btn'); // インポートボタン
    const popup = document.getElementById('popup');
    const closePopupBtn = document.getElementById('close-popup');
    const form = document.getElementById('store-form');
    const storeList = document.getElementById('store-list');
    let storeData = []; // お店情報を格納する配列
    let editTarget = null; // 修正対象アイテムを追跡

    // 初期状態でポップアップを非表示に設定
    popup.classList.add('hidden');

    // エンターキーでポップアップが閉じないようにする
    form.addEventListener('keydown', (event) => {
        if (event.key === 'Enter') {
            event.preventDefault();
        }
    });

    // フィルタ入力のイベントリスナーを設定
    filterInput.addEventListener('input', () => {
        const filterValue = filterInput.value.toLowerCase(); // 小文字に変換して比較
        refreshStoreList(filterValue); // フィルタリングを適用
    });
    
    // 「お店を登録する」ボタンでポップアップを表示
    addStoreBtn.addEventListener('click', () => {
        popup.classList.remove('hidden');
        editTarget = null; // 新規登録モード
        form.reset(); // フォームをリセット
    });

    // 「キャンセル」ボタンでポップアップを閉じる
    closePopupBtn.addEventListener('click', () => {
        popup.classList.add('hidden');
    });

    // フォーム送信処理
    form.addEventListener('submit', (event) => {
        event.preventDefault(); // ページリロードを防ぐ

        const name = document.getElementById('store-name').value;
        const address = document.getElementById('store-address').value; // 住所を取得
        const rating = parseInt(document.getElementById('store-rating').value);
        const notes = document.getElementById('store-notes').value;
        const storeEntry = { name, address, rating, notes };

        if (editTarget) {
            const index = Array.from(storeList.children).indexOf(editTarget);
            if (index >= 0) {
                storeData[index] = storeEntry; // 配列内の該当項目を更新
            }
            editTarget.querySelector('.store-name').textContent = name;
            editTarget.querySelector('.store-details').innerHTML = `
                <span class="star-rating">${''.repeat(rating)}${''.repeat(5 - rating)}</span> | メモ: ${notes}
            `;
            editTarget.querySelector('.store-address').textContent = `住所: ${address}`; // 住所を更新
        } else {
            storeData.push(storeEntry); // 新規データを追加
            refreshStoreList(); // リストを更新
        }

        form.reset();
        popup.classList.add('hidden'); // ポップアップを閉じる
    });

    // 一覧をリフレッシュしてすべてのリストアイテムを再描画
    const refreshStoreList = (filterValue = '') => {
        storeList.innerHTML = ''; // 既存のリストをクリア
        storeData
            .filter(store => store.name.toLowerCase().includes(filterValue)) // フィルタリング処理
            .forEach(store => {
            const newStore = document.createElement('li');
            const storeTop = document.createElement('div');
            storeTop.classList.add('store-top');

            const storeName = document.createElement('p');
            storeName.classList.add('store-name');
            storeName.textContent = store.name;

            const actionButtons = document.createElement('div');
            actionButtons.classList.add('action-buttons');

            const editButton = document.createElement('button');
            editButton.classList.add('edit-btn');
            //editButton.textContent = '修正';
            editButton.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // 鉛筆マーク
            editButton.addEventListener('click', () => {
                editTarget = newStore;
                popup.classList.remove('hidden');
                const index = Array.from(storeList.children).indexOf(newStore);
                const storeEntry = storeData[index]; // 修正対象のデータを取得
                document.getElementById('store-name').value = storeEntry.name;
                document.getElementById('store-address').value = storeEntry.address;
                document.getElementById('store-rating').value = storeEntry.rating;
                document.getElementById('store-notes').value = storeEntry.notes;
            });

            const deleteButton = document.createElement('button');
            deleteButton.classList.add('delete-btn');
            //deleteButton.textContent = '削除';
            deleteButton.innerHTML = '<i class="fas fa-trash-alt"></i>'; // ゴミ箱マーク
            deleteButton.addEventListener('click', () => {
                storeList.removeChild(newStore);
                storeData = storeData.filter(item => item !== store); // データから削除
                refreshStoreList(); // 再描画
            });

            actionButtons.appendChild(editButton);
            actionButtons.appendChild(deleteButton);

            storeTop.appendChild(storeName);
            storeTop.appendChild(actionButtons);

            const storeDetails = document.createElement('p');
            storeDetails.classList.add('store-details');
            storeDetails.innerHTML = `
                <span class="star-rating">${''.repeat(store.rating)}${''.repeat(5 - store.rating)}</span> | メモ: ${store.notes}
            `;

            const storeAddress = document.createElement('p');
            storeAddress.classList.add('store-address');
            storeAddress.textContent = `住所: ${store.address}`; // 住所を表示

            newStore.appendChild(storeTop);
            newStore.appendChild(storeDetails);
            newStore.appendChild(storeAddress);

            // リストアイテムにクリックイベントを追加
            newStore.addEventListener('click', () => {
                const index = Array.from(storeList.children).indexOf(newStore);
                const storeEntry = storeData[index]; // 最新のデータを取得
                showLocationOnMap(storeEntry.address); // 最新の住所で地図を表示
            });
            storeList.appendChild(newStore);
        });
    };

    // エクスポート機能
    exportBtn.addEventListener('click', () => {
        if (storeData.length === 0) {
            alert("エクスポートするデータがありません。");
            return;
        }

        const csvContent = storeData.map(store =>
            `${store.name},${store.address},${store.rating},${store.notes}`
        ).join("\n");
        const blob = new Blob([csvContent], { type: "text/csv" });
        const url = URL.createObjectURL(blob);

        const a = document.createElement("a");
        a.href = url;
        a.download = "store_data.csv";
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url); // URLを解放
    });

    // インポート機能
    importBtn.addEventListener('click', () => {
        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = '.csv';
        fileInput.style.display = 'none';

        fileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            if (file) {
                const reader = new FileReader();
                reader.onload = (e) => {
                    const rows = e.target.result.split("\n");
                    storeData = []; // 既存のデータをクリア
                    rows.forEach(row => {
                        const [name, address, rating, notes] = row.split(",");
                        const storeEntry = { name, address, rating: parseInt(rating), notes };
                        storeData.push(storeEntry);
                    });
                    refreshStoreList(); // 一覧をリフレッシュ
                };
                reader.readAsText(file);
            }
        });

        // ファイル選択ダイアログを開く
        document.body.appendChild(fileInput);
        fileInput.click();
        document.body.removeChild(fileInput);
    });
});

// Google Maps 初期化コード
let map;
let marker;

function initMap() {
    map = new google.maps.Map(document.getElementById("map"), {
        center: { lat: 35.6895, lng: 139.6917 },
        zoom: 13,
    });

    const input = document.getElementById("store-address");
    const autocomplete = new google.maps.places.Autocomplete(input);

    autocomplete.addListener("place_changed", () => {
        const place = autocomplete.getPlace();
        if (!place.geometry || !place.geometry.location) {
            alert("有効な場所を選択してください");
            return;
        }

        map.setCenter(place.geometry.location);
        map.setZoom(15);

        if (marker) {
            marker.setMap(null);
        }
        marker = new google.maps.Marker({
            position: place.geometry.location,
            map: map,
        });
    });
}

// 地図に住所の場所を表示する関数
function showLocationOnMap(address) {
    const geocoder = new google.maps.Geocoder(); // Geocodingサービスを利用
    geocoder.geocode({ address: address }, (results, status) => {
        if (status === 'OK' && results[0]) {
            const location = results[0].geometry.location;
            map.setCenter(location); // 地図の中心を設定
            map.setZoom(15);

            if (marker) {
                marker.setMap(null); // 既存のマーカーを削除
            }
            marker = new google.maps.Marker({
                position: location,
                map: map,
            });
        } else {
            alert("住所が見つかりませんでした: " + status);
        }
    });
}
style.css
styles.css
/* 全体の基本スタイル */
body {
    margin: 0;
    font-family: 'Roboto', sans-serif;
    background-color: #ffffff;
    color: #333;
}

#popup.hidden {
    display: none;
}

.container {
    display: flex;
    flex-direction: column; /* デフォルトでは縦並びに変更 */
    height: 100vh;
}

.sidebar {
    width: 100%; /* デフォルトで幅を100%に変更 */
    background-color: #ffffff;
    padding: 20px;
    border-bottom: 1px solid #f0ad4e; /* 横線を追加 */
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

.map {
    flex: 1;
    padding: 20px;
    background-color: #ffffff;
}

/* サイドバーの中身 */
.sidebar h1 {
    font-size: 1.5em;
    margin: 0;
    color: #f97316;
    text-align: center; /* 中央寄せに変更 */
}

.action-container {
    display: flex;
    flex-wrap: wrap; /* スマホ対応の折り返し */
    gap: 10px;
    justify-content: center; /* ボタンを中央揃え */
}

.action-container button {
    padding: 10px;
    font-size: 0.9em;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    background-color: #f97316;
    color: #ffffff;
    transition: background-color 0.3s;
    flex: 1; /* ボタンが均等に並ぶよう調整 */
}

.action-container button:hover {
    background-color: #e66a12;
}

.filter-container {
    margin: 20px auto;
    width: 90%;
    text-align: center; /* フィルタを中央揃え */
}

.filter-container input {
    width: 100%;
    padding: 10px;
    font-size: 1em;
    border: 1px solid #f0ad4e;
    border-radius: 5px;
    outline: none;
}

.filter-container input:focus {
    border-color: #f97316;
    box-shadow: 0 0 5px rgba(249, 115, 22, 0.5);
}

.sidebar ul {
    list-style: none;
    padding: 0;
    margin: 0;
}

.sidebar ul li {
    background-color: #fcf8f2;
    margin-bottom: 10px;
    padding: 10px;
    border-radius: 5px;
    border: 1px solid #f0ad4e;
    transition: transform 0.3s, box-shadow 0.3s;
}

.sidebar ul li:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}

.store-top {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.store-name {
    font-weight: bold;
    font-size: 1.2em;
    margin: 0;
    color: #333;
}

.store-details {
    font-size: 0.9em;
    color: #999;
    margin: 5px 0 0;
}

.star-rating {
    color: #999;
    font-size: 1em;
    margin: 0;
}

.store-address {
    font-size: 0.9em;
    color: #777;
    margin: 5px 0 0;
}

#map-container {
    width: 100%;
    height: calc(100% - 50px);
    background-color: #fcf8f2;
    border-radius: 10px;
    border: 2px solid #f0ad4e;
}

/* ポップアップスタイル */
#popup {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.6);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 10;
}

.popup-content {
    background-color: #ffffff;
    padding: 30px;
    border-radius: 10px;
    width: 400px;
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
    animation: fadeIn 0.3s ease-in-out;
}

.popup-content h2 {
    margin-top: 0;
    color: #f97316;
}

.popup-content form {
    display: flex;
    flex-direction: column;
    gap: 15px;
}

.popup-content form label {
    font-weight: bold;
    color: #555;
}

.popup-content form input,
.popup-content form textarea {
    padding: 10px;
    font-size: 1em;
    border: 1px solid #f0ad4e;
    border-radius: 5px;
    background-color: #ffffff;
    outline: none;
}

.popup-content form input:focus,
.popup-content form textarea:focus {
    border-color: #f97316;
    box-shadow: 0 0 5px rgba(249, 115, 22, 0.5);
}

.popup-content button {
    padding: 10px;
    font-size: 1em;
    color: #ffffff;
    background-color: #f97316;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s;
}

.popup-content button:hover {
    background-color: #e66a12;
}

@keyframes fadeIn {
    from {
        opacity: 0;
        transform: scale(0.9);
    }
    to {
        opacity: 1;
        transform: scale(1);
    }
}

/* メディアクエリでレスポンシブデザインを調整 */
@media (min-width: 768px) {
    .container {
        flex-direction: row;
    }

    .sidebar {
        width: 30%;
        border-bottom: none;
        border-right: 1px solid #f0ad4e;
    }

    .map {
        flex: 1;
    }
}

@media (min-width: 1200px) {
    .sidebar h1 {
        font-size: 1.8em;
    }

    .action-container button {
        font-size: 1em;
    }

    .filter-container input {
        font-size: 1.1em;
    }
}

.action-buttons button {
    padding: 5px;
    font-size: 1.2em; /* アイコンサイズを調整 */
    border: none;
    border-radius: 5px;
    cursor: pointer;
    background-color: transparent; /* 背景色を透明に */
    color: #f97316; /* アイコンカラー */
    transition: color 0.3s;
}

.action-buttons button:hover {
    color: #e66a12; /* ホバー時の色を変更 */
}
loadGoogleMaps.js
loadGoogleMaps.js
// Google Maps APIキーを設定する
const apiKey = 'YOUR-API-KEY'; // ご自身のAPIキーを指定してください。

// Google Mapsのスクリプトを動的に読み込む
function loadGoogleMaps() {
    const script = document.createElement('script');
    script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=initMap`;
    script.async = true;
    document.head.appendChild(script);
}

// スクリプトを読み込む関数を実行
loadGoogleMaps();

「loadGoogleMaps.js」ではご自身のAPIキーを指定してください。

loadGoogleMaps.js
const apiKey = 'YOUR-API-KEY'; // ご自身のAPIキーを指定してください。

AIエージェントを利用してみて

  • アプリ開発にかかった時間は1日間程度です。
  • Microsoft Copilotのため無料です。
  • Maps JavaScript APIも無料の範囲内での実装です。
     
  • 指示はなるべく細かく伝えた方が修正の範囲も限定できて良い
  • ときどき指示した内容で修正しようと元のファイルを大幅に書き換えることがあるので、都度都度自分のタイミングでバックアップを取っておいたほうが良い
     
  • 特に見た目(UI)をモダナイズしたいときはとても活躍してくれます!
    これが一番のGood Point!:raised_hands:

Clineで開発した場合

  • 完成したのがこちら:point_down:

image.png

 なお、API Providerは「Google Gemini」を利用しました。
 VSCodeの拡張で「Cline」をインストールしてAPI Providerを設定します。

 image.png

  • Clineでの開発はAPIのトークン数とにらめっこしながら修正。
    でもシンプルなアプリ開発なら無料の範囲内で実装できる。
  • Clineもときどき元のファイルを大幅に書き換えることがあるので都度都度自分のタイミングでバックアップを取っておいた方が負い
  • Clineは一度の指示ですべてのファイルを書き換えてくれる
  • Clineはエラーや不整合があると自ら再度修正を実行してくれる
    でもそのおかげで無限ループに入るときがある。(その時は新しいタスクでやり直す。)

Microsoft CopilotとClineの比較

Microsoft Copilot Cline
Goodなところ 料金を気にせず利用できる 一度に複数ファイルを修正してくれる
Not Goodなところ トークン数の上限ですべて出力されないことがある ちまちました修正のためにトークンが消費される

なお、ClineはMCPサーバを利用するなど外部APIと連携できるため、使い方によってはもっと恩恵を受けることができると思います!

おわりに

  • これからも面白そうなアプリを思いついたら、AIエージェントと仲良く開発をしていく。
  • ハッピーなAIエージェントライフを!
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?