このコミットが含まれているのは:
+99
-11
@@ -21,6 +21,7 @@ export type GekanatorQuestionKind =
|
||||
| 'source'
|
||||
| 'title'
|
||||
| 'original_date'
|
||||
| 'post_similarity'
|
||||
|
||||
export type GekanatorQuestionSource =
|
||||
| 'default'
|
||||
@@ -36,6 +37,18 @@ export type GekanatorQuestionCondition =
|
||||
| { type: 'original-month-day'; monthDay: string }
|
||||
| { type: 'title-length-greater-than'; length: number }
|
||||
| { type: 'title-has-ascii' }
|
||||
| {
|
||||
type: 'post-similarity'
|
||||
postId: number
|
||||
answer: GekanatorAnswerValue
|
||||
threshold: number
|
||||
}
|
||||
|
||||
export type GekanatorExtraQuestion = {
|
||||
id: number
|
||||
text: string
|
||||
source: GekanatorQuestionSource
|
||||
priorityWeight: number }
|
||||
|
||||
export type StoredGekanatorQuestion = {
|
||||
id: string
|
||||
@@ -43,7 +56,8 @@ export type StoredGekanatorQuestion = {
|
||||
kind: GekanatorQuestionKind
|
||||
condition: GekanatorQuestionCondition
|
||||
source?: GekanatorQuestionSource
|
||||
priorityWeight?: number }
|
||||
priorityWeight?: number
|
||||
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
|
||||
|
||||
export type GekanatorQuestion = {
|
||||
id: string
|
||||
@@ -52,8 +66,24 @@ export type GekanatorQuestion = {
|
||||
condition: GekanatorQuestionCondition
|
||||
source: GekanatorQuestionSource
|
||||
priorityWeight: number
|
||||
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
|
||||
test: (post: Post) => boolean }
|
||||
|
||||
|
||||
const directExampleAnswerFor = (
|
||||
question: StoredGekanatorQuestion,
|
||||
post: Post,
|
||||
): GekanatorAnswerValue | null => {
|
||||
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
|
||||
if (direct)
|
||||
return direct
|
||||
|
||||
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
|
||||
return question.condition.answer
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||
const counts = new Map<T, number> ()
|
||||
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
||||
@@ -172,24 +202,59 @@ const questionableTag = (post: Post, key: string): boolean => {
|
||||
|
||||
const questionMatches = (
|
||||
post: Post,
|
||||
condition: GekanatorQuestionCondition,
|
||||
question: StoredGekanatorQuestion,
|
||||
): boolean => {
|
||||
switch (condition.type)
|
||||
const directAnswer = directExampleAnswerFor (question, post)
|
||||
if (directAnswer)
|
||||
return question.condition.type === 'post-similarity'
|
||||
? directAnswer === question.condition.answer
|
||||
: directAnswer === 'yes'
|
||||
|
||||
switch (question.condition.type)
|
||||
{
|
||||
case 'tag':
|
||||
return questionableTag (post, condition.key)
|
||||
return questionableTag (post, question.condition.key)
|
||||
case 'source':
|
||||
return hostOf (post) === condition.host
|
||||
return hostOf (post) === question.condition.host
|
||||
case 'original-year':
|
||||
return originalYearOf (post) === condition.year
|
||||
return originalYearOf (post) === question.condition.year
|
||||
case 'original-month':
|
||||
return originalMonthOf (post) === condition.month
|
||||
return originalMonthOf (post) === question.condition.month
|
||||
case 'original-month-day':
|
||||
return originalMonthDayOf (post) === condition.monthDay
|
||||
return originalMonthDayOf (post) === question.condition.monthDay
|
||||
case 'title-length-greater-than':
|
||||
return (post.title?.length ?? 0) > condition.length
|
||||
return (post.title?.length ?? 0) > question.condition.length
|
||||
case 'title-has-ascii':
|
||||
return /[A-Za-z0-9]/.test (post.title ?? '')
|
||||
case 'post-similarity':
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const expectedAnswerForQuestion = (
|
||||
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
|
||||
post: Post | null,
|
||||
): GekanatorAnswerValue | null => {
|
||||
if (!(question) || !(post))
|
||||
return null
|
||||
|
||||
const directAnswer = directExampleAnswerFor (question, post)
|
||||
if (directAnswer)
|
||||
return directAnswer
|
||||
|
||||
switch (question.condition.type)
|
||||
{
|
||||
case 'tag':
|
||||
case 'source':
|
||||
case 'original-year':
|
||||
case 'original-month':
|
||||
case 'original-month-day':
|
||||
case 'title-length-greater-than':
|
||||
case 'title-has-ascii':
|
||||
return questionMatches (post, question) ? 'yes' : 'no'
|
||||
case 'post-similarity':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +265,7 @@ export const restoreGekanatorQuestion = (
|
||||
...question,
|
||||
source: question.source ?? 'default',
|
||||
priorityWeight: question.priorityWeight ?? 1,
|
||||
test: (post: Post) => questionMatches (post, question.condition) })
|
||||
test: (post: Post) => questionMatches (post, question) })
|
||||
|
||||
|
||||
export const storeGekanatorQuestion = (
|
||||
@@ -211,7 +276,8 @@ export const storeGekanatorQuestion = (
|
||||
kind: question.kind,
|
||||
condition: question.condition,
|
||||
source: question.source,
|
||||
priorityWeight: question.priorityWeight })
|
||||
priorityWeight: question.priorityWeight,
|
||||
exampleAnswers: question.exampleAnswers })
|
||||
|
||||
|
||||
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
||||
@@ -226,6 +292,15 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
|
||||
}
|
||||
|
||||
|
||||
export const fetchGekanatorExtraQuestions = async (
|
||||
gameId: number,
|
||||
): Promise<GekanatorExtraQuestion[]> => {
|
||||
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
|
||||
`/gekanator/games/${ gameId }/extra_questions`)
|
||||
return data.questions
|
||||
}
|
||||
|
||||
|
||||
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||
const tagCounts = countBy (posts.flatMap (post =>
|
||||
post.tags
|
||||
@@ -393,3 +468,16 @@ export const saveGekanatorQuestionSuggestion = async ({
|
||||
gekanator_game_id: gekanatorGameId,
|
||||
question_text: questionText,
|
||||
answer })
|
||||
|
||||
|
||||
export const saveGekanatorExtraQuestionAnswers = async ({
|
||||
gameId,
|
||||
answers,
|
||||
}: {
|
||||
gameId: number
|
||||
answers: { questionId: number; answer: GekanatorAnswerValue }[]
|
||||
}) =>
|
||||
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
|
||||
answers: answers.map (item => ({
|
||||
question_id: item.questionId,
|
||||
answer: item.answer })) })
|
||||
|
||||
@@ -11,7 +11,9 @@ export const postsKeys = {
|
||||
export const gekanatorKeys = {
|
||||
root: ['gekanator'] as const,
|
||||
posts: () => ['gekanator', 'posts'] as const,
|
||||
questions: () => ['gekanator', 'questions'] as const }
|
||||
questions: () => ['gekanator', 'questions'] as const,
|
||||
extraQuestions: (gameId: number) =>
|
||||
['gekanator', 'games', gameId, 'extra-questions'] as const }
|
||||
|
||||
export const tagsKeys = {
|
||||
root: ['tags'] as const,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
@@ -6,9 +6,12 @@ import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { buildGekanatorQuestions,
|
||||
expectedAnswerForQuestion,
|
||||
fetchGekanatorExtraQuestions,
|
||||
fetchGekanatorQuestions,
|
||||
fetchGekanatorPosts,
|
||||
restoreGekanatorQuestion,
|
||||
saveGekanatorExtraQuestionAnswers,
|
||||
saveGekanatorGame,
|
||||
saveGekanatorQuestionSuggestion,
|
||||
storeGekanatorQuestion } from '@/lib/gekanator'
|
||||
@@ -19,6 +22,7 @@ import type { FC } from 'react'
|
||||
|
||||
import type { GekanatorAnswerLog,
|
||||
GekanatorAnswerValue,
|
||||
GekanatorExtraQuestion,
|
||||
GekanatorQuestion,
|
||||
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||||
import type { Post } from '@/types'
|
||||
@@ -31,6 +35,7 @@ type Phase =
|
||||
| 'end'
|
||||
| 'review'
|
||||
| 'question_suggestion'
|
||||
| 'extra_questions'
|
||||
| 'learned'
|
||||
|
||||
type AnswerOption = {
|
||||
@@ -87,7 +92,10 @@ type StoredGekanatorGame = {
|
||||
gameSeed?: string
|
||||
questionSuggestion: string
|
||||
questionSuggestionAnswer: GekanatorAnswerValue
|
||||
questionSuggestionCount?: number }
|
||||
questionSuggestionCount?: number
|
||||
extraQuestions?: GekanatorExtraQuestion[]
|
||||
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
|
||||
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
|
||||
|
||||
const answerOptions: AnswerOption[] = [
|
||||
{ label: 'はい', value: 'yes' },
|
||||
@@ -219,6 +227,16 @@ const loadStoredGame = (): StoredGekanatorGame | null => {
|
||||
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
|
||||
|
||||
|
||||
const resettableExtraQuestionState = (): {
|
||||
extraQuestions: GekanatorExtraQuestion[]
|
||||
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
|
||||
extraQuestionState: 'idle'
|
||||
} => ({
|
||||
extraQuestions: [],
|
||||
extraQuestionAnswers: { },
|
||||
extraQuestionState: 'idle' })
|
||||
|
||||
|
||||
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||||
switch (answer)
|
||||
{
|
||||
@@ -236,6 +254,55 @@ const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||||
}
|
||||
|
||||
|
||||
const answerScalarFor = (
|
||||
answer: GekanatorAnswerValue | null,
|
||||
): number | null => {
|
||||
switch (answer)
|
||||
{
|
||||
case 'yes':
|
||||
return 1
|
||||
case 'partial':
|
||||
return .5
|
||||
case 'probably_no':
|
||||
return -.5
|
||||
case 'no':
|
||||
return -1
|
||||
case 'unknown':
|
||||
case null:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const deltaForExpectedAnswer = (
|
||||
expected: GekanatorAnswerValue | null,
|
||||
answer: GekanatorAnswerValue,
|
||||
): number => {
|
||||
if (answer === 'unknown' || expected === null || expected === 'unknown')
|
||||
return 0
|
||||
|
||||
if (expected === 'yes' || expected === 'no')
|
||||
return deltaFor (expected === 'yes', answer)
|
||||
|
||||
const expectedScalar = answerScalarFor (expected)
|
||||
const answerScalar = answerScalarFor (answer)
|
||||
if (expectedScalar === null || answerScalar === null)
|
||||
return 0
|
||||
|
||||
const distance = Math.abs (expectedScalar - answerScalar)
|
||||
if (distance >= 2)
|
||||
return -4
|
||||
if (distance >= 1.5)
|
||||
return -2
|
||||
if (distance >= 1)
|
||||
return 0
|
||||
if (distance >= .5)
|
||||
return 2
|
||||
|
||||
return 4
|
||||
}
|
||||
|
||||
|
||||
const answerWeightFor = (
|
||||
questionId: string,
|
||||
softenedQuestionIds: Set<string>,
|
||||
@@ -277,10 +344,11 @@ const recalculateScores = ({
|
||||
|
||||
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
||||
posts.forEach (post => {
|
||||
const expected = expectedAnswerForQuestion (question, post)
|
||||
nextScores.set (
|
||||
post.id,
|
||||
(nextScores.get (post.id) ?? 0)
|
||||
+ deltaFor (question.test (post), answer.answer) * weight)
|
||||
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -318,9 +386,10 @@ const candidatePostsFor = ({
|
||||
switch (answer.answer)
|
||||
{
|
||||
case 'yes':
|
||||
return question.test (post)
|
||||
case 'no':
|
||||
return !(question.test (post))
|
||||
case 'no': {
|
||||
const expected = expectedAnswerForQuestion (question, post)
|
||||
return expected === null || expected === 'unknown' || expected === answer.answer
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -378,20 +447,19 @@ const previewAnswer = ({
|
||||
answer: GekanatorAnswerValue
|
||||
}): AnswerPreview => {
|
||||
const hardFilteredPosts =
|
||||
answer === 'yes'
|
||||
? posts.filter (post => question.test (post))
|
||||
: answer === 'no'
|
||||
? posts.filter (post => !(question.test (post)))
|
||||
: posts
|
||||
answer === 'unknown'
|
||||
? posts
|
||||
: posts.filter (post => expectedAnswerForQuestion (question, post) === answer)
|
||||
const nextPosts =
|
||||
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
|
||||
answer !== 'unknown' && hardFilteredPosts.length > 0
|
||||
? hardFilteredPosts
|
||||
: posts
|
||||
const nextScores = new Map (scores)
|
||||
nextPosts.forEach (post => {
|
||||
const expected = expectedAnswerForQuestion (question, post)
|
||||
nextScores.set (
|
||||
post.id,
|
||||
(nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer))
|
||||
(nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer))
|
||||
})
|
||||
|
||||
const confidences = confidencesFor (nextPosts, nextScores)
|
||||
@@ -497,6 +565,8 @@ const sameConditionValue = (
|
||||
return String (condition.length)
|
||||
case 'title-has-ascii':
|
||||
return ''
|
||||
case 'post-similarity':
|
||||
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,16 +827,13 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
||||
const expectedAnswerFor = (
|
||||
question: GekanatorQuestion | undefined,
|
||||
correctPost: Post | null,
|
||||
): GekanatorAnswerValue | null => {
|
||||
if (!(question) || !(correctPost))
|
||||
return null
|
||||
|
||||
return question.test (correctPost) ? 'yes' : 'no'
|
||||
}
|
||||
): GekanatorAnswerValue | null =>
|
||||
expectedAnswerForQuestion (question, correctPost)
|
||||
|
||||
|
||||
const GekanatorPage: FC = () => {
|
||||
const storedGame = useMemo (loadStoredGame, [])
|
||||
const queryClient = useQueryClient ()
|
||||
const [gameSeed, setGameSeed] = useState (
|
||||
storedGame?.gameSeed ?? createGameSeed ())
|
||||
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
||||
@@ -810,6 +877,14 @@ const GekanatorPage: FC = () => {
|
||||
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
||||
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
||||
storedGame?.questionSuggestionCount ?? 0)
|
||||
const [extraQuestions, setExtraQuestions] = useState<GekanatorExtraQuestion[]> (
|
||||
storedGame?.extraQuestions ?? [])
|
||||
const [extraQuestionAnswers, setExtraQuestionAnswers] =
|
||||
useState<Record<string, GekanatorAnswerValue>> (
|
||||
storedGame?.extraQuestionAnswers ?? { })
|
||||
const [extraQuestionState, setExtraQuestionState] = useState<
|
||||
'idle' | 'loading' | 'ready' | 'empty' | 'saved'
|
||||
> (storedGame?.extraQuestionState ?? 'idle')
|
||||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||
|
||||
const { data: posts = [], isLoading, error } = useQuery ({
|
||||
@@ -876,7 +951,10 @@ const GekanatorPage: FC = () => {
|
||||
gameSeed,
|
||||
questionSuggestion,
|
||||
questionSuggestionAnswer,
|
||||
questionSuggestionCount }
|
||||
questionSuggestionCount,
|
||||
extraQuestions,
|
||||
extraQuestionAnswers,
|
||||
extraQuestionState }
|
||||
|
||||
try
|
||||
{
|
||||
@@ -908,7 +986,10 @@ const GekanatorPage: FC = () => {
|
||||
gameSeed,
|
||||
questionSuggestion,
|
||||
questionSuggestionAnswer,
|
||||
questionSuggestionCount])
|
||||
questionSuggestionCount,
|
||||
extraQuestions,
|
||||
extraQuestionAnswers,
|
||||
extraQuestionState])
|
||||
|
||||
const eligiblePosts = useMemo (
|
||||
() => candidatePostsFor ({
|
||||
@@ -985,10 +1066,25 @@ const GekanatorPage: FC = () => {
|
||||
setQuestionSuggestion ('')
|
||||
setQuestionSuggestionAnswer ('yes')
|
||||
}})
|
||||
const extraQuestionAnswersMutation = useMutation ({
|
||||
mutationFn: saveGekanatorExtraQuestionAnswers,
|
||||
onSuccess: () => {
|
||||
setExtraQuestionState ('saved')
|
||||
setPhase ('learned')
|
||||
}})
|
||||
|
||||
const resetExtraQuestionState = () => {
|
||||
const next = resettableExtraQuestionState ()
|
||||
setExtraQuestions (next.extraQuestions)
|
||||
setExtraQuestionAnswers (next.extraQuestionAnswers)
|
||||
setExtraQuestionState (next.extraQuestionState)
|
||||
extraQuestionAnswersMutation.reset ()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
clearStoredGame ()
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
setPhase ('intro')
|
||||
setScores (new Map ())
|
||||
setAnswers ([])
|
||||
@@ -1010,6 +1106,7 @@ const GekanatorPage: FC = () => {
|
||||
setQuestionSuggestion ('')
|
||||
setQuestionSuggestionAnswer ('yes')
|
||||
setQuestionSuggestionCount (0)
|
||||
resetExtraQuestionState ()
|
||||
setHistory ([])
|
||||
}
|
||||
|
||||
@@ -1188,6 +1285,7 @@ const GekanatorPage: FC = () => {
|
||||
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
resetExtraQuestionState ()
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setReviewGuessedPostId (guessedPostId)
|
||||
@@ -1203,6 +1301,7 @@ const GekanatorPage: FC = () => {
|
||||
|
||||
saveMutation.reset ()
|
||||
questionSuggestionMutation.reset ()
|
||||
resetExtraQuestionState ()
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
setSelectingCorrectPost (false)
|
||||
@@ -1236,6 +1335,7 @@ const GekanatorPage: FC = () => {
|
||||
}
|
||||
|
||||
const saveAndLearn = () => {
|
||||
resetExtraQuestionState ()
|
||||
saveReviewedResult (() => setPhase ('learned'))
|
||||
}
|
||||
|
||||
@@ -1344,6 +1444,7 @@ const GekanatorPage: FC = () => {
|
||||
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
resetExtraQuestionState ()
|
||||
setAnswers (answers.map ((answer, i) =>
|
||||
i === index ? { ...answer, answer: value } : answer))
|
||||
}
|
||||
@@ -1353,6 +1454,7 @@ const GekanatorPage: FC = () => {
|
||||
{
|
||||
setSaved (false)
|
||||
setSavedGameId (null)
|
||||
resetExtraQuestionState ()
|
||||
setReviewCorrectPostId (post.id)
|
||||
setSelectingCorrectPost (false)
|
||||
setSearch ('')
|
||||
@@ -1383,6 +1485,60 @@ const GekanatorPage: FC = () => {
|
||||
})
|
||||
.slice (0, 20)
|
||||
|
||||
const loadExtraQuestions = async (gameId: number) => {
|
||||
extraQuestionAnswersMutation.reset ()
|
||||
setExtraQuestionState ('loading')
|
||||
setExtraQuestions ([])
|
||||
setExtraQuestionAnswers ({ })
|
||||
setPhase ('extra_questions')
|
||||
|
||||
try
|
||||
{
|
||||
const questions = await queryClient.fetchQuery ({
|
||||
queryKey: gekanatorKeys.extraQuestions (gameId),
|
||||
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
|
||||
setExtraQuestions (questions)
|
||||
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
|
||||
}
|
||||
catch
|
||||
{
|
||||
setExtraQuestionState ('empty')
|
||||
}
|
||||
}
|
||||
|
||||
const startExtraQuestions = () => {
|
||||
if (reviewCorrectPostId === null || saveMutation.isPending)
|
||||
return
|
||||
|
||||
saveReviewedResult (gameId => {
|
||||
void loadExtraQuestions (gameId)
|
||||
})
|
||||
}
|
||||
|
||||
const answerExtraQuestion = (
|
||||
questionId: number,
|
||||
value: GekanatorAnswerValue,
|
||||
) => {
|
||||
setExtraQuestionAnswers ({
|
||||
...extraQuestionAnswers,
|
||||
[String (questionId)]: value })
|
||||
}
|
||||
|
||||
const saveExtraQuestions = () => {
|
||||
if (
|
||||
savedGameId === null
|
||||
|| extraQuestionAnswersMutation.isPending
|
||||
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
|
||||
)
|
||||
return
|
||||
|
||||
extraQuestionAnswersMutation.mutate ({
|
||||
gameId: savedGameId,
|
||||
answers: extraQuestions.map (question => ({
|
||||
questionId: question.id,
|
||||
answer: extraQuestionAnswers[String (question.id)] })) })
|
||||
}
|
||||
|
||||
const dialogue =
|
||||
phase === 'learned' && resultWon
|
||||
? <>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||||
@@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => {
|
||||
onClick={() => setPhase ('question_suggestion')}>
|
||||
質問を追加
|
||||
</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:opacity-50"
|
||||
disabled={reviewCorrectPostId === null
|
||||
|| saveMutation.isPending
|
||||
|| extraQuestionState === 'loading'
|
||||
|| extraQuestionAnswersMutation.isPending}
|
||||
onClick={startExtraQuestions}>
|
||||
追加で質問に答へる
|
||||
</button>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
|
||||
</p>)}
|
||||
</div>)}
|
||||
|
||||
{phase === 'extra_questions' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">追加学習</p>
|
||||
<p className="text-xl font-bold">追加で 2 問まで答へてください。</p>
|
||||
</div>
|
||||
|
||||
{extraQuestionState === 'loading' && (
|
||||
<p>追加質問を読み込んでゐます...</p>)}
|
||||
|
||||
{extraQuestionState === 'empty' && (
|
||||
<p>追加で学習できる質問はありませんでした。</p>)}
|
||||
|
||||
{extraQuestionState === 'ready' && (
|
||||
<div className="space-y-3">
|
||||
{extraQuestions.map ((question, index) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className="rounded border border-yellow-100 p-3
|
||||
dark:border-red-900">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
追加質問 {index + 1}
|
||||
</div>
|
||||
<div className="font-bold">{question.text}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{answerOptions.map (option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn (
|
||||
'rounded border px-3 py-2',
|
||||
extraQuestionAnswers[String (question.id)] === option.value
|
||||
? 'border-pink-600 bg-pink-600 text-white'
|
||||
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900')}
|
||||
onClick={() => answerExtraQuestion (question.id, option.value)}>
|
||||
{option.label}
|
||||
</button>))}
|
||||
</div>
|
||||
</div>))}
|
||||
</div>)}
|
||||
|
||||
{extraQuestionAnswersMutation.isError && (
|
||||
<p className="text-sm text-red-600">
|
||||
学習内容を保存できませんでした。通信状態を確認してもう一度試して。
|
||||
</p>)}
|
||||
|
||||
<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={extraQuestionAnswersMutation.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={
|
||||
extraQuestionState !== 'ready'
|
||||
|| extraQuestionAnswersMutation.isPending
|
||||
|| extraQuestions.some (
|
||||
question => !(extraQuestionAnswers[String (question.id)]))
|
||||
}
|
||||
onClick={saveExtraQuestions}>
|
||||
学習する
|
||||
</button>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{phase === 'learned' && (
|
||||
<div className="space-y-3">
|
||||
<p>覚えたよ.次はもっと見通す.</p>
|
||||
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||
|
||||
新しい課題から参照
ユーザをブロックする