Skip to content

Commit 0729be1

Browse files
Merge pull request #33 from kmc-jp/feature/article-posting-page
ヘッダーに「記事を書く」リンクを追加し、記事投稿機能を実装
2 parents e03bf20 + 8357453 commit 0729be1

File tree

4 files changed

+169
-2
lines changed

4 files changed

+169
-2
lines changed

src/app/components/header.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export default function Header({ onMenuClick }: HeaderProps) {
2121
<Link href="/search">
2222
<Image src="/search.svg" alt="Search" width={24} height={24} className="cursor-pointer" />
2323
</Link>
24+
<Link
25+
href="/post"
26+
className="flex items-center px-4 py-2 rounded-2xl bg-sky-600 hover:bg-sky-700 transition-colors duration-200">
27+
<span className="text-s font-bold text-white">記事を書く</span>
28+
</Link>
2429
<div className="flex items-center px-4 py-2 rounded-2xl border border-gray-300 hover:bg-gray-200 transition-colors duration-200">
2530
<Link
2631
href="/auth"

src/app/post/page.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use client'
2+
3+
import { useState, useId } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { postArticle } from '@/lib/api';
6+
import { ApiError, ApiErrorType } from '@/lib/types';
7+
8+
export default function PostPage() {
9+
const [author, setAuthor] = useState('');
10+
const [title, setTitle] = useState('');
11+
const [content, setContent] = useState('');
12+
const [isSubmitting, setIsSubmitting] = useState(false);
13+
const [error, setError] = useState<string | null>(null);
14+
const router = useRouter();
15+
const authorId = useId();
16+
const titleId = useId();
17+
const contentId = useId();
18+
19+
const handleSubmit = async (e: React.FormEvent) => {
20+
e.preventDefault();
21+
setError(null);
22+
23+
if (!author.trim() || !title.trim() || !content.trim()) {
24+
setError('すべての項目を入力してください');
25+
return;
26+
}
27+
28+
setIsSubmitting(true);
29+
30+
try {
31+
const result = await postArticle(author.trim(), title.trim(), content.trim());
32+
33+
if (result instanceof ApiError) {
34+
switch (result.errorType) {
35+
case ApiErrorType.BAD_REQUEST:
36+
setError('存在しないユーザー名、もしくは不正な入力です。もう一度ご確認ください。');
37+
setIsSubmitting(false);
38+
return;
39+
default:
40+
setError('記事の投稿に失敗しました。もう一度お試しください。');
41+
setIsSubmitting(false);
42+
return;
43+
}
44+
}
45+
46+
router.push(`/articles/${result.id}`);
47+
} catch {
48+
setError('予期しないエラーが発生しました');
49+
setIsSubmitting(false);
50+
}
51+
};
52+
53+
return (
54+
<div className="bg-white mx-4 mt-4 px-7 pt-10 pb-20 rounded-sm">
55+
<h1 className="text-3xl font-bold text-gray-800 mb-8">新規記事投稿</h1>
56+
57+
<form onSubmit={handleSubmit} className="space-y-6">
58+
<div>
59+
<label htmlFor={authorId} className="block text-sm font-medium text-gray-700 mb-2">
60+
ユーザー名
61+
</label>
62+
<input
63+
type="text"
64+
id={authorId}
65+
value={author}
66+
onChange={(e) => setAuthor(e.target.value)}
67+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-sky-500"
68+
placeholder="あなたの名前を入力"
69+
disabled={isSubmitting}
70+
/>
71+
</div>
72+
73+
<div>
74+
<label htmlFor={titleId} className="block text-sm font-medium text-gray-700 mb-2">
75+
タイトル
76+
</label>
77+
<input
78+
type="text"
79+
id={titleId}
80+
value={title}
81+
onChange={(e) => setTitle(e.target.value)}
82+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-sky-500"
83+
placeholder="記事のタイトルを入力"
84+
disabled={isSubmitting}
85+
/>
86+
</div>
87+
88+
<div>
89+
<label htmlFor={contentId} className="block text-sm font-medium text-gray-700 mb-2">
90+
内容
91+
</label>
92+
<textarea
93+
id={contentId}
94+
value={content}
95+
onChange={(e) => setContent(e.target.value)}
96+
rows={10}
97+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-sky-500"
98+
placeholder="記事の内容を入力"
99+
disabled={isSubmitting}
100+
/>
101+
</div>
102+
103+
{error && (
104+
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
105+
{error}
106+
</div>
107+
)}
108+
109+
<div className="flex justify-end">
110+
<button
111+
type="button"
112+
onClick={() => router.back()}
113+
className="mr-4 px-6 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
114+
disabled={isSubmitting}
115+
>
116+
キャンセル
117+
</button>
118+
<button
119+
type="submit"
120+
className="px-6 py-2 bg-sky-600 text-white rounded-md hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed"
121+
disabled={isSubmitting}
122+
>
123+
{isSubmitting ? '投稿中...' : '投稿する'}
124+
</button>
125+
</div>
126+
</form>
127+
</div>
128+
);
129+
}

src/lib/api.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { ApiError, ApiErrorType, ArticleResponse, toArticleResponse, toArticlesResponse } from "./types";
1+
import { ApiError, ApiErrorType, type ArticleResponse, toArticleResponse, toArticlesResponse } from "./types";
22

33
const API_BASE_URL = '/api';
44

5-
async function fetchAPI(path: string): Promise<any | ApiError> {
5+
async function fetchAPI(path: string): Promise<Response | ApiError> {
66
const url = `${API_BASE_URL}${path}`;
77
const res = await fetch(url, {
88
method: 'GET',
@@ -65,4 +65,36 @@ export async function searchArticles(query: string): Promise<ArticleResponse[]|
6565
}
6666

6767
return await toArticlesResponse(rawResponse);
68+
}
69+
70+
export async function postArticle(author: string, title: string, content: string): Promise<ArticleResponse | ApiError> {
71+
const url = `${API_BASE_URL}/articles`;
72+
73+
try {
74+
const res = await fetch(url, {
75+
method: 'POST',
76+
headers: {
77+
'Content-Type': 'application/json'
78+
},
79+
body: JSON.stringify({
80+
author: author,
81+
title: title,
82+
content: content
83+
})
84+
});
85+
86+
if (!res.ok) {
87+
if (res.status === 404) {
88+
return new ApiError(ApiErrorType.NOT_FOUND);
89+
}
90+
if (res.status === 400) {
91+
return new ApiError(ApiErrorType.BAD_REQUEST);
92+
}
93+
return new ApiError(ApiErrorType.FAILED_REQUEST);
94+
}
95+
96+
return await toArticleResponse(res);
97+
} catch {
98+
return new ApiError(ApiErrorType.FAILED_REQUEST);
99+
}
68100
}

src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const ApiErrorType = {
22
FAILED_VALIDATION: 0,
33
NOT_FOUND: 1,
44
FAILED_REQUEST: 2,
5+
BAD_REQUEST: 3,
56
} as const;
67

78
export type ApiErrorType = (typeof ApiErrorType)[keyof typeof ApiErrorType];

0 commit comments

Comments
 (0)