このコミットが含まれているのは:
2026-06-16 21:52:10 +09:00
コミット 673a5dbd23
2個のファイルの変更413行の追加267行の削除
+12 -1
ファイルの表示
@@ -192,7 +192,10 @@ class GekanatorGamesController < ApplicationController
answer_value = answer['answer'].to_s answer_value = answer['answer'].to_s
next if answer_value.blank? || answer_value == 'unknown' next if answer_value.blank? || answer_value == 'unknown'
question = accepted_questions[answer['question_id'].to_s] question_id = game_answer_question_id(answer)
next if question_id.blank?
question = accepted_questions[question_id.to_s]
next unless learnable_game_answer_question?(question) next unless learnable_game_answer_question?(question)
example = example =
@@ -251,6 +254,7 @@ class GekanatorGamesController < ApplicationController
end end
def learnable_game_answer_question? question def learnable_game_answer_question? question
return false if question.nil?
return true if question.kind == 'post_similarity' return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag' return false unless question.kind == 'tag'
@@ -258,4 +262,11 @@ class GekanatorGamesController < ApplicationController
key = condition[:key].to_s key = condition[:key].to_s
!key.start_with?('nico:') !key.start_with?('nico:')
end end
def game_answer_question_id answer
answer['question_id'] ||
answer[:question_id] ||
answer['questionId'] ||
answer[:questionId]
end
end end
+223 -88
ファイルの表示
@@ -110,6 +110,7 @@ type StoredGekanatorGame = {
savedGameId: number | null savedGameId: number | null
learnedExampleCount?: number | null learnedExampleCount?: number | null
gameSeed?: string gameSeed?: string
questionSuggestionEntryMode?: 'search' | 'new'
questionSuggestionSearch?: string questionSuggestionSearch?: string
questionSuggestionSelectedId?: number | null questionSuggestionSelectedId?: number | null
questionSuggestion: string questionSuggestion: string
@@ -141,6 +142,10 @@ type QuestionBuildMode =
| 'split' | 'split'
| 'confirmation' | 'confirmation'
type QuestionSuggestionEntryMode =
| 'search'
| 'new'
type MascotState = type MascotState =
| 'idle' | 'idle'
| 'thinking_far' | 'thinking_far'
@@ -256,6 +261,7 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame =>
winningRunTargetId: game.winningRunTargetId ?? null, winningRunTargetId: game.winningRunTargetId ?? null,
winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null,
learnedExampleCount: game.learnedExampleCount ?? null, learnedExampleCount: game.learnedExampleCount ?? null,
questionSuggestionEntryMode: game.questionSuggestionEntryMode ?? 'search',
questionSuggestionSearch: game.questionSuggestionSearch ?? '', questionSuggestionSearch: game.questionSuggestionSearch ?? '',
questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? null, questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? null,
askedQuestionBank: game.askedQuestionBank?.map (question => ({ askedQuestionBank: game.askedQuestionBank?.map (question => ({
@@ -1125,17 +1131,31 @@ const applyQuestionAnswerDeltaToScores = ({
if (!(questionUsesPostSimilarityGraphForScoring (question))) if (!(questionUsesPostSimilarityGraphForScoring (question)))
return return
// `post_similarities` is the propagation graph. Directly matched posts
// get only the base delta; only non-direct neighbors get `base delta * cos`.
// When several matched posts point at the same neighbor, keep the largest
// absolute propagated contribution instead of summing all of them.
const propagatedDeltaByPostId = new Map<number, number> ()
matched.forEach (postId => { matched.forEach (postId => {
const post = materialIndex.postById.get (postId) const post = materialIndex.postById.get (postId)
post?.postSimilarityEdges?.forEach (edge => { post?.postSimilarityEdges?.forEach (edge => {
if (!Number.isFinite (edge.cos) || edge.cos <= 0) if (!Number.isFinite (edge.cos) || edge.cos <= 0)
return return
if (matched.has (edge.targetPostId))
return
nextScores.set ( const propagatedDelta = baseDelta * edge.cos
edge.targetPostId, const current = propagatedDeltaByPostId.get (edge.targetPostId)
(nextScores.get (edge.targetPostId) ?? 0) + baseDelta * edge.cos) if (current === undefined || Math.abs (propagatedDelta) > Math.abs (current))
propagatedDeltaByPostId.set (edge.targetPostId, propagatedDelta)
}) })
}) })
propagatedDeltaByPostId.forEach ((propagatedDelta, postId) => {
nextScores.set (
postId,
(nextScores.get (postId) ?? 0) + propagatedDelta)
})
} }
const buildIndexedQuestion = ( const buildIndexedQuestion = (
@@ -1294,7 +1314,7 @@ const buildQuestionsForCandidateIds = (
.forEach (([term]) => { .forEach (([term]) => {
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-contains', text: String (term) }, condition: { type: 'title-contains', text: String (term) },
text: `題名に「${ term }」が含まれる?`, text: `タイトルに「${ term }」が含まれる?`,
kind: 'title', kind: 'title',
priorityWeight: .99, priorityWeight: .99,
materialIndex })) materialIndex }))
@@ -1313,7 +1333,7 @@ const buildQuestionsForCandidateIds = (
if (asciiCount > 0 && asciiCount < total) if (asciiCount > 0 && asciiCount < total)
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-has-ascii' }, condition: { type: 'title-has-ascii' },
text: '題名に英数字が混じっている?', text: 'タイトルに英数字が混じっている?',
kind: 'title', kind: 'title',
priorityWeight: .68, priorityWeight: .68,
materialIndex })) materialIndex }))
@@ -1367,7 +1387,7 @@ const buildQuestionsForCandidateIds = (
.forEach (term => { .forEach (term => {
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-contains', text: term }, condition: { type: 'title-contains', text: term },
text: `題名に「${ term }」が含まれる?`, text: `タイトルに「${ term }」が含まれる?`,
kind: 'title', kind: 'title',
priorityWeight: .99, priorityWeight: .99,
materialIndex })) materialIndex }))
@@ -1386,7 +1406,7 @@ const buildQuestionsForCandidateIds = (
if (materialIndex.titleAsciiPostIds.has (targetPostId)) if (materialIndex.titleAsciiPostIds.has (targetPostId))
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-has-ascii' }, condition: { type: 'title-has-ascii' },
text: '題名に英数字が混じっている?', text: 'タイトルに英数字が混じっている?',
kind: 'title', kind: 'title',
priorityWeight: .68, priorityWeight: .68,
materialIndex })) materialIndex }))
@@ -1415,6 +1435,8 @@ const candidatePostsForState = ({
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, number>
}): Post[] => { }): Post[] => {
const dynamicMatchIndex = new Map<string, Set<number>> () const dynamicMatchIndex = new Map<string, Set<number>> ()
const answerAllowsHardFilter = (answer: GekanatorAnswerValue): boolean =>
answer === 'yes' || answer === 'no'
return posts.filter (post => { return posts.filter (post => {
if (rejectedPostIds.has (post.id)) if (rejectedPostIds.has (post.id))
@@ -1427,7 +1449,7 @@ const candidatePostsForState = ({
return true return true
if (softenedQuestionIds.has (answer.questionId)) if (softenedQuestionIds.has (answer.questionId))
return true return true
if (!(answer.answer === 'yes' || answer.answer === 'no')) if (!(answerAllowsHardFilter (answer.answer)))
return true return true
const question = questionById.get (answer.questionId) const question = questionById.get (answer.questionId)
@@ -2717,6 +2739,7 @@ const GekanatorBackdrop: FC<{
const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)` const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)`
const [activeDirection, setActiveDirection] = useState (nextDirection) const [activeDirection, setActiveDirection] = useState (nextDirection)
const activeDirectionRef = useRef (activeDirection) const activeDirectionRef = useRef (activeDirection)
const guessAnimationControlsRef = useRef<ReturnType<typeof animate>[]> ([])
const flipTimerRef = useRef<number | null> (null) const flipTimerRef = useRef<number | null> (null)
const [displayedBackdropMode, setDisplayedBackdropMode] = const [displayedBackdropMode, setDisplayedBackdropMode] =
useState<'normal' | 'winning_run' | 'guess'> (backdropMode) useState<'normal' | 'winning_run' | 'guess'> (backdropMode)
@@ -2731,15 +2754,30 @@ const GekanatorBackdrop: FC<{
const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed) const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed)
const [isFlippingTiles, setIsFlippingTiles] = useState (false) const [isFlippingTiles, setIsFlippingTiles] = useState (false)
const [isCrossfading, setIsCrossfading] = useState (false) const [isCrossfading, setIsCrossfading] = useState (false)
const renderedSettings = settingsForMode (displayedBackdropMode)
const renderedTileCount = const isLeavingGuessBackdrop = displayedBackdropMode === 'guess' && backdropMode !== 'guess'
renderedSettings.columns * renderedSettings.rows
const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount) const renderBackdropMode = isLeavingGuessBackdrop ? backdropMode : displayedBackdropMode
const isGuessPresentation =
phase === 'guess' || backdropMode === 'guess' || displayedBackdropMode === 'guess' const renderWinningRunCount = (isLeavingGuessBackdrop
? winningRunQuestionCount
: displayedWinningRunCount)
const renderThumbnails = isLeavingGuessBackdrop ? nextThumbnails : displayedThumbnails
const renderIsCrossfading = isCrossfading && !(isLeavingGuessBackdrop)
const renderedSettings = settingsForMode (renderBackdropMode)
const renderedTileCount = renderedSettings.columns * renderedSettings.rows
const renderedScale = scaleForMode (renderBackdropMode, renderWinningRunCount)
const isGuessPresentation = backdropMode === 'guess'
const crossfadeDuration = motionMode === 'calm' ? .95 : .75 const crossfadeDuration = motionMode === 'calm' ? .95 : .75
useEffect (() => { useEffect (() => {
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
if (motionMode === 'off') if (motionMode === 'off')
return return
@@ -2752,9 +2790,11 @@ const GekanatorBackdrop: FC<{
const controls = [ const controls = [
animate (x, 0, { duration, ease }), animate (x, 0, { duration, ease }),
animate (y, 0, { duration, ease })] animate (y, 0, { duration, ease })]
guessAnimationControlsRef.current = controls
return () => { return () => {
controls.forEach (control => control.stop ()) controls.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
} }
}, [isGuessPresentation, motionMode, visualSeed, x, y]) }, [isGuessPresentation, motionMode, visualSeed, x, y])
@@ -2771,13 +2811,21 @@ const GekanatorBackdrop: FC<{
if (motionMode === 'off' || nextThumbnails.length === 0) if (motionMode === 'off' || nextThumbnails.length === 0)
{ {
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
x.set (0) x.set (0)
y.set (0) y.set (0)
return return
} }
if (isGuessPresentation) if (isGuessPresentation)
{
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
x.set (0)
y.set (0)
return return
}
const speed = 33.333333 / marqueeDuration const speed = 33.333333 / marqueeDuration
let animationFrame: number let animationFrame: number
@@ -2795,8 +2843,7 @@ const GekanatorBackdrop: FC<{
animationFrame = window.requestAnimationFrame (tick) animationFrame = window.requestAnimationFrame (tick)
return () => window.cancelAnimationFrame (animationFrame) return () => window.cancelAnimationFrame (animationFrame)
}, [ }, [x,
x,
y, y,
marqueeDuration, marqueeDuration,
motionMode, motionMode,
@@ -2809,12 +2856,14 @@ const GekanatorBackdrop: FC<{
setActiveDirection (nextDirection) setActiveDirection (nextDirection)
} }
if (flipTimerRef.current !== null) { if (flipTimerRef.current !== null)
{
window.clearTimeout (flipTimerRef.current) window.clearTimeout (flipTimerRef.current)
flipTimerRef.current = null flipTimerRef.current = null
} }
if (motionMode === 'off') { if (motionMode === 'off')
{
applyDirection () applyDirection ()
setIsFlippingTiles (false) setIsFlippingTiles (false)
setIsCrossfading (false) setIsCrossfading (false)
@@ -2822,7 +2871,8 @@ const GekanatorBackdrop: FC<{
return return
} }
if (backdropMode === 'guess' && guessThumbnail) { if (backdropMode === 'guess' && guessThumbnail)
{
setIsFlippingTiles (false) setIsFlippingTiles (false)
setIsCrossfading (false) setIsCrossfading (false)
setDisplayedBackdropMode ('guess') setDisplayedBackdropMode ('guess')
@@ -2834,10 +2884,9 @@ const GekanatorBackdrop: FC<{
return return
} }
if ( if (displayedBackdropMode === 'winning_run'
displayedBackdropMode === 'winning_run' && backdropMode === 'winning_run')
&& backdropMode === 'winning_run' {
) {
applyDirection () applyDirection ()
setDisplayedBackdropMode ('winning_run') setDisplayedBackdropMode ('winning_run')
setDisplayedWinningRunCount (winningRunQuestionCount) setDisplayedWinningRunCount (winningRunQuestionCount)
@@ -2850,7 +2899,8 @@ const GekanatorBackdrop: FC<{
return return
} }
if (nextThumbnails.length === 0) { if (nextThumbnails.length === 0)
{
applyDirection () applyDirection ()
setIsFlippingTiles (false) setIsFlippingTiles (false)
setIsCrossfading (false) setIsCrossfading (false)
@@ -2858,16 +2908,32 @@ const GekanatorBackdrop: FC<{
return return
} }
if (displayedBackdropMode === 'guess' && backdropMode !== 'guess')
{
applyDirection ()
x.set (0)
y.set (0)
setIsFlippingTiles (false)
setIsCrossfading (false)
setDisplayedBackdropMode (backdropMode)
setDisplayedWinningRunCount (winningRunQuestionCount)
setDisplayedThumbnails (nextThumbnails)
setFromThumbnails (nextThumbnails)
setToThumbnails (nextThumbnails)
setFlipVisualSeed (visualSeed)
return
}
const sameTiles = const sameTiles =
displayedThumbnails.length === nextThumbnails.length displayedThumbnails.length === nextThumbnails.length
&& displayedThumbnails.every ( && displayedThumbnails.every ((thumbnail, index) => thumbnail === nextThumbnails[index])
(thumbnail, index) => thumbnail === nextThumbnails[index])
if (sameTiles && flipVisualSeed === visualSeed) { if (sameTiles && flipVisualSeed === visualSeed)
if ( {
activeDirection.x !== nextDirection.x if (activeDirection.x !== nextDirection.x
|| activeDirection.y !== nextDirection.y || activeDirection.y !== nextDirection.y)
)
applyDirection () applyDirection ()
return return
} }
@@ -2895,13 +2961,13 @@ const GekanatorBackdrop: FC<{
}, (shouldCrossfade ? crossfadeDuration : tileFlipDuration) * 1000) }, (shouldCrossfade ? crossfadeDuration : tileFlipDuration) * 1000)
return () => { return () => {
if (flipTimerRef.current !== null) { if (flipTimerRef.current !== null)
{
window.clearTimeout (flipTimerRef.current) window.clearTimeout (flipTimerRef.current)
flipTimerRef.current = null flipTimerRef.current = null
} }
} }
}, [ }, [motionMode,
motionMode,
backdropMode, backdropMode,
displayedBackdropMode, displayedBackdropMode,
guessThumbnail, guessThumbnail,
@@ -2917,38 +2983,39 @@ const GekanatorBackdrop: FC<{
x, x,
y]) y])
const renderTileSet = ({ const renderTileSet = (
mode, { mode,
thumbnails, thumbnails,
settings, settings,
tileCount, tileCount,
scale, scale,
opacity, opacity,
withFlip, withFlip }: { mode: 'normal' | 'winning_run' | 'guess'
}: {
mode: 'normal' | 'winning_run' | 'guess'
thumbnails: string[] thumbnails: string[]
settings: { columns: number; rows: number; opacity: number } settings: { columns: number; rows: number; opacity: number }
tileCount: number tileCount: number
scale: number scale: number
opacity?: number opacity?: number
withFlip?: boolean withFlip?: boolean }) => {
}) => ( const guessModeFlg = mode === 'guess'
return (
<motion.div <motion.div
className="absolute inset-0" className="absolute inset-0"
initial={opacity === undefined initial={guessModeFlg
? false
: (opacity == null
? undefined ? undefined
: { : { opacity: opacity === 0 ? 1 : 0, scale, x: '0%', y: '0%' })}
opacity: opacity === 0 ? 1 : 0, animate={{ opacity: opacity ?? 1,
scale, scale,
x: mode === 'guess' ? guessFocusOffset.x : '0%', x: guessModeFlg ? guessFocusOffset.x : '0%',
y: mode === 'guess' ? guessFocusOffset.y : '0%' }} y: guessModeFlg ? guessFocusOffset.y : '0%' }}
animate={{ opacity: opacity ?? 1, scale, transition={guessModeFlg
x: mode === 'guess' ? guessFocusOffset.x : '0%', ? { duration: 0 }
y: mode === 'guess' ? guessFocusOffset.y : '0%' }} : (mode === 'winning_run'
transition={(mode === 'winning_run' || mode === 'guess')
? { duration: crossfadeDuration, ease: [.16, 1, .3, 1] } ? { duration: crossfadeDuration, ease: [.16, 1, .3, 1] }
: { duration: .2 }}> : { duration: .2 })}>
{Array.from ({ length: 9 }, (_, duplicate) => { {Array.from ({ length: 9 }, (_, duplicate) => {
const column = duplicate % 3 const column = duplicate % 3
const row = Math.floor (duplicate / 3) const row = Math.floor (duplicate / 3)
@@ -2957,7 +3024,7 @@ const GekanatorBackdrop: FC<{
<motion.div <motion.div
key={`${ mode }:${ duplicate }`} key={`${ mode }:${ duplicate }`}
className="absolute grid overflow-hidden" className="absolute grid overflow-hidden"
layout={mode !== 'normal'} layout={mode === 'winning_run'}
style={{ style={{
left: `${ column * 33.333333 }%`, left: `${ column * 33.333333 }%`,
top: `${ row * 33.333333 }%`, top: `${ row * 33.333333 }%`,
@@ -2987,7 +3054,7 @@ const GekanatorBackdrop: FC<{
<motion.div <motion.div
key={`${ mode }:${ duplicate }:${ index }`} key={`${ mode }:${ duplicate }:${ index }`}
className="relative overflow-hidden" className="relative overflow-hidden"
layout={mode !== 'normal'} layout={mode === 'winning_run'}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }} transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
style={{ perspective: 1600 }}> style={{ perspective: 1600 }}>
{(mode !== 'normal' || !(withFlip)) {(mode !== 'normal' || !(withFlip))
@@ -3029,6 +3096,7 @@ const GekanatorBackdrop: FC<{
</motion.div>) </motion.div>)
})} })}
</motion.div>) </motion.div>)
}
if (motionMode === 'off' || nextThumbnails.length === 0) if (motionMode === 'off' || nextThumbnails.length === 0)
return ( return (
@@ -3046,15 +3114,16 @@ const GekanatorBackdrop: FC<{
height: 'calc(max(100vw, 100vh) * 3)' }}> height: 'calc(max(100vw, 100vh) * 3)' }}>
<motion.div <motion.div
className="relative h-full w-full"> className="relative h-full w-full">
{isCrossfading {renderIsCrossfading
? ( ? (
<> <>
{renderTileSet ({ {renderTileSet ({
mode: displayedBackdropMode, mode: displayedBackdropMode,
thumbnails: fromThumbnails, thumbnails: fromThumbnails,
settings: renderedSettings, settings: settingsForMode (displayedBackdropMode),
tileCount: renderedTileCount, tileCount: (settingsForMode (displayedBackdropMode).columns
scale: renderedScale, * settingsForMode (displayedBackdropMode).rows),
scale: scaleForMode (displayedBackdropMode, displayedWinningRunCount),
opacity: 0 })} opacity: 0 })}
{renderTileSet ({ {renderTileSet ({
mode: backdropMode, mode: backdropMode,
@@ -3064,13 +3133,14 @@ const GekanatorBackdrop: FC<{
scale: scaleForMode (backdropMode, winningRunQuestionCount), scale: scaleForMode (backdropMode, winningRunQuestionCount),
opacity: 1 })} opacity: 1 })}
</>) </>)
: renderTileSet ({ : (
mode: displayedBackdropMode, renderTileSet ({
thumbnails: displayedThumbnails, mode: renderBackdropMode,
thumbnails: renderThumbnails,
settings: renderedSettings, settings: renderedSettings,
tileCount: renderedTileCount, tileCount: renderedTileCount,
scale: renderedScale, scale: renderedScale,
withFlip: isFlippingTiles })} withFlip: isFlippingTiles && !(isLeavingGuessBackdrop) }))}
</motion.div> </motion.div>
</motion.div> </motion.div>
</div> </div>
@@ -3150,6 +3220,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
storedGame?.savedGameId ?? null) storedGame?.savedGameId ?? null)
const [learnedExampleCount, setLearnedExampleCount] = useState<number | null> ( const [learnedExampleCount, setLearnedExampleCount] = useState<number | null> (
storedGame?.learnedExampleCount ?? null) storedGame?.learnedExampleCount ?? null)
const [questionSuggestionEntryMode, setQuestionSuggestionEntryMode] =
useState<QuestionSuggestionEntryMode> (
storedGame?.questionSuggestionEntryMode ?? 'search')
const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState ( const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState (
storedGame?.questionSuggestionSearch ?? '') storedGame?.questionSuggestionSearch ?? '')
const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] = const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] =
@@ -3190,11 +3263,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
[posts, acceptedQuestions]) [posts, acceptedQuestions])
useEffect (() => { useEffect (() => {
if ( if (posts.length === 0
posts.length === 0
|| storedAskedQuestionBankIds.length === 0 || storedAskedQuestionBankIds.length === 0
|| !(acceptedQuestionsFetched) || !(acceptedQuestionsFetched))
)
return return
const questionById = new Map ( const questionById = new Map (
@@ -3207,8 +3278,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
.map (questionId => questionById.get (questionId)) .map (questionId => questionById.get (questionId))
.filter ((question): question is GekanatorQuestion => question !== undefined)) .filter ((question): question is GekanatorQuestion => question !== undefined))
setStoredAskedQuestionBankIds ([]) setStoredAskedQuestionBankIds ([])
}, [ }, [posts,
posts,
storedAskedQuestionBankIds, storedAskedQuestionBankIds,
acceptedQuestionsFetched, acceptedQuestionsFetched,
askedQuestionBank, askedQuestionBank,
@@ -3250,6 +3320,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
savedGameId, savedGameId,
learnedExampleCount, learnedExampleCount,
gameSeed, gameSeed,
questionSuggestionEntryMode,
questionSuggestionSearch, questionSuggestionSearch,
questionSuggestionSelectedId, questionSuggestionSelectedId,
questionSuggestion, questionSuggestion,
@@ -3293,6 +3364,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
savedGameId, savedGameId,
learnedExampleCount, learnedExampleCount,
gameSeed, gameSeed,
questionSuggestionEntryMode,
questionSuggestionSearch, questionSuggestionSearch,
questionSuggestionSelectedId, questionSuggestionSelectedId,
questionSuggestion, questionSuggestion,
@@ -3355,6 +3427,28 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
question => question.recordId === questionSuggestionSelectedId) question => question.recordId === questionSuggestionSelectedId)
?? null, ?? null,
[acceptedQuestions, questionSuggestionSelectedId]) [acceptedQuestions, questionSuggestionSelectedId])
const canSubmitQuestionSuggestion = useMemo (() => {
if (!(canPersistGame) || reviewCorrectPostId === null)
return false
if (questionSuggestionEntryMode === 'search')
return selectedSuggestedQuestion !== null
return questionSuggestion.trim () !== ''
}, [
canPersistGame,
reviewCorrectPostId,
questionSuggestionEntryMode,
selectedSuggestedQuestion,
questionSuggestion])
const canShowNewQuestionSuggestionButton = useMemo (() => {
return questionSuggestionEntryMode === 'search'
&& questionSuggestionSearch.trim () !== ''
&& searchableSuggestedQuestions.length === 0
}, [
questionSuggestionEntryMode,
questionSuggestionSearch,
searchableSuggestedQuestions.length])
const recentFirstQuestionPenaltyById = useMemo (() => { const recentFirstQuestionPenaltyById = useMemo (() => {
const penalties = new Map<string, number> () const penalties = new Map<string, number> ()
@@ -3494,6 +3588,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
onSuccess: async data => { onSuccess: async data => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setQuestionSuggestionCount (data.count) setQuestionSuggestionCount (data.count)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('') setQuestionSuggestion ('')
@@ -3544,6 +3639,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setGameSeed (createGameSeed ()) setGameSeed (createGameSeed ())
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('') setQuestionSuggestion ('')
@@ -3876,6 +3972,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setReviewGuessedPostId (guessedPostId) setReviewGuessedPostId (guessedPostId)
@@ -3895,6 +3992,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setSelectingCorrectPost (false) setSelectingCorrectPost (false)
@@ -3925,6 +4023,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
} }
const saveAndReset = () => { const saveAndReset = () => {
if (saveMutation.isError)
{
reset ()
return
}
if (!(canPersistGame)) if (!(canPersistGame))
{ {
reset () reset ()
@@ -3948,11 +4052,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const submitQuestionSuggestion = () => { const submitQuestionSuggestion = () => {
const questionText = questionSuggestion.trim () const questionText = questionSuggestion.trim ()
const selectedQuestion = selectedSuggestedQuestion const selectedQuestion = selectedSuggestedQuestion
if ( if (!(canSubmitQuestionSuggestion) || questionSuggestionMutation.isPending)
!(canPersistGame)
|| (selectedQuestion === null && !(questionText))
|| questionSuggestionMutation.isPending
)
return return
saveReviewedResult (gekanatorGameId => { saveReviewedResult (gekanatorGameId => {
@@ -4092,6 +4192,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
resetExtraQuestionState () resetExtraQuestionState ()
@@ -4105,6 +4206,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
resetExtraQuestionState () resetExtraQuestionState ()
@@ -4798,8 +4900,15 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
<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">
{questionSuggestionEntryMode === 'search'
? 'まず既存質問を探してください。'
: '新しい質問を追加します。'}
</p>
</div> </div>
{questionSuggestionEntryMode === 'search'
? (
<>
<label className="block space-y-2"> <label className="block space-y-2">
<span className="font-bold"></span> <span className="font-bold"></span>
<input <input
@@ -4830,12 +4939,35 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setQuestionSuggestion ('') setQuestionSuggestion ('')
}}> }}>
<div className="font-bold">{question.text}</div> <div className="font-bold">{question.text}</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
{question.kind}
</div>
</button>))} </button>))}
</div> </div>
</div>)} </div>)}
{selectedSuggestedQuestion && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {selectedSuggestedQuestion.text}
</p>)}
{canShowNewQuestionSuggestionButton && (
<div className="space-y-2">
<p className="text-sm text-neutral-600 dark:text-neutral-300">
</p>
<div className="flex flex-wrap gap-2">
<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"
onClick={() => {
setQuestionSuggestionEntryMode ('new')
setQuestionSuggestionSelectedId (null)
setQuestionSuggestionSearch ('')
}}>
</button>
</div>
</div>)}
</>)
: (
<div className="space-y-2"> <div className="space-y-2">
<div className="font-bold"></div> <div className="font-bold"></div>
<textarea <textarea
@@ -4849,11 +4981,20 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
bg-white px-3 py-2 dark:border-red-700 bg-white px-3 py-2 dark:border-red-700
dark:bg-red-950" dark:bg-red-950"
placeholder="適切な既存質問がない場合だけ新規追加してください。"/> placeholder="適切な既存質問がない場合だけ新規追加してください。"/>
<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"
onClick={() => {
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestion ('')
}}>
</button>
</div> </div>
{selectedSuggestedQuestion && ( </div>)}
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {selectedSuggestedQuestion.text}
</p>)}
<label className="block space-y-2"> <label className="block space-y-2">
<span className="font-bold">稿</span> <span className="font-bold">稿</span>
<select <select
@@ -4886,15 +5027,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
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 disabled:opacity-50" dark:hover:bg-red-900 disabled:opacity-50"
disabled={ disabled={!(canSubmitQuestionSuggestion)
!(canPersistGame)
|| reviewCorrectPostId === null
|| (
questionSuggestion.trim () === ''
&& selectedSuggestedQuestion === null)
|| saveMutation.isPending || saveMutation.isPending
|| questionSuggestionMutation.isPending || questionSuggestionMutation.isPending}
}
onClick={submitQuestionSuggestion}> onClick={submitQuestionSuggestion}>
</button> </button>