diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..827fcf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma +/prisma/*.db +/prisma/*.db-journal diff --git a/README.md b/README.md index 08f95d3..42e563d 100644 --- a/README.md +++ b/README.md @@ -1 +1,97 @@ -# todo-review-test \ No newline at end of file +# TODO管理アプリ + +簡易的なTODO管理Webアプリケーションです。 + +## 技術スタック + +- **フレームワーク**: Next.js 14 (App Router) +- **言語**: TypeScript +- **データベース**: SQLite (Prisma ORM) +- **スタイリング**: Tailwind CSS +- **テスト**: Vitest + Testing Library + +## 機能 + +- TODOの追加 +- TODOの一覧表示 +- TODOの完了/未完了の切り替え +- TODOの削除 +- 完了済みTODOと未完了TODOの分類表示 + +## セットアップ + +### 1. 依存関係のインストール + +```bash +npm install +``` + +### 2. 環境変数の設定 + +`.env`ファイルを作成し、以下の内容を追加してください: + +``` +DATABASE_URL="file:./prisma/dev.db" +``` + +### 3. データベースの初期化 + +```bash +npm run db:push +npm run db:generate +``` + +### 4. 開発サーバーの起動 + +```bash +npm run dev +``` + +ブラウザで [http://localhost:3000](http://localhost:3000) を開いてください。 + +## スクリプト + +- `npm run dev` - 開発サーバーを起動 +- `npm run build` - プロダクションビルド +- `npm run start` - プロダクションサーバーを起動 +- `npm run lint` - ESLintでコードをチェック +- `npm run test` - テストを実行 +- `npm run test:watch` - ウォッチモードでテストを実行 +- `npm run db:push` - データベーススキーマをプッシュ +- `npm run db:generate` - Prismaクライアントを生成 +- `npm run db:studio` - Prisma Studioを起動 + +## プロジェクト構造 + +``` +todo-review-test/ +├── app/ +│ ├── api/ +│ │ └── todos/ # TODO APIエンドポイント +│ ├── globals.css # グローバルスタイル +│ ├── layout.tsx # ルートレイアウト +│ └── page.tsx # ホームページ +├── components/ +│ ├── TodoForm.tsx # TODO追加フォーム +│ ├── TodoList.tsx # TODO一覧 +│ └── TodoItem.tsx # TODOアイテム +├── lib/ +│ └── prisma.ts # Prismaクライアント +├── prisma/ +│ └── schema.prisma # データベーススキーマ +└── tests/ # テストファイル +``` + +## 拡張性 + +このアプリケーションは以下の点で拡張しやすく設計されています: + +- **型安全性**: TypeScriptとPrismaによる完全な型安全性 +- **モジュール設計**: コンポーネントとAPIルートの分離 +- **データベース**: SQLiteからPostgreSQLなどへの移行が容易 +- **テスト**: 包括的なテストカバレッジ +- **バリデーション**: Zodによる入力検証 + +## ライセンス + +MIT diff --git a/app/api/todos/[id]/route.ts b/app/api/todos/[id]/route.ts new file mode 100644 index 0000000..cfa085c --- /dev/null +++ b/app/api/todos/[id]/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const updateTodoSchema = z.object({ + title: z.string().min(1, 'タイトルは必須です').max(200, 'タイトルは200文字以内で入力してください').optional(), + description: z.string().max(1000, '説明は1000文字以内で入力してください').optional(), + completed: z.boolean().optional(), +}) + +// GET: 特定のTODOを取得 +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const todo = await prisma.todo.findUnique({ + where: { id }, + }) + + if (!todo) { + return NextResponse.json( + { error: 'TODOが見つかりません' }, + { status: 404 } + ) + } + + return NextResponse.json(todo) + } catch (error) { + console.error('Error fetching todo:', error) + return NextResponse.json( + { error: 'TODOの取得に失敗しました' }, + { status: 500 } + ) + } +} + +// PATCH: TODOを更新 +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const validatedData = updateTodoSchema.parse(body) + + const todo = await prisma.todo.update({ + where: { id }, + data: validatedData, + }) + + return NextResponse.json(todo) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ) + } + if (error instanceof Error && error.message.includes('Record to update not found')) { + return NextResponse.json( + { error: 'TODOが見つかりません' }, + { status: 404 } + ) + } + console.error('Error updating todo:', error) + return NextResponse.json( + { error: 'TODOの更新に失敗しました' }, + { status: 500 } + ) + } +} + +// DELETE: TODOを削除 +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + await prisma.todo.delete({ + where: { id }, + }) + + return NextResponse.json({ message: 'TODOを削除しました' }) + } catch (error) { + if (error instanceof Error && error.message.includes('Record to delete does not exist')) { + return NextResponse.json( + { error: 'TODOが見つかりません' }, + { status: 404 } + ) + } + console.error('Error deleting todo:', error) + return NextResponse.json( + { error: 'TODOの削除に失敗しました' }, + { status: 500 } + ) + } +} diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts new file mode 100644 index 0000000..fa04a16 --- /dev/null +++ b/app/api/todos/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const todoSchema = z.object({ + title: z.string().min(1, 'タイトルは必須です').max(200, 'タイトルは200文字以内で入力してください'), + description: z.string().max(1000, '説明は1000文字以内で入力してください').optional(), +}) + +// GET: すべてのTODOを取得 +export async function GET() { + try { + const todos = await prisma.todo.findMany({ + orderBy: { + createdAt: 'desc', + }, + }) + return NextResponse.json(todos) + } catch (error) { + console.error('Error fetching todos:', error) + return NextResponse.json( + { error: 'TODOの取得に失敗しました' }, + { status: 500 } + ) + } +} + +// POST: 新しいTODOを作成 +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const validatedData = todoSchema.parse(body) + + const todo = await prisma.todo.create({ + data: { + title: validatedData.title, + description: validatedData.description, + }, + }) + + return NextResponse.json(todo, { status: 201 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0].message }, + { status: 400 } + ) + } + console.error('Error creating todo:', error) + return NextResponse.json( + { error: 'TODOの作成に失敗しました' }, + { status: 500 } + ) + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..13d40b8 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + color: var(--foreground); + background: var(--background); + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..73da52a --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "TODO管理アプリ", + description: "簡易的なTODO管理Webアプリケーション", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..0b1c216 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,20 @@ +import TodoList from '@/components/TodoList' +import TodoForm from '@/components/TodoForm' + +export default function Home() { + return ( +
+
+

+ TODO管理アプリ +

+
+ +
+ +
+
+
+
+ ) +} diff --git a/components/TodoForm.tsx b/components/TodoForm.tsx new file mode 100644 index 0000000..304ab71 --- /dev/null +++ b/components/TodoForm.tsx @@ -0,0 +1,91 @@ +'use client' + +import { useState } from 'react' + +export default function TodoForm() { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setIsSubmitting(true) + + try { + const response = await fetch('/api/todos', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: title.trim(), + description: description.trim() || undefined, + }), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || 'TODOの作成に失敗しました') + } + + // フォームをリセット + setTitle('') + setDescription('') + + // ページをリロードしてTODOリストを更新 + window.location.reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'TODOの作成に失敗しました') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ + setTitle(e.target.value)} + required + maxLength={200} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder="TODOのタイトルを入力" + disabled={isSubmitting} + /> +
+
+ +