このコミットが含まれているのは:
@@ -11,9 +11,10 @@ export type GekanatorAnswerValue =
|
||||
| 'unknown'
|
||||
|
||||
export type GekanatorAnswerLog = {
|
||||
questionId: string
|
||||
questionText: string
|
||||
answer: GekanatorAnswerValue }
|
||||
questionId: string
|
||||
questionText: string
|
||||
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,
|
||||
@@ -342,9 +351,9 @@ const chooseQuestion = ({
|
||||
}
|
||||
|
||||
const rank = (
|
||||
questionsToRank: GekanatorQuestion[],
|
||||
candidates: { post: Post; score: number }[],
|
||||
focusCandidates: { post: Post; score: number }[],
|
||||
questionsToRank: GekanatorQuestion[],
|
||||
candidates: { 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))
|
||||
question: GekanatorQuestion
|
||||
score: number
|
||||
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 ([])
|
||||
}
|
||||
|
||||
@@ -637,9 +657,10 @@ const GekanatorPage: FC = () => {
|
||||
reviewGuessedPostId,
|
||||
reviewCorrectPostId }])
|
||||
const nextAnswers = [...answers, {
|
||||
questionId: currentQuestion.id,
|
||||
questionText: currentQuestion.text,
|
||||
answer: value }]
|
||||
questionId: currentQuestion.id,
|
||||
questionText: currentQuestion.text,
|
||||
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,29 +1195,49 @@ const GekanatorPage: FC = () => {
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold">質問と回答</div>
|
||||
<div className="space-y-2">
|
||||
{answers.map ((answer, index) => (
|
||||
<div
|
||||
key={`${ answer.questionId }:${ index }`}
|
||||
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">{answer.questionText}</div>
|
||||
<select
|
||||
value={answer.answer}
|
||||
className="mt-2 rounded border border-yellow-300 bg-white px-2 py-1
|
||||
dark:border-red-700 dark:bg-red-950"
|
||||
onChange={ev =>
|
||||
correctAnswerAt (
|
||||
index,
|
||||
ev.target.value as GekanatorAnswerValue)}>
|
||||
{answerOptions.map (option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>))}
|
||||
</select>
|
||||
</div>))}
|
||||
{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
|
||||
dark:border-red-900">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
質問 {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="rounded border border-yellow-300 bg-white px-2
|
||||
py-1
|
||||
dark:border-red-700 dark:bg-red-950"
|
||||
onChange={ev =>
|
||||
correctAnswerAt (
|
||||
index,
|
||||
ev.target.value as GekanatorAnswerValue)}>
|
||||
{answerOptions.map (option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>))}
|
||||
</select>
|
||||
</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>)}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする