1
2

はじめに

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

要件と仕様

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

image.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ファイル

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

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

実装

バックエンド(FastAPI)

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

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")

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

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エンドポイントを実装します。

@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のエンドポイントから提供します。

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

まとめ

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

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

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

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

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

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

参考リンク

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

1
2
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
2