グカネータ作成 / 質問パターン修正 (#41) #364
@@ -0,0 +1,19 @@
|
||||
class GekanatorQuestionSuggestionsController < ApplicationController
|
||||
def create
|
||||
return head :not_found unless current_user&.admin?
|
||||
|
||||
game = GekanatorGame.find_by(id: params.require(:gekanator_game_id))
|
||||
return head :not_found unless game
|
||||
|
||||
suggestion = GekanatorQuestionSuggestion.new(
|
||||
gekanator_game: game,
|
||||
user: current_user,
|
||||
question_text: params.require(:question_text))
|
||||
|
||||
if suggestion.save
|
||||
render json: { id: suggestion.id }, status: :created
|
||||
else
|
||||
render_validation_error suggestion
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,9 @@ class GekanatorGame < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :guessed_post, class_name: 'Post'
|
||||
belongs_to :correct_post, class_name: 'Post'
|
||||
has_many :question_suggestions,
|
||||
class_name: 'GekanatorQuestionSuggestion',
|
||||
dependent: :delete_all
|
||||
|
||||
validates :answers, presence: true
|
||||
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class GekanatorQuestionSuggestion < ApplicationRecord
|
||||
MAX_QUESTIONS_PER_GAME = 1
|
||||
|
||||
belongs_to :gekanator_game
|
||||
belongs_to :user
|
||||
|
||||
validates :question_text, presence: true, length: { maximum: 1000 }
|
||||
validates :processed, inclusion: { in: [true, false] }
|
||||
validate :question_suggestion_limit_per_game, on: :create
|
||||
|
||||
private
|
||||
|
||||
def question_suggestion_limit_per_game
|
||||
return if gekanator_game_id.blank?
|
||||
|
||||
count = GekanatorQuestionSuggestion.where(gekanator_game_id:).count
|
||||
if count >= MAX_QUESTIONS_PER_GAME
|
||||
errors.add(:base, '質問追加数を超えてゐます.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -65,6 +65,9 @@ Rails.application.routes.draw do
|
||||
|
||||
namespace :gekanator do
|
||||
resources :games, only: [:create], controller: '/gekanator_games'
|
||||
resources :question_suggestions,
|
||||
only: [:create],
|
||||
controller: '/gekanator_question_suggestions'
|
||||
end
|
||||
|
||||
resources :users, only: [:create, :update] do
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class CreateGekanatorQuestionSuggestions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :gekanator_question_suggestions do |t|
|
||||
t.references :gekanator_game,
|
||||
null: false,
|
||||
foreign_key: { on_delete: :cascade }
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.text :question_text, null: false
|
||||
t.boolean :processed, null: false, default: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
生成ファイル
+14
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_08_000000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -63,6 +63,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
||||
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
||||
end
|
||||
|
||||
create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "gekanator_game_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.text "question_text", null: false
|
||||
t.boolean "processed", default: false, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["gekanator_game_id"], name: "index_gekanator_question_suggestions_on_gekanator_game_id"
|
||||
t.index ["user_id"], name: "index_gekanator_question_suggestions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.binary "ip_address", limit: 16, null: false
|
||||
t.datetime "banned_at"
|
||||
@@ -496,6 +507,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
||||
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
||||
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||
add_foreign_key "gekanator_games", "users"
|
||||
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
|
||||
add_foreign_key "gekanator_question_suggestions", "users"
|
||||
add_foreign_key "material_versions", "materials"
|
||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||
add_foreign_key "material_versions", "tags"
|
||||
|
||||
@@ -13,7 +13,8 @@ export type GekanatorAnswerValue =
|
||||
export type GekanatorAnswerLog = {
|
||||
questionId: string
|
||||
questionText: string
|
||||
answer: GekanatorAnswerValue }
|
||||
answer: GekanatorAnswerValue
|
||||
originalAnswer: GekanatorAnswerValue }
|
||||
|
||||
export type GekanatorQuestionKind =
|
||||
| 'tag'
|
||||
@@ -293,4 +294,17 @@ export const saveGekanatorGame = async ({
|
||||
answers: answers.map (answer => ({
|
||||
question_id: answer.questionId,
|
||||
question_text: answer.questionText,
|
||||
answer: answer.answer })) })
|
||||
answer: answer.answer,
|
||||
original_answer: answer.originalAnswer })) })
|
||||
|
||||
|
||||
export const saveGekanatorQuestionSuggestion = async ({
|
||||
gekanatorGameId,
|
||||
questionText,
|
||||
}: {
|
||||
gekanatorGameId: number
|
||||
questionText: string
|
||||
}): Promise<{ id: number }> =>
|
||||
await apiPost ('/gekanator/question_suggestions', {
|
||||
gekanator_game_id: gekanatorGameId,
|
||||
question_text: questionText })
|
||||
|
||||
@@ -7,7 +7,8 @@ import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { buildGekanatorQuestions,
|
||||
fetchGekanatorPosts,
|
||||
saveGekanatorGame } from '@/lib/gekanator'
|
||||
saveGekanatorGame,
|
||||
saveGekanatorQuestionSuggestion } from '@/lib/gekanator'
|
||||
import { gekanatorKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -25,6 +26,7 @@ type Phase =
|
||||
| 'continue'
|
||||
| 'end'
|
||||
| 'review'
|
||||
| 'question_suggestion'
|
||||
| 'learned'
|
||||
|
||||
type AnswerOption = {
|
||||
@@ -66,6 +68,9 @@ const answerOptions: AnswerOption[] = [
|
||||
{ label: 'たぶんいいえ', value: 'probably_no' },
|
||||
{ label: 'わからない', value: 'unknown' }]
|
||||
|
||||
const answerLabelFor = (value: GekanatorAnswerValue): string =>
|
||||
answerOptions.find (option => option.value === value)?.label ?? value
|
||||
|
||||
const questionsBetweenGuesses = 25
|
||||
const minQuestionsBeforeCertainGuess = 5
|
||||
const certainGuessPercent = 99.5
|
||||
@@ -313,10 +318,14 @@ const chooseQuestion = ({
|
||||
const scoredPosts = posts
|
||||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||
.sort ((a, b) => b.score - a.score)
|
||||
const topScore = scoredPosts[0]?.score ?? 0
|
||||
const nearTopCandidates = scoredPosts.filter (item => topScore - item.score <= 2)
|
||||
const focusCandidates =
|
||||
nearTopCandidates.length >= 2 && nearTopCandidates.length <= 8 ? nearTopCandidates : []
|
||||
const maxScore = scoredPosts[0]?.score ?? 0
|
||||
const weightedPosts = scoredPosts.map (item => ({
|
||||
...item,
|
||||
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
|
||||
const totalWeight =
|
||||
weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||||
const normalisedWeightedPosts =
|
||||
weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight }))
|
||||
|
||||
const signatureFor = (
|
||||
question: GekanatorQuestion,
|
||||
@@ -344,7 +353,7 @@ const chooseQuestion = ({
|
||||
const rank = (
|
||||
questionsToRank: GekanatorQuestion[],
|
||||
candidates: { post: Post; score: number }[],
|
||||
focusCandidates: { post: Post; score: number }[],
|
||||
weightedCandidates: { post: Post; score: number; weight: number }[],
|
||||
) => {
|
||||
const redundant = redundantSignatures (candidates)
|
||||
const nonTagCount =
|
||||
@@ -361,49 +370,36 @@ const chooseQuestion = ({
|
||||
if (yes === 0 || no === 0)
|
||||
return null
|
||||
|
||||
const focusSignature = signatureFor (question, focusCandidates)
|
||||
const focusYes = focusSignature.split ('').filter (value => value === '1').length
|
||||
const focusNo = focusCandidates.length - focusYes
|
||||
const separatesFocus = focusCandidates.length >= 2 && focusYes > 0 && focusNo > 0
|
||||
const yesWeight = weightedCandidates.reduce (
|
||||
(sum, item) => sum + (question.test (item.post) ? item.weight : 0),
|
||||
0)
|
||||
const noWeight = 1 - yesWeight
|
||||
if (yesWeight <= 0 || noWeight <= 0)
|
||||
return null
|
||||
|
||||
const splitScore = Math.abs (candidates.length / 2 - yes)
|
||||
const focusSplitScore = focusCandidates.length >= 2
|
||||
? Math.abs (focusCandidates.length / 2 - focusYes)
|
||||
: 0
|
||||
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? 12 : 0
|
||||
const weightedSplitScore = Math.abs (.5 - yesWeight)
|
||||
const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length
|
||||
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
|
||||
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||||
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
|
||||
const focusPenalty =
|
||||
focusCandidates.length >= 2 && !(separatesFocus) ? candidates.length * 10 : 0
|
||||
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
|
||||
|
||||
return { question,
|
||||
score: focusPenalty
|
||||
+ focusSplitScore * 20
|
||||
+ splitScore
|
||||
score: weightedSplitScore * 100
|
||||
+ unweightedSplitScore * 8
|
||||
+ tagPenalty
|
||||
+ narrowPenalty,
|
||||
narrow: narrowPenalty > 0,
|
||||
separatesFocus }
|
||||
narrow: narrowPenalty > 0 }
|
||||
})
|
||||
.filter ((item): item is {
|
||||
question: GekanatorQuestion
|
||||
score: number
|
||||
narrow: boolean
|
||||
separatesFocus: boolean } => item !== null && Number.isFinite (item.score))
|
||||
narrow: boolean } => item !== null && Number.isFinite (item.score))
|
||||
.sort ((a, b) => a.score - b.score)
|
||||
}
|
||||
|
||||
const unansweredQuestions =
|
||||
questions.filter (question => !(askedIds.has (question.id)))
|
||||
const ranked = rank (unansweredQuestions, scoredPosts, focusCandidates)
|
||||
|
||||
if (focusCandidates.length >= 2)
|
||||
return (
|
||||
ranked.find (item => item.separatesFocus && !(item.narrow))
|
||||
?? ranked.find (item => item.separatesFocus)
|
||||
?? ranked.find (item => !(item.narrow))
|
||||
?? ranked[0]
|
||||
)?.question ?? null
|
||||
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
|
||||
|
||||
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
|
||||
}
|
||||
@@ -434,6 +430,17 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
||||
</div>)
|
||||
|
||||
|
||||
const expectedAnswerFor = (
|
||||
question: GekanatorQuestion | undefined,
|
||||
correctPost: Post | null,
|
||||
): GekanatorAnswerValue | null => {
|
||||
if (!(question) || !(correctPost))
|
||||
return null
|
||||
|
||||
return question.test (correctPost) ? 'yes' : 'no'
|
||||
}
|
||||
|
||||
|
||||
const GekanatorPage: FC = () => {
|
||||
const [phase, setPhase] = useState<Phase> ('intro')
|
||||
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
||||
@@ -451,6 +458,8 @@ const GekanatorPage: FC = () => {
|
||||
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
|
||||
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
|
||||
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (null)
|
||||
const [savedGameId, setSavedGameId] = useState<number | null> (null)
|
||||
const [questionSuggestion, setQuestionSuggestion] = useState ('')
|
||||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||
|
||||
const { data: posts = [], isLoading, error } = useQuery ({
|
||||
@@ -471,6 +480,9 @@ const GekanatorPage: FC = () => {
|
||||
const scoringQuestions = useMemo (() => {
|
||||
return mergeQuestions ([...questions, ...askedQuestionBank])
|
||||
}, [questions, askedQuestionBank])
|
||||
const scoringQuestionById = useMemo (
|
||||
() => new Map (scoringQuestions.map (question => [question.id, question])),
|
||||
[scoringQuestions])
|
||||
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
|
||||
const nonRejectedPosts = useMemo (
|
||||
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
|
||||
@@ -510,10 +522,16 @@ const GekanatorPage: FC = () => {
|
||||
posts.find (post => post.id === reviewCorrectPostId) ?? null
|
||||
const saveMutation = useMutation ({
|
||||
mutationFn: saveGekanatorGame,
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, variables) => {
|
||||
setSaved (true)
|
||||
setResultWon (reviewGuessedPostId === reviewCorrectPostId)
|
||||
setPhase ('learned')
|
||||
setSavedGameId (data.id)
|
||||
setResultWon (variables.guessedPostId === variables.correctPostId)
|
||||
}})
|
||||
const questionSuggestionMutation = useMutation ({
|
||||
mutationFn: saveGekanatorQuestionSuggestion,
|
||||
onSuccess: () => {
|
||||
setQuestionSuggestion ('')
|
||||
reset ()
|
||||
}})
|
||||
|
||||
const reset = () => {
|
||||
@@ -534,6 +552,8 @@ const GekanatorPage: FC = () => {
|
||||
setActiveGuessId (null)
|
||||
setReviewGuessedPostId (null)
|
||||
setReviewCorrectPostId (null)
|
||||
setSavedGameId (null)
|
||||
setQuestionSuggestion ('')
|
||||
setHistory ([])
|
||||
}
|
||||
|
||||
@@ -639,7 +659,8 @@ const GekanatorPage: FC = () => {
|
||||
const nextAnswers = [...answers, {
|
||||
questionId: currentQuestion.id,
|
||||
questionText: currentQuestion.text,
|
||||
answer: value }]
|
||||
answer: value,
|
||||
originalAnswer: value }]
|
||||
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
||||
const nextAskedQuestionBank = [
|
||||
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
|
||||
@@ -705,6 +726,9 @@ const GekanatorPage: FC = () => {
|
||||
return
|
||||
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setReviewGuessedPostId (guessedPostId)
|
||||
setReviewCorrectPostId (correctPostId)
|
||||
setSearch ('')
|
||||
@@ -717,24 +741,51 @@ const GekanatorPage: FC = () => {
|
||||
return
|
||||
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setSelectingCorrectPost (false)
|
||||
setSearch ('')
|
||||
setPhase ('review')
|
||||
}
|
||||
|
||||
const saveReviewedResult = () => {
|
||||
const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
|
||||
if (
|
||||
reviewGuessedPostId === null
|
||||
|| reviewCorrectPostId === null
|
||||
|| saveMutation.isPending
|
||||
|| saved
|
||||
)
|
||||
return
|
||||
|
||||
if (savedGameId !== null)
|
||||
{
|
||||
onSuccess (savedGameId)
|
||||
return
|
||||
}
|
||||
|
||||
saveMutation.mutate ({
|
||||
guessedPostId: reviewGuessedPostId,
|
||||
correctPostId: reviewCorrectPostId,
|
||||
answers })
|
||||
answers },
|
||||
{ onSuccess: data => onSuccess (data.id) })
|
||||
}
|
||||
|
||||
const saveAndReset = () => {
|
||||
saveReviewedResult (reset)
|
||||
}
|
||||
|
||||
const saveAndLearn = () => {
|
||||
saveReviewedResult (() => setPhase ('learned'))
|
||||
}
|
||||
|
||||
const submitQuestionSuggestion = () => {
|
||||
const questionText = questionSuggestion.trim ()
|
||||
if (!(questionText) || questionSuggestionMutation.isPending)
|
||||
return
|
||||
|
||||
saveReviewedResult (gekanatorGameId => {
|
||||
questionSuggestionMutation.mutate ({ gekanatorGameId, questionText })
|
||||
})
|
||||
}
|
||||
|
||||
const rejectGuess = () => {
|
||||
@@ -811,6 +862,8 @@ const GekanatorPage: FC = () => {
|
||||
}
|
||||
|
||||
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setAnswers (answers.map ((answer, i) =>
|
||||
i === index ? { ...answer, answer: value } : answer))
|
||||
}
|
||||
@@ -818,6 +871,8 @@ const GekanatorPage: FC = () => {
|
||||
const selectCorrectPost = (post: Post) => {
|
||||
if (phase === 'review')
|
||||
{
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setReviewCorrectPostId (post.id)
|
||||
setSelectingCorrectPost (false)
|
||||
setSearch ('')
|
||||
@@ -1006,7 +1061,7 @@ const GekanatorPage: FC = () => {
|
||||
</div>
|
||||
{saveMutation.isError && (
|
||||
<p className="text-sm text-red-600">
|
||||
学習ログの保存に失敗しました。もう一度試せます。
|
||||
記録できませんでした。通信状態を確認してもう一度試して。
|
||||
</p>)}
|
||||
</div>)}
|
||||
|
||||
@@ -1045,7 +1100,7 @@ const GekanatorPage: FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">ゲーム終了</p>
|
||||
<p className="text-xl font-bold">今回の結果を保存できます。</p>
|
||||
<p className="text-xl font-bold">グカカカカwwwww</p>
|
||||
</div>
|
||||
|
||||
{reviewGuessedPost && (
|
||||
@@ -1076,7 +1131,7 @@ const GekanatorPage: FC = () => {
|
||||
|
||||
{saveMutation.isError && (
|
||||
<p className="text-sm text-red-600">
|
||||
学習ログの保存に失敗しました。もう一度試せます。
|
||||
記録できませんでした。通信状態を確認してもう一度試して。
|
||||
</p>)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -1084,20 +1139,27 @@ const GekanatorPage: FC = () => {
|
||||
type="button"
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
hover:bg-pink-500 disabled:opacity-50"
|
||||
disabled={
|
||||
reviewCorrectPostId === null || saveMutation.isPending || saved
|
||||
}
|
||||
onClick={saveReviewedResult}>
|
||||
保存
|
||||
disabled={reviewCorrectPostId === null || saveMutation.isPending}
|
||||
onClick={saveAndReset}>
|
||||
もう一度
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-yellow-300 px-4 py-2
|
||||
hover:bg-yellow-100 dark:border-red-700
|
||||
dark:hover:bg-red-900"
|
||||
disabled={reviewCorrectPostId === null || saveMutation.isPending || saved}
|
||||
disabled={reviewCorrectPostId === null || saveMutation.isPending}
|
||||
onClick={startReview}>
|
||||
質問と回答を訂正する
|
||||
修正
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-yellow-300 px-4 py-2
|
||||
hover:bg-yellow-100 dark:border-red-700
|
||||
dark:hover:bg-red-900"
|
||||
disabled={saveMutation.isPending || questionSuggestionMutation.isPending}
|
||||
onClick={() => setPhase ('question_suggestion')}>
|
||||
質問を追加
|
||||
</button>
|
||||
</div>
|
||||
</div>)}
|
||||
@@ -1133,7 +1195,12 @@ const GekanatorPage: FC = () => {
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">質問と回答</div>
|
||||
<div className="space-y-2">
|
||||
{answers.map ((answer, index) => (
|
||||
{answers.map ((answer, index) => {
|
||||
const expectedAnswer = expectedAnswerFor (
|
||||
scoringQuestionById.get (answer.questionId),
|
||||
reviewCorrectPost)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ answer.questionId }:${ index }`}
|
||||
className="rounded border border-yellow-100 p-3
|
||||
@@ -1142,9 +1209,21 @@ const GekanatorPage: FC = () => {
|
||||
質問 {index + 1}
|
||||
</div>
|
||||
<div className="font-bold">{answer.questionText}</div>
|
||||
<div className="mt-2 grid gap-1 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<span className="text-neutral-500">グカネータ判定: </span>
|
||||
{expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-neutral-500">実際の回答: </span>
|
||||
{answerLabelFor (answer.originalAnswer)}
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-neutral-500">修正後: </span>
|
||||
<select
|
||||
value={answer.answer}
|
||||
className="mt-2 rounded border border-yellow-300 bg-white px-2 py-1
|
||||
className="rounded border border-yellow-300 bg-white px-2
|
||||
py-1
|
||||
dark:border-red-700 dark:bg-red-950"
|
||||
onChange={ev =>
|
||||
correctAnswerAt (
|
||||
@@ -1155,7 +1234,10 @@ const GekanatorPage: FC = () => {
|
||||
{option.label}
|
||||
</option>))}
|
||||
</select>
|
||||
</div>))}
|
||||
</label>
|
||||
</div>
|
||||
</div>)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1166,7 +1248,7 @@ const GekanatorPage: FC = () => {
|
||||
|
||||
{saveMutation.isError && (
|
||||
<p className="text-sm text-red-600">
|
||||
学習ログの保存に失敗しました。もう一度試せます。
|
||||
記録できませんでした。通信状態を確認してもう一度試して。
|
||||
</p>)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -1175,10 +1257,12 @@ const GekanatorPage: FC = () => {
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
hover:bg-pink-500 disabled:opacity-50"
|
||||
disabled={
|
||||
reviewCorrectPostId === null || saveMutation.isPending || saved
|
||||
reviewCorrectPostId === null
|
||||
|| saveMutation.isPending
|
||||
|| questionSuggestionMutation.isPending
|
||||
}
|
||||
onClick={saveReviewedResult}>
|
||||
保存
|
||||
onClick={saveAndLearn}>
|
||||
完了
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1191,6 +1275,51 @@ const GekanatorPage: FC = () => {
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{phase === 'question_suggestion' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">質問追加</p>
|
||||
<p className="text-xl font-bold">どんな質問なら見分けられさう?</p>
|
||||
</div>
|
||||
<label className="block space-y-2">
|
||||
<span className="font-bold">質問候補</span>
|
||||
<textarea
|
||||
value={questionSuggestion}
|
||||
onChange={ev => setQuestionSuggestion (ev.target.value)}
|
||||
className="min-h-24 w-full rounded border border-yellow-300
|
||||
bg-white px-3 py-2 dark:border-red-700
|
||||
dark:bg-red-950"/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-neutral-300 px-4 py-2
|
||||
hover:bg-neutral-100 dark:border-neutral-700
|
||||
dark:hover:bg-red-900"
|
||||
disabled={saveMutation.isPending || questionSuggestionMutation.isPending}
|
||||
onClick={() => setPhase ('end')}>
|
||||
戻る
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
hover:bg-pink-500 disabled:opacity-50"
|
||||
disabled={
|
||||
reviewCorrectPostId === null
|
||||
|| questionSuggestion.trim () === ''
|
||||
|| saveMutation.isPending
|
||||
|| questionSuggestionMutation.isPending
|
||||
}
|
||||
onClick={submitQuestionSuggestion}>
|
||||
追加してもう一度
|
||||
</button>
|
||||
</div>
|
||||
{(saveMutation.isError || questionSuggestionMutation.isError) && (
|
||||
<p className="text-sm text-red-600">
|
||||
記録できませんでした。通信状態を確認してもう一度試して。
|
||||
</p>)}
|
||||
</div>)}
|
||||
|
||||
{phase === 'learned' && (
|
||||
<div className="space-y-3">
|
||||
<p>覚えたよ.次はもっと見通す.</p>
|
||||
@@ -1233,7 +1362,7 @@ const GekanatorPage: FC = () => {
|
||||
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
||||
{saveMutation.isError && (
|
||||
<p className="text-sm text-red-600">
|
||||
学習ログの保存に失敗しました。もう一度試せます。
|
||||
記録できませんでした。通信状態を確認してもう一度試して。
|
||||
</p>)}
|
||||
</div>
|
||||
</section>)}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする