はじめに
初めましての人もそうでない人もこんにちは!
最近友人とお風呂に入る時は先にシャワーを浴びて湯船に浸かるか掛け湯だけして湯船に浸かるかで討論をしてきました!
皆さんはどちらが先ですか?私は掛け湯だけして湯船に入る派ですね!
さてそんな関係ないことは置いといて今回はGoとTypeScriptで質問サイトを作成していきたいと思います!
使用技術
フロントエンド
- React
- TypeScript
バックエンド
- Go
- Supabase
DB構造
questions
テーブル
カラム名 | データ型 | 説明 |
---|---|---|
id | uuid | 主キー(UUID) |
title | text | 質問のタイトル |
contents | text | 質問の内容 |
comments
テーブル
カラム名 | データ型 | 説明 |
---|---|---|
id | uuid | 主キー(UUID) |
question_id | uuid |
questions テーブルとの外部キー |
created_at | timestamp | コメントの作成日時 |
content | text | コメントの内容 |
主なディレクトリ構成
.quest/
├── backend/
│ ├── main.go
│ ├── .env
│ ├── go.mod
│ └── go.sum
└── frontend/
├──src/
... ├── App.tsx
├── Answer.tsx
├── Home.tsx
...
何をしたいのか
- タイトルとその内容を入力する画面を追加して投稿する
- 投稿されたタイトルをHome画面にリストとして表示
- タイトルクリック後、詳細表示
作ってみよう!
フロントエンド
npx create-react-app frontend --template typescript
cd frontend
npm install react-router-dom
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Send } from 'lucide-react';
const App: React.FC = () => {
const [title, setTitle] = useState<string>('');
const [contents, setContents] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<boolean>(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
const question = { id: crypto.randomUUID(), title, contents };
try {
const response = await fetch('http://localhost:8080/questions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(question),
});
if (!response.ok) {
throw new Error('質問の投稿に失敗しました');
}
setSuccess(true);
setTitle('');
setContents('');
setTimeout(() => {
navigate('/');
}, 2000);
} catch (err) {
setError((err as Error).message);
}
};
return (
<div className="container mx-auto p-8 max-w-6xl">
<button
onClick={() => navigate('/')}
className="mb-8 bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md flex items-center text-lg transition duration-300"
>
<ArrowLeft className="inline-block mr-2" size={24} />
戻る
</button>
<div className="bg-white rounded-lg shadow-md p-8">
<h1 className="text-3xl font-bold mb-8 text-gray-800">質問を投稿する</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="title" className="block mb-2 text-xl text-gray-700">タイトル:</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="w-full p-4 text-lg border rounded-md shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
/>
</div>
<div>
<label htmlFor="contents" className="block mb-2 text-xl text-gray-700">内容:</label>
<textarea
id="contents"
value={contents}
onChange={(e) => setContents(e.target.value)}
required
className="w-full p-4 text-lg border rounded-md shadow-sm h-64 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
/>
</div>
<button type="submit" className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-md flex items-center text-lg transition duration-300">
<Send className="inline-block mr-2" size={24} />
投稿
</button>
</form>
{error && <p className="text-red-500 mt-6 text-lg">{error}</p>}
{success && <p className="text-green-500 mt-6 text-lg">質問が正常に投稿されました!</p>}
</div>
</div>
);
};
export default App;
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Search, Plus } from 'lucide-react';
type Question = {
id: string;
title: string;
contents: string;
};
const Home: React.FC = () => {
const [questions, setQuestions] = useState<Question[]>([]);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const navigate = useNavigate();
useEffect(() => {
const fetchQuestions = async () => {
try {
const response = await fetch('http://localhost:8080/questions');
if (!response.ok) {
throw new Error('質問の取得に失敗しました');
}
const data: Question[] = await response.json();
setQuestions(data);
} catch (err) {
setError((err as Error).message);
}
};
fetchQuestions();
}, []);
const filteredQuestions = questions.filter(q =>
q.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
q.contents.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="container mx-auto p-8 max-w-6xl">
<h1 className="text-4xl font-bold mb-12 text-gray-800">質問一覧</h1>
<div className="mb-8 flex">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="質問を検索..."
className="flex-grow p-3 text-lg border rounded-l-md focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
<button className="bg-blue-500 text-white px-6 py-3 rounded-r-md hover:bg-blue-600 transition duration-300">
<Search size={24} />
</button>
</div>
{error && <p className="text-red-500 mb-8 text-lg">{error}</p>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredQuestions.map((question) => (
<Link
key={question.id}
to={`/question/${question.id}`}
className="bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition duration-300 transform hover:-translate-y-1"
>
<h2 className="text-2xl font-semibold text-blue-600 hover:text-blue-800 mb-3">{question.title}</h2>
<p className="text-gray-600 mb-4">{question.contents.substring(0, 150)}...</p>
</Link>
))}
</div>
<button
onClick={() => navigate('/post')}
className="fixed bottom-12 right-12 bg-blue-500 hover:bg-blue-600 text-white p-4 rounded-full shadow-lg transition duration-300"
>
<Plus size={32} />
</button>
</div>
);
};
export default Home;
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Send } from 'lucide-react';
type Question = {
id: string;
title: string;
contents: string;
};
type Comment = {
id: string;
question_id: string;
content: string;
created_at: string;
};
const Answer: React.FC = () => {
const [question, setQuestion] = useState<Question | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [error, setError] = useState<string | null>(null);
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const fetchQuestionAndComments = async () => {
try {
const questionResponse = await fetch(`http://localhost:8080/questions/${id}`);
if (!questionResponse.ok) {
throw new Error('質問の取得に失敗しました');
}
const questionData: Question = await questionResponse.json();
setQuestion(questionData);
const commentsResponse = await fetch(`http://localhost:8080/comments?question_id=${id}`);
if (!commentsResponse.ok) {
throw new Error('コメントの取得に失敗しました');
}
const commentsData: Comment[] = await commentsResponse.json();
setComments(commentsData);
} catch (err) {
setError((err as Error).message);
}
};
fetchQuestionAndComments();
}, [id]);
const handleCommentSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('http://localhost:8080/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
question_id: id,
content: newComment,
}),
});
if (!response.ok) {
throw new Error('コメントの投稿に失敗しました');
}
const postedComment: Comment = await response.json();
setComments([postedComment, ...comments]);
setNewComment('');
} catch (err) {
setError((err as Error).message);
}
};
if (error) return <div className="text-red-500 text-lg">エラー: {error}</div>;
if (!question) return <div className="text-gray-600 text-lg">読み込み中...</div>;
return (
<div className="container mx-auto p-8 max-w-6xl">
<button
onClick={() => navigate('/')}
className="mb-8 bg-gray-100 hover:bg-gray-200 text-gray-800 px-6 py-3 rounded-md flex items-center text-lg transition duration-300"
>
<ArrowLeft className="inline-block mr-2" size={24} />
戻る
</button>
<div className="bg-white rounded-lg shadow-md p-8 mb-12">
<h1 className="text-3xl font-bold mb-6 text-gray-800">{question.title}</h1>
<p className="whitespace-pre-wrap text-gray-700 text-lg leading-relaxed">{question.contents}</p>
</div>
<h2 className="text-2xl font-bold mb-6 text-gray-800">コメント</h2>
<form onSubmit={handleCommentSubmit} className="mb-12">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="w-full p-4 text-lg border rounded-md shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
placeholder="コメントを入力してください"
rows={4}
/>
<button
type="submit"
className="mt-4 bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-md flex items-center text-lg transition duration-300"
>
<Send className="inline-block mr-2" size={24} />
コメントを投稿
</button>
</form>
<div className="space-y-6">
{comments.map((comment) => (
<div key={comment.id} className="bg-gray-50 p-6 rounded-lg">
<p className="text-gray-800 text-lg mb-2">{comment.content}</p>
<small className="text-gray-500 block">
{new Date(comment.created_at).toLocaleString()}
</small>
</div>
))}
</div>
</div>
);
};
export default Answer;
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import './index.css';
import Home from './Home';
import App from './App';
import Ans from './Answer';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/post" element={<App />} />
<Route path="/question/:id" element={<Ans />} />
</Routes>
</Router>
</React.StrictMode>
);
reportWebVitals();
今回は省きますがCSSファイルなども作成してみなさんのお好みでデザインしちゃってください!
バックエンド
mkdir backend
cd backend
touch main.go
touch .env
go mod init backend
go get github.com/joho/godotenv
go get github.com/rs/cors
go get github.com/google/uuid
package main
import (
"bytes"
"time"
"encoding/json"
"github.com/google/uuid"
"log"
"net/http"
"os"
"github.com/joho/godotenv"
"github.com/rs/cors"
"fmt"
"strings"
)
type Question struct {
ID string `json:"id"`
Title string `json:"title"`
Contents string `json:"contents"`
}
type Comment struct {
ID string `json:"id"`
QuestionID string `json:"question_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
}
func handlePostComment(w http.ResponseWriter, r *http.Request) {
var comment Comment
err := json.NewDecoder(r.Body).Decode(&comment)
if err != nil {
http.Error(w, "無効な入力です", http.StatusBadRequest)
return
}
comment.ID = uuid.New().String()
comment.CreatedAt = time.Now().Format(time.RFC3339)
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseKey := os.Getenv("SUPABASE_KEY")
if supabaseURL == "" || supabaseKey == "" {
http.Error(w, "Supabaseの認証情報が設定されていません", http.StatusInternalServerError)
return
}
insertURL := fmt.Sprintf("%s/rest/v1/comments", supabaseURL)
jsonData, err := json.Marshal(comment)
if err != nil {
http.Error(w, "データのマーシャルに失敗しました", http.StatusInternalServerError)
return
}
req, err := http.NewRequest("POST", insertURL, bytes.NewBuffer(jsonData))
if err != nil {
http.Error(w, "リクエストの作成に失敗しました", http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("apikey", supabaseKey)
req.Header.Set("Authorization", "Bearer "+supabaseKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
http.Error(w, "リクエストの送信に失敗しました", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
http.Error(w, "コメントの挿入に失敗しました", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(comment)
}
func handleGetComments(w http.ResponseWriter, r *http.Request) {
questionID := r.URL.Query().Get("question_id")
if questionID == "" {
http.Error(w, "question_idが必要です", http.StatusBadRequest)
return
}
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseKey := os.Getenv("SUPABASE_KEY")
if supabaseURL == "" || supabaseKey == "" {
http.Error(w, "Supabaseの認証情報が設定されていません", http.StatusInternalServerError)
return
}
fetchURL := fmt.Sprintf("%s/rest/v1/comments?question_id=eq.%s&order=created_at.desc", supabaseURL, questionID)
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
http.Error(w, "リクエストの作成に失敗しました", http.StatusInternalServerError)
return
}
req.Header.Set("apikey", supabaseKey)
req.Header.Set("Authorization", "Bearer "+supabaseKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
http.Error(w, "コメントの取得に失敗しました", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "コメントの取得に失敗しました", http.StatusInternalServerError)
return
}
var comments []Comment
err = json.NewDecoder(resp.Body).Decode(&comments)
if err != nil {
http.Error(w, "コメントのデコードに失敗しました", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(comments)
}
func handleGetQuestion(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
if len(parts) != 3 {
http.Error(w, "無効なリクエストです", http.StatusBadRequest)
return
}
questionID := parts[2]
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseKey := os.Getenv("SUPABASE_KEY")
if supabaseURL == "" || supabaseKey == "" {
log.Println("SupabaseのURLまたはAPIキーが設定されていません")
http.Error(w, "Supabaseの認証情報が設定されていません", http.StatusInternalServerError)
return
}
fetchURL := fmt.Sprintf("%s/rest/v1/questions?id=eq.%s", supabaseURL, questionID)
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
log.Println("リクエストの作成中にエラーが発生しました:", err)
http.Error(w, "リクエストの作成に失敗しました", http.StatusInternalServerError)
return
}
req.Header.Set("apikey", supabaseKey)
req.Header.Set("Authorization", "Bearer "+supabaseKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Println("質問の取得中にエラーが発生しました:", err)
http.Error(w, "質問の取得に失敗しました", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Println("質問の取得に失敗しました。Supabaseのステータスコード:", resp.StatusCode)
http.Error(w, "質問の取得に失敗しました", http.StatusInternalServerError)
return
}
var questions []Question
err = json.NewDecoder(resp.Body).Decode(&questions)
if err != nil {
log.Println("質問のデコード中にエラーが発生しました:", err)
http.Error(w, "質問のデコードに失敗しました", http.StatusInternalServerError)
return
}
if len(questions) == 0 {
http.Error(w, "質問が見つかりません", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(questions[0])
}
func handleQuestions(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/questions/"):
handleGetQuestion(w, r)
case r.Method == http.MethodGet:
handleGetQuestions(w, r)
case r.Method == http.MethodPost:
handlePostQuestion(w, r)
default:
http.Error(w, "許可されていないメソッドです", http.StatusMethodNotAllowed)
}
}
func main() {
err := godotenv.Load(".env")
if err != nil {
log.Fatal(".envファイルのロード中にエラーが発生しました")
}
mux := http.NewServeMux()
mux.HandleFunc("/comments", handleComments)
mux.HandleFunc("/questions", handleQuestions)
mux.HandleFunc("/questions/", handleQuestions)
c := cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
})
handler := c.Handler(mux)
log.Println("サーバーがポート8080で起動しています...")
log.Fatal(http.ListenAndServe(":8080", handler))
}
func handlePostQuestion(w http.ResponseWriter, r *http.Request) {
var q Question
err := json.NewDecoder(r.Body).Decode(&q)
if err != nil {
log.Println("リクエストボディのデコード中にエラーが発生しました:", err)
http.Error(w, "無効な入力です", http.StatusBadRequest)
return
}
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseKey := os.Getenv("SUPABASE_KEY")
if supabaseURL == "" || supabaseKey == "" {
log.Println("SupabaseのURLまたはAPIキーが設定されていません")
http.Error(w, "Supabaseの認証情報が設定されていません", http.StatusInternalServerError)
return
}
insertURL := fmt.Sprintf("%s/rest/v1/questions", supabaseURL)
jsonData, err := json.Marshal(q)
if err != nil {
log.Println("質問データのマーシャル中にエラーが発生しました:", err)
http.Error(w, "データのマーシャルに失敗しました", http.StatusInternalServerError)
return
}
req, err := http.NewRequest("POST", insertURL, bytes.NewBuffer(jsonData))
if err != nil {
log.Println("リクエストの作成中にエラーが発生しました:", err)
http.Error(w, "リクエストの作成に失敗しました", http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("apikey", supabaseKey)
req.Header.Set("Authorization", "Bearer "+supabaseKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Println("質問データの挿入中にエラーが発生しました:", err)
http.Error(w, "質問の挿入に失敗しました", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
log.Println("質問の挿入に失敗しました。Supabaseのステータスコード:", resp.StatusCode)
http.Error(w, "質問の挿入に失敗しました", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(q)
}
func handleGetQuestions(w http.ResponseWriter, r *http.Request) {
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseKey := os.Getenv("SUPABASE_KEY")
if supabaseURL == "" || supabaseKey == "" {
log.Println("SupabaseのURLまたはAPIキーが設定されていません")
http.Error(w, "Supabaseの認証情報が設定されていません", http.StatusInternalServerError)
return
}
fetchURL := fmt.Sprintf("%s/rest/v1/questions", supabaseURL)
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
log.Println("リクエストの作成中にエラーが発生しました:", err)
http.Error(w, "リクエストの作成に失敗しました", http.StatusInternalServerError)
return
}
req.Header.Set("apikey", supabaseKey)
req.Header.Set("Authorization", "Bearer "+supabaseKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Println("質問データの取得中にエラーが発生しました:", err)
http.Error(w, "質問の取得に失敗しました", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Println("質問データの取得に失敗しました。Supabaseのステータスコード:", resp.StatusCode)
http.Error(w, "質問の取得に失敗しました", http.StatusInternalServerError)
return
}
var questions []Question
err = json.NewDecoder(resp.Body).Decode(&questions)
if err != nil {
log.Println("質問データのデコード中にエラーが発生しました:", err)
http.Error(w, "データのデコードに失敗しました", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(questions)
}
SUPABASE_URL=SupabaseURLを入れてください。
SUPABASE_KEY=Supabaseキーを入れてください
これにて完成です!
実行してみよう!
それではfrontend
ディレクトリとbackend
ディレクトリにそれぞれ移動して実行してみましょう!
frontend
npm start
backend
go run main.go
すると...
無事に作成できました!
投稿用のページもうまくできていますね!
それではいくつか質問を投稿していきたいと思います!
Supabaseのquestionsテーブルもこのようになっています!
それでは、それぞれの投稿フォームに回答をしていきたいと思います!
無事に投稿できましたね!ちゃんとquestion_idに紐づいて回答できているのでうまくいったのではないでしょうか!
ホーム画面に戻りまして検索機能もあるので試したいと思います!
こちらもちゃんと動いていますね!よかったです!
おわりに
開発をしていたら想定以上にエラーの嵐が舞い降りてきてうまく動作した時ついうっかり「よっしゃー」と叫んでしまいましたw
多分ですけど今回外部キーを使った開発って大学の2年の春頃に行なった授業の課題以来のような気がしますね...(なお成績はあまり良くなかった希ガス)
時が経つのは本当に早いなーと思いますね!
それでは今回の記事はいかがだったでしょうか?
またどこかの記事でお会いしましょう!