FastAPIで作るドラッグ＆ドロップ可能な付箋アプリ

## はじめに
こんにちは！今回は、FastAPIを使ってドラッグ＆ドロップ可能な付箋アプリを作成する方法をご紹介します。このアプリケーションは、シンプルなカンバンボードのように機能し、ToDo、Doing、Doneの3つのカラムに付箋を自由に移動させることができます。また、付箋の色を自由に変更することもできます。

## 要件と仕様

このプロジェクトでは、以下の要件と仕様を満たすアプリケーションを開発します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/f6fe6af4-7fdd-3a7b-b3ce-26d30399ecec.png)

### 機能要件

1. ユーザーは新しい付箋を作成できる
2. 付箋にはテキスト内容を入力できる
3. 付箋は「ToDo」「Doing」「Done」の3つのカラムのいずれかに属する
4. ユーザーは付箋をドラッグ＆ドロップで異なるカラムに移動できる
5. 付箋の色を変更できる
6. 付箋を削除できる
7. アプリケーションを再読み込みしても、付箋の状態が保持される

### 技術仕様

1. バックエンド：FastAPIを使用
2. フロントエンド：HTML, CSS, バニラJavaScriptを使用（フレームワーク不使用）
3. データ保存：ローカルのJSONファイルを使用
4. RESTful APIの実装：
   - GET /notes：全ての付箋を取得
   - POST /notes：新しい付箋を作成
   - PUT /notes/{note_id}：既存の付箋を更新
   - DELETE /notes/{note_id}：付箋を削除
5. フロントエンドとバックエンドの通信：Fetch APIを使用

### 非機能要件

1. レスポンシブデザイン：様々な画面サイズに対応
2. パフォーマンス：付箋の操作がスムーズに行える
3. データの永続性：アプリケーションを閉じても、再度開いたときに前回の状態が復元される

### 制約条件

1. データベースは使用せず、ローカルのJSONファイルでデータを管理する
2. ユーザー認証は実装しない（シングルユーザーを想定）
3. オフライン動作は考慮しない（常にサーバーとの接続が必要）

## 使用技術

- バックエンド: FastAPI
- フロントエンド: HTML, CSS, JavaScript (バニラJS)
- データ保存: ローカルJSONファイル

## アプリケーションの動作フロー

以下のシーケンス図は、このアプリケーションの主要な動作フローを示しています。

```mermaid
sequenceDiagram
    participant User
    participant Browser
    participant FastAPI
    participant JSONFile

    User->>Browser: アプリにアクセス
    Browser->>FastAPI: GET /app
    FastAPI-->>Browser: HTMLコンテンツ
    Browser->>FastAPI: GET /notes
    FastAPI->>JSONFile: 付箋データ読み込み
    JSONFile-->>FastAPI: 付箋データ
    FastAPI-->>Browser: 付箋データ (JSON)
    Browser->>Browser: 付箋を表示

    User->>Browser: 新規付箋作成
    Browser->>FastAPI: POST /notes
    FastAPI->>JSONFile: 新規付箋データ保存
    FastAPI-->>Browser: 新規付箋データ (JSON)
    Browser->>Browser: 新規付箋を表示

    User->>Browser: 付箋を編集
    Browser->>FastAPI: PUT /notes/{note_id}
    FastAPI->>JSONFile: 更新された付箋データ保存
    FastAPI-->>Browser: 更新結果

    User->>Browser: 付箋をドラッグ＆ドロップ
    Browser->>FastAPI: PUT /notes/{note_id}
    FastAPI->>JSONFile: 更新された付箋位置を保存
    FastAPI-->>Browser: 更新結果

    User->>Browser: 付箋を削除
    Browser->>FastAPI: DELETE /notes/{note_id}
    FastAPI->>JSONFile: 付箋データ削除
    FastAPI-->>Browser: 削除結果
    Browser->>Browser: 付箋表示を削除
```
```mermaid
classDiagram
    class StickyNoteCreate {
        +str content
        +int x
        +int y
        +str status
        +str color
    }
    class StickyNote {
        +str id
        +str content
        +int x
        +int y
        +str status
        +str color
    }
    class FastAPI {
        +get_notes()
        +create_note(note: StickyNoteCreate)
        +update_note(note_id: str, updated_note: StickyNoteCreate)
        +delete_note(note_id: str)
    }
    StickyNoteCreate <|-- StickyNote
    FastAPI ..> StickyNote : uses
    FastAPI ..> StickyNoteCreate : uses
```
## 実装

### バックエンド（FastAPI）

まず、必要なライブラリをインポートし、FastAPIアプリケーションを設定します。

```python
import json
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from typing import List
import uuid

app = FastAPI()

# 静的ファイルの提供
app.mount("/static", StaticFiles(directory="static"), name="static")
```

次に、付箋のデータモデルを定義します。

```python
class StickyNoteCreate(BaseModel):
    content: str = ""
    x: int = 0
    y: int = 0
    status: str = "todo"
    color: str = "#feff9c"  # デフォルトの色

class StickyNote(StickyNoteCreate):
    id: str

notes: List[StickyNote] = []

# JSONファイルからノートを読み込む
try:
    with open("notes.json", "r") as f:
        notes = [StickyNote(**note) for note in json.load(f)]
except FileNotFoundError:
    notes = []
```

APIエンドポイントを実装します。

```python
@app.get("/notes")
async def get_notes():
    return notes

@app.post("/notes")
async def create_note(note: StickyNoteCreate):
    new_note = StickyNote(id=str(uuid.uuid4()), **note.dict())
    notes.append(new_note)
    save_notes()
    return new_note

@app.put("/notes/{note_id}")
async def update_note(note_id: str, updated_note: StickyNoteCreate):
    for i, note in enumerate(notes):
        if note.id == note_id:
            notes[i] = StickyNote(id=note_id, **updated_note.dict())
            save_notes()
            return notes[i]
    raise HTTPException(status_code=404, detail="Note not found")

@app.delete("/notes/{note_id}")
async def delete_note(note_id: str):
    for i, note in enumerate(notes):
        if note.id == note_id:
            del notes[i]
            save_notes()
            return {"message": "Note deleted"}
    raise HTTPException(status_code=404, detail="Note not found")

def save_notes():
    with open("notes.json", "w") as f:
        json.dump([note.dict() for note in notes], f)
```

### フロントエンド（HTML, CSS, JavaScript）

フロントエンドは、HTMLテンプレート文字列として実装し、FastAPIのエンドポイントから提供します。

```python
html_content = """
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Kanban Sticky Notes</title>
    <style>
        body {
            font-family: 'Comic Sans MS', cursive, sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f0f0f0;
        }
        h1 {
            text-align: center;
            color: #333;
        }
        .board {
            display: flex;
            justify-content: space-around;
            margin-top: 20px;
        }
        .column {
            flex: 1;
            margin: 0 10px;
            background-color: #e0e0e0;
            border-radius: 10px;
            padding: 10px;
            min-height: 500px;
        }
        .column h2 {
            text-align: center;
            color: #444;
        }
        .sticky-note {
            position: relative;
            width: 200px;
            min-height: 100px;
            padding: 10px;
            margin: 10px auto;
            border-radius: 5px;
            box-shadow: 0 4px 8px rgba(0,0,0,0.1);
            cursor: move;
            transition: transform 0.1s;
        }
        .sticky-note:hover {
            transform: scale(1.05);
        }
        .sticky-note textarea {
            width: 100%;
            height: 80px;
            border: none;
            background-color: transparent;
            resize: none;
            font-family: inherit;
        }
        .delete-btn {
            position: absolute;
            top: 5px;
            right: 5px;
            cursor: pointer;
        }
        .color-picker {
            position: absolute;
            bottom: 5px;
            right: 5px;
        }
        #new-note-btn {
            display: block;
            margin: 20px auto;
            padding: 10px 20px;
            font-size: 16px;
            background-color: #4CAF50;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        #new-note-btn:hover {
            background-color: #45a049;
        }
    </style>
</head>
<body>
    <h1>🌈 Kanban Sticky Notes 🌈</h1>
    <button id="new-note-btn" onclick="createNote()">✨ New Note ✨</button>
    <div class="board">
        <div id="todo" class="column">
            <h2>📝 To Do</h2>
        </div>
        <div id="doing" class="column">
            <h2>🔨 Doing</h2>
        </div>
        <div id="done" class="column">
            <h2>✅ Done</h2>
        </div>
    </div>

    <script>
        const colors = ['#feff9c', '#ff7eb9', '#ff65a3', '#7afcff', '#fff740'];

        function createNote() {
            const note = {
                content: "",
                x: 0,
                y: 0,
                status: "todo",
                color: colors[Math.floor(Math.random() * colors.length)]
            };

            fetch('/notes', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(note),
            })
            .then(response => {
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                return response.json();
            })
            .then(data => {
                renderNote(data);
            })
            .catch(error => {
                console.error('Error:', error);
            });
        }

        function renderNote(note) {
            const noteElement = document.createElement('div');
            noteElement.className = 'sticky-note';
            noteElement.id = note.id;
            noteElement.style.backgroundColor = note.color;

            const textarea = document.createElement('textarea');
            textarea.value = note.content;
            textarea.oninput = () => updateNote(note.id, textarea.value, note.x, note.y, note.status, note.color);

            const deleteBtn = document.createElement('span');
            deleteBtn.className = 'delete-btn';
            deleteBtn.innerHTML = '❌';
            deleteBtn.onclick = () => deleteNote(note.id);

            const colorPicker = document.createElement('input');
            colorPicker.type = 'color';
            colorPicker.className = 'color-picker';
            colorPicker.value = note.color;
            colorPicker.onchange = (e) => {
                noteElement.style.backgroundColor = e.target.value;
                updateNote(note.id, textarea.value, note.x, note.y, note.status, e.target.value);
            };

            noteElement.appendChild(textarea);
            noteElement.appendChild(deleteBtn);
            noteElement.appendChild(colorPicker);

            noteElement.draggable = true;
            noteElement.ondragstart = dragStart;
            noteElement.ondragend = dragEnd;
            document.getElementById(note.status).appendChild(noteElement);
        }

        function updateNote(id, content, x, y, status, color) {
            fetch(`/notes/${id}`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({id, content, x, y, status, color}),
            })
            .then(response => {
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
            })
            .catch(error => {
                console.error('Error updating note:', error);
            });
        }

        function deleteNote(id) {
            fetch(`/notes/${id}`, {method: 'DELETE'})
            .then(response => {
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                document.getElementById(id).remove();
            })
            .catch(error => {
                console.error('Error deleting note:', error);
            });
        }

        function dragStart(e) {
            e.dataTransfer.setData('text/plain', e.target.id);
            setTimeout(() => {
                e.target.style.opacity = '0.5';
            }, 0);
        }

        function dragEnd(e) {
            e.target.style.opacity = '1';
        }

        document.querySelectorAll('.column').forEach(column => {
            column.ondragover = allowDrop;
            column.ondrop = drop;
        });

        function allowDrop(e) {
            e.preventDefault();
        }

        function drop(e) {
            e.preventDefault();
            const noteId = e.dataTransfer.getData('text');
            const noteElement
function drop(e) {
            e.preventDefault();
            const noteId = e.dataTransfer.getData('text');
            const noteElement = document.getElementById(noteId);
            const newStatus = e.target.closest('.column').id;
            e.target.closest('.column').appendChild(noteElement);
            const content = noteElement.querySelector('textarea').value;
            const color = noteElement.style.backgroundColor;
            updateNote(noteId, content, 0, 0, newStatus, color);
        }

        // Load existing notes
        fetch('/notes')
        .then(response => response.json())
        .then(notes => {
            notes.forEach(renderNote);
        })
        .catch(error => {
            console.error('Error loading notes:', error);
        });
    </script>
</body>
</html>
"""

@app.get("/app")
async def get_app():
    return HTMLResponse(content=html_content)
```

## アプリケーションの実行方法

1. 必要なライブラリをインストールします：
   ```
   pip install fastapi uvicorn
   ```

2. `main.py`ファイルを作成し、上記のコードを記述します。

3. 以下のコマンドでアプリケーションを実行します：
   ```
   uvicorn main:app --reload
   ```

4. ブラウザで `http://localhost:8000/app` にアクセスします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/f8a99a97-a5c5-c367-3140-5e0e0ad1150f.png)


## まとめ

このプロジェクトでは、FastAPIを使用してバックエンドを構築し、フロントエンドはバニラJavaScriptで実装しました。ドラッグ＆ドロップ機能や色の変更など、インタラクティブな要素を取り入れることで、使いやすい付箋アプリケーションを作成することができました。

主な学びポイントは以下の通りです：

1. FastAPIを使用したRESTful APIの実装
2. バニラJavaScriptによるドラッグ＆ドロップ機能の実装
3. フロントエンドとバックエンドの連携
4. JSONファイルを使用したシンプルなデータ永続化

このアプリケーションは、さらなる機能拡張の余地があります。例えば、以下のような改善点が考えられます：

- ユーザー認証の追加
- データベースを使用してより堅牢なデータ管理を行う
- 付箋の期限や優先度の設定機能
- 複数のボードの管理機能

FastAPIとJavaScriptを組み合わせることで、シンプルながら機能的なWebアプリケーションを素早く開発できることが分かりました。ぜひ、このプロジェクトを基にして、自分なりのアイデアを追加し、さらに発展させてみてください！

## 参考リンク

- [FastAPI公式ドキュメント](https://fastapi.tiangolo.com/)
- [MDN Web Docs: Drag and Drop API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API)
- [JavaScript.info: Drag'n'Drop with mouse events](https://javascript.info/mouse-drag-and-drop)

以上で、FastAPIを使用したドラッグ＆ドロップ可能な付箋アプリの作成方法の解説を終わります。