コミットを比較
3 コミット
ffebce36b9
...
62d0830aec
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 62d0830aec | |||
| cda90b76d2 | |||
| 673a5dbd23 |
@@ -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
|
||||||
|
|||||||
@@ -151,6 +151,154 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'learns accepted non-nico tag answers from camelCase main game logs' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
tag_question = GekanatorQuestion.create!(
|
||||||
|
text: 'MAD 要素がある?',
|
||||||
|
kind: 'tag',
|
||||||
|
source: 'admin_curated',
|
||||||
|
status: 'accepted',
|
||||||
|
priority_weight: 1.0,
|
||||||
|
condition: { type: 'tag', key: 'meme:MAD' },
|
||||||
|
created_by: admin
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: correct_post.id,
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
questionId: 'tag:meme:MAD',
|
||||||
|
question_text: 'MAD 要素がある?',
|
||||||
|
answer: 'yes',
|
||||||
|
original_answer: 'yes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: 'tag:meme:missing',
|
||||||
|
question_text: '存在しない質問?',
|
||||||
|
answer: 'yes',
|
||||||
|
original_answer: 'yes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionId: 'tag:meme:MAD',
|
||||||
|
question_text: 'MAD 要素がある?',
|
||||||
|
answer: 'unknown',
|
||||||
|
original_answer: 'unknown'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}.to change { GekanatorQuestionExample.count }.by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(json['learned_example_count']).to eq(1)
|
||||||
|
|
||||||
|
example = GekanatorQuestionExample.last
|
||||||
|
expect(example).to have_attributes(
|
||||||
|
gekanator_question_id: tag_question.id,
|
||||||
|
post_id: correct_post.id,
|
||||||
|
user_id: admin.id,
|
||||||
|
answer: 'yes',
|
||||||
|
source: 'post_game_answer'
|
||||||
|
)
|
||||||
|
expect(example.gekanator_game_id).to eq(json['id'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not learn fact questions or nico tag questions from main game logs' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'example.com 由来?',
|
||||||
|
kind: 'source',
|
||||||
|
condition: { type: 'source', host: 'example.com' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '題名に結束バンドを含む?',
|
||||||
|
kind: 'title',
|
||||||
|
condition: { type: 'title-contains', text: '結束バンド' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '2024 年投稿?',
|
||||||
|
kind: 'original_date',
|
||||||
|
condition: { type: 'original-year', year: 2024 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'ニコニコにぼっちタグ?',
|
||||||
|
kind: 'tag',
|
||||||
|
condition: { type: 'tag', key: 'nico:ぼっち' }
|
||||||
|
}
|
||||||
|
].each do |attributes|
|
||||||
|
GekanatorQuestion.create!(
|
||||||
|
text: attributes[:text],
|
||||||
|
kind: attributes[:kind],
|
||||||
|
source: 'admin_curated',
|
||||||
|
status: 'accepted',
|
||||||
|
priority_weight: 1.0,
|
||||||
|
condition: attributes[:condition],
|
||||||
|
created_by: admin
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: correct_post.id,
|
||||||
|
answers: [
|
||||||
|
{ question_id: 'source:example.com', answer: 'yes' },
|
||||||
|
{ question_id: 'title:contains:結束バンド', answer: 'yes' },
|
||||||
|
{ question_id: 'original-year:2024', answer: 'yes' },
|
||||||
|
{ question_id: 'tag:nico:ぼっち', answer: 'yes' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}.not_to change { GekanatorQuestionExample.count }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(json['learned_example_count']).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates an existing main game example instead of duplicating it' do
|
||||||
|
sign_in_as admin
|
||||||
|
|
||||||
|
tag_question = GekanatorQuestion.create!(
|
||||||
|
text: '喜多ちゃんが関係してる?',
|
||||||
|
kind: 'tag',
|
||||||
|
source: 'admin_curated',
|
||||||
|
status: 'accepted',
|
||||||
|
priority_weight: 1.0,
|
||||||
|
condition: { type: 'tag', key: 'character:喜多郁代' },
|
||||||
|
created_by: admin
|
||||||
|
)
|
||||||
|
existing = GekanatorQuestionExample.create!(
|
||||||
|
gekanator_question: tag_question,
|
||||||
|
post: correct_post,
|
||||||
|
user: admin,
|
||||||
|
answer: 'no',
|
||||||
|
source: 'post_game_answer',
|
||||||
|
weight: 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: correct_post.id,
|
||||||
|
answers: [
|
||||||
|
{
|
||||||
|
question_id: 'tag:character:喜多郁代',
|
||||||
|
answer: 'yes',
|
||||||
|
original_answer: 'yes'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}.not_to change { GekanatorQuestionExample.count }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(json['learned_example_count']).to eq(1)
|
||||||
|
expect(existing.reload.answer).to eq('yes')
|
||||||
|
expect(existing.sample_count).to eq(2)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST /gekanator/question_suggestions' do
|
describe 'POST /gekanator/question_suggestions' do
|
||||||
@@ -249,7 +397,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
expect(GekanatorQuestionSuggestion.last.processed).to eq(false)
|
expect(GekanatorQuestionSuggestion.last.processed).to eq(false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'limits suggestions to three per game' do
|
it 'allows more than three suggestions per game' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
3.times do |i|
|
3.times do |i|
|
||||||
@@ -267,9 +415,10 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
question_text: 'fourth question?',
|
question_text: 'fourth question?',
|
||||||
answer: 'yes'
|
answer: 'yes'
|
||||||
}
|
}
|
||||||
}.not_to change { GekanatorQuestionSuggestion.count }
|
}.to change { GekanatorQuestionSuggestion.count }.by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(json['count']).to eq(4)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'allows a non-admin user to suggest a question for their own game' do
|
it 'allows a non-admin user to suggest a question for their own game' do
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ const postSimilarityQuestion = (
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const sourceQuestion = (
|
||||||
|
host: string,
|
||||||
|
): GekanatorQuestion => ({
|
||||||
|
id: `source:${ host }`,
|
||||||
|
text: `${ host }?`,
|
||||||
|
kind: 'source',
|
||||||
|
condition: {
|
||||||
|
type: 'source',
|
||||||
|
host },
|
||||||
|
source: 'default',
|
||||||
|
priorityWeight: 1,
|
||||||
|
test: candidate => new URL (candidate.url).hostname === host,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const answer = (
|
const answer = (
|
||||||
question: GekanatorQuestion,
|
question: GekanatorQuestion,
|
||||||
value: GekanatorAnswerValue,
|
value: GekanatorAnswerValue,
|
||||||
@@ -64,7 +79,7 @@ const answer = (
|
|||||||
|
|
||||||
|
|
||||||
describe('candidatePostsFor', () => {
|
describe('candidatePostsFor', () => {
|
||||||
it('lets recovered candidates ignore old answers but not later answers', () => {
|
it('does not hard-filter semantic post_similarity answers', () => {
|
||||||
const posts = [post (1), post (2), post (3)]
|
const posts = [post (1), post (2), post (3)]
|
||||||
const oldQuestion = postSimilarityQuestion ('old', {
|
const oldQuestion = postSimilarityQuestion ('old', {
|
||||||
1: 'no',
|
1: 'no',
|
||||||
@@ -77,6 +92,29 @@ describe('candidatePostsFor', () => {
|
|||||||
3: 'yes',
|
3: 'yes',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const candidates = candidatePostsFor ({
|
||||||
|
posts,
|
||||||
|
questions: [oldQuestion, laterQuestion],
|
||||||
|
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
|
||||||
|
softenedQuestionIds: new Set (),
|
||||||
|
rejectedPostIds: new Set (),
|
||||||
|
recoveredCandidatePosts: new Map ([
|
||||||
|
[1, 1],
|
||||||
|
[3, 1],
|
||||||
|
]) })
|
||||||
|
|
||||||
|
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets recovered candidates ignore old fact answers but not later fact answers', () => {
|
||||||
|
const posts = [
|
||||||
|
{ ...post (1), url: 'https://other.example/posts/1' },
|
||||||
|
post (2),
|
||||||
|
{ ...post (3), url: 'https://example.com/posts/3' },
|
||||||
|
]
|
||||||
|
const oldQuestion = sourceQuestion ('old.example.com')
|
||||||
|
const laterQuestion = sourceQuestion ('example.com')
|
||||||
|
|
||||||
const candidates = candidatePostsFor ({
|
const candidates = candidatePostsFor ({
|
||||||
posts,
|
posts,
|
||||||
questions: [oldQuestion, laterQuestion],
|
questions: [oldQuestion, laterQuestion],
|
||||||
@@ -112,7 +150,7 @@ describe('candidatePostsFor', () => {
|
|||||||
|
|
||||||
|
|
||||||
describe('hardFilteredPostsForAnswer', () => {
|
describe('hardFilteredPostsForAnswer', () => {
|
||||||
it('returns zero candidates without falling back to the original pool', () => {
|
it('keeps the original pool for semantic post_similarity answers', () => {
|
||||||
const posts = [post (1), post (2)]
|
const posts = [post (1), post (2)]
|
||||||
const question = postSimilarityQuestion ('question', {
|
const question = postSimilarityQuestion ('question', {
|
||||||
1: 'yes',
|
1: 'yes',
|
||||||
@@ -123,7 +161,41 @@ describe('hardFilteredPostsForAnswer', () => {
|
|||||||
posts,
|
posts,
|
||||||
question,
|
question,
|
||||||
answer: 'no',
|
answer: 'no',
|
||||||
})).toEqual ([])
|
})).toEqual (posts)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hard-filters fact answers only for yes and no', () => {
|
||||||
|
const posts = [
|
||||||
|
{ ...post (1), url: 'https://example.com/posts/1' },
|
||||||
|
{ ...post (2), url: 'https://other.example/posts/2' },
|
||||||
|
]
|
||||||
|
const question = sourceQuestion ('example.com')
|
||||||
|
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'yes',
|
||||||
|
}).map (candidate => candidate.id)).toEqual ([1])
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'no',
|
||||||
|
}).map (candidate => candidate.id)).toEqual ([2])
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'partial',
|
||||||
|
})).toEqual (posts)
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'probably_no',
|
||||||
|
})).toEqual (posts)
|
||||||
|
expect(hardFilteredPostsForAnswer ({
|
||||||
|
posts,
|
||||||
|
question,
|
||||||
|
answer: 'unknown',
|
||||||
|
})).toEqual (posts)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const hardFilteredPostsForAnswer = ({
|
|||||||
if (!(questionIsFactLikeForHardFiltering (question)))
|
if (!(questionIsFactLikeForHardFiltering (question)))
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
if (answer === 'unknown')
|
if (!(answer === 'yes' || answer === 'no'))
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
return posts.filter (post => {
|
return posts.filter (post => {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
|
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
|
||||||
@@ -49,6 +52,57 @@ const blocked = (
|
|||||||
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)])
|
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)])
|
||||||
|
|
||||||
|
|
||||||
|
const gekanatorPageSource = readFileSync (
|
||||||
|
resolve (process.cwd (), 'src/pages/GekanatorPage.tsx'),
|
||||||
|
'utf8')
|
||||||
|
|
||||||
|
const gekanatorBackdropSource = gekanatorPageSource.slice (
|
||||||
|
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
|
||||||
|
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
|
||||||
|
|
||||||
|
|
||||||
|
describe('GekanatorBackdrop regression structure', () => {
|
||||||
|
it('keeps displayedBackdropMode as the render-time source of truth', () => {
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('isLeavingGuessBackdrop')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderBackdropMode')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderWinningRunCount')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderThumbnails')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderIsCrossfading')
|
||||||
|
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"const renderedSettings = settingsForMode (displayedBackdropMode)")
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
'scaleForMode (displayedBackdropMode, displayedWinningRunCount)')
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"backdropMode === 'guess' || displayedBackdropMode === 'guess'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not split guess into a separate renderer or force a remount', () => {
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('renderStaticGuessBackdrop')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('guessZoomAnimationKey')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('shouldAnimateGuessZoomIn')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain ('previousBackdropModeRef')
|
||||||
|
expect(gekanatorBackdropSource).not.toContain (
|
||||||
|
'if (isGuessPresentation && guessThumbnail)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps tile keys independent from backdrop mode', () => {
|
||||||
|
expect(gekanatorBackdropSource).toContain ('key={duplicate}')
|
||||||
|
expect(gekanatorBackdropSource).toContain ('key={`${ duplicate }:${ index }`}')
|
||||||
|
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*mode/)
|
||||||
|
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*displayedBackdropMode/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps guess on the shared scale, x, and y animation path', () => {
|
||||||
|
expect(gekanatorBackdropSource).toContain ('animate={{ scale: renderedScale')
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%'")
|
||||||
|
expect(gekanatorBackdropSource).toContain (
|
||||||
|
"y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%'")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
describe('isQuestionHardFilteredAfterAnswers', () => {
|
describe('isQuestionHardFilteredAfterAnswers', () => {
|
||||||
it('blocks only contradictory or redundant month questions after a yes answer', () => {
|
it('blocks only contradictory or redundant month questions after a yes answer', () => {
|
||||||
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
|
||||||
|
|||||||
+256
-209
@@ -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)
|
||||||
@@ -2730,16 +2753,18 @@ const GekanatorBackdrop: FC<{
|
|||||||
nextThumbnails)
|
nextThumbnails)
|
||||||
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 renderedSettings = settingsForMode (displayedBackdropMode)
|
const renderedSettings = settingsForMode (displayedBackdropMode)
|
||||||
const renderedTileCount =
|
const renderedTileCount = renderedSettings.columns * renderedSettings.rows
|
||||||
renderedSettings.columns * renderedSettings.rows
|
|
||||||
const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount)
|
const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount)
|
||||||
|
|
||||||
const isGuessPresentation =
|
const isGuessPresentation =
|
||||||
phase === 'guess' || backdropMode === 'guess' || displayedBackdropMode === 'guess'
|
backdropMode === 'guess' || displayedBackdropMode === 'guess'
|
||||||
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 +2777,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 +2798,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 +2830,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,22 +2843,23 @@ 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)
|
|
||||||
setFlipVisualSeed (visualSeed)
|
setFlipVisualSeed (visualSeed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backdropMode === 'guess' && guessThumbnail) {
|
if (backdropMode === 'guess' && guessThumbnail)
|
||||||
|
{
|
||||||
setIsFlippingTiles (false)
|
setIsFlippingTiles (false)
|
||||||
setIsCrossfading (false)
|
|
||||||
setDisplayedBackdropMode ('guess')
|
setDisplayedBackdropMode ('guess')
|
||||||
setDisplayedWinningRunCount (winningRunQuestionCount)
|
setDisplayedWinningRunCount (winningRunQuestionCount)
|
||||||
setDisplayedThumbnails (nextThumbnails)
|
setDisplayedThumbnails (nextThumbnails)
|
||||||
@@ -2834,10 +2869,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)
|
||||||
@@ -2845,29 +2879,28 @@ const GekanatorBackdrop: FC<{
|
|||||||
setFromThumbnails (nextThumbnails)
|
setFromThumbnails (nextThumbnails)
|
||||||
setToThumbnails (nextThumbnails)
|
setToThumbnails (nextThumbnails)
|
||||||
setIsFlippingTiles (false)
|
setIsFlippingTiles (false)
|
||||||
setIsCrossfading (false)
|
|
||||||
setFlipVisualSeed (visualSeed)
|
setFlipVisualSeed (visualSeed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextThumbnails.length === 0) {
|
if (nextThumbnails.length === 0)
|
||||||
|
{
|
||||||
applyDirection ()
|
applyDirection ()
|
||||||
setIsFlippingTiles (false)
|
setIsFlippingTiles (false)
|
||||||
setIsCrossfading (false)
|
|
||||||
setFlipVisualSeed (visualSeed)
|
setFlipVisualSeed (visualSeed)
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2876,10 +2909,7 @@ const GekanatorBackdrop: FC<{
|
|||||||
|
|
||||||
setFromThumbnails (currentThumbnails)
|
setFromThumbnails (currentThumbnails)
|
||||||
setToThumbnails (nextThumbnails)
|
setToThumbnails (nextThumbnails)
|
||||||
const shouldCrossfade =
|
setIsFlippingTiles (true)
|
||||||
displayedBackdropMode === 'winning_run' && backdropMode === 'normal'
|
|
||||||
setIsCrossfading (shouldCrossfade)
|
|
||||||
setIsFlippingTiles (!(shouldCrossfade))
|
|
||||||
|
|
||||||
flipTimerRef.current = window.setTimeout (() => {
|
flipTimerRef.current = window.setTimeout (() => {
|
||||||
setDisplayedBackdropMode (backdropMode)
|
setDisplayedBackdropMode (backdropMode)
|
||||||
@@ -2888,20 +2918,19 @@ const GekanatorBackdrop: FC<{
|
|||||||
setFromThumbnails (nextThumbnails)
|
setFromThumbnails (nextThumbnails)
|
||||||
setToThumbnails (nextThumbnails)
|
setToThumbnails (nextThumbnails)
|
||||||
setIsFlippingTiles (false)
|
setIsFlippingTiles (false)
|
||||||
setIsCrossfading (false)
|
|
||||||
applyDirection ()
|
applyDirection ()
|
||||||
setFlipVisualSeed (visualSeed)
|
setFlipVisualSeed (visualSeed)
|
||||||
flipTimerRef.current = null
|
flipTimerRef.current = null
|
||||||
}, (shouldCrossfade ? crossfadeDuration : tileFlipDuration) * 1000)
|
}, 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,
|
||||||
@@ -2912,124 +2941,10 @@ const GekanatorBackdrop: FC<{
|
|||||||
visualSeed,
|
visualSeed,
|
||||||
activeDirection,
|
activeDirection,
|
||||||
winningRunQuestionCount,
|
winningRunQuestionCount,
|
||||||
crossfadeDuration,
|
|
||||||
tileFlipDuration,
|
tileFlipDuration,
|
||||||
x,
|
x,
|
||||||
y])
|
y])
|
||||||
|
|
||||||
const renderTileSet = ({
|
|
||||||
mode,
|
|
||||||
thumbnails,
|
|
||||||
settings,
|
|
||||||
tileCount,
|
|
||||||
scale,
|
|
||||||
opacity,
|
|
||||||
withFlip,
|
|
||||||
}: {
|
|
||||||
mode: 'normal' | 'winning_run' | 'guess'
|
|
||||||
thumbnails: string[]
|
|
||||||
settings: { columns: number; rows: number; opacity: number }
|
|
||||||
tileCount: number
|
|
||||||
scale: number
|
|
||||||
opacity?: number
|
|
||||||
withFlip?: boolean
|
|
||||||
}) => (
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0"
|
|
||||||
initial={opacity === undefined
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
opacity: opacity === 0 ? 1 : 0,
|
|
||||||
scale,
|
|
||||||
x: mode === 'guess' ? guessFocusOffset.x : '0%',
|
|
||||||
y: mode === 'guess' ? guessFocusOffset.y : '0%' }}
|
|
||||||
animate={{ opacity: opacity ?? 1, scale,
|
|
||||||
x: mode === 'guess' ? guessFocusOffset.x : '0%',
|
|
||||||
y: mode === 'guess' ? guessFocusOffset.y : '0%' }}
|
|
||||||
transition={(mode === 'winning_run' || mode === 'guess')
|
|
||||||
? { duration: crossfadeDuration, ease: [.16, 1, .3, 1] }
|
|
||||||
: { duration: .2 }}>
|
|
||||||
{Array.from ({ length: 9 }, (_, duplicate) => {
|
|
||||||
const column = duplicate % 3
|
|
||||||
const row = Math.floor (duplicate / 3)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={`${ mode }:${ duplicate }`}
|
|
||||||
className="absolute grid overflow-hidden"
|
|
||||||
layout={mode !== 'normal'}
|
|
||||||
style={{
|
|
||||||
left: `${ column * 33.333333 }%`,
|
|
||||||
top: `${ row * 33.333333 }%`,
|
|
||||||
width: '33.333333%',
|
|
||||||
height: '33.333333%',
|
|
||||||
gridTemplateColumns:
|
|
||||||
`repeat(${ settings.columns }, minmax(0, 1fr))`,
|
|
||||||
gridTemplateRows:
|
|
||||||
`repeat(${ settings.rows }, minmax(0, 1fr))` }}
|
|
||||||
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}>
|
|
||||||
{Array.from ({ length: tileCount }, (_, index) => {
|
|
||||||
const currentThumbnail =
|
|
||||||
thumbnails[index % Math.max (thumbnails.length, 1)]
|
|
||||||
const frontThumbnail =
|
|
||||||
withFlip
|
|
||||||
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
|
|
||||||
: currentThumbnail
|
|
||||||
const backThumbnail =
|
|
||||||
withFlip
|
|
||||||
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
|
|
||||||
: currentThumbnail
|
|
||||||
const thumbnail = currentThumbnail
|
|
||||||
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
|
|
||||||
return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={`${ mode }:${ duplicate }:${ index }`}
|
|
||||||
className="relative overflow-hidden"
|
|
||||||
layout={mode !== 'normal'}
|
|
||||||
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
|
|
||||||
style={{ perspective: 1600 }}>
|
|
||||||
{(mode !== 'normal' || !(withFlip))
|
|
||||||
? (
|
|
||||||
<img
|
|
||||||
src={['intro', 'end'].includes (phase)
|
|
||||||
? mascotAsset
|
|
||||||
: thumbnail}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
style={{ opacity: settings.opacity }}/>)
|
|
||||||
: (
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0"
|
|
||||||
initial={{ rotateY: 0 }}
|
|
||||||
animate={{ rotateY: 180 }}
|
|
||||||
transition={{
|
|
||||||
duration: tileFlipDuration,
|
|
||||||
ease: 'easeInOut' }}
|
|
||||||
style={{ transformStyle: 'preserve-3d' }}>
|
|
||||||
<img
|
|
||||||
src={backThumbnail}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
style={{
|
|
||||||
backfaceVisibility: 'hidden',
|
|
||||||
opacity: settings.opacity,
|
|
||||||
transform: 'rotateY(180deg)' }}/>
|
|
||||||
<img
|
|
||||||
src={frontThumbnail}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
style={{
|
|
||||||
backfaceVisibility: 'hidden',
|
|
||||||
opacity: settings.opacity }}/>
|
|
||||||
</motion.div>)}
|
|
||||||
</motion.div>)
|
|
||||||
})}
|
|
||||||
</motion.div>)
|
|
||||||
})}
|
|
||||||
</motion.div>)
|
|
||||||
|
|
||||||
if (motionMode === 'off' || nextThumbnails.length === 0)
|
if (motionMode === 'off' || nextThumbnails.length === 0)
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50 via-white
|
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50 via-white
|
||||||
@@ -3045,32 +2960,99 @@ const GekanatorBackdrop: FC<{
|
|||||||
width: 'calc(max(100vw, 100vh) * 3)',
|
width: 'calc(max(100vw, 100vh) * 3)',
|
||||||
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
|
animate={{ scale: renderedScale,
|
||||||
|
x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%',
|
||||||
|
y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%' }}
|
||||||
|
transition={(displayedBackdropMode === 'winning_run'
|
||||||
|
|| displayedBackdropMode === 'guess')
|
||||||
|
? { duration: motionMode === 'calm' ? .95 : .75,
|
||||||
|
ease: [.16, 1, .3, 1] }
|
||||||
|
: { duration: .2 }}>
|
||||||
|
{Array.from ({ length: 9 }, (_, duplicate) => {
|
||||||
|
const column = duplicate % 3
|
||||||
|
const row = Math.floor (duplicate / 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={duplicate}
|
||||||
|
className="absolute grid overflow-hidden"
|
||||||
|
layout={displayedBackdropMode !== 'normal'}
|
||||||
|
style={{
|
||||||
|
left: `${ column * 33.333333 }%`,
|
||||||
|
top: `${ row * 33.333333 }%`,
|
||||||
|
width: '33.333333%',
|
||||||
|
height: '33.333333%',
|
||||||
|
gridTemplateColumns:
|
||||||
|
`repeat(${ renderedSettings.columns }, minmax(0, 1fr))`,
|
||||||
|
gridTemplateRows:
|
||||||
|
`repeat(${ renderedSettings.rows }, minmax(0, 1fr))` }}
|
||||||
|
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}>
|
||||||
|
{Array.from ({ length: renderedTileCount }, (_, index) => {
|
||||||
|
const currentThumbnail =
|
||||||
|
displayedThumbnails[
|
||||||
|
index % Math.max (displayedThumbnails.length, 1)]
|
||||||
|
const frontThumbnail =
|
||||||
|
isFlippingTiles
|
||||||
|
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
|
||||||
|
: currentThumbnail
|
||||||
|
const backThumbnail =
|
||||||
|
isFlippingTiles
|
||||||
|
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
|
||||||
|
: currentThumbnail
|
||||||
|
const thumbnail =
|
||||||
|
displayedBackdropMode === 'winning_run'
|
||||||
|
|| displayedBackdropMode === 'guess'
|
||||||
|
? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
|
||||||
|
: currentThumbnail
|
||||||
|
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={`${ duplicate }:${ index }`}
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
layout={displayedBackdropMode !== 'normal'}
|
||||||
|
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
|
||||||
|
style={{ perspective: 1600 }}>
|
||||||
|
{(displayedBackdropMode !== 'normal' || !(isFlippingTiles))
|
||||||
? (
|
? (
|
||||||
<>
|
<img
|
||||||
{renderTileSet ({
|
src={['intro', 'end'].includes (phase)
|
||||||
mode: displayedBackdropMode,
|
? mascotAsset
|
||||||
thumbnails: fromThumbnails,
|
: thumbnail}
|
||||||
settings: renderedSettings,
|
alt=""
|
||||||
tileCount: renderedTileCount,
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
scale: renderedScale,
|
style={{ opacity: renderedSettings.opacity }}/>)
|
||||||
opacity: 0 })}
|
: (
|
||||||
{renderTileSet ({
|
<motion.div
|
||||||
mode: backdropMode,
|
className="absolute inset-0"
|
||||||
thumbnails: toThumbnails,
|
initial={{ rotateY: 0 }}
|
||||||
settings: targetSettings,
|
animate={{ rotateY: 180 }}
|
||||||
tileCount: targetTileCount,
|
transition={{
|
||||||
scale: scaleForMode (backdropMode, winningRunQuestionCount),
|
duration: tileFlipDuration,
|
||||||
opacity: 1 })}
|
ease: 'easeInOut' }}
|
||||||
</>)
|
style={{ transformStyle: 'preserve-3d' }}>
|
||||||
: renderTileSet ({
|
<img
|
||||||
mode: displayedBackdropMode,
|
src={backThumbnail}
|
||||||
thumbnails: displayedThumbnails,
|
alt=""
|
||||||
settings: renderedSettings,
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
tileCount: renderedTileCount,
|
style={{
|
||||||
scale: renderedScale,
|
backfaceVisibility: 'hidden',
|
||||||
withFlip: isFlippingTiles })}
|
opacity: renderedSettings.opacity,
|
||||||
|
transform: 'rotateY(180deg)' }}/>
|
||||||
|
<img
|
||||||
|
src={frontThumbnail}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
style={{
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
opacity: renderedSettings.opacity }}/>
|
||||||
|
</motion.div>)}
|
||||||
|
</motion.div>)
|
||||||
|
})}
|
||||||
|
</motion.div>)
|
||||||
|
})}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3150,6 +3132,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 +3175,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 +3190,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 +3232,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
savedGameId,
|
savedGameId,
|
||||||
learnedExampleCount,
|
learnedExampleCount,
|
||||||
gameSeed,
|
gameSeed,
|
||||||
|
questionSuggestionEntryMode,
|
||||||
questionSuggestionSearch,
|
questionSuggestionSearch,
|
||||||
questionSuggestionSelectedId,
|
questionSuggestionSelectedId,
|
||||||
questionSuggestion,
|
questionSuggestion,
|
||||||
@@ -3293,6 +3276,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
savedGameId,
|
savedGameId,
|
||||||
learnedExampleCount,
|
learnedExampleCount,
|
||||||
gameSeed,
|
gameSeed,
|
||||||
|
questionSuggestionEntryMode,
|
||||||
questionSuggestionSearch,
|
questionSuggestionSearch,
|
||||||
questionSuggestionSelectedId,
|
questionSuggestionSelectedId,
|
||||||
questionSuggestion,
|
questionSuggestion,
|
||||||
@@ -3355,6 +3339,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 +3500,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 +3551,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 +3884,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 +3904,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 +3935,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 +3964,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 +4104,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 +4118,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 +4812,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 +4851,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 +4893,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 +4939,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>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする