🐷

話題の v0 を使って、アプリ(Trelloクローン)作ってみた。(next、supabase、cursor使用)

2024/09/04に公開

はじめに

みなさんはv0を使ってますか!!
v0とはvercelが開発したAI駆動のUI生成ツールです。このようなものは他にも、createやclaudeなどがあります。特に、v0はUI生成に特化したツールになっております。
今回は実際に、こちらを使ってTrello(カンバン型TODO管理アプリ)を再現してみました。
再現したと言っても、メインのカンバンの部分だけですけど。。。

前提条件

  • [Next.js、shadcn/ui、supabase](必須ではない)

本文

1. [まず、どんなものを作るのか]

  • 下記の画像を与えます。
  • プロンプト
この画像はTrelloの画面のスクリーンショットです。
画像のUIを完全再現してください。

たったこれだけなのに、記事にしているのがもうしわけないですね、、、

  • 成果物

少しUIは違いますが、メインのカンバン部分はぱっと見再現してもらえました!
ただ、この時点ではカードを追加する機能やリストの追加機能、ドラッグ&ドロップなどの機能は基本的に動きません。
ここから、修正していきます!

2. [機能、見た目共に本家に近づけていく]

  • 機能として足りない&動かないところを書き出す。

    • 例えば、カードを追加、ドラッグ&ドロップなど
  • あまり多くの指示を一度に与えると修正の精度が悪くなる気がするので、3つ程度に収めておくといいかも(自分のプロンプトが悪いだけかもです。)

  • そんなこんなで、v17(多いかも!!)ほどで、こんな感じにできました。

    もっと似せることはできるけど、こんくらいが後々いじりやすいなと思ったのでこれくらいで。
    ちなみにこのときに、修正用のプロンプトと一緒に本家のコンポーネントコピペして、ここを???みたいに修正してとやるとめっちゃ精度高く修正できた。
    こんな感じで、プロンプトと一緒にやってあげる。よくあるよね。

ただ、v0上で使えるパッケージは限られているので、例えば今回のドラッグ&ドロップは実装してくれるけど、機能としてはプレビューできないです!
てことで、cursorで修正していきます。

3. [cursorで修正]

ここでは、ここまで作成したものをcursor上で修正していきます。

  • 今回はnextでsupabaseも使用しようと思っていたので、下記コマンドを実行(ただnextプロジェクト立てたい人は、-e with-supabaseはいりません。)
npx create-next-app -e with-supabase
  • ここから、v0のインストールボタンを押して、コードをコピーします。
  • ターミナルでルートディレクトリにいる状態でペーストしましょう。
  • ここで言い忘れていたのですが、v0はデフォルトでshadcn/uiを使用しています。ここでは、必要になってくる依存関係を一括でインストールできます。
    色々、質問されるのですが、一旦yesで続けてください。
  • shadcn/uiのコンポーネントファイルが、components/ui配下に作成されます。また、components配下に、単一ページのコンポーネントが作成されます。

・page.tsx

import { TrelloBoard } from "@/components/trello-board";

export default function Home() {
  return (
    <main className="">
      <TrelloBoard />
    </main>
  );
}

・components/trello-board

'use client'

import React, { useState, useRef, useEffect } from 'react'
import { Search, Bell, HelpCircle, User, ChevronDown, Star, Users, Zap, MoreHorizontal, Plus, X, Edit, Trash, Check } from 'lucide-react'
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  DndContext,
  DragOverlay,
  useSensors,
  useSensor,
  PointerSensor,
  closestCorners,
  DragStartEvent,
  DragEndEvent,
  useDraggable,
  useDroppable,
} from '@dnd-kit/core'
import {
  SortableContext,
  arrayMove,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useSortable } from '@dnd-kit/sortable'

type Card = {
  id: string
  content: string
}

type List = {
  id: string
  title: string
  cards: Card[]
}

function SortableCard({ card, listId, onEdit, onDelete }: { card: Card; listId: string; onEdit: () => void; onDelete: () => void }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: card.id, data: { listId } })

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  }

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className="bg-white p-2 rounded-lg shadow mb-2 cursor-move"
    >
      <div className="flex justify-between items-center">
        <span className="text-[#273859]">{card.content}</span>
        <div className="flex space-x-2">
          <button
            className="text-gray-500 hover:text-blue-500"
            onClick={(e) => {
              e.stopPropagation();
              onEdit();
            }}
            aria-label="カードを編集"
          >
            <Edit className="w-4 h-4" />
          </button>
          <button
            className="text-gray-500 hover:text-red-500"
            onClick={(e) => {
              e.stopPropagation();
              onDelete();
            }}
            aria-label="カードを削除"
          >
            <Trash className="w-4 h-4" />
          </button>
        </div>
      </div>
    </div>
  )
}

function DroppableList({ list, children }: { list: List; children: React.ReactNode }) {
  const { setNodeRef } = useDroppable({
    id: list.id,
    data: { type: 'list', listId: list.id },
  });

  return (
    <div ref={setNodeRef} className="bg-[#f1f2f4] rounded-lg w-72 flex-shrink-0 shadow-md">
      {children}
    </div>
  );
}

function DropIndicator() {
  return (
    <div className="bg-blue-200 border-2 border-blue-400 border-dashed rounded-lg p-2 mb-2">
      <div className="h-8 flex items-center justify-center text-blue-500">
        ここにドロップ
      </div>
    </div>
  );
}

export function TrelloBoard() {
  const [lists, setLists] = useState<List[]>([
    { id: '1', title: 'To Do', cards: [{ id: '1', content: 'プロジェクト計画' }, { id: '2', content: 'キックオフミーティング' }] },
    { id: '2', title: '作業中', cards: [] },
    { id: '3', title: '完了', cards: [] },
  ])
  const [searchTerm, setSearchTerm] = useState('')
  const [editingCard, setEditingCard] = useState<{ listId: string, cardId: string } | null>(null)
  const [editingList, setEditingList] = useState<string | null>(null)
  const [addingCard, setAddingCard] = useState<string | null>(null)
  const [newCardContent, setNewCardContent] = useState('')
  const [isAddingList, setIsAddingList] = useState(false)
  const [newListTitle, setNewListTitle] = useState('')
  const [activeCard, setActiveCard] = useState<Card | null>(null)
  const [activeDroppable, setActiveDroppable] = useState<string | null>(null)

  const addCardRef = useRef<HTMLDivElement>(null)
  const addListRef = useRef<HTMLDivElement>(null)

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8,
      },
    })
  )

  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (addingCard && addCardRef.current && !addCardRef.current.contains(event.target as Node)) {
        setAddingCard(null)
        setNewCardContent('')
      }
      if (isAddingList && addListRef.current && !addListRef.current.contains(event.target as Node)) {
        setIsAddingList(false)
        setNewListTitle('')
      }
    }

    document.addEventListener('mousedown', handleClickOutside)
    return () => {
      document.removeEventListener('mousedown', handleClickOutside)
    }
  }, [addingCard, isAddingList])

  const addCard = (listId: string, content: string) => {
    if (content.trim() === '') return
    const newCard = { id: Date.now().toString(), content }
    setLists(lists.map(list => 
      list.id === listId ? { ...list, cards: [...list.cards, newCard] } : list
    ))
    setNewCardContent('')
    setAddingCard(null)
  }

  const updateCard = (listId: string, cardId: string, content: string) => {
    setLists(lists.map(list => 
      list.id === listId ? {
        ...list,
        cards: list.cards.map(card => 
          card.id === cardId ? { ...card, content } : card
        )
      } : list
    ))
    setEditingCard(null)
  }

  const deleteCard = (listId: string, cardId: string) => {
    setLists(lists.map(list => 
      list.id === listId ? {
        ...list,
        cards: list.cards.filter(card => card.id !== cardId)
      } : list
    ))
  }

  const addList = () => {
    if (newListTitle.trim() === '') return
    const newList: List = {
      id: Date.now().toString(),
      title: newListTitle,
      cards: []
    }
    setLists([...lists, newList])
    setNewListTitle('')
    setIsAddingList(false)
  }

  const updateListTitle = (listId: string, title: string) => {
    setLists(lists.map(list => 
      list.id === listId ? { ...list, title } : list
    ))
    setEditingList(null)
  }

  const deleteList = (listId: string) => {
    setLists(lists.filter(list => list.id !== listId))
  }

  const filteredLists = lists.map(list => ({
    ...list,
    cards: list.cards.filter(card => 
      card.content.toLowerCase().includes(searchTerm.toLowerCase())
    )
  }))

  const handleDragStart = (event: DragStartEvent) => {
    const { active } = event
    const activeList = lists.find(list => list.cards.some(card => card.id === active.id))
    if (activeList) {
      const activeCard = activeList.cards.find(card => card.id === active.id)
      if (activeCard) {
        setActiveCard(activeCard)
      }
    }
  }

  const handleDragOver = (event: DragOverEvent) => {
    const { over } = event;
    setActiveDroppable(over ? over.id as string : null);
  }

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event

    if (!over) return

    const activeListId = active.data.current?.listId
    const overListId = over.id

    if (activeListId !== overListId) {
      setLists(lists => {
        const activeList = lists.find(list => list.id === activeListId)
        const overList = lists.find(list => list.id === overListId)

        if (!activeList || !overList) return lists

        const activeCardIndex = activeList.cards.findIndex(card => card.id === active.id)
        const overCardIndex = overList.cards.findIndex(card => card.id === over.id)

        const newLists = lists.map(list => {
          if (list.id === activeListId) {
            return {
              ...list,
              cards: list.cards.filter(card => card.id !== active.id)
            }
          }
          if (list.id === overListId) {
            const newCards = [...list.cards]
            newCards.splice(overCardIndex >= 0 ? overCardIndex : list.cards.length, 0, activeList.cards[activeCardIndex])
            return {
              ...list,
              cards: newCards
            }
          }
          return list
        })

        return newLists
      })
    } else {
      setLists(lists => {
        const activeList = lists.find(list => list.id === activeListId)
        if (!activeList) return lists

        const oldIndex = activeList.cards.findIndex(card => card.id === active.id)
        const newIndex = activeList.cards.findIndex(card => card.id === over.id)

        return lists.map(list => {
          if (list.id === activeListId) {
            return {
              ...list,
              cards: arrayMove(list.cards, oldIndex, newIndex)
            }
          }
          return list
        })
      })
    }

    setActiveCard(null)
    setActiveDroppable(null)
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="flex flex-col h-screen bg-[#cd5a91]">
        <header className="flex items-center justify-between px-4 py-2 bg-[#b84f80]">
          <div className="flex items-center space-x-4">
            <button className="text-white">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                width="24"
                height="24"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <rect width="18" height="18" x="3" y="3" rx="2" />
                <path d="M7 7h.01" />
                <path d="M7 12h.01" />
                <path d="M7 17h.01" />
                <path d="M12 7h5" />
                <path d="M12 12h5" />
                <path d="M12 17h5" />
              </svg>
            </button>
            <img src="/placeholder.svg" alt="Trello Logo" className="h-6" />
            <div className="flex items-center space-x-2">
              <span className="text-white font-semibold">ワークスペース</span>
              <ChevronDown className="w-4 h-4 text-white" />
            </div>
            <div className="flex items-center space-x-2">
              <span className="text-white font-semibold">最近</span>
              <ChevronDown className="w-4 h-4 text-white" />
            </div>
            <div className="flex items-center space-x-2">
              <span className="text-white font-semibold">スター付き</span>
              <ChevronDown className="w-4 h-4 text-white" />
            </div>
            <div className="flex items-center space-x-2">
              <span className="text-white font-semibold">テンプレート</span>
              <ChevronDown className="w-4 h-4 text-white" />
            </div>
            <button className="bg-[#0052cc] text-white px-3 py-1 rounded hover:bg-[#0043a7]">作成</button>
          </div>
          <div className="flex items-center space-x-4">
            <div className="relative">
              <Search className="w-5 h-5 text-white absolute left-3 top-1/2 transform -translate-y-1/2" />
              <input
                type="text"
                placeholder="検索"
                className="bg-[#b84f80] text-white pl-10 pr-4 py-1 rounded w-64 placeholder-gray-300"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
              />
            </div>
            <Bell className="w-6 h-6 text-white" />
            <HelpCircle className="w-6 h-6 text-white" />
            <User className="w-6 h-6 text-white" />
          </div>
        </header>
        <div className="flex-1 overflow-hidden">
          <div className="flex items-center space-x-4 px-4 py-2">
            <div className="flex items-center space-x-2">
              <div className="w-8 h-8 bg-[#b84f80] rounded-sm flex items-center justify-center text-white font-bold">T</div>
              <span className="text-white font-bold text-lg">Trello ワークスペース</span>
              <span className="text-white text-xs bg-[#b84f80] px-1 rounded">無料</span>
            </div>
            <ChevronDown className="w-4 h-4 text-white" />
            <Star className="w-5 h-5 text-white" />
            <Users className="w-5 h-5 text-white" />
            <div className="flex-1" />
            <Zap className="w-5 h-5 text-white" />
            <div className="h-6 w-px bg-white/30" />
            <button className="flex items-center space-x-1 bg-[#0052cc] text-white px-3 py-1 rounded hover:bg-[#0043a7]">
              <Users className="w-4 h-4" />
              <span>共有する</span>
            </button>
            <MoreHorizontal className="w-5 h-5 text-white" />
          </div>
          <div className="flex space-x-4 p-4 overflow-x-auto items-start">
            {filteredLists.map(list => (
              <DroppableList key={list.id} list={list}>
                <div className="p-3 flex justify-between items-center">
                  {editingList === list.id ? (
                    <input
                      type="text"
                      className="border rounded px-2 py-1 w-full mr-2 text-sm text-[#182a4d]"
                      value={list.title}
                      onChange={(e) => setLists(lists.map(l => l.id === list.id ? { ...l, title: e.target.value } : l))}
                      onBlur={() => updateListTitle(list.id, list.title)}
                      onKeyPress={(e) => e.key === 'Enter' && updateListTitle(list.id, list.title)}
                      autoFocus
                    />
                  ) : (
                    <span 
                      onClick={() => setEditingList(list.id)}
                      className="text-[#182a4d] font-semibold text-sm cursor-pointer"
                    >
                      {list.title}
                    </span>
                  )}
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button variant="ghost" className="h-8 w-8 p-0">
                        <span className="sr-only">メニューを開く</span>
                        <MoreHorizontal className="h-4 w-4" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      <DropdownMenuItem onClick={() => setEditingList(list.id)}>
                        リスト名を編集
                      </DropdownMenuItem>
                      <DropdownMenuItem onClick={() => deleteList(list.id)}>
                        リストを削除
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </div>
                <div className="px-2 pb-2">
                  <SortableContext items={list.cards.map(card => card.id)} strategy={verticalListSortingStrategy}>
                    {list.cards.map(card => (
                      editingCard?.listId === list.id && editingCard?.cardId === card.id ? (
                        <div key={card.id} className="bg-white p-2 rounded-lg shadow mb-2">
                          <input
                            type="text"
                            className="border rounded px-2 py-1 w-full mr-2 text-[#273859]"
                            value={card.content}
                            onChange={(e) => setLists(lists.map(l => 
                              l.id === list.id ? {
                                ...l,
                                cards: l.cards.map(c => c.id === card.id ? { ...c, content: e.target.value } : c)
                              } : l
                            ))}
                            onBlur={() => updateCard(list.id, card.id, card.content)}
                            onKeyPress={(e) => e.key === 'Enter' && updateCard(list.id, card.id, card.content)}
                            autoFocus
                          />
                        </div>
                      ) : (
                        <SortableCard
                          key={card.id}
                          card={card}
                          listId={list.id}
                          onEdit={() => setEditingCard({ listId: list.id, cardId: card.id })}
                          onDelete={() => deleteCard(list.id, card.id)}
                        />
                      )
                    ))}
                    {activeDroppable === list.id && list.cards.length === 0 && <DropIndicator />}
                  </SortableContext>
                  {addingCard === list.id ? (
                    <div ref={addCardRef} className="bg-white p-2 rounded-lg shadow">
                      <input
                        type="text"
                        className="w-full p-2 rounded-lg mb-2 focus:outline-none border border-gray-300 text-[#273859]"
                        placeholder="このカードに名前を入力..."
                        value={newCardContent}
                        onChange={(e) => setNewCardContent(e.target.value)}
                        autoFocus
                      />
                      <div className="flex justify-between items-center">
                        <button
                          className="bg-[#0052cc] text-white px-3 py-1 rounded-md hover:bg-[#0043a7]"
                          onClick={() => addCard(list.id, newCardContent)}
                        >
                          カードを追加
                        </button>
                        <button
                          className="text-gray-500 hover:text-gray-700"
                          onClick={() => setAddingCard(null)}
                        >
                          <X className="w-6 h-6" />
                        </button>
                      </div>
                    </div>
                  ) : (
                    <button
                      className="flex items-center space-x-2 text-gray-600 hover:bg-gray-200 w-full p-2 rounded-lg transition-colors duration-200"
                      onClick={() => setAddingCard(list.id)}
                    >
                      <Plus className="w-5 h-5" />
                      <span>カードを追加</span>
                    </button>
                  )}
                </div>
              </DroppableList>
            ))}
            {isAddingList ? (
              <div ref={addListRef} className="bg-[#f1f2f4] rounded-lg w-72 flex-shrink-0 shadow-md p-2">
                <input
                  type="text"
                  placeholder="リストのタイトルを入力"
                  className="w-full p-2 border rounded-lg mb-2 text-[#273859]"
                  value={newListTitle}
                  onChange={(e) => setNewListTitle(e.target.value)}
                  onKeyPress={(e) => e.key === 'Enter' && addList()}
                  autoFocus
                />
                <div className="flex justify-between">
                  <button
                    className="bg-[#0052cc] text-white px-4 py-1 rounded-lg hover:bg-[#0043a7]"
                    onClick={addList}
                  >
                    リストを追加
                  </button>
                  <button
                    className="text-gray-500 hover:text-gray-700"
                    onClick={() => setIsAddingList(false)}
                  >
                    <X className="w-6 h-6" />
                  </button>
                </div>
              </div>
            ) : (
              <button
                className="bg-[#ffffff3d] text-white rounded-lg w-72 flex-shrink-0 p-2 flex items-center space-x-2 hover:bg-[#ffffff52] transition-colors duration-200"
                onClick={() => setIsAddingList(true)}
              >
                <Plus className="w-5 h-5" />
                <span>もう1つリストを追加</span>
              </button>
            )}
          </div>
        </div>
      </div>
      <DragOverlay>
        {activeCard ? (
          <div className="bg-white p-2 rounded-lg shadow opacity-80">
            {activeCard.content}
          </div>
        ) : null}
      </DragOverlay>
    </DndContext>
  )
}
  • ドラッグ&ドロップの修正をcursor composerなどで修正していきます。(詳しくはやりません。要望があればやります!)
    ドラッグ&ドロップで少しバグはありますが、一応実装できました!ここまでで、大体1時間かからないくらいです!
    ファイルの分割が課題ですが、後ほど実装した際は追記していきます!

注意点

  • [v0上ではプレビューできるパッケージが限られている。]
  • [cursor composerでの修正は、認識してないうちにファイルが作られてしまう可能性があるので、gitで変更点を確認をする。(あんまり説明はしてないけど。。。)]

まとめ

v0使用して1時間ほどで作ってみました。小さな課題はあるものの、コンポーネント単位のUI生成なら今のところ最強に感じます。そして何より楽しいです。。。!
お遊び感覚で使ってみてください!

参考資料


Discussion