このコミットが含まれているのは:
@@ -0,0 +1,17 @@
|
|||||||
|
class GekanatorGamesController < ApplicationController
|
||||||
|
def create
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
answers = params.require(:answers).as_json
|
||||||
|
|
||||||
|
game = GekanatorGame.create!(
|
||||||
|
user: current_user,
|
||||||
|
guessed_post_id: params.require(:guessed_post_id),
|
||||||
|
correct_post_id: params[:correct_post_id].presence,
|
||||||
|
won: bool?(:won),
|
||||||
|
question_count: params.require(:question_count),
|
||||||
|
answers:)
|
||||||
|
|
||||||
|
render json: { id: game.id }, status: :created
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class GekanatorGame < ApplicationRecord
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
belongs_to :guessed_post, class_name: 'Post'
|
||||||
|
belongs_to :correct_post, class_name: 'Post', optional: true
|
||||||
|
|
||||||
|
validates :answers, presence: true
|
||||||
|
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validates :won, inclusion: { in: [true, false] }
|
||||||
|
validate :correct_post_required_when_lost
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def correct_post_required_when_lost
|
||||||
|
return if won || correct_post_id.present?
|
||||||
|
|
||||||
|
errors.add(:correct_post_id, '外れた時は正解の投稿を指定してくださぃ.')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,6 +11,16 @@ class Post < ApplicationRecord
|
|||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
has_many :post_versions
|
has_many :post_versions
|
||||||
|
has_many :gekanator_guessed_games,
|
||||||
|
class_name: 'GekanatorGame',
|
||||||
|
foreign_key: :guessed_post_id,
|
||||||
|
dependent: :delete_all,
|
||||||
|
inverse_of: :guessed_post
|
||||||
|
has_many :gekanator_correct_games,
|
||||||
|
class_name: 'GekanatorGame',
|
||||||
|
foreign_key: :correct_post_id,
|
||||||
|
dependent: :nullify,
|
||||||
|
inverse_of: :correct_post
|
||||||
|
|
||||||
has_many :parent_post_implications,
|
has_many :parent_post_implications,
|
||||||
class_name: 'PostImplication',
|
class_name: 'PostImplication',
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :gekanator do
|
||||||
|
resources :games, only: [:create], controller: '/gekanator_games'
|
||||||
|
end
|
||||||
|
|
||||||
resources :users, only: [:create, :update] do
|
resources :users, only: [:create, :update] do
|
||||||
collection do
|
collection do
|
||||||
post :verify
|
post :verify
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class CreateGekanatorGames < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :gekanator_games do |t|
|
||||||
|
t.references :user, foreign_key: true
|
||||||
|
t.references :guessed_post, null: false, foreign_key: { to_table: :posts }
|
||||||
|
t.references :correct_post, foreign_key: { to_table: :posts }
|
||||||
|
t.boolean :won, null: false
|
||||||
|
t.integer :question_count, null: false
|
||||||
|
t.json :answers, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_check_constraint :gekanator_games,
|
||||||
|
'question_count >= 0',
|
||||||
|
name: 'chk_gekanator_games_question_count_nonnegative'
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+38
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_06_07_000000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -48,6 +48,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "gekanator_games", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "user_id"
|
||||||
|
t.bigint "guessed_post_id", null: false
|
||||||
|
t.bigint "correct_post_id"
|
||||||
|
t.boolean "won", null: false
|
||||||
|
t.integer "question_count", null: false
|
||||||
|
t.json "answers", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
|
||||||
|
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
|
||||||
|
t.index ["user_id"], name: "index_gekanator_games_on_user_id"
|
||||||
|
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "ip_addresses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.binary "ip_address", limit: 16, null: false
|
t.binary "ip_address", limit: 16, null: false
|
||||||
t.datetime "banned_at"
|
t.datetime "banned_at"
|
||||||
@@ -137,6 +152,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "post_id", null: false
|
||||||
|
t.bigint "tag_id", null: false
|
||||||
|
t.integer "begin_ms", null: false
|
||||||
|
t.integer "end_ms", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
|
||||||
|
t.index ["tag_id"], name: "fk_rails_8be3847903"
|
||||||
|
t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
|
||||||
|
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "post_id", null: false
|
t.bigint "post_id", null: false
|
||||||
t.bigint "tag_id", null: false
|
t.bigint "tag_id", null: false
|
||||||
@@ -187,8 +215,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.datetime "original_created_before"
|
t.datetime "original_created_before"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
|
t.integer "video_ms"
|
||||||
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
|
||||||
t.index ["url"], name: "index_posts_on_url", unique: true
|
t.index ["url"], name: "index_posts_on_url", unique: true
|
||||||
|
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
|
||||||
|
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
|
||||||
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -339,6 +370,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
|
t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at"
|
||||||
|
t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42"
|
||||||
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
|
t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at"
|
||||||
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
|
t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id"
|
||||||
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
|
t.index ["user_id"], name: "index_theatre_watching_users_on_user_id"
|
||||||
@@ -478,6 +510,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
|
||||||
|
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
|
||||||
|
add_foreign_key "gekanator_games", "users"
|
||||||
add_foreign_key "material_versions", "materials"
|
add_foreign_key "material_versions", "materials"
|
||||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
add_foreign_key "material_versions", "materials", column: "parent_id"
|
||||||
add_foreign_key "material_versions", "tags"
|
add_foreign_key "material_versions", "tags"
|
||||||
@@ -495,6 +530,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
|
|||||||
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
add_foreign_key "post_implications", "posts", column: "parent_post_id"
|
||||||
add_foreign_key "post_similarities", "posts"
|
add_foreign_key "post_similarities", "posts"
|
||||||
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
||||||
|
add_foreign_key "post_tag_sections", "posts"
|
||||||
|
add_foreign_key "post_tag_sections", "tags"
|
||||||
add_foreign_key "post_tags", "posts"
|
add_foreign_key "post_tags", "posts"
|
||||||
add_foreign_key "post_tags", "tags"
|
add_foreign_key "post_tags", "tags"
|
||||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Gekanator games API', type: :request do
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
let!(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') }
|
||||||
|
let!(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') }
|
||||||
|
|
||||||
|
describe 'POST /gekanator/games' do
|
||||||
|
it 'stores a won game' do
|
||||||
|
sign_in_as user
|
||||||
|
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
won: true,
|
||||||
|
question_count: 3,
|
||||||
|
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
game = GekanatorGame.find(json['id'])
|
||||||
|
expect(game.user).to eq(user)
|
||||||
|
expect(game.guessed_post).to eq(guessed_post)
|
||||||
|
expect(game.correct_post).to be_nil
|
||||||
|
expect(game.won).to eq(true)
|
||||||
|
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores a lost game with the correct post' do
|
||||||
|
sign_in_as user
|
||||||
|
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
correct_post_id: correct_post.id,
|
||||||
|
won: false,
|
||||||
|
question_count: 4,
|
||||||
|
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(GekanatorGame.find(json['id']).correct_post).to eq(correct_post)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects a lost game without the correct post' do
|
||||||
|
sign_in_as user
|
||||||
|
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
won: false,
|
||||||
|
question_count: 4,
|
||||||
|
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a user' do
|
||||||
|
post '/gekanator/games', params: {
|
||||||
|
guessed_post_id: guessed_post.id,
|
||||||
|
won: true,
|
||||||
|
question_count: 1,
|
||||||
|
answers: [] }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,6 +18,7 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
|
|||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
||||||
import MorePage from '@/pages/MorePage'
|
import MorePage from '@/pages/MorePage'
|
||||||
|
import GekanatorPage from '@/pages/GekanatorPage'
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import TOSPage from '@/pages/TOSPage.mdx'
|
import TOSPage from '@/pages/TOSPage.mdx'
|
||||||
@@ -80,6 +81,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||||
<Route path="/tos" element={<TOSPage/>}/>
|
<Route path="/tos" element={<TOSPage/>}/>
|
||||||
|
<Route path="/gekanator" element={<GekanatorPage/>}/>
|
||||||
<Route path="/more" element={<MorePage/>}/>
|
<Route path="/more" element={<MorePage/>}/>
|
||||||
<Route path="*" element={<NotFound/>}/>
|
<Route path="*" element={<NotFound/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
|||||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||||
|
{ name: 'グカネータ', to: '/gekanator' },
|
||||||
{ name: '上映会 (β)', to: '/theatres/1' }] },
|
{ name: '上映会 (β)', to: '/theatres/1' }] },
|
||||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||||
{ name: '一覧', to: '/users', visible: false },
|
{ name: '一覧', to: '/users', visible: false },
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { apiPost } from '@/lib/api'
|
||||||
|
import { fetchPosts } from '@/lib/posts'
|
||||||
|
|
||||||
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
|
export type GekanatorAnswerValue =
|
||||||
|
| 'yes'
|
||||||
|
| 'no'
|
||||||
|
| 'partial'
|
||||||
|
| 'probably_no'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export type GekanatorAnswerLog = {
|
||||||
|
questionId: string
|
||||||
|
questionText: string
|
||||||
|
answer: GekanatorAnswerValue }
|
||||||
|
|
||||||
|
export type GekanatorQuestionKind =
|
||||||
|
| 'tag'
|
||||||
|
| 'title'
|
||||||
|
| 'date'
|
||||||
|
| 'media'
|
||||||
|
| 'source'
|
||||||
|
| 'structure'
|
||||||
|
|
||||||
|
export type GekanatorQuestion = {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
kind: GekanatorQuestionKind
|
||||||
|
test: (post: Post) => boolean }
|
||||||
|
|
||||||
|
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
|
||||||
|
const counts = new Map<T, number> ()
|
||||||
|
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const median = (values: number[]): number => {
|
||||||
|
const sorted = [...values].sort ((a, b) => a - b)
|
||||||
|
return sorted[Math.floor (sorted.length / 2)] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const hostOf = (post: Post): string | null => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new URL (post.url).hostname.replace (/^www\./, '')
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
|
||||||
|
`${ category }:${ name }`
|
||||||
|
|
||||||
|
|
||||||
|
const tagFromQuestionKey = (key: string): { category: string; name: string } => {
|
||||||
|
const [category, ...rest] = key.split (':')
|
||||||
|
return { category: category ?? '', name: rest.join (':') }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '')
|
||||||
|
|
||||||
|
|
||||||
|
const questionableTag = (post: Post, key: string): boolean => {
|
||||||
|
const { category, name } = tagFromQuestionKey (key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
post.tags.some (tag =>
|
||||||
|
tag.name === name
|
||||||
|
&& tag.category === category
|
||||||
|
&& !(tag.category === 'meta')
|
||||||
|
&& !(tag.name.includes ('タグ希望'))
|
||||||
|
&& !(tag.name.includes ('bot操作'))))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
||||||
|
const limit = 200
|
||||||
|
const first = await fetchPosts ({
|
||||||
|
url: '', title: '', tags: '', match: 'all',
|
||||||
|
originalCreatedFrom: '', originalCreatedTo: '',
|
||||||
|
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
||||||
|
page: 1, limit, order: 'original_created_at:desc' })
|
||||||
|
const posts = [...first.posts]
|
||||||
|
const totalPages = Math.ceil (first.count / limit)
|
||||||
|
|
||||||
|
for (let page = 2; page <= totalPages; page++)
|
||||||
|
{
|
||||||
|
const data = await fetchPosts ({
|
||||||
|
url: '', title: '', tags: '', match: 'all',
|
||||||
|
originalCreatedFrom: '', originalCreatedTo: '',
|
||||||
|
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
||||||
|
page, limit, order: 'original_created_at:desc' })
|
||||||
|
posts.push (...data.posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
||||||
|
const tagCounts = countBy (posts.flatMap (post =>
|
||||||
|
post.tags
|
||||||
|
.filter (tag =>
|
||||||
|
!(tag.category === 'meta')
|
||||||
|
&& !(tag.name.includes ('タグ希望'))
|
||||||
|
&& !(tag.name.includes ('bot操作')))
|
||||||
|
.map (tag => tagQuestionKey (tag))))
|
||||||
|
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
|
||||||
|
const tagMedian = median (posts.map (post => post.tags.length))
|
||||||
|
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
|
||||||
|
const currentYear = new Date ().getFullYear ()
|
||||||
|
|
||||||
|
const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
|
||||||
|
[...counts.entries ()]
|
||||||
|
.filter (([, count]) => count > 0 && count < posts.length)
|
||||||
|
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
|
||||||
|
- Math.abs (posts.length / 2 - b[1]))
|
||||||
|
.slice (0, 80)
|
||||||
|
|
||||||
|
const tagQuestions = usefulEntries (tagCounts)
|
||||||
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, 80)
|
||||||
|
.map (([key]) => {
|
||||||
|
const { category, name } = tagFromQuestionKey (String (key))
|
||||||
|
const label = category === 'nico' ? nicoTagLabel (name) : name
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `tag:${ key }`,
|
||||||
|
text: category === 'nico'
|
||||||
|
? `ニコニコに「${ label }」といふタグが付いてゐる?`
|
||||||
|
: `内容として「${ label }」に関係しさう?`,
|
||||||
|
kind: 'tag' as const,
|
||||||
|
test: (post: Post) => questionableTag (post, String (key)) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceQuestions = usefulEntries (hosts)
|
||||||
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
|
.slice (0, 20)
|
||||||
|
.map (([host]) => ({
|
||||||
|
id: `source:${ host }`,
|
||||||
|
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
||||||
|
kind: 'source' as const,
|
||||||
|
test: (post: Post) => hostOf (post) === host }))
|
||||||
|
|
||||||
|
return [
|
||||||
|
...sourceQuestions,
|
||||||
|
{
|
||||||
|
id: 'title:present',
|
||||||
|
text: '題名が付いてゐる投稿?',
|
||||||
|
kind: 'title',
|
||||||
|
test: post => Boolean (post.title) },
|
||||||
|
{
|
||||||
|
id: 'title:long',
|
||||||
|
text: '題名が長めの投稿?',
|
||||||
|
kind: 'title',
|
||||||
|
test: post => (post.title?.length ?? 0) > titleLengthMedian },
|
||||||
|
{
|
||||||
|
id: 'title:ascii',
|
||||||
|
text: '題名に英数字が混じってゐる?',
|
||||||
|
kind: 'title',
|
||||||
|
test: post => /[A-Za-z0-9]/.test (post.title ?? '') },
|
||||||
|
{
|
||||||
|
id: 'media:thumbnail',
|
||||||
|
text: 'ぱっと見でサムネが付いてゐる投稿?',
|
||||||
|
kind: 'media',
|
||||||
|
test: post => Boolean (post.thumbnail || post.thumbnailBase) },
|
||||||
|
{
|
||||||
|
id: 'media:video-source',
|
||||||
|
text: '動画として見られる投稿?',
|
||||||
|
kind: 'media',
|
||||||
|
test: post => /nicovideo|youtube|youtu\.be/.test (post.url) },
|
||||||
|
{
|
||||||
|
id: 'structure:many-tags',
|
||||||
|
text: 'タグが多めに付いてゐる投稿?',
|
||||||
|
kind: 'structure',
|
||||||
|
test: post => post.tags.length > tagMedian },
|
||||||
|
{
|
||||||
|
id: 'structure:no-title',
|
||||||
|
text: '題名がまだ付いてゐない投稿?',
|
||||||
|
kind: 'structure',
|
||||||
|
test: post => !(post.title) },
|
||||||
|
{
|
||||||
|
id: 'date:recent',
|
||||||
|
text: '最近追加されたほうの投稿?',
|
||||||
|
kind: 'date',
|
||||||
|
test: post => new Date (post.createdAt).getFullYear () >= currentYear - 1 },
|
||||||
|
{
|
||||||
|
id: 'date:old',
|
||||||
|
text: 'むかし追加されたほうの投稿?',
|
||||||
|
kind: 'date',
|
||||||
|
test: post => new Date (post.createdAt).getFullYear () <= currentYear - 3 },
|
||||||
|
{
|
||||||
|
id: 'date:original-known',
|
||||||
|
text: 'オリジナルの投稿日時が分かってゐる投稿?',
|
||||||
|
kind: 'date',
|
||||||
|
test: post => Boolean (post.originalCreatedFrom || post.originalCreatedBefore) },
|
||||||
|
...tagQuestions]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const saveGekanatorGame = async ({
|
||||||
|
guessedPostId,
|
||||||
|
correctPostId,
|
||||||
|
won,
|
||||||
|
answers,
|
||||||
|
}: {
|
||||||
|
guessedPostId: number
|
||||||
|
correctPostId: number | null
|
||||||
|
won: boolean
|
||||||
|
answers: GekanatorAnswerLog[]
|
||||||
|
}): Promise<{ id: number }> =>
|
||||||
|
await apiPost ('/gekanator/games', {
|
||||||
|
guessed_post_id: guessedPostId,
|
||||||
|
correct_post_id: correctPostId,
|
||||||
|
won,
|
||||||
|
question_count: answers.length,
|
||||||
|
answers: answers.map (answer => ({
|
||||||
|
question_id: answer.questionId,
|
||||||
|
question_text: answer.questionText,
|
||||||
|
answer: answer.answer })) })
|
||||||
@@ -8,6 +8,10 @@ export const postsKeys = {
|
|||||||
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
|
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
|
||||||
['posts', 'changes', p] as const }
|
['posts', 'changes', p] as const }
|
||||||
|
|
||||||
|
export const gekanatorKeys = {
|
||||||
|
root: ['gekanator'] as const,
|
||||||
|
posts: () => ['gekanator', 'posts'] as const }
|
||||||
|
|
||||||
export const tagsKeys = {
|
export const tagsKeys = {
|
||||||
root: ['tags'] as const,
|
root: ['tags'] as const,
|
||||||
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
|
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
|
||||||
|
|||||||
@@ -0,0 +1,886 @@
|
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import MainArea from '@/components/layout/MainArea'
|
||||||
|
import { SITE_TITLE } from '@/config'
|
||||||
|
import { buildGekanatorQuestions,
|
||||||
|
fetchGekanatorPosts,
|
||||||
|
saveGekanatorGame } from '@/lib/gekanator'
|
||||||
|
import { gekanatorKeys } from '@/lib/queryKeys'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { GekanatorAnswerLog,
|
||||||
|
GekanatorAnswerValue,
|
||||||
|
GekanatorQuestion } from '@/lib/gekanator'
|
||||||
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
|
type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'learned'
|
||||||
|
|
||||||
|
type AnswerOption = {
|
||||||
|
label: string
|
||||||
|
value: GekanatorAnswerValue }
|
||||||
|
|
||||||
|
type Confidence = {
|
||||||
|
post: Post
|
||||||
|
score: number
|
||||||
|
percent: number }
|
||||||
|
|
||||||
|
type AnswerPreview = {
|
||||||
|
answer: GekanatorAnswerValue
|
||||||
|
top: Confidence | null
|
||||||
|
candidateCount: number
|
||||||
|
effectiveCandidates: number
|
||||||
|
entropy: number }
|
||||||
|
|
||||||
|
type GameSnapshot = {
|
||||||
|
phase: Phase
|
||||||
|
scores: Map<number, number>
|
||||||
|
answers: GekanatorAnswerLog[]
|
||||||
|
askedIds: Set<string>
|
||||||
|
candidateIds: Set<number> | null
|
||||||
|
softenedQuestionIds: Set<string>
|
||||||
|
questionBank: GekanatorQuestion[]
|
||||||
|
search: string
|
||||||
|
rejectedPostIds: Set<number>
|
||||||
|
lastGuessQuestionCount: number
|
||||||
|
lastRejectedGuessId: number | null
|
||||||
|
activeGuessId: number | null }
|
||||||
|
|
||||||
|
const answerOptions: AnswerOption[] = [
|
||||||
|
{ label: 'はい', value: 'yes' },
|
||||||
|
{ label: 'いいえ', value: 'no' },
|
||||||
|
{ label: '部分的にそう', value: 'partial' },
|
||||||
|
{ label: 'たぶんいいえ', value: 'probably_no' },
|
||||||
|
{ label: 'わからない', value: 'unknown' }]
|
||||||
|
|
||||||
|
const questionsBetweenGuesses = 25
|
||||||
|
const hardMaxQuestions = 80
|
||||||
|
const softenedAnswerWeight = .35
|
||||||
|
const confidenceTemperature = 6
|
||||||
|
|
||||||
|
|
||||||
|
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||||||
|
switch (answer)
|
||||||
|
{
|
||||||
|
case 'yes':
|
||||||
|
return matched ? 4 : -4
|
||||||
|
case 'no':
|
||||||
|
return matched ? -4 : 4
|
||||||
|
case 'partial':
|
||||||
|
return matched ? 2 : -1
|
||||||
|
case 'probably_no':
|
||||||
|
return matched ? -2 : 2
|
||||||
|
case 'unknown':
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const answerWeightFor = (
|
||||||
|
questionId: string,
|
||||||
|
softenedQuestionIds: Set<string>,
|
||||||
|
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
|
||||||
|
|
||||||
|
|
||||||
|
const questionDifficulty = (question: GekanatorQuestion): number => {
|
||||||
|
if (question.id === 'structure:many-tags')
|
||||||
|
return 6
|
||||||
|
if (question.id.startsWith ('date:'))
|
||||||
|
return 5
|
||||||
|
if (question.id === 'title:long' || question.id === 'title:ascii')
|
||||||
|
return 4
|
||||||
|
if (question.kind === 'source')
|
||||||
|
return 4
|
||||||
|
if (question.kind === 'tag')
|
||||||
|
return 3
|
||||||
|
if (question.kind === 'title' || question.kind === 'structure')
|
||||||
|
return 2
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const recalculateScores = ({
|
||||||
|
posts,
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
softenedQuestionIds,
|
||||||
|
}: {
|
||||||
|
posts: Post[]
|
||||||
|
questions: GekanatorQuestion[]
|
||||||
|
answers: GekanatorAnswerLog[]
|
||||||
|
softenedQuestionIds: Set<string>
|
||||||
|
}): Map<number, number> => {
|
||||||
|
const questionById = new Map (questions.map (question => [question.id, question]))
|
||||||
|
const nextScores = new Map<number, number> ()
|
||||||
|
|
||||||
|
answers.forEach (answer => {
|
||||||
|
const question = questionById.get (answer.questionId)
|
||||||
|
if (!(question))
|
||||||
|
return
|
||||||
|
|
||||||
|
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
||||||
|
posts.forEach (post => {
|
||||||
|
nextScores.set (
|
||||||
|
post.id,
|
||||||
|
(nextScores.get (post.id) ?? 0)
|
||||||
|
+ deltaFor (question.test (post), answer.answer) * weight)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return nextScores
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
|
||||||
|
if (posts.length === 0)
|
||||||
|
return []
|
||||||
|
|
||||||
|
const raw = posts.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||||
|
const maxScore = Math.max (...raw.map (({ score }) => score))
|
||||||
|
const weighted = raw.map (item => ({
|
||||||
|
...item,
|
||||||
|
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
|
||||||
|
const total = weighted.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||||||
|
|
||||||
|
return weighted
|
||||||
|
.map (({ post, score, weight }) => ({
|
||||||
|
post,
|
||||||
|
score,
|
||||||
|
percent: weight / total * 100 }))
|
||||||
|
.sort ((a, b) => b.percent - a.percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const entropyFor = (confidences: Confidence[]): number =>
|
||||||
|
confidences.reduce ((sum, item) => {
|
||||||
|
const p = item.percent / 100
|
||||||
|
return p > 0 ? sum - p * Math.log2 (p) : sum
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
|
||||||
|
const effectiveCandidatesFor = (confidences: Confidence[]): number => {
|
||||||
|
const concentration = confidences.reduce ((sum, item) => {
|
||||||
|
const p = item.percent / 100
|
||||||
|
return sum + p * p
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return concentration > 0 ? 1 / concentration : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const previewAnswer = ({
|
||||||
|
posts,
|
||||||
|
scores,
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
}: {
|
||||||
|
posts: Post[]
|
||||||
|
scores: Map<number, number>
|
||||||
|
question: GekanatorQuestion
|
||||||
|
answer: GekanatorAnswerValue
|
||||||
|
}): AnswerPreview => {
|
||||||
|
const hardFilteredPosts =
|
||||||
|
answer === 'yes'
|
||||||
|
? posts.filter (post => question.test (post))
|
||||||
|
: answer === 'no'
|
||||||
|
? posts.filter (post => !(question.test (post)))
|
||||||
|
: posts
|
||||||
|
const nextPosts =
|
||||||
|
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
|
||||||
|
? hardFilteredPosts
|
||||||
|
: posts
|
||||||
|
const nextScores = new Map (scores)
|
||||||
|
nextPosts.forEach (post => {
|
||||||
|
nextScores.set (
|
||||||
|
post.id,
|
||||||
|
(nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer))
|
||||||
|
})
|
||||||
|
|
||||||
|
const confidences = confidencesFor (nextPosts, nextScores)
|
||||||
|
|
||||||
|
return {
|
||||||
|
answer,
|
||||||
|
top: confidences[0] ?? null,
|
||||||
|
candidateCount: nextPosts.length,
|
||||||
|
effectiveCandidates: effectiveCandidatesFor (confidences),
|
||||||
|
entropy: entropyFor (confidences) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
|
||||||
|
const byId = new Map<string, GekanatorQuestion> ()
|
||||||
|
questions.forEach (question => byId.set (question.id, question))
|
||||||
|
return [...byId.values ()]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const softenNextQuestionIds = ({
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
softenedQuestionIds,
|
||||||
|
}: {
|
||||||
|
questions: GekanatorQuestion[]
|
||||||
|
answers: GekanatorAnswerLog[]
|
||||||
|
softenedQuestionIds: Set<string>
|
||||||
|
}): Set<string> | null => {
|
||||||
|
const questionById = new Map (questions.map (question => [question.id, question]))
|
||||||
|
const candidate = [...answers]
|
||||||
|
.reverse ()
|
||||||
|
.map (answer => {
|
||||||
|
const question = questionById.get (answer.questionId)
|
||||||
|
return { answer, question }
|
||||||
|
})
|
||||||
|
.filter ((item): item is {
|
||||||
|
answer: GekanatorAnswerLog
|
||||||
|
question: GekanatorQuestion } =>
|
||||||
|
item.question !== undefined
|
||||||
|
&& item.answer.answer !== 'unknown'
|
||||||
|
&& !(softenedQuestionIds.has (item.answer.questionId)))
|
||||||
|
.sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0]
|
||||||
|
|
||||||
|
if (!(candidate))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return new Set ([...softenedQuestionIds, candidate.answer.questionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const chooseQuestion = ({
|
||||||
|
posts,
|
||||||
|
questions,
|
||||||
|
scores,
|
||||||
|
askedIds,
|
||||||
|
}: {
|
||||||
|
posts: Post[]
|
||||||
|
questions: GekanatorQuestion[]
|
||||||
|
scores: Map<number, number>
|
||||||
|
askedIds: Set<string>
|
||||||
|
}): GekanatorQuestion | null => {
|
||||||
|
const scoredPosts = posts
|
||||||
|
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||||
|
.sort ((a, b) => b.score - a.score)
|
||||||
|
|
||||||
|
const signatureFor = (
|
||||||
|
question: GekanatorQuestion,
|
||||||
|
candidates: { post: Post; score: number }[],
|
||||||
|
): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('')
|
||||||
|
|
||||||
|
const invertedSignature = (signature: string): string =>
|
||||||
|
signature.replace (/[01]/g, value => value === '1' ? '0' : '1')
|
||||||
|
|
||||||
|
const redundantSignatures = (
|
||||||
|
candidates: { post: Post; score: number }[],
|
||||||
|
): Set<string> => {
|
||||||
|
const signatures = new Set<string> ()
|
||||||
|
questions
|
||||||
|
.filter (question => askedIds.has (question.id))
|
||||||
|
.forEach (question => {
|
||||||
|
const signature = signatureFor (question, candidates)
|
||||||
|
signatures.add (signature)
|
||||||
|
signatures.add (invertedSignature (signature))
|
||||||
|
})
|
||||||
|
|
||||||
|
return signatures
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = (
|
||||||
|
questionsToRank: GekanatorQuestion[],
|
||||||
|
candidates: { post: Post; score: number }[],
|
||||||
|
) => {
|
||||||
|
const redundant = redundantSignatures (candidates)
|
||||||
|
|
||||||
|
return questionsToRank
|
||||||
|
.map (question => {
|
||||||
|
const signature = signatureFor (question, candidates)
|
||||||
|
if (redundant.has (signature))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const yes = signature.split ('').filter (value => value === '1').length
|
||||||
|
const no = candidates.length - yes
|
||||||
|
if (yes === 0 || no === 0)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const splitScore = Math.abs (candidates.length / 2 - yes)
|
||||||
|
const answerPreviews = answerOptions.map (option =>
|
||||||
|
previewAnswer ({
|
||||||
|
posts: candidates.map (({ post }) => post),
|
||||||
|
scores,
|
||||||
|
question,
|
||||||
|
answer: option.value }))
|
||||||
|
const expectedEntropy =
|
||||||
|
answerPreviews.reduce ((sum, preview) => sum + preview.entropy, 0)
|
||||||
|
/ answerPreviews.length
|
||||||
|
const expectedCandidateCount =
|
||||||
|
answerPreviews.reduce ((sum, preview) => sum + preview.candidateCount, 0)
|
||||||
|
/ answerPreviews.length
|
||||||
|
const kindPenalty = askedIds.has (question.kind) ? 2 : 0
|
||||||
|
const tagPenalty = question.kind === 'tag' ? 0 : 10
|
||||||
|
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||||||
|
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
|
||||||
|
|
||||||
|
return { question,
|
||||||
|
score: splitScore + expectedEntropy + expectedCandidateCount / 8
|
||||||
|
+ kindPenalty + tagPenalty + narrowPenalty,
|
||||||
|
narrow: narrowPenalty > 0 }
|
||||||
|
})
|
||||||
|
.filter ((item): item is {
|
||||||
|
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)
|
||||||
|
|
||||||
|
return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const bestPost = (posts: Post[], scores: Map<number, number>): Post | null =>
|
||||||
|
posts
|
||||||
|
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||||
|
.sort ((a, b) => b.score - a.score)[0]?.post ?? null
|
||||||
|
|
||||||
|
|
||||||
|
const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
||||||
|
<div className="flex gap-3 items-center min-w-0">
|
||||||
|
<img
|
||||||
|
src={post.thumbnail || post.thumbnailBase || undefined}
|
||||||
|
alt={post.title || post.url}
|
||||||
|
className="w-16 h-16 rounded object-cover bg-yellow-100"/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/posts/${ post.id }`}
|
||||||
|
className="font-bold text-pink-700 dark:text-pink-200 break-words">
|
||||||
|
#{post.id} {post.title || post.url}
|
||||||
|
</PrefetchLink>
|
||||||
|
<div className="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-1">
|
||||||
|
{post.tags.slice (0, 6).map (tag => tag.name).join (' / ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
|
||||||
|
|
||||||
|
const GekanatorPage: FC = () => {
|
||||||
|
const [phase, setPhase] = useState<Phase> ('intro')
|
||||||
|
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
||||||
|
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
|
||||||
|
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
|
||||||
|
const [candidateIds, setCandidateIds] = useState<Set<number> | null> (null)
|
||||||
|
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
|
||||||
|
const [questionBank, setQuestionBank] = useState<GekanatorQuestion[]> ([])
|
||||||
|
const [search, setSearch] = useState ('')
|
||||||
|
const [saved, setSaved] = useState (false)
|
||||||
|
const [resultWon, setResultWon] = useState<boolean | null> (null)
|
||||||
|
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
|
||||||
|
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
|
||||||
|
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
|
||||||
|
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
|
||||||
|
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||||
|
|
||||||
|
const { data: posts = [], isLoading, error } = useQuery ({
|
||||||
|
queryKey: gekanatorKeys.posts (),
|
||||||
|
queryFn: fetchGekanatorPosts })
|
||||||
|
|
||||||
|
const candidatePosts = useMemo (
|
||||||
|
() => posts.filter (post => candidateIds === null || candidateIds.has (post.id)),
|
||||||
|
[posts, candidateIds])
|
||||||
|
const eligiblePosts = useMemo (
|
||||||
|
() => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))),
|
||||||
|
[candidatePosts, rejectedPostIds])
|
||||||
|
const questions = useMemo (
|
||||||
|
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
||||||
|
[eligiblePosts, posts])
|
||||||
|
const scoringQuestions = useMemo (() => {
|
||||||
|
return mergeQuestions ([...questions, ...questionBank])
|
||||||
|
}, [questions, questionBank])
|
||||||
|
const topScoredPosts = useMemo (
|
||||||
|
() => eligiblePosts
|
||||||
|
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||||
|
.sort ((a, b) => b.score - a.score)
|
||||||
|
.slice (0, 3),
|
||||||
|
[eligiblePosts, scores])
|
||||||
|
const currentQuestion = chooseQuestion ({
|
||||||
|
posts: eligiblePosts, questions: scoringQuestions, scores, askedIds })
|
||||||
|
const answerPreviews = useMemo (
|
||||||
|
() => currentQuestion
|
||||||
|
? answerOptions.map (option => previewAnswer ({
|
||||||
|
posts: eligiblePosts,
|
||||||
|
scores,
|
||||||
|
question: currentQuestion,
|
||||||
|
answer: option.value }))
|
||||||
|
: [],
|
||||||
|
[currentQuestion, eligiblePosts, scores])
|
||||||
|
const guess = bestPost (eligiblePosts, scores)
|
||||||
|
const displayedGuess =
|
||||||
|
posts.find (post => post.id === activeGuessId) ?? guess
|
||||||
|
const saveMutation = useMutation ({ mutationFn: saveGekanatorGame })
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setPhase ('intro')
|
||||||
|
setScores (new Map ())
|
||||||
|
setAnswers ([])
|
||||||
|
setAskedIds (new Set ())
|
||||||
|
setCandidateIds (null)
|
||||||
|
setSoftenedQuestionIds (new Set ())
|
||||||
|
setQuestionBank ([])
|
||||||
|
setSearch ('')
|
||||||
|
setSaved (false)
|
||||||
|
setResultWon (null)
|
||||||
|
setRejectedPostIds (new Set ())
|
||||||
|
setLastGuessQuestionCount (0)
|
||||||
|
setLastRejectedGuessId (null)
|
||||||
|
setActiveGuessId (null)
|
||||||
|
setHistory ([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = (value: GekanatorAnswerValue) => {
|
||||||
|
if (!(currentQuestion))
|
||||||
|
{
|
||||||
|
setActiveGuessId (guess?.id ?? null)
|
||||||
|
setPhase ('guess')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory ([...history, {
|
||||||
|
phase,
|
||||||
|
scores: new Map (scores),
|
||||||
|
answers: [...answers],
|
||||||
|
askedIds: new Set (askedIds),
|
||||||
|
candidateIds: candidateIds === null ? null : new Set (candidateIds),
|
||||||
|
softenedQuestionIds: new Set (softenedQuestionIds),
|
||||||
|
questionBank: [...questionBank],
|
||||||
|
search,
|
||||||
|
rejectedPostIds: new Set (rejectedPostIds),
|
||||||
|
lastGuessQuestionCount,
|
||||||
|
lastRejectedGuessId,
|
||||||
|
activeGuessId }])
|
||||||
|
const nextAnswers = [...answers, {
|
||||||
|
questionId: currentQuestion.id,
|
||||||
|
questionText: currentQuestion.text,
|
||||||
|
answer: value }]
|
||||||
|
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
||||||
|
const nextQuestionBank = [
|
||||||
|
...questionBank.filter (question => question.id !== currentQuestion.id),
|
||||||
|
currentQuestion]
|
||||||
|
const hardFilteredPosts =
|
||||||
|
value === 'yes'
|
||||||
|
? eligiblePosts.filter (post => currentQuestion.test (post))
|
||||||
|
: value === 'no'
|
||||||
|
? eligiblePosts.filter (post => !(currentQuestion.test (post)))
|
||||||
|
: eligiblePosts
|
||||||
|
let nextCandidateIds =
|
||||||
|
(value === 'yes' || value === 'no') && hardFilteredPosts.length > 0
|
||||||
|
? new Set (hardFilteredPosts.map (post => post.id))
|
||||||
|
: candidateIds
|
||||||
|
let nextSoftenedQuestionIds = new Set (softenedQuestionIds)
|
||||||
|
let nextScores = recalculateScores ({
|
||||||
|
posts,
|
||||||
|
questions: nextQuestionBank,
|
||||||
|
answers: nextAnswers,
|
||||||
|
softenedQuestionIds: nextSoftenedQuestionIds })
|
||||||
|
|
||||||
|
let nextEligiblePosts =
|
||||||
|
posts.filter (post =>
|
||||||
|
(nextCandidateIds === null || nextCandidateIds.has (post.id))
|
||||||
|
&& !(rejectedPostIds.has (post.id)))
|
||||||
|
let nextScoringQuestions = mergeQuestions ([
|
||||||
|
...buildGekanatorQuestions (nextEligiblePosts.length > 1 ? nextEligiblePosts : posts),
|
||||||
|
...nextQuestionBank])
|
||||||
|
while (
|
||||||
|
nextAnswers.length < hardMaxQuestions
|
||||||
|
&& nextEligiblePosts.length > 1
|
||||||
|
&& !(chooseQuestion ({
|
||||||
|
posts: nextEligiblePosts,
|
||||||
|
questions: nextScoringQuestions,
|
||||||
|
scores: nextScores,
|
||||||
|
askedIds: nextAskedIds }))
|
||||||
|
)
|
||||||
|
{
|
||||||
|
const softened = softenNextQuestionIds ({
|
||||||
|
questions: nextQuestionBank,
|
||||||
|
answers: nextAnswers,
|
||||||
|
softenedQuestionIds: nextSoftenedQuestionIds })
|
||||||
|
if (!(softened))
|
||||||
|
break
|
||||||
|
|
||||||
|
nextSoftenedQuestionIds = softened
|
||||||
|
nextCandidateIds = null
|
||||||
|
nextEligiblePosts = posts.filter (post => !(rejectedPostIds.has (post.id)))
|
||||||
|
nextScoringQuestions = mergeQuestions ([
|
||||||
|
...buildGekanatorQuestions (nextEligiblePosts),
|
||||||
|
...nextQuestionBank])
|
||||||
|
nextScores = recalculateScores ({
|
||||||
|
posts,
|
||||||
|
questions: nextQuestionBank,
|
||||||
|
answers: nextAnswers,
|
||||||
|
softenedQuestionIds: nextSoftenedQuestionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
setScores (nextScores)
|
||||||
|
setAskedIds (nextAskedIds)
|
||||||
|
setCandidateIds (nextCandidateIds)
|
||||||
|
setSoftenedQuestionIds (nextSoftenedQuestionIds)
|
||||||
|
setQuestionBank (nextQuestionBank)
|
||||||
|
setAnswers (nextAnswers)
|
||||||
|
|
||||||
|
const nextGuess = bestPost (nextEligiblePosts, nextScores)
|
||||||
|
const nextQuestionCount = answers.length + 1
|
||||||
|
const definitelyKnown = nextEligiblePosts.length === 1
|
||||||
|
const enoughQuestions =
|
||||||
|
nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses
|
||||||
|
const shouldGuess =
|
||||||
|
nextQuestionCount >= hardMaxQuestions
|
||||||
|
|| definitelyKnown
|
||||||
|
|| enoughQuestions
|
||||||
|
if (shouldGuess)
|
||||||
|
{
|
||||||
|
setActiveGuessId (nextGuess?.id ?? null)
|
||||||
|
setLastGuessQuestionCount (nextQuestionCount)
|
||||||
|
setPhase ('guess')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveResult = (won: boolean, correctPostId: number | null) => {
|
||||||
|
const guessedPostId =
|
||||||
|
won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id
|
||||||
|
if (!(guessedPostId) || saved)
|
||||||
|
return
|
||||||
|
|
||||||
|
setSaved (true)
|
||||||
|
setResultWon (won)
|
||||||
|
saveMutation.mutate ({
|
||||||
|
guessedPostId,
|
||||||
|
correctPostId,
|
||||||
|
won,
|
||||||
|
answers })
|
||||||
|
setPhase ('learned')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectGuess = () => {
|
||||||
|
if (!(displayedGuess))
|
||||||
|
return
|
||||||
|
|
||||||
|
setLastRejectedGuessId (displayedGuess.id)
|
||||||
|
if (answers.length >= hardMaxQuestions)
|
||||||
|
{
|
||||||
|
setSearch (' ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
|
||||||
|
setActiveGuessId (null)
|
||||||
|
setSearch ('')
|
||||||
|
setLastGuessQuestionCount (answers.length)
|
||||||
|
setPhase ('continue')
|
||||||
|
}
|
||||||
|
|
||||||
|
const undoAnswer = () => {
|
||||||
|
const snapshot = history[history.length - 1]
|
||||||
|
if (!(snapshot) || saved)
|
||||||
|
return
|
||||||
|
|
||||||
|
setPhase (snapshot.phase)
|
||||||
|
setScores (snapshot.scores)
|
||||||
|
setAnswers (snapshot.answers)
|
||||||
|
setAskedIds (snapshot.askedIds)
|
||||||
|
setCandidateIds (snapshot.candidateIds)
|
||||||
|
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
|
||||||
|
setQuestionBank (snapshot.questionBank)
|
||||||
|
setSearch (snapshot.search)
|
||||||
|
setRejectedPostIds (snapshot.rejectedPostIds)
|
||||||
|
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
|
||||||
|
setLastRejectedGuessId (snapshot.lastRejectedGuessId)
|
||||||
|
setActiveGuessId (snapshot.activeGuessId)
|
||||||
|
setHistory (history.slice (0, -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const softenAndContinue = () => {
|
||||||
|
const softened = softenNextQuestionIds ({
|
||||||
|
questions: scoringQuestions, answers, softenedQuestionIds })
|
||||||
|
if (!(softened))
|
||||||
|
return
|
||||||
|
|
||||||
|
setSoftenedQuestionIds (softened)
|
||||||
|
setCandidateIds (null)
|
||||||
|
setScores (
|
||||||
|
recalculateScores ({ posts,
|
||||||
|
questions: scoringQuestions,
|
||||||
|
answers,
|
||||||
|
softenedQuestionIds: softened }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPosts = posts
|
||||||
|
.filter (post => {
|
||||||
|
const needle = search.trim ().toLowerCase ()
|
||||||
|
if (!(needle))
|
||||||
|
return false
|
||||||
|
if (/^\d+$/.test (needle) && post.id === Number (needle))
|
||||||
|
return true
|
||||||
|
|
||||||
|
return [post.title, post.url, ...post.tags.map (tag => tag.name)]
|
||||||
|
.filter ((value): value is string => Boolean (value))
|
||||||
|
.some (value => value.toLowerCase ().includes (needle))
|
||||||
|
})
|
||||||
|
.sort ((a, b) => {
|
||||||
|
const id = Number (search.trim ())
|
||||||
|
if (Number.isFinite (id))
|
||||||
|
return Number (b.id === id) - Number (a.id === id)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
.slice (0, 20)
|
||||||
|
|
||||||
|
const dialogue =
|
||||||
|
phase === 'learned' && resultWon
|
||||||
|
? <>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||||||
|
: <>私は<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>.質問から投稿を何でも当ててみせるよ</>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainArea className="bg-yellow-50 dark:bg-red-975">
|
||||||
|
<Helmet>
|
||||||
|
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<p className="text-sm text-pink-700 dark:text-pink-200">おたのしみ</p>
|
||||||
|
<h1 className="text-3xl font-bold text-pink-700 dark:text-pink-200">
|
||||||
|
グカネータ
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-yellow-300 bg-white p-4
|
||||||
|
shadow-sm dark:border-red-800 dark:bg-red-950">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="grid h-24 w-24 shrink-0 place-items-center rounded-lg
|
||||||
|
bg-yellow-200 text-center text-sm font-bold
|
||||||
|
text-pink-700 dark:bg-red-900 dark:text-pink-100">
|
||||||
|
洗澡鹿
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{dialogue}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading && <p>投稿を読み込んでゐます...</p>}
|
||||||
|
{Boolean (error) && <p>投稿を読み込めませんでした.</p>}
|
||||||
|
|
||||||
|
{phase === 'intro' && !(isLoading) && posts.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500"
|
||||||
|
onClick={() => setPhase ('question')}>
|
||||||
|
はじめる
|
||||||
|
</button>)}
|
||||||
|
|
||||||
|
{phase === 'question' && currentQuestion && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
質問 {answers.length + 1}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold">{currentQuestion.text}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-yellow-100 px-3 py-2
|
||||||
|
text-sm dark:border-red-900">
|
||||||
|
<div className="font-bold">現在候補: {eligiblePosts.length} 件</div>
|
||||||
|
{topScoredPosts.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{topScoredPosts.map (item => (
|
||||||
|
<span key={item.post.id}>
|
||||||
|
#{item.post.id}: score {item.score.toFixed (1)}
|
||||||
|
</span>))}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
{answerPreviews.length > 0 && (
|
||||||
|
<div className="grid gap-2 text-sm md:grid-cols-2">
|
||||||
|
{answerOptions.map (option => {
|
||||||
|
const preview =
|
||||||
|
answerPreviews.find (item => item.answer === option.value)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className="rounded border border-yellow-100 px-3 py-2
|
||||||
|
dark:border-red-900">
|
||||||
|
<span className="font-bold">{option.label}</span>
|
||||||
|
{' '}
|
||||||
|
<span className="text-neutral-600 dark:text-neutral-300">
|
||||||
|
なら候補 {preview ? preview.candidateCount : 0} 件
|
||||||
|
</span>
|
||||||
|
</div>)
|
||||||
|
})}
|
||||||
|
</div>)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{answerOptions.map (option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-yellow-300 px-3 py-2
|
||||||
|
hover:bg-yellow-100 dark:border-red-700
|
||||||
|
dark:hover:bg-red-900"
|
||||||
|
onClick={() => answer (option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</button>))}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-neutral-300 px-3 py-2
|
||||||
|
hover:bg-neutral-100 dark:border-neutral-700
|
||||||
|
dark:hover:bg-red-900"
|
||||||
|
onClick={undoAnswer}>
|
||||||
|
戻る
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{phase === 'question' && !(currentQuestion) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xl font-bold">
|
||||||
|
さっきまでの答へを少し疑って考へ直すよ.
|
||||||
|
</p>
|
||||||
|
{answers.length >= hardMaxQuestions || eligiblePosts.length <= 1
|
||||||
|
? (
|
||||||
|
<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={() => {
|
||||||
|
setActiveGuessId (guess?.id ?? null)
|
||||||
|
setPhase ('guess')
|
||||||
|
}}>
|
||||||
|
推測へ
|
||||||
|
</button>)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500"
|
||||||
|
onClick={softenAndContinue}>
|
||||||
|
考へ直す
|
||||||
|
</button>)}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{phase === 'guess' && displayedGuess && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xl font-bold">これを想像してゐたね?</p>
|
||||||
|
<PostMiniCard post={displayedGuess}/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500"
|
||||||
|
onClick={() => saveResult (true, null)}>
|
||||||
|
当たり
|
||||||
|
</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"
|
||||||
|
onClick={rejectGuess}>
|
||||||
|
違ふ
|
||||||
|
</button>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<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={undoAnswer}>
|
||||||
|
戻る
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{phase === 'continue' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xl font-bold">続けますか?</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500"
|
||||||
|
onClick={() => setPhase ('question')}>
|
||||||
|
はい
|
||||||
|
</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"
|
||||||
|
onClick={() => setSearch (' ')}>
|
||||||
|
いいえ
|
||||||
|
</button>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<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={undoAnswer}>
|
||||||
|
戻る
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{phase === 'learned' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>覚えたよ.次はもっと見通す.</p>
|
||||||
|
{saveMutation.isError && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
ただし学習ログの保存には失敗しました.
|
||||||
|
</p>)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
|
hover:bg-pink-500"
|
||||||
|
onClick={reset}>
|
||||||
|
もう一回
|
||||||
|
</button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{['guess', 'continue', 'question'].includes (phase) && search !== '' && (
|
||||||
|
<section className="rounded-lg border border-yellow-300 bg-white p-4
|
||||||
|
dark:border-red-800 dark:bg-red-950">
|
||||||
|
<label className="block space-y-2">
|
||||||
|
<span className="font-bold">正解の投稿</span>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={ev => setSearch (ev.target.value)}
|
||||||
|
className="w-full rounded border border-yellow-300 bg-white px-3 py-2
|
||||||
|
dark:border-red-700 dark:bg-red-950"
|
||||||
|
placeholder="投稿 Id.・タイトル・URL・タグで検索"/>
|
||||||
|
</label>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{filteredPosts.map (post => (
|
||||||
|
<button
|
||||||
|
key={post.id}
|
||||||
|
type="button"
|
||||||
|
className={cn ('block w-full rounded border border-yellow-200 p-3',
|
||||||
|
'text-left hover:bg-yellow-100',
|
||||||
|
'dark:border-red-800 dark:hover:bg-red-900')}
|
||||||
|
onClick={() => saveResult (false, post.id)}>
|
||||||
|
<PostMiniCard post={post}/>
|
||||||
|
</button>))}
|
||||||
|
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
||||||
|
</div>
|
||||||
|
</section>)}
|
||||||
|
</div>
|
||||||
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default GekanatorPage
|
||||||
新しい課題から参照
ユーザをブロックする