2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

質問サイトを作成してみた

Posted at

はじめに

初めましての人もそうでない人もこんにちは!

最近友人とお風呂に入る時は先にシャワーを浴びて湯船に浸かるか掛け湯だけして湯船に浸かるかで討論をしてきました!
皆さんはどちらが先ですか?私は掛け湯だけして湯船に入る派ですね!

さてそんな関係ないことは置いといて今回は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
src/ App.tsx
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;
src/ Home.tsx
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;
src/ Answer.tsx
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;
src/ index.tsx
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
backend/ main.go
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)
}
backend/ .env
SUPABASE_URL=SupabaseURLを入れてください。
SUPABASE_KEY=Supabaseキーを入れてください

これにて完成です!

実行してみよう!

それではfrontendディレクトリとbackendディレクトリにそれぞれ移動して実行してみましょう!
frontend

npm start

backend

go run main.go

すると...

image.png

無事に作成できました!

image.png

投稿用のページもうまくできていますね!
それではいくつか質問を投稿していきたいと思います!

image.png

2つほど投稿しました!
image.png

Supabaseのquestionsテーブルもこのようになっています!

それでは、それぞれの投稿フォームに回答をしていきたいと思います!

image.png

image.png

image.png

無事に投稿できましたね!ちゃんとquestion_idに紐づいて回答できているのでうまくいったのではないでしょうか!

ホーム画面に戻りまして検索機能もあるので試したいと思います!

image.png

こちらもちゃんと動いていますね!よかったです!

おわりに

開発をしていたら想定以上にエラーの嵐が舞い降りてきてうまく動作した時ついうっかり「よっしゃー」と叫んでしまいましたw
多分ですけど今回外部キーを使った開発って大学の2年の春頃に行なった授業の課題以来のような気がしますね...(なお成績はあまり良くなかった希ガス)
時が経つのは本当に早いなーと思いますね!

それでは今回の記事はいかがだったでしょうか?
またどこかの記事でお会いしましょう!

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?