グカネータ / 質問パターン見直し (#41) #365

マージ済み
みてるぞ が 20 個のコミットを feature/041 から main へマージ 2026-06-12 01:35:32 +09:00
8個のファイルの変更306行の追加90行の削除
コミット a1ea35a7ec の変更だけを表示してゐます - すべてのコミットを表示
+19
ファイルの表示
@@ -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
+3
ファイルの表示
@@ -2,6 +2,9 @@ class GekanatorGame < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :guessed_post, class_name: 'Post' belongs_to :guessed_post, class_name: 'Post'
belongs_to :correct_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 :answers, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 } validates :question_count, numericality: { greater_than_or_equal_to: 0 }
+21
ファイルの表示
@@ -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
+3
ファイルの表示
@@ -65,6 +65,9 @@ Rails.application.routes.draw do
namespace :gekanator do namespace :gekanator do
resources :games, only: [:create], controller: '/gekanator_games' resources :games, only: [:create], controller: '/gekanator_games'
resources :question_suggestions,
only: [:create],
controller: '/gekanator_question_suggestions'
end end
resources :users, only: [:create, :update] do resources :users, only: [:create, :update] do
+14
ファイルの表示
@@ -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. # 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| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", 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" t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end 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| create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.binary "ip_address", limit: 16, null: false t.binary "ip_address", limit: 16, null: false
t.datetime "banned_at" 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: "correct_post_id"
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id" add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
add_foreign_key "gekanator_games", "users" 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"
add_foreign_key "material_versions", "materials", column: "parent_id" add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags" add_foreign_key "material_versions", "tags"
+16 -2
ファイルの表示
@@ -13,7 +13,8 @@ export type GekanatorAnswerValue =
export type GekanatorAnswerLog = { export type GekanatorAnswerLog = {
questionId: string questionId: string
questionText: string questionText: string
answer: GekanatorAnswerValue } answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue }
export type GekanatorQuestionKind = export type GekanatorQuestionKind =
| 'tag' | 'tag'
@@ -293,4 +294,17 @@ export const saveGekanatorGame = async ({
answers: answers.map (answer => ({ answers: answers.map (answer => ({
question_id: answer.questionId, question_id: answer.questionId,
question_text: answer.questionText, 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 })
+188 -59
ファイルの表示
@@ -7,7 +7,8 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions, import { buildGekanatorQuestions,
fetchGekanatorPosts, fetchGekanatorPosts,
saveGekanatorGame } from '@/lib/gekanator' saveGekanatorGame,
saveGekanatorQuestionSuggestion } from '@/lib/gekanator'
import { gekanatorKeys } from '@/lib/queryKeys' import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -25,6 +26,7 @@ type Phase =
| 'continue' | 'continue'
| 'end' | 'end'
| 'review' | 'review'
| 'question_suggestion'
| 'learned' | 'learned'
type AnswerOption = { type AnswerOption = {
@@ -66,6 +68,9 @@ const answerOptions: AnswerOption[] = [
{ label: 'たぶんいいえ', value: 'probably_no' }, { label: 'たぶんいいえ', value: 'probably_no' },
{ label: 'わからない', value: 'unknown' }] { label: 'わからない', value: 'unknown' }]
const answerLabelFor = (value: GekanatorAnswerValue): string =>
answerOptions.find (option => option.value === value)?.label ?? value
const questionsBetweenGuesses = 25 const questionsBetweenGuesses = 25
const minQuestionsBeforeCertainGuess = 5 const minQuestionsBeforeCertainGuess = 5
const certainGuessPercent = 99.5 const certainGuessPercent = 99.5
@@ -313,10 +318,14 @@ const chooseQuestion = ({
const scoredPosts = posts const scoredPosts = posts
.map (post => ({ post, score: scores.get (post.id) ?? 0 })) .map (post => ({ post, score: scores.get (post.id) ?? 0 }))
.sort ((a, b) => b.score - a.score) .sort ((a, b) => b.score - a.score)
const topScore = scoredPosts[0]?.score ?? 0 const maxScore = scoredPosts[0]?.score ?? 0
const nearTopCandidates = scoredPosts.filter (item => topScore - item.score <= 2) const weightedPosts = scoredPosts.map (item => ({
const focusCandidates = ...item,
nearTopCandidates.length >= 2 && nearTopCandidates.length <= 8 ? nearTopCandidates : [] 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 = ( const signatureFor = (
question: GekanatorQuestion, question: GekanatorQuestion,
@@ -344,7 +353,7 @@ const chooseQuestion = ({
const rank = ( const rank = (
questionsToRank: GekanatorQuestion[], questionsToRank: GekanatorQuestion[],
candidates: { post: Post; score: number }[], candidates: { post: Post; score: number }[],
focusCandidates: { post: Post; score: number }[], weightedCandidates: { post: Post; score: number; weight: number }[],
) => { ) => {
const redundant = redundantSignatures (candidates) const redundant = redundantSignatures (candidates)
const nonTagCount = const nonTagCount =
@@ -361,49 +370,36 @@ const chooseQuestion = ({
if (yes === 0 || no === 0) if (yes === 0 || no === 0)
return null return null
const focusSignature = signatureFor (question, focusCandidates) const yesWeight = weightedCandidates.reduce (
const focusYes = focusSignature.split ('').filter (value => value === '1').length (sum, item) => sum + (question.test (item.post) ? item.weight : 0),
const focusNo = focusCandidates.length - focusYes 0)
const separatesFocus = focusCandidates.length >= 2 && focusYes > 0 && focusNo > 0 const noWeight = 1 - yesWeight
if (yesWeight <= 0 || noWeight <= 0)
return null
const splitScore = Math.abs (candidates.length / 2 - yes) const weightedSplitScore = Math.abs (.5 - yesWeight)
const focusSplitScore = focusCandidates.length >= 2 const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length
? Math.abs (focusCandidates.length / 2 - focusYes) const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
: 0
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? 12 : 0
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08) const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0 const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
const focusPenalty =
focusCandidates.length >= 2 && !(separatesFocus) ? candidates.length * 10 : 0
return { question, return { question,
score: focusPenalty score: weightedSplitScore * 100
+ focusSplitScore * 20 + unweightedSplitScore * 8
+ splitScore
+ tagPenalty + tagPenalty
+ narrowPenalty, + narrowPenalty,
narrow: narrowPenalty > 0, narrow: narrowPenalty > 0 }
separatesFocus }
}) })
.filter ((item): item is { .filter ((item): item is {
question: GekanatorQuestion question: GekanatorQuestion
score: number score: number
narrow: boolean narrow: boolean } => item !== null && Number.isFinite (item.score))
separatesFocus: boolean } => item !== null && Number.isFinite (item.score))
.sort ((a, b) => a.score - b.score) .sort ((a, b) => a.score - b.score)
} }
const unansweredQuestions = const unansweredQuestions =
questions.filter (question => !(askedIds.has (question.id))) questions.filter (question => !(askedIds.has (question.id)))
const ranked = rank (unansweredQuestions, scoredPosts, focusCandidates) const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
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
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
} }
@@ -434,6 +430,17 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
</div>) </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 GekanatorPage: FC = () => {
const [phase, setPhase] = useState<Phase> ('intro') const [phase, setPhase] = useState<Phase> ('intro')
const [scores, setScores] = useState<Map<number, number>> (new Map ()) const [scores, setScores] = useState<Map<number, number>> (new Map ())
@@ -451,6 +458,8 @@ const GekanatorPage: FC = () => {
const [activeGuessId, setActiveGuessId] = useState<number | null> (null) const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null) const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
const [reviewCorrectPostId, setReviewCorrectPostId] = 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 [history, setHistory] = useState<GameSnapshot[]> ([])
const { data: posts = [], isLoading, error } = useQuery ({ const { data: posts = [], isLoading, error } = useQuery ({
@@ -471,6 +480,9 @@ const GekanatorPage: FC = () => {
const scoringQuestions = useMemo (() => { const scoringQuestions = useMemo (() => {
return mergeQuestions ([...questions, ...askedQuestionBank]) return mergeQuestions ([...questions, ...askedQuestionBank])
}, [questions, askedQuestionBank]) }, [questions, askedQuestionBank])
const scoringQuestionById = useMemo (
() => new Map (scoringQuestions.map (question => [question.id, question])),
[scoringQuestions])
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
const nonRejectedPosts = useMemo ( const nonRejectedPosts = useMemo (
() => posts.filter (post => !(rejectedPostIds.has (post.id))), () => posts.filter (post => !(rejectedPostIds.has (post.id))),
@@ -510,10 +522,16 @@ const GekanatorPage: FC = () => {
posts.find (post => post.id === reviewCorrectPostId) ?? null posts.find (post => post.id === reviewCorrectPostId) ?? null
const saveMutation = useMutation ({ const saveMutation = useMutation ({
mutationFn: saveGekanatorGame, mutationFn: saveGekanatorGame,
onSuccess: () => { onSuccess: (data, variables) => {
setSaved (true) setSaved (true)
setResultWon (reviewGuessedPostId === reviewCorrectPostId) setSavedGameId (data.id)
setPhase ('learned') setResultWon (variables.guessedPostId === variables.correctPostId)
}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: () => {
setQuestionSuggestion ('')
reset ()
}}) }})
const reset = () => { const reset = () => {
@@ -534,6 +552,8 @@ const GekanatorPage: FC = () => {
setActiveGuessId (null) setActiveGuessId (null)
setReviewGuessedPostId (null) setReviewGuessedPostId (null)
setReviewCorrectPostId (null) setReviewCorrectPostId (null)
setSavedGameId (null)
setQuestionSuggestion ('')
setHistory ([]) setHistory ([])
} }
@@ -639,7 +659,8 @@ const GekanatorPage: FC = () => {
const nextAnswers = [...answers, { const nextAnswers = [...answers, {
questionId: currentQuestion.id, questionId: currentQuestion.id,
questionText: currentQuestion.text, questionText: currentQuestion.text,
answer: value }] answer: value,
originalAnswer: value }]
const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
const nextAskedQuestionBank = [ const nextAskedQuestionBank = [
...askedQuestionBank.filter (question => question.id !== currentQuestion.id), ...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
@@ -705,6 +726,9 @@ const GekanatorPage: FC = () => {
return return
saveMutation.reset () saveMutation.reset ()
questionSuggestionMutation.reset ()
setSaved (false)
setSavedGameId (null)
setReviewGuessedPostId (guessedPostId) setReviewGuessedPostId (guessedPostId)
setReviewCorrectPostId (correctPostId) setReviewCorrectPostId (correctPostId)
setSearch ('') setSearch ('')
@@ -717,24 +741,51 @@ const GekanatorPage: FC = () => {
return return
saveMutation.reset () saveMutation.reset ()
questionSuggestionMutation.reset ()
setSaved (false)
setSavedGameId (null)
setSelectingCorrectPost (false) setSelectingCorrectPost (false)
setSearch ('') setSearch ('')
setPhase ('review') setPhase ('review')
} }
const saveReviewedResult = () => { const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
if ( if (
reviewGuessedPostId === null reviewGuessedPostId === null
|| reviewCorrectPostId === null || reviewCorrectPostId === null
|| saveMutation.isPending || saveMutation.isPending
|| saved
) )
return return
if (savedGameId !== null)
{
onSuccess (savedGameId)
return
}
saveMutation.mutate ({ saveMutation.mutate ({
guessedPostId: reviewGuessedPostId, guessedPostId: reviewGuessedPostId,
correctPostId: reviewCorrectPostId, 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 = () => { const rejectGuess = () => {
@@ -811,6 +862,8 @@ const GekanatorPage: FC = () => {
} }
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
setSaved (false)
setSavedGameId (null)
setAnswers (answers.map ((answer, i) => setAnswers (answers.map ((answer, i) =>
i === index ? { ...answer, answer: value } : answer)) i === index ? { ...answer, answer: value } : answer))
} }
@@ -818,6 +871,8 @@ const GekanatorPage: FC = () => {
const selectCorrectPost = (post: Post) => { const selectCorrectPost = (post: Post) => {
if (phase === 'review') if (phase === 'review')
{ {
setSaved (false)
setSavedGameId (null)
setReviewCorrectPostId (post.id) setReviewCorrectPostId (post.id)
setSelectingCorrectPost (false) setSelectingCorrectPost (false)
setSearch ('') setSearch ('')
@@ -1006,7 +1061,7 @@ const GekanatorPage: FC = () => {
</div> </div>
{saveMutation.isError && ( {saveMutation.isError && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
</p>)} </p>)}
</div>)} </div>)}
@@ -1045,7 +1100,7 @@ const GekanatorPage: FC = () => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<p className="text-sm text-neutral-500"></p> <p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"></p> <p className="text-xl font-bold">wwwww</p>
</div> </div>
{reviewGuessedPost && ( {reviewGuessedPost && (
@@ -1076,7 +1131,7 @@ const GekanatorPage: FC = () => {
{saveMutation.isError && ( {saveMutation.isError && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
</p>)} </p>)}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -1084,20 +1139,27 @@ const GekanatorPage: FC = () => {
type="button" type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500 disabled:opacity-50" hover:bg-pink-500 disabled:opacity-50"
disabled={ disabled={reviewCorrectPostId === null || saveMutation.isPending}
reviewCorrectPostId === null || saveMutation.isPending || saved onClick={saveAndReset}>
}
onClick={saveReviewedResult}>
</button> </button>
<button <button
type="button" type="button"
className="rounded border border-yellow-300 px-4 py-2 className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700 hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900" dark:hover:bg-red-900"
disabled={reviewCorrectPostId === null || saveMutation.isPending || saved} disabled={reviewCorrectPostId === null || saveMutation.isPending}
onClick={startReview}> 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> </button>
</div> </div>
</div>)} </div>)}
@@ -1133,7 +1195,12 @@ const GekanatorPage: FC = () => {
<div className="space-y-2"> <div className="space-y-2">
<div className="font-bold"></div> <div className="font-bold"></div>
<div className="space-y-2"> <div className="space-y-2">
{answers.map ((answer, index) => ( {answers.map ((answer, index) => {
const expectedAnswer = expectedAnswerFor (
scoringQuestionById.get (answer.questionId),
reviewCorrectPost)
return (
<div <div
key={`${ answer.questionId }:${ index }`} key={`${ answer.questionId }:${ index }`}
className="rounded border border-yellow-100 p-3 className="rounded border border-yellow-100 p-3
@@ -1142,9 +1209,21 @@ const GekanatorPage: FC = () => {
{index + 1} {index + 1}
</div> </div>
<div className="font-bold">{answer.questionText}</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 <select
value={answer.answer} 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" dark:border-red-700 dark:bg-red-950"
onChange={ev => onChange={ev =>
correctAnswerAt ( correctAnswerAt (
@@ -1155,7 +1234,10 @@ const GekanatorPage: FC = () => {
{option.label} {option.label}
</option>))} </option>))}
</select> </select>
</div>))} </label>
</div>
</div>)
})}
</div> </div>
</div> </div>
@@ -1166,7 +1248,7 @@ const GekanatorPage: FC = () => {
{saveMutation.isError && ( {saveMutation.isError && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
</p>)} </p>)}
<div className="flex flex-wrap gap-2"> <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 className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500 disabled:opacity-50" hover:bg-pink-500 disabled:opacity-50"
disabled={ disabled={
reviewCorrectPostId === null || saveMutation.isPending || saved reviewCorrectPostId === null
|| saveMutation.isPending
|| questionSuggestionMutation.isPending
} }
onClick={saveReviewedResult}> onClick={saveAndLearn}>
</button> </button>
<button <button
type="button" type="button"
@@ -1191,6 +1275,51 @@ const GekanatorPage: FC = () => {
</div> </div>
</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' && ( {phase === 'learned' && (
<div className="space-y-3"> <div className="space-y-3">
<p></p> <p></p>
@@ -1233,7 +1362,7 @@ const GekanatorPage: FC = () => {
{search.trim () && filteredPosts.length === 0 && '見つかりません.'} {search.trim () && filteredPosts.length === 0 && '見つかりません.'}
{saveMutation.isError && ( {saveMutation.isError && (
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
</p>)} </p>)}
</div> </div>
</section>)} </section>)}