コミットを比較

...

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ c10ba7a698 #306 2026-06-25 08:11:41 +09:00
みてるぞ fa6c547cc9 #306 2026-06-25 07:44:11 +09:00
42個のファイルの変更1446行の追加1150行の削除
+13
ファイルの表示
@@ -268,7 +268,20 @@ const value =
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation. - In TSX, indent with 4-space logical indentation.
- In TypeScript and TSX, convert every leading run of 8 spaces to a tab
character.
- A leading tab is exactly equivalent to 8 leading spaces. - A leading tab is exactly equivalent to 8 leading spaces.
- Never place a closing parenthesis at the beginning of a line.
- Never place a closing square bracket at the beginning of a line.
- For object literals and other associative-array-style braces, do not place
the closing brace at the beginning of a line. Function, lambda, callback, and
block closing braces are exempt and should stay on their own line when that
fits the local style.
- When writing braces on a single line in TypeScript or TSX JavaScript
context, put exactly one space inside the braces, as in `{ value }` or
`{ key: value }`.
- Do not add inner spaces to React/JSX expression braces, as in
`prop={value}`, `{children}`, or `<Component>{{...props}}</Component>`.
- Keep a tag's closing marker on the same line as the final prop when the tag - Keep a tag's closing marker on the same line as the final prop when the tag
spans multiple lines. spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code - Do not put `/>` or `>` on its own line unless the existing surrounding code
+161 -14
ファイルの表示
@@ -12,33 +12,47 @@ class MaterialsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
tag_id = params[:tag_id].presence filters = material_index_filters
parent_id = params[:parent_id].presence q = Material.includes(:material_export_items,
unclassified = bool?(:unclassified) thumbnail_attachment: :blob,
file_attachment: :blob,
tag: :tag_name)
q = q.where(file_suppressed_at: nil) if filters[:suppression] == 'active'
q = q.where.not(file_suppressed_at: nil) if filters[:suppression] == 'suppressed'
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
q = q.where('materials.created_at >= ?', filters[:created_from]) if filters[:created_from]
q = q.where('materials.created_at <= ?', filters[:created_to]) if filters[:created_to]
q = q.where('materials.updated_at >= ?', filters[:updated_from]) if filters[:updated_from]
q = q.where('materials.updated_at <= ?', filters[:updated_to]) if filters[:updated_to]
q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file q = material_index_join_tag_name(q) if material_index_needs_tag_name?(filters)
if unclassified q = material_index_join_file_blob(q) if material_index_needs_file_blob?(filters)
q = q.where(tag_id: nil) q = apply_material_query(q, filters[:q]) if filters[:q].present?
else q = apply_material_media_kind(q, filters[:media_kind])
q = q.where(tag_id:) if tag_id
q = q.where(parent_id:) if parent_id
end
count = q.count count = q.distinct.count(:id)
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset) materials =
q
.order(Arel.sql(material_index_order_sql(filters)))
.limit(limit)
.offset(offset)
.to_a
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count } render json: { materials: MaterialRepr.list_many(materials, host: request.base_url),
count: }
end end
def show def show
material = material =
Material Material
.includes(:tag, :material_export_items) .includes(:tag, :material_export_items)
.with_attached_thumbnail
.with_attached_file .with_attached_file
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:) render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
end end
@@ -83,6 +97,8 @@ class MaterialsController < ApplicationController
end end
if material if material
MaterialThumbnailGenerator.generate!(material)
material.reload
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render_validation_error material render_validation_error material
@@ -134,6 +150,9 @@ class MaterialsController < ApplicationController
raise raise
end end
MaterialThumbnailGenerator.generate!(material)
material.reload
render json: MaterialRepr.base(material, host: request.base_url) render json: MaterialRepr.base(material, host: request.base_url)
end end
@@ -196,6 +215,134 @@ class MaterialsController < ApplicationController
private private
def material_index_filters
tag_state = params[:tag_state].to_s.presence
tag_state = 'untagged' if bool?(:unclassified)
tag_state = 'all' unless ['all', 'tagged', 'untagged'].include?(tag_state)
media_kind = params[:media_kind].to_s.presence
unless ['all', 'image', 'video', 'audio', 'file_other', 'url_only'].include?(media_kind)
media_kind = 'all'
end
suppression = params[:suppression].to_s.presence
suppression = 'active' unless ['active', 'suppressed', 'all'].include?(suppression)
sort = params[:sort].to_s.presence
unless ['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
'version_no', 'id'].include?(sort)
sort = 'created_at'
end
direction = params[:direction].to_s.downcase
direction = 'desc' unless ['asc', 'desc'].include?(direction)
{ q: params[:q].to_s.strip.presence,
tag_state:,
media_kind:,
suppression:,
created_from: parse_time_param(:created_from),
created_to: parse_time_param(:created_to),
updated_from: parse_time_param(:updated_from),
updated_to: parse_time_param(:updated_to),
sort:,
direction: }
end
def parse_time_param name
value = params[name].to_s.strip
return nil if value.blank?
Time.zone.parse(value)
rescue ArgumentError
nil
end
def material_index_needs_tag_name? filters
filters[:q].present? || filters[:sort] == 'tag_name'
end
def material_index_needs_file_blob? filters
filters[:q].present? ||
filters[:media_kind] != 'all' ||
['media_kind', 'file_byte_size'].include?(filters[:sort])
end
def material_index_join_tag_name q
q.left_joins(tag: :tag_name)
end
def material_index_join_file_blob q
q.joins(<<~SQL.squish)
LEFT JOIN active_storage_attachments material_file_attachments
ON material_file_attachments.record_type = 'Material'
AND material_file_attachments.record_id = materials.id
AND material_file_attachments.name = 'file'
LEFT JOIN active_storage_blobs material_file_blobs
ON material_file_blobs.id = material_file_attachments.blob_id
SQL
end
def apply_material_query q, term
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
'material_file_blobs.filename LIKE :q',
q: like)
end
def apply_material_media_kind q, media_kind
case media_kind
when 'image'
q.where('material_file_blobs.content_type LIKE ?', 'image/%')
when 'video'
q.where('material_file_blobs.content_type LIKE ?', 'video/%')
when 'audio'
q.where('material_file_blobs.content_type LIKE ?', 'audio/%')
when 'file_other'
q.where('material_file_attachments.id IS NOT NULL')
.where.not('material_file_blobs.content_type LIKE ?', 'image/%')
.where.not('material_file_blobs.content_type LIKE ?', 'video/%')
.where.not('material_file_blobs.content_type LIKE ?', 'audio/%')
when 'url_only'
q.where('material_file_attachments.id IS NULL').where.not(url: [nil, ''])
else
q
end
end
def material_index_order_sql filters
direction = filters[:direction] == 'asc' ? 'ASC' : 'DESC'
sort_sql =
case filters[:sort]
when 'tag_name'
'tag_names.name'
when 'media_kind'
material_media_kind_sql
when 'file_byte_size'
'material_file_blobs.byte_size'
when 'updated_at'
'materials.updated_at'
when 'version_no'
'materials.version_no'
when 'id'
'materials.id'
else
'materials.created_at'
end
"#{ sort_sql } #{ direction }, materials.id #{ direction }"
end
def material_media_kind_sql
"CASE " \
"WHEN material_file_attachments.id IS NULL AND materials.url IS NOT NULL THEN 5 " \
"WHEN material_file_blobs.content_type LIKE 'image/%' THEN 1 " \
"WHEN material_file_blobs.content_type LIKE 'video/%' THEN 2 " \
"WHEN material_file_blobs.content_type LIKE 'audio/%' THEN 3 " \
"WHEN material_file_attachments.id IS NOT NULL THEN 4 " \
"ELSE 6 END"
end
def upsert_export_paths! material def upsert_export_paths! material
raw = params[:export_paths] raw = params[:export_paths]
return if raw.blank? return if raw.blank?
+13
ファイルの表示
@@ -15,6 +15,7 @@ class Material < ApplicationRecord
has_many :material_export_items, dependent: :destroy has_many :material_export_items, dependent: :destroy
has_one_attached :file, dependent: :purge has_one_attached :file, dependent: :purge
has_one_attached :thumbnail, dependent: :purge
validates :tag_id, presence: true, uniqueness: true validates :tag_id, presence: true, uniqueness: true
@@ -28,6 +29,18 @@ class Material < ApplicationRecord
file.blob.content_type file.blob.content_type
end end
def file_byte_size
return nil unless file&.attached?
file.blob.byte_size
end
def file_filename
return nil unless file&.attached?
file.blob.filename.to_s
end
def file_suppressed? = file_suppressed_at.present? def file_suppressed? = file_suppressed_at.present?
def snapshot_export_paths def snapshot_export_paths
+64
ファイルの表示
@@ -17,6 +17,11 @@ module MaterialRepr
Rails.application.routes.url_helpers.rails_storage_proxy_url( Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:) material.file, host:)
end, end,
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
file_byte_size: material.file_byte_size,
export_paths: export_paths(material), export_paths: export_paths(material),
export_items: export_items(material)) export_items: export_items(material))
end end
@@ -25,6 +30,28 @@ module MaterialRepr
materials.map { |m| base(m, host:) } materials.map { |m| base(m, host:) }
end end
def list material, host:
{ id: material.id,
version_no: material.version_no,
url: material.url,
tag: compact_tag(material.tag),
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
content_type: material.content_type,
file_byte_size: material.file_byte_size,
file_suppressed_at: material.file_suppressed_at,
created_at: material.created_at,
updated_at: material.updated_at,
export_paths: export_paths(material),
export_items: export_items(material) }
end
def list_many materials, host:
materials.map { |m| list(m, host:) }
end
def export_paths material def export_paths material
material.material_export_items.each_with_object({ }) do |item, hash| material.material_export_items.each_with_object({ }) do |item, hash|
hash[item.profile] = item.enabled ? item.export_path : '' hash[item.profile] = item.enabled ? item.export_path : ''
@@ -39,4 +66,41 @@ module MaterialRepr
enabled: item.enabled } enabled: item.enabled }
end end
end end
def thumbnail_url material, host:
return nil if material.file_suppressed?
return nil unless material.thumbnail.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.thumbnail, host:)
end
def thumbnail_fallback_text material
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
end
def thumbnail_fallback_kind material
material.tag.present? ? 'tag_name' : 'created_at'
end
def media_kind material
return 'suppressed' if material.file_suppressed?
return 'url_only' unless material.file.attached?
content_type = material.file.blob.content_type.to_s
return 'image' if content_type.start_with?('image/')
return 'video' if content_type.start_with?('video/')
return 'audio' if content_type.start_with?('audio/')
'file_other'
end
def compact_tag tag
return nil unless tag
{ id: tag.id,
name: tag.name,
category: tag.category,
deprecated_at: tag.deprecated_at }
end
end end
+86
ファイルの表示
@@ -0,0 +1,86 @@
# frozen_string_literal: true
require 'mini_magick'
require 'open3'
require 'tempfile'
class MaterialThumbnailGenerator
SIZE = '180x180'
class << self
def generate! material
new(material).generate!
rescue ActiveStorage::FileNotFoundError, ArgumentError, MiniMagick::Error => e
Rails.logger.warn("Material thumbnail generation skipped: #{ e.class }: #{ e.message }")
nil
end
end
def initialize material
@material = material
end
def generate!
@material.thumbnail.purge if @material.thumbnail.attached?
return unless @material.file.attached?
return unless image? || video?
@material.file.blob.open do |file|
thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path)
attach_thumbnail(thumbnail) if thumbnail
end
end
private
def image? = content_type.start_with?('image/')
def video? = content_type.start_with?('video/')
def content_type = @material.file.blob.content_type.to_s
def image_thumbnail path
image = MiniMagick::Image.open(path)
image.resize(SIZE)
image.format('jpg')
image
end
def video_thumbnail path
[1, 0].each do |seconds|
tempfile = Tempfile.new(['material-thumbnail', '.jpg'])
tempfile.close
ok = extract_video_frame(path, tempfile.path, seconds)
next unless ok && File.size?(tempfile.path)
return image_thumbnail(tempfile.path)
ensure
tempfile&.unlink
end
nil
end
def extract_video_frame input_path, output_path, seconds
_stdout, stderr, status =
Open3.capture3('ffmpeg',
'-y',
'-ss', seconds.to_s,
'-i', input_path,
'-frames:v', '1',
'-f', 'image2',
output_path)
Rails.logger.warn("ffmpeg thumbnail failed: #{ stderr }") unless status.success?
status.success?
rescue Errno::ENOENT => e
Rails.logger.warn("ffmpeg unavailable for material thumbnail: #{ e.message }")
false
end
def attach_thumbnail image
@material.thumbnail.attach(io: File.open(image.path),
filename: 'material-thumbnail.jpg',
content_type: 'image/jpeg')
end
end
+7 -30
ファイルの表示
@@ -2,7 +2,6 @@ import { Fragment, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
@@ -17,15 +16,13 @@ const FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { const FILTER_LABELS: Record<MaterialFilter, string> = {
present: '素材あり', present: '素材あり',
missing: '素材なし', missing: '素材なし',
any: 'すべて', any: 'すべて'}
}
const setChildrenById = ( const setChildrenById = (
tags: MaterialSidebarTag[], tags: MaterialSidebarTag[],
targetId: number, targetId: number,
children: MaterialSidebarTag[], children: MaterialSidebarTag[]): MaterialSidebarTag[] => (
): MaterialSidebarTag[] => (
tags.map (tag => { tags.map (tag => {
if (tag.id === targetId) if (tag.id === targetId)
return { ...tag, children } return { ...tag, children }
@@ -39,8 +36,7 @@ const setChildrenById = (
const materialPath = ( const materialPath = (
tagName: string, tagName: string,
materialFilter: MaterialFilter, materialFilter: MaterialFilter): string => `/materials?q=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }`
): string => `/materials?tag=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }`
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({ const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
@@ -63,8 +59,7 @@ const updateMaterialFilterQuery = (
pathname: string, pathname: string,
locationSearch: string, locationSearch: string,
navigate: ReturnType<typeof useNavigate>, navigate: ReturnType<typeof useNavigate>,
materialFilter: MaterialFilter, materialFilter: MaterialFilter) => {
) => {
const qs = new URLSearchParams (locationSearch) const qs = new URLSearchParams (locationSearch)
qs.set ('material_filter', materialFilter) qs.set ('material_filter', materialFilter)
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`) navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
@@ -104,8 +99,7 @@ const MaterialTreeNode: FC<{
const { data } = useQuery ({ const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0, enabled: open && tag.hasChildren && tag.children.length === 0})
})
useEffect (() => { useEffect (() => {
if (open && data && tag.children.length === 0) if (open && data && tag.children.length === 0)
@@ -164,8 +158,7 @@ const MobileMaterialTreeNode: FC<{
const { data } = useQuery ({ const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0, enabled: open && tag.hasChildren && tag.children.length === 0})
})
useEffect (() => { useEffect (() => {
if (open && data && tag.children.length === 0) if (open && data && tag.children.length === 0)
@@ -233,8 +226,7 @@ const MaterialSidebar: FC = () => {
const { data: rootTags = [], isLoading, isError } = useQuery ({ const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }), queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }), queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
})
useEffect (() => { useEffect (() => {
setDesktopTags (rootTags) setDesktopTags (rootTags)
@@ -279,14 +271,6 @@ const MaterialSidebar: FC = () => {
<> <>
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950 <div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
dark:text-stone-100 md:hidden"> dark:text-stone-100 md:hidden">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-stone-700 dark:text-stone-200"></span>
<PrefetchLink
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
</PrefetchLink>
</div>
<MaterialFilterButtons <MaterialFilterButtons
materialFilter={materialFilter} materialFilter={materialFilter}
onChange={handleFilterChange}/> onChange={handleFilterChange}/>
@@ -310,13 +294,6 @@ const MaterialSidebar: FC = () => {
<MaterialFilterButtons <MaterialFilterButtons
materialFilter={materialFilter} materialFilter={materialFilter}
onChange={handleFilterChange}/> onChange={handleFilterChange}/>
<div>
<PrefetchLink
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
</PrefetchLink>
</div>
{isLoading && ( {isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)} <p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && ( {isError && (
+1 -2
ファイルの表示
@@ -107,8 +107,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
loadCompleteTimerRef.current = setTimeout (() => { loadCompleteTimerRef.current = setTimeout (() => {
onError?.({ onError?.({
eventName: 'loadCompleteTimeout', eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed', reason: 'niconico video length was not reported by embed'})
})
}, LOAD_COMPLETE_TIMEOUT_MS) }, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError]) }, [clearLoadCompleteTimer, onError])
+1 -2
ファイルの表示
@@ -19,8 +19,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props, errors }: Props) => (
) => (
<FormField label="オリジナルの作成日時" messages={errors}> <FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
+5 -10
ファイルの表示
@@ -37,8 +37,7 @@ const renderTagTree = (
path: string, path: string,
suppressClickRef: MutableRefObject<boolean>, suppressClickRef: MutableRefObject<boolean>,
parentTagId?: number, parentTagId?: number,
sp?: boolean, sp?: boolean): ReactNode[] => {
): ReactNode[] => {
const key = `${ path }-${ tag.id }` const key = `${ path }-${ tag.id }`
const self = ( const self = (
@@ -64,8 +63,7 @@ const renderTagTree = (
const isDescendant = ( const isDescendant = (
root: Tag, root: Tag,
targetId: number, targetId: number): boolean => {
): boolean => {
if (!(root.children)) if (!(root.children))
return false return false
@@ -83,8 +81,7 @@ const isDescendant = (
const findTag = ( const findTag = (
byCat: TagByCategory, byCat: TagByCategory,
id: number, id: number): Tag | undefined => {
): Tag | undefined => {
const walk = (nodes: Tag[]): Tag | undefined => { const walk = (nodes: Tag[]): Tag | undefined => {
for (const t of nodes) for (const t of nodes)
{ {
@@ -130,8 +127,7 @@ const buildTagByCategory = (post: Post): TagByCategory => {
const changeCategory = async ( const changeCategory = async (
tagId: number, tagId: number,
category: Category, category: Category): Promise<void> => {
): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category }) await apiPatch (`/tags/${ tagId }`, { category })
} }
@@ -294,8 +290,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
addEventListener ('click', e => { addEventListener ('click', e => {
e.preventDefault () e.preventDefault ()
e.stopPropagation () e.stopPropagation ()
suppressClickRef.current = false suppressClickRef.current = false}, { capture: true, once: true })
}, { capture: true, once: true })
}} }}
onDragCancel={() => { onDragCancel={() => {
setActiveTagId (null) setActiveTagId (null)
+1 -2
ファイルの表示
@@ -16,8 +16,7 @@ const range = (start: number, end: number): number[] =>
const getPages = ( const getPages = (
page: number, page: number,
total: number, total: number,
siblingCount: number, siblingCount: number): (number | '…')[] => {
): (number | '…')[] => {
if (total <= 1) if (total <= 1)
return [1] return [1]
+1 -2
ファイルの表示
@@ -103,8 +103,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => { choice: options => new Promise (resolve => {
push ({ kind: 'choice', push ({ kind: 'choice',
options: options as ChoiceOptions<string>, options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void }) resolve: resolve as (value: string | null) => void })}) }), [push])
}) }), [push])
const active = queue[0] const active = queue[0]
+6 -14
ファイルの表示
@@ -10,8 +10,7 @@ const buttonVariants = cva (
'rounded-md text-sm font-medium transition-colors', 'rounded-md text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'].join (' '),
].join (' '),
{ {
variants: { variants: {
variant: { variant: {
@@ -31,20 +30,15 @@ const buttonVariants = cva (
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800', 'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
link: link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300', 'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
},
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10', icon: 'h-10 w-10'}},
},
},
defaultVariants: { defaultVariants: {
variant: 'default', variant: 'default',
size: 'default', size: 'default'}})
},
})
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -60,10 +54,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />)
) })
}
)
Button.displayName = "Button" Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }
+10 -20
ファイルの表示
@@ -22,11 +22,9 @@ const DialogOverlay = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className)}
)}
{...props} {...props}
/> />))
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
@@ -62,8 +60,7 @@ const DialogContent = React.forwardRef<
<span className="sr-only"></span> <span className="sr-only"></span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>))
))
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ const DialogHeader = ({
@@ -73,11 +70,9 @@ const DialogHeader = ({
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className className)}
)}
{...props} {...props}
/> />)
)
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ const DialogFooter = ({
@@ -87,11 +82,9 @@ const DialogFooter = ({
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className className)}
)}
{...props} {...props}
/> />)
)
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
@@ -102,11 +95,9 @@ const DialogTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className)}
)}
{...props} {...props}
/> />))
))
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
@@ -117,8 +108,7 @@ const DialogDescription = React.forwardRef<
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />))
))
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName
export { export {
+3 -6
ファイルの表示
@@ -9,14 +9,11 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)}
)} )}
ref={ref} ref={ref}
/> {...props}
) />
}
)
) )
} }
) )
+3 -6
ファイルの表示
@@ -12,18 +12,15 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className className
)}
)} )}
{...props} {...props}
ref={ref} ref={ref}
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" className={cn(
)}
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
</SwitchPrimitives.Root> )}
))
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>
)) ))
+10 -24
ファイルの表示
@@ -17,11 +17,9 @@ const ToastViewport = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className className
)}
)} )}
/> {...props}
))
/> />
)) ))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName
@@ -34,11 +32,7 @@ const toastVariants = cva(
default: "border bg-background text-foreground", default: "border bg-background text-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", "destructive group border-destructive bg-destructive text-destructive-foreground",
defaultVariants: { },
variant: "default",
},
}
)
}, },
defaultVariants: { variant: "default" } } defaultVariants: { variant: "default" } }
) )
@@ -50,9 +44,7 @@ const Toast = React.forwardRef<
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
/> ref={ref}
)
})
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
@@ -63,11 +55,9 @@ const ToastAction = React.forwardRef<
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
className >(({ className, ...props }, ref) => (
)}
<ToastPrimitives.Action <ToastPrimitives.Action
/> ref={ref}
))
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className className
@@ -78,14 +68,12 @@ const ToastClose = React.forwardRef<
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
className React.ElementRef<typeof ToastPrimitives.Close>,
)}
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
</ToastPrimitives.Close> className={cn(
))
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className className
)} )}
@@ -96,8 +84,7 @@ const ToastTitle = React.forwardRef<
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ))
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName
/>
))
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
@@ -108,8 +95,7 @@ const ToastDescription = React.forwardRef<
{...props} {...props}
/> />
)) ))
/> ToastTitle.displayName = ToastPrimitives.Title.displayName
))
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
+1 -2
ファイルの表示
@@ -26,6 +26,5 @@ export const Toaster = () => {
<ToastClose /> <ToastClose />
</Toast>))} </Toast>))}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
)
) )
+11 -25
ファイルの表示
@@ -58,8 +58,7 @@ const addToRemoveQueue = (toastId: string) => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId)
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId})
})
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout)
@@ -70,16 +69,13 @@ export const reducer = (state: State, action: Action): State => {
case "ADD_TOAST": case "ADD_TOAST":
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)}
}
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t)}
),
}
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action
@@ -100,23 +96,18 @@ export const reducer = (state: State, action: Action): State => {
t.id === toastId || toastId === undefined t.id === toastId || toastId === undefined
? { ? {
...t, ...t,
open: false, open: false}
} : t)}
: t
),
}
} }
case "REMOVE_TOAST": case "REMOVE_TOAST":
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: []}
}
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId)}
}
} }
} }
@@ -139,8 +130,7 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id }})
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({ dispatch({
@@ -151,15 +141,12 @@ function toast({ ...props }: Toast) {
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss()
}, }}})
},
})
return { return {
id: id, id: id,
dismiss, dismiss,
update, update}
}
} }
function useToast() { function useToast() {
@@ -178,8 +165,7 @@ function useToast() {
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })}
}
} }
export { useToast, toast } export { useToast, toast }
+1 -2
ファイルの表示
@@ -3,8 +3,7 @@ const ENV: string = 'development'
const config = { const config = {
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002', API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002',
SITE_TITLE: 'ぼざクリ タグ広場' SITE_TITLE: 'ぼざクリ タグ広場'}
}
export const API_BASE_URL = config.API_BASE_URL export const API_BASE_URL = config.API_BASE_URL
export const SITE_TITLE = config.SITE_TITLE export const SITE_TITLE = config.SITE_TITLE
+5 -10
ファイルの表示
@@ -10,8 +10,7 @@ export const CATEGORIES = [
'general', 'general',
'material', 'material',
'meta', 'meta',
'nico', 'nico'] as const
] as const
export const CATEGORY_NAMES: Record<Category, string> = { export const CATEGORY_NAMES: Record<Category, string> = {
deerjikist: 'ニジラー', deerjikist: 'ニジラー',
@@ -20,16 +19,14 @@ export const CATEGORY_NAMES: Record<Category, string> = {
general: '一般', general: '一般',
material: '素材', material: '素材',
meta: 'メタタグ', meta: 'メタタグ',
nico: 'ニコニコタグ', nico: 'ニコニコタグ'} as const
} as const
export const FETCH_POSTS_ORDER_FIELDS = [ export const FETCH_POSTS_ORDER_FIELDS = [
'title', 'title',
'url', 'url',
'original_created_at', 'original_created_at',
'created_at', 'created_at',
'updated_at', 'updated_at'] as const
] as const
export const PLATFORMS = ['nico', 'youtube'] as const export const PLATFORMS = ['nico', 'youtube'] as const
@@ -43,13 +40,11 @@ export const TAG_COLOUR = {
general: 'cyan', general: 'cyan',
material: 'orange', material: 'orange',
meta: 'yellow', meta: 'yellow',
nico: 'gray', nico: 'gray'} as const satisfies Record<Category, string>
} as const satisfies Record<Category, string>
export const USER_ROLES = ['admin', 'member', 'guest'] as const export const USER_ROLES = ['admin', 'member', 'guest'] as const
export const ViewFlagBehavior = { export const ViewFlagBehavior = {
OnShowedDetail: 1, OnShowedDetail: 1,
OnClickedLink: 2, OnClickedLink: 2,
NotAuto: 3, NotAuto: 3} as const
} as const
+6 -12
ファイルの表示
@@ -23,8 +23,7 @@ const apiP = async <T> (
method: 'post' | 'put' | 'patch', method: 'post' | 'put' | 'patch',
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => {
): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt)) const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (opt?.responseType === 'blob') if (opt?.responseType === 'blob')
return res.data as T return res.data as T
@@ -34,8 +33,7 @@ const apiP = async <T> (
export const apiGet = async <T> ( export const apiGet = async <T> (
path: string, path: string,
opt?: Opt, opt?: Opt): Promise<T> => {
): Promise<T> => {
const res = await client.get (path, withUserCode (opt)) const res = await client.get (path, withUserCode (opt))
if (opt?.responseType === 'blob') if (opt?.responseType === 'blob')
return res.data as T return res.data as T
@@ -46,28 +44,24 @@ export const apiGet = async <T> (
export const apiPost = async <T> ( export const apiPost = async <T> (
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
): Promise<T> => apiP ('post', path, body, opt)
export const apiPut = async <T> ( export const apiPut = async <T> (
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
): Promise<T> => apiP ('put', path, body, opt)
export const apiPatch = async <T> ( export const apiPatch = async <T> (
path: string, path: string,
body?: unknown, body?: unknown,
opt?: Opt, opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async <T = void> ( export const apiDelete = async <T = void> (
path: string, path: string,
opt?: Opt, opt?: Opt): Promise<T> => {
): Promise<T> => {
const res = await client.delete (path, withUserCode (opt)) const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '') if (res.data == null || res.data === '')
return undefined as T return undefined as T
+14 -28
ファイルの表示
@@ -104,8 +104,7 @@ export type BuildGekanatorQuestionsOptions = {
export const normalizeTitleLengthCondition = ( export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition, condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
): GekanatorQuestionCondition => {
switch (condition.type) switch (condition.type)
{ {
case 'title-length-greater-than': case 'title-length-greater-than':
@@ -119,8 +118,7 @@ export const normalizeTitleLengthCondition = (
export const titleLengthMinimumForCondition = ( export const titleLengthMinimumForCondition = (
condition: GekanatorQuestionCondition, condition: GekanatorQuestionCondition): number | null => {
): number | null => {
switch (condition.type) switch (condition.type)
{ {
case 'title-length-at-least': case 'title-length-at-least':
@@ -134,8 +132,7 @@ export const titleLengthMinimumForCondition = (
export const questionIdForCondition = ( export const questionIdForCondition = (
condition: NonPostSimilarityCondition, condition: NonPostSimilarityCondition): string => {
): string => {
switch (condition.type) switch (condition.type)
{ {
case 'tag': case 'tag':
@@ -161,8 +158,7 @@ export const questionIdForCondition = (
const directExampleAnswerFor = ( const directExampleAnswerFor = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion,
post: Post, post: Post): GekanatorAnswerValue | null => {
): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity' && question.kind !== 'tag') if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null return null
@@ -178,15 +174,13 @@ const directExampleAnswerFor = (
export const isLearnedSemanticQuestion = ( export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion, question: StoredGekanatorQuestion | GekanatorQuestion): boolean =>
): boolean =>
question.kind === 'post_similarity' question.kind === 'post_similarity'
&& question.source === 'user_suggested' && question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = ( export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null, answer: GekanatorAnswerValue | null): LearnedSemanticSide => {
): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial') if (answer === 'yes' || answer === 'partial')
return 'positive' return 'positive'
@@ -314,8 +308,7 @@ const questionableTag = (post: Post, key: string): boolean => {
const questionMatches = ( const questionMatches = (
post: Post, post: Post,
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion): boolean => {
): boolean => {
const directAnswer = directExampleAnswerFor (question, post) const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer) if (directAnswer)
return question.kind === 'post_similarity' return question.kind === 'post_similarity'
@@ -350,8 +343,7 @@ const questionMatches = (
export const expectedAnswerForQuestion = ( export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined, question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null, post: Post | null): GekanatorAnswerValue | null => {
): GekanatorAnswerValue | null => {
if (!(question) || !(post)) if (!(question) || !(post))
return null return null
@@ -382,14 +374,12 @@ export const expectedAnswerForQuestion = (
export const learnedSemanticSideForPost = ( export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined, question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null, post: Post | null): LearnedSemanticSide =>
): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post)) learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = ( export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion, question: StoredGekanatorQuestion): GekanatorQuestion => {
): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition) const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = { const normalizedQuestion = {
...question, ...question,
@@ -408,8 +398,7 @@ export const restoreGekanatorQuestion = (
export const storeGekanatorQuestion = ( export const storeGekanatorQuestion = (
question: GekanatorQuestion, question: GekanatorQuestion): StoredGekanatorQuestion => ({
): StoredGekanatorQuestion => ({
id: question.condition.type === 'title-length-greater-than' id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }` ? `title:length-at-least:${ question.condition.length + 1 }`
: question.id, : question.id,
@@ -436,8 +425,7 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
export const fetchGekanatorExtraQuestions = async ( export const fetchGekanatorExtraQuestions = async (
gameId: number, gameId: number,
nonce?: string, nonce?: string): Promise<GekanatorExtraQuestion[]> => {
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`, `/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined }) { params: nonce ? { nonce } : undefined })
@@ -447,8 +435,7 @@ export const fetchGekanatorExtraQuestions = async (
export const buildGekanatorQuestions = ( export const buildGekanatorQuestions = (
posts: Post[], posts: Post[],
options: BuildGekanatorQuestionsOptions = { }, options: BuildGekanatorQuestionsOptions = { }): GekanatorQuestion[] => {
): GekanatorQuestion[] => {
const { const {
includeTitleContains = true, includeTitleContains = true,
tagQuestionCap = 192, tagQuestionCap = 192,
@@ -490,8 +477,7 @@ export const buildGekanatorQuestions = (
const usefulEntries = <T extends string | number> ( const usefulEntries = <T extends string | number> (
counts: Map<T, number>, counts: Map<T, number>,
cap: number, cap: number) =>
) =>
[...counts.entries ()] [...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length) .filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1]) .sort ((a, b) => Math.abs (posts.length / 2 - a[1])
+4 -8
ファイルの表示
@@ -32,8 +32,7 @@ export const candidatePostsFor = (
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string> softenedQuestionIds: Set<string>
rejectedPostIds: Set<number> rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }, recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): Post[] => {
): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question])) const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => { return posts.filter (post => {
@@ -76,8 +75,7 @@ export const candidatePostsFor = (
export const hardFilteredPostsForAnswer = ( export const hardFilteredPostsForAnswer = (
{ posts, question, answer }: { posts: Post[] { posts, question, answer }: { posts: Post[]
question: GekanatorQuestion question: GekanatorQuestion
answer: GekanatorAnswerValue }, answer: GekanatorAnswerValue }): Post[] => {
): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts return posts
@@ -98,8 +96,7 @@ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', '
export const allConcreteAnswerOptionsExhausted = ( export const allConcreteAnswerOptionsExhausted = (
posts: Post[], posts: Post[],
question: GekanatorQuestion | null, question: GekanatorQuestion | null): boolean => {
): boolean => {
if (!(question)) if (!(question))
return false return false
@@ -125,8 +122,7 @@ export const recoverCandidatePosts = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState> recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number> eligiblePostIds: Set<number>
answerCountAtRecovery: number answerCountAtRecovery: number
recoveryStepCount: number }, recoveryStepCount: number }): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => { recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts) const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount) const targetSize = nextRecoveryTargetSize (recoveryStepCount)
+8 -16
ファイルの表示
@@ -8,8 +8,7 @@ import type {
export const monthForCondition = ( export const monthForCondition = (
condition: GekanatorQuestion['condition'], condition: GekanatorQuestion['condition']): number | null => {
): number | null => {
if (condition.type === 'original-month') if (condition.type === 'original-month')
return condition.month return condition.month
@@ -24,8 +23,7 @@ export const monthForCondition = (
const isTitleLengthContradiction = ( const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate) const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous) const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null) if (candidateLength === null || previousLength === null)
@@ -45,8 +43,7 @@ const isTitleLengthContradiction = (
const isQuestionRedundantAfterAnswers = ( const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion, question: GekanatorQuestion,
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
): boolean => answers.some (answer => {
const previous = answer.questionCondition const previous = answer.questionCondition
return previous !== undefined return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer) && isTitleLengthContradiction (question.condition, previous, answer.answer)
@@ -56,8 +53,7 @@ const isQuestionRedundantAfterAnswers = (
const isSourceFactBlocked = ( const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source') if (candidate.type !== 'source' || previous.type !== 'source')
return false return false
@@ -76,8 +72,7 @@ const isSourceFactBlocked = (
const isOriginalYearFactBlocked = ( const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year') if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false return false
@@ -96,8 +91,7 @@ const isOriginalYearFactBlocked = (
const isOriginalMonthFactBlocked = ( const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
switch (answer) switch (answer)
{ {
case 'yes': case 'yes':
@@ -143,8 +137,7 @@ const isOriginalMonthFactBlocked = (
const isFactQuestionBlocked = ( const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue, answer: GekanatorAnswerValue): boolean => {
): boolean => {
if (!(answer === 'yes' || answer === 'no')) if (!(answer === 'yes' || answer === 'no'))
return false return false
@@ -156,8 +149,7 @@ const isFactQuestionBlocked = (
export const isQuestionHardFilteredAfterAnswers = ( export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion, question: GekanatorQuestion,
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
): boolean => answers.some (answer => {
const previous = answer.questionCondition const previous = answer.questionCondition
if (previous === undefined) if (previous === undefined)
return false return false
+21 -28
ファイルの表示
@@ -8,19 +8,12 @@ import {
import type { import type {
Material, Material,
FetchMaterialsParams,
MaterialFilter, MaterialFilter,
MaterialSidebarTag, MaterialSidebarTag,
MaterialTagTree, MaterialTagTree,
} from '@/types' } from '@/types'
export type FetchMaterialsParams = {
page?: number
limit?: number
tagId?: number | null
parentId?: number | null
unclassified?: boolean
}
export type FetchMaterialTreeParams = { export type FetchMaterialTreeParams = {
parentId?: number | null parentId?: number | null
materialFilter: MaterialFilter materialFilter: MaterialFilter
@@ -36,23 +29,28 @@ const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
export const parseMaterialFilter = ( export const parseMaterialFilter = (
value: unknown, value: unknown,
fallback: MaterialFilter = 'present', fallback: MaterialFilter = 'present'): MaterialFilter =>
): MaterialFilter =>
typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter) typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter)
? value as MaterialFilter ? value as MaterialFilter
: fallback : fallback
export const fetchMaterials = async ( export const fetchMaterials = async (
{ page, limit, tagId, parentId, unclassified }: FetchMaterialsParams, { q, tagState, mediaKind, suppression, createdFrom, createdTo,
): Promise<MaterialIndexResponse> => updatedFrom, updatedTo, sort, direction, page, limit }: FetchMaterialsParams): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: { await apiGet ('/materials', { params: {
...(page != null && { page }), ...(q && { q }),
...(limit != null && { limit }), tag_state: tagState,
...(tagId != null && { tag_id: tagId }), media_kind: mediaKind,
...(parentId != null && { parent_id: parentId }), suppression,
...(unclassified && { unclassified: '1' }), ...(createdFrom && { created_from: createdFrom }),
} }) ...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }),
sort,
direction,
page,
limit} })
export const fetchMaterial = async (id: string): Promise<Material | null> => { export const fetchMaterial = async (id: string): Promise<Material | null> => {
@@ -70,18 +68,15 @@ export const fetchMaterial = async (id: string): Promise<Material | null> => {
export const fetchMaterialTagTree = async ( export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams, { parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { params: { await apiGet ('/tags/with-depth', { params: {
...(parentId != null && { parent: String (parentId) }), ...(parentId != null && { parent: String (parentId) }),
material_filter: materialFilter, material_filter: materialFilter} })
} })
export const fetchMaterialTagByName = async ( export const fetchMaterialTagByName = async (
name: string, name: string,
materialFilter: MaterialFilter, materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
): Promise<MaterialTagTree | null> => {
try try
{ {
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`, return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
@@ -102,13 +97,11 @@ export const createMaterial = async (formData: FormData): Promise<Material> =>
export const updateMaterial = async ( export const updateMaterial = async (
id: string, id: string,
formData: FormData, formData: FormData): Promise<Material> =>
): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData) await apiPut (`/materials/${ id }`, formData)
export const suppressMaterialFile = async ( export const suppressMaterialFile = async (
id: string, id: string,
payload: { reason: string; purge?: boolean }, payload: { reason: string; purge?: boolean }): Promise<Material> =>
): Promise<Material> =>
await apiPatch (`/materials/${ id }/suppress_file`, payload) await apiPatch (`/materials/${ id }/suppress_file`, payload)
+3 -6
ファイルの表示
@@ -5,8 +5,7 @@ import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async ( export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
): Promise<{
posts: Post[] posts: Post[]
count: number }> => count: number }> =>
await apiGet ('/posts', { params: { await apiGet ('/posts', { params: {
@@ -33,8 +32,7 @@ export const fetchPostChanges = async (
post?: string post?: string
tag?: string tag?: string
page: number page: number
limit: number }, limit: number }): Promise<{
): Promise<{
versions: PostVersion[] versions: PostVersion[]
count: number }> => count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }), await apiGet ('/posts/versions', { params: { ...(post && { post }),
@@ -52,8 +50,7 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: { { baseVersionNo, force, merge }: {
baseVersionNo?: number baseVersionNo?: number
force?: boolean force?: boolean
merge?: boolean } merge?: boolean }) =>
) =>
await apiPut<Post> ( await apiPut<Post> (
`/posts/${ post.id }`, `/posts/${ post.id }`,
{ title: post.title, { title: post.title,
+3 -9
ファイルの表示
@@ -1,5 +1,6 @@
import type { import type {
FetchNicoTagsParams, FetchNicoTagsParams,
FetchMaterialsParams,
FetchPostsParams, FetchPostsParams,
FetchTagsParams, FetchTagsParams,
MaterialFilter, MaterialFilter,
@@ -32,13 +33,7 @@ export const tagsKeys = {
export const materialsKeys = { export const materialsKeys = {
root: ['materials'] as const, root: ['materials'] as const,
index: (p: { index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
page?: number
limit?: number
tagId?: number | null
parentId?: number | null
unclassified?: boolean
}) => ['materials', 'index', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) => byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const, ['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const, show: (id: string) => ['materials', id] as const,
@@ -47,8 +42,7 @@ export const materialsKeys = {
materialFilter: MaterialFilter materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const, }) => ['materials', 'tree', p] as const,
unclassified: (p: { page?: number; limit?: number } = { }) => unclassified: (p: { page?: number; limit?: number } = { }) =>
['materials', 'unclassified', p] as const, ['materials', 'unclassified', p] as const}
}
export const wikiKeys = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,
+4 -8
ファイルの表示
@@ -11,8 +11,7 @@ import type { Deerjikist,
export const fetchTags = async ( export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated, updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams, page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
): Promise<{ tags: Tag[]
count: number }> => count: number }> =>
await apiGet ('/tags', { params: { await apiGet ('/tags', { params: {
...(post != null && { post }), ...(post != null && { post }),
@@ -31,8 +30,7 @@ export const fetchTags = async (
export const fetchNicoTags = async ( export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams, { name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[]
): Promise<{ tags: NicoTag[]
count: number }> => count: number }> =>
await apiGet ('/tags/nico', { params: { await apiGet ('/tags/nico', { params: {
page, page,
@@ -70,14 +68,12 @@ export const fetchTagChanges = async (
{ id, page, limit }: { { id, page, limit }: {
id?: string id?: string
page: number page: number
limit: number }, limit: number }): Promise<{
): Promise<{
versions: TagVersion[] versions: TagVersion[]
count: number }> => count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })
export const fetchDeerjikistsByTag = async ( export const fetchDeerjikistsByTag = async (
id: string, id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> =>
await apiGet (`/tags/${ id }/deerjikists`) await apiGet (`/tags/${ id }/deerjikists`)
+1 -2
ファイルの表示
@@ -4,5 +4,4 @@ const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = ( export const canEditContent = (
user: Pick<User, 'role'> | null | undefined, user: Pick<User, 'role'> | null | undefined): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
+2 -4
ファイルの表示
@@ -12,8 +12,7 @@ export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
export const dateString = ( export const dateString = (
d: string | Date, d: string | Date,
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null, unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
): string =>
toDate (d).toLocaleString ( toDate (d).toLocaleString (
'ja-JP-u-ca-japanese', 'ja-JP-u-ca-japanese',
{ era: 'long', { era: 'long',
@@ -28,8 +27,7 @@ export const dateString = (
export const originalCreatedAtString = ( export const originalCreatedAtString = (
f: string | Date | null, f: string | Date | null,
b: string | Date | null, b: string | Date | null): string => {
): string => {
const from = f ? toDate (f) : null const from = f ? toDate (f) : null
const before = b ? toDate (b) : null const before = b ? toDate (b) : null
+3 -6
ファイルの表示
@@ -4,22 +4,19 @@ import type { WikiPage } from '@/types'
export const fetchWikiPages = async ( export const fetchWikiPages = async (
{ title }: { title?: string }, { title }: { title?: string }): Promise<WikiPage[]> =>
): Promise<WikiPage[]> =>
await apiGet ('/wiki', { params: { title } }) await apiGet ('/wiki', { params: { title } })
export const fetchWikiPage = async ( export const fetchWikiPage = async (
id: string, id: string,
{ version }: { version?: string }, { version }: { version?: string }): Promise<WikiPage> =>
): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } }) await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
export const fetchWikiPageByTitle = async ( export const fetchWikiPageByTitle = async (
title: string, title: string,
{ version }: { version?: string }, { version }: { version?: string }): Promise<WikiPage | null> => {
): Promise<WikiPage | null> => {
try try
{ {
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
+73 -146
ファイルの表示
@@ -238,8 +238,7 @@ const createGameSeed = (): string => {
const normalizeStoredQuestionId = ( const normalizeStoredQuestionId = (
questionId: string, questionId: string,
condition?: GekanatorQuestionCondition, condition?: GekanatorQuestionCondition): string => {
): string => {
if (condition?.type === 'title-length-greater-than') if (condition?.type === 'title-length-greater-than')
return `title:length-at-least:${ condition.length + 1 }` return `title:length-at-least:${ condition.length + 1 }`
@@ -312,8 +311,7 @@ const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
const shouldReplaceMergedQuestion = ( const shouldReplaceMergedQuestion = (
current: GekanatorQuestion | undefined, current: GekanatorQuestion | undefined,
candidate: GekanatorQuestion, candidate: GekanatorQuestion): boolean => {
): boolean => {
if (!(current)) if (!(current))
return true return true
@@ -408,8 +406,7 @@ const loadRecentGames = (): RecentGameSummary[] => {
const storeRecentGameSummary = ( const storeRecentGameSummary = (
summary: RecentGameSummary, summary: RecentGameSummary): RecentGameSummary[] => {
): RecentGameSummary[] => {
const next = const next =
[summary, [summary,
...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt ...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt
@@ -459,8 +456,7 @@ const resettableExtraQuestionState = (): {
const recoveredCandidateMapFromStored = ( const recoveredCandidateMapFromStored = (
items: RecoveredCandidatePost[], items: RecoveredCandidatePost[],
scores: [number, number][], scores: [number, number][]): Map<number, RecoveredCandidateState> => {
): Map<number, RecoveredCandidateState> => {
const storedScores = new Map (scores) const storedScores = new Map (scores)
return new Map (items.map (item => [item.postId, { return new Map (items.map (item => [item.postId, {
@@ -470,8 +466,7 @@ const recoveredCandidateMapFromStored = (
const storedRecoveredCandidatesFromMap = ( const storedRecoveredCandidatesFromMap = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>, recoveredCandidatePosts: Map<number, RecoveredCandidateState>): RecoveredCandidatePost[] =>
): RecoveredCandidatePost[] =>
[...recoveredCandidatePosts.entries ()] [...recoveredCandidatePosts.entries ()]
.map (([postId, recoveredCandidate]) => ({ .map (([postId, recoveredCandidate]) => ({
postId, postId,
@@ -513,8 +508,7 @@ const distributionEntropy = (weights: number[]): number =>
const questionCategoryPenalty = ( const questionCategoryPenalty = (
question: GekanatorQuestion, question: GekanatorQuestion,
answerCount: number, answerCount: number,
repeatPenalty: number, repeatPenalty: number): number => {
): number => {
const earlyFactor = Math.max (0, (3 - answerCount) / 3) const earlyFactor = Math.max (0, (3 - answerCount) / 3)
const titleLengthPenalty = (() => { const titleLengthPenalty = (() => {
if (titleLengthMinimumForCondition (question.condition) == null) if (titleLengthMinimumForCondition (question.condition) == null)
@@ -553,8 +547,7 @@ const relatedPostIdsOf = (post: Post): number[] => {
const userPriorWeightsFor = ( const userPriorWeightsFor = (
posts: Post[], posts: Post[],
recentGames: RecentGameSummary[], recentGames: RecentGameSummary[]): Map<number, number> => {
): Map<number, number> => {
const postById = new Map (posts.map (post => [post.id, post])) const postById = new Map (posts.map (post => [post.id, post]))
const weights = new Map<number, number> () const weights = new Map<number, number> ()
const addWeight = (postId: number, weight: number) => { const addWeight = (postId: number, weight: number) => {
@@ -581,14 +574,12 @@ const userPriorWeightsFor = (
const answerWeightFor = ( const answerWeightFor = (
questionId: string, questionId: string,
softenedQuestionIds: Set<string>, softenedQuestionIds: Set<string>): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
const scoreWeightForAnswer = ( const scoreWeightForAnswer = (
answer: GekanatorAnswerLog, answer: GekanatorAnswerLog,
softenedQuestionIds: Set<string>, softenedQuestionIds: Set<string>): number =>
): number =>
answerWeightFor (answer.questionId, softenedQuestionIds) answerWeightFor (answer.questionId, softenedQuestionIds)
* ( * (
answer.questionPurpose === 'learning_user_suggested' answer.questionPurpose === 'learning_user_suggested'
@@ -638,8 +629,7 @@ const titleTermPattern =
const addPostIdToIndex = <K extends string | number> ( const addPostIdToIndex = <K extends string | number> (
index: Map<K, Set<number>>, index: Map<K, Set<number>>,
key: K, key: K,
postId: number, postId: number) => {
) => {
const current = index.get (key) const current = index.get (key)
if (current) if (current)
{ {
@@ -652,8 +642,7 @@ const addPostIdToIndex = <K extends string | number> (
const buildMaterialIndex = ( const buildMaterialIndex = (
posts: Post[], posts: Post[]): GekanatorQuestionMaterialIndex => {
): GekanatorQuestionMaterialIndex => {
const postById = new Map<number, Post> () const postById = new Map<number, Post> ()
const tagKeysByPostId = new Map<number, string[]> () const tagKeysByPostId = new Map<number, string[]> ()
const postIdsByTagKey = new Map<string, Set<number>> () const postIdsByTagKey = new Map<string, Set<number>> ()
@@ -784,8 +773,7 @@ const originalDateQuestionTextFor = (
condition: Extract< condition: Extract<
GekanatorQuestionCondition, GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' } { type: 'original-year' | 'original-month' | 'original-month-day' }
>, >): string => {
): string => {
switch (condition.type) switch (condition.type)
{ {
case 'original-year': case 'original-year':
@@ -852,8 +840,7 @@ const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:'))
const isUserSuggestedLearnedSemanticQuestion = ( const isUserSuggestedLearnedSemanticQuestion = (
question: GekanatorQuestion, question: GekanatorQuestion): boolean => isLearnedSemanticQuestion (question)
): boolean => isLearnedSemanticQuestion (question)
type LearnedSemanticCandidateStats = { type LearnedSemanticCandidateStats = {
@@ -872,8 +859,7 @@ const learnedSemanticStatsForCandidateIds = (
question }: { question }: {
candidateIds: number[] candidateIds: number[]
posts: Post[] posts: Post[]
question: GekanatorQuestion }, question: GekanatorQuestion }): LearnedSemanticCandidateStats => {
): LearnedSemanticCandidateStats => {
const candidateIdSet = new Set (candidateIds) const candidateIdSet = new Set (candidateIds)
const positiveIds = new Set<number> () const positiveIds = new Set<number> ()
const negativeIds = new Set<number> () const negativeIds = new Set<number> ()
@@ -915,8 +901,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
question }: { question }: {
candidateIds: number[] candidateIds: number[]
posts: Post[] posts: Post[]
question: GekanatorQuestion }, question: GekanatorQuestion }): boolean => {
): boolean => {
if (!(isUserSuggestedLearnedSemanticQuestion (question))) if (!(isUserSuggestedLearnedSemanticQuestion (question)))
return false return false
@@ -936,8 +921,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
const directSemanticAnswerForPost = ( const directSemanticAnswerForPost = (
question: GekanatorQuestion, question: GekanatorQuestion,
post: Post, post: Post): GekanatorAnswerValue | null => {
): GekanatorAnswerValue | null => {
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct) if (direct)
return direct return direct
@@ -956,8 +940,7 @@ const learnedSemanticLearningValueForTopPosts = (
question: GekanatorQuestion question: GekanatorQuestion
learningTargetPosts: Post[] learningTargetPosts: Post[]
candidateIds: number[] candidateIds: number[]
posts: Post[] }, posts: Post[] }): { missingTopCount: number
): { missingTopCount: number
knownCount: number knownCount: number
hasLearningValue: boolean } => { hasLearningValue: boolean } => {
const missingTopCount = const missingTopCount =
@@ -1000,8 +983,7 @@ const learningTargetPostsForCandidates = ({
const questionPurposeCountsFor = ( const questionPurposeCountsFor = (
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[]): {
): {
effectiveUserSuggestedCount: number effectiveUserSuggestedCount: number
learningUserSuggestedCount: number learningUserSuggestedCount: number
normalQuestionCount: number normalQuestionCount: number
@@ -1041,8 +1023,7 @@ const questionPurposeCountsFor = (
const learnedSemanticNarrowPenaltyForStats = ( const learnedSemanticNarrowPenaltyForStats = (
candidateCount: number, candidateCount: number,
stats: LearnedSemanticCandidateStats, stats: LearnedSemanticCandidateStats): number => {
): number => {
const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08) const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08)
return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0 return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0
} }
@@ -1050,8 +1031,7 @@ const learnedSemanticNarrowPenaltyForStats = (
const learnedSemanticScoreDeltaForExpectedAnswer = ( const learnedSemanticScoreDeltaForExpectedAnswer = (
userAnswer: GekanatorAnswerValue, userAnswer: GekanatorAnswerValue,
expectedAnswer: GekanatorAnswerValue | null, expectedAnswer: GekanatorAnswerValue | null): number => {
): number => {
switch (userAnswer) switch (userAnswer)
{ {
case 'yes': case 'yes':
@@ -1089,8 +1069,7 @@ const learnedSemanticScoreDeltaForExpectedAnswer = (
const scoreDropDeltaForRecoveredPost = ( const scoreDropDeltaForRecoveredPost = (
postId: number, postId: number,
totalScore: number, totalScore: number,
recoveredCandidatePosts: Map<number, RecoveredCandidateState>, recoveredCandidatePosts: Map<number, RecoveredCandidateState>): number => {
): number => {
const recoveredCandidate = recoveredCandidatePosts.get (postId) const recoveredCandidate = recoveredCandidatePosts.get (postId)
if (recoveredCandidate == null) if (recoveredCandidate == null)
return totalScore return totalScore
@@ -1114,8 +1093,7 @@ const postPassesScoreDrop = (
recoveredCandidatePosts }: { recoveredCandidatePosts }: {
postId: number postId: number
scores: Map<number, number> scores: Map<number, number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }, recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): boolean => {
): boolean => {
if (!(activeCandidateScoreDropEnabled (scores))) if (!(activeCandidateScoreDropEnabled (scores)))
return true return true
@@ -1129,8 +1107,7 @@ const postPassesScoreDrop = (
// `post_similarities` is the score-propagation graph, not the question kind. // `post_similarities` is the score-propagation graph, not the question kind.
const questionUsesPostSimilarityPropagationGraphForScoring = ( const questionUsesPostSimilarityPropagationGraphForScoring = (
question: GekanatorQuestion, question: GekanatorQuestion): boolean =>
): boolean =>
(question.kind === 'post_similarity' (question.kind === 'post_similarity'
&& !(isUserSuggestedLearnedSemanticQuestion (question))) && !(isUserSuggestedLearnedSemanticQuestion (question)))
|| (question.kind === 'tag' || (question.kind === 'tag'
@@ -1139,8 +1116,7 @@ const questionUsesPostSimilarityPropagationGraphForScoring = (
const questionSupportsAnswerBasedHardFiltering = ( const questionSupportsAnswerBasedHardFiltering = (
question: GekanatorQuestion, question: GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
&& !(isUserSuggestedLearnedSemanticQuestion (question)) && !(isUserSuggestedLearnedSemanticQuestion (question))
@@ -1160,8 +1136,7 @@ const usesLearnedTagExamples = (question: GekanatorQuestion): boolean =>
const searchedQuestionsFor = ( const searchedQuestionsFor = (
questions: GekanatorQuestion[], questions: GekanatorQuestion[],
search: string, search: string): GekanatorQuestion[] => {
): GekanatorQuestion[] => {
const needle = search.trim () const needle = search.trim ()
if (!(needle)) if (!(needle))
return [] return []
@@ -1235,8 +1210,7 @@ type QuestionMatchResolver = {
const buildGekanatorMatchIndex = ( const buildGekanatorMatchIndex = (
posts: Post[], posts: Post[],
questions: GekanatorQuestion[], questions: GekanatorQuestion[]): GekanatorMatchIndex => new Map (
): GekanatorMatchIndex => new Map (
questions.map (question => [ questions.map (question => [
question.id, question.id,
new Set ( new Set (
@@ -1285,8 +1259,7 @@ const matchingPostIdsForQuestion = ({
const positiveMatchingPostIdsForQuestion = ( const positiveMatchingPostIdsForQuestion = (
resolver: QuestionMatchResolver, resolver: QuestionMatchResolver): Set<number> => {
): Set<number> => {
if (isUserSuggestedLearnedSemanticQuestion (resolver.question)) if (isUserSuggestedLearnedSemanticQuestion (resolver.question))
{ {
const cached = resolver.dynamicMatchIndex?.get (resolver.question.id) const cached = resolver.dynamicMatchIndex?.get (resolver.question.id)
@@ -1356,8 +1329,7 @@ const matchingWeightInCandidates = (
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
question: GekanatorQuestion question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex }, dynamicMatchIndex?: GekanatorMatchIndex }): number => {
): number => {
const matched = positiveMatchingPostIdsForQuestion ({ const matched = positiveMatchingPostIdsForQuestion ({
posts, posts,
materialIndex, materialIndex,
@@ -1380,8 +1352,7 @@ const signatureForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
question: GekanatorQuestion question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex }, dynamicMatchIndex?: GekanatorMatchIndex }): string => {
): string => {
if (isUserSuggestedLearnedSemanticQuestion (question)) if (isUserSuggestedLearnedSemanticQuestion (question))
{ {
const postById = new Map (posts.map (post => [post.id, post])) const postById = new Map (posts.map (post => [post.id, post]))
@@ -1421,8 +1392,7 @@ const postIdsForHardAnswer = (
posts: Post[] posts: Post[]
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
dynamicMatchIndex?: GekanatorMatchIndex }, dynamicMatchIndex?: GekanatorMatchIndex }): number[] => {
): number[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question))) if (!(questionSupportsAnswerBasedHardFiltering (question)))
return candidateIds return candidateIds
@@ -1562,8 +1532,7 @@ const buildIndexedQuestion = (
text: string text: string
kind: GekanatorQuestionKind kind: GekanatorQuestionKind
priorityWeight: number priorityWeight: number
materialIndex: GekanatorQuestionMaterialIndex }, materialIndex: GekanatorQuestionMaterialIndex }): GekanatorQuestion => ({
): GekanatorQuestion => ({
id: questionIdForCondition (condition), id: questionIdForCondition (condition),
text, text,
kind, kind,
@@ -1578,8 +1547,7 @@ const buildIndexedQuestion = (
const rankedEntriesForCounts = <T extends string | number> ( const rankedEntriesForCounts = <T extends string | number> (
{ counts, total, cap }: { counts: Map<T, number> { counts, total, cap }: { counts: Map<T, number>
total: number total: number
cap: number }, cap: number }): [T, number][] =>
): [T, number][] =>
([...counts.entries ()] ([...counts.entries ()]
.filter (([, count]) => count > 0 && count < total) .filter (([, count]) => count > 0 && count < total)
.sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1])) .sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1]))
@@ -1594,8 +1562,7 @@ const buildQuestionsForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
acceptedQuestions: GekanatorQuestion[] acceptedQuestions: GekanatorQuestion[]
mode?: QuestionBuildMode mode?: QuestionBuildMode
confirmationPostId?: number | null }, confirmationPostId?: number | null }): GekanatorQuestion[] => {
): GekanatorQuestion[] => {
const total = candidateIds.length const total = candidateIds.length
const confirmationPost = (() => { const confirmationPost = (() => {
if (confirmationPostId == null) if (confirmationPostId == null)
@@ -1652,8 +1619,7 @@ const buildQuestionsForCandidateIds = (
condition: Extract< condition: Extract<
GekanatorQuestionCondition, GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' } { type: 'original-year' | 'original-month' | 'original-month-day' }
>, >): GekanatorQuestion => {
): GekanatorQuestion => {
const priorityWeight = (() => { const priorityWeight = (() => {
if (condition.type === 'original-year') if (condition.type === 'original-year')
return 1.04 return 1.04
@@ -1673,8 +1639,7 @@ const buildQuestionsForCandidateIds = (
const specialMonthDays = rankedEntriesForCounts ({ const specialMonthDays = rankedEntriesForCounts ({
counts: monthDayCounts, counts: monthDayCounts,
total, total,
cap: factCap cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
if (mode === 'split') if (mode === 'split')
{ {
@@ -2124,8 +2089,7 @@ type ExclusiveConditionGroup =
const exclusiveConditionGroupFor = ( const exclusiveConditionGroupFor = (
condition: GekanatorQuestion['condition'], condition: GekanatorQuestion['condition']): ExclusiveConditionGroup | null => {
): ExclusiveConditionGroup | null => {
switch (condition.type) switch (condition.type)
{ {
case 'original-month': case 'original-month':
@@ -2144,8 +2108,7 @@ const exclusiveConditionGroupFor = (
const sameConditionValue = ( const sameConditionValue = (
left: GekanatorQuestion['condition'], left: GekanatorQuestion['condition'],
right: GekanatorQuestion['condition'], right: GekanatorQuestion['condition']): boolean => {
): boolean => {
const leftTitleLength = titleLengthMinimumForCondition (left) const leftTitleLength = titleLengthMinimumForCondition (left)
const rightTitleLength = titleLengthMinimumForCondition (right) const rightTitleLength = titleLengthMinimumForCondition (right)
if (leftTitleLength != null || rightTitleLength != null) if (leftTitleLength != null || rightTitleLength != null)
@@ -2187,8 +2150,7 @@ const sameConditionValue = (
const isMonthCrossMatch = ( const isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition']): boolean => {
): boolean => {
const candidateMonth = monthForCondition (candidate) const candidateMonth = monthForCondition (candidate)
const previousMonth = monthForCondition (previous) const previousMonth = monthForCondition (previous)
if (candidateMonth == null || previousMonth == null) if (candidateMonth == null || previousMonth == null)
@@ -2204,8 +2166,7 @@ const isMonthCrossMatch = (
const isExclusiveContradiction = ( const isExclusiveContradiction = (
candidate: GekanatorQuestion['condition'], candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition']): boolean => {
): boolean => {
const candidateGroup = exclusiveConditionGroupFor (candidate) const candidateGroup = exclusiveConditionGroupFor (candidate)
const previousGroup = exclusiveConditionGroupFor (previous) const previousGroup = exclusiveConditionGroupFor (previous)
@@ -2242,16 +2203,14 @@ const contradictionPenaltyFor = ({
case 'no': case 'no':
if ( if (
sameConditionValue (question.condition, previous) sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous) || isMonthCrossMatch (question.condition, previous))
)
return sum + 40 return sum + 40
return sum return sum
case 'probably_no': case 'probably_no':
if ( if (
sameConditionValue (question.condition, previous) sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous) || isMonthCrossMatch (question.condition, previous))
)
return sum + 20 return sum + 20
return sum return sum
@@ -2281,8 +2240,7 @@ const chooseQuestion = (
recentFirstQuestionPenaltyById: Map<string, number> recentFirstQuestionPenaltyById: Map<string, number>
userPriorWeights: Map<number, number> userPriorWeights: Map<number, number>
materialIndex: GekanatorQuestionMaterialIndex materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex }, matchIndex: GekanatorMatchIndex }): QuestionSelection | null => {
): QuestionSelection | null => {
const dynamicMatchIndex = new Map<string, Set<number>> () const dynamicMatchIndex = new Map<string, Set<number>> ()
const invertedSignature = (signature: string): string => const invertedSignature = (signature: string): string =>
@@ -2327,8 +2285,7 @@ const chooseQuestion = (
const rank = ( const rank = (
questionsToRank: GekanatorQuestion[], questionsToRank: GekanatorQuestion[],
candidates: { post: Post; score: number }[], candidates: { post: Post; score: number }[],
weightedCandidates: { post: Post; score: number; weight: number }[], weightedCandidates: { post: Post; score: number; weight: number }[]) => {
) => {
const redundant = redundantSignatures (candidates.map (item => item.post)) const redundant = redundantSignatures (candidates.map (item => item.post))
const candidateById = new Map (candidates.map (item => [item.post.id, item.post])) const candidateById = new Map (candidates.map (item => [item.post.id, item.post]))
const candidateIds = candidates.map (item => item.post.id) const candidateIds = candidates.map (item => item.post.id)
@@ -2613,8 +2570,7 @@ const chooseQuestion = (
if ( if (
effectiveRatio < targetEffectiveUserSuggestedQuestionRatio effectiveRatio < targetEffectiveUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& effectiveUserSuggestedPool.length > 0 && effectiveUserSuggestedPool.length > 0)
)
{ {
selectedPool = effectiveUserSuggestedPool selectedPool = effectiveUserSuggestedPool
selectedPurpose = 'effective_user_suggested' selectedPurpose = 'effective_user_suggested'
@@ -2622,8 +2578,7 @@ const chooseQuestion = (
else if ( else if (
learningRatio < targetLearningUserSuggestedQuestionRatio learningRatio < targetLearningUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& learningUserSuggestedPool.length > 0 && learningUserSuggestedPool.length > 0)
)
{ {
selectedPool = learningUserSuggestedPool selectedPool = learningUserSuggestedPool
selectedPurpose = 'learning_user_suggested' selectedPurpose = 'learning_user_suggested'
@@ -2683,8 +2638,7 @@ const chooseQuestion = (
const winningRunPriorityFor = ( const winningRunPriorityFor = (
expected: GekanatorAnswerValue, expected: GekanatorAnswerValue): number | null => {
): number | null => {
if (expected === 'yes') if (expected === 'yes')
return 0 return 0
if (expected === 'partial') if (expected === 'partial')
@@ -2719,8 +2673,7 @@ const chooseWinningRunQuestion = ({
materialIndex, materialIndex,
acceptedQuestions, acceptedQuestions,
mode: 'confirmation', mode: 'confirmation',
confirmationPostId: targetPost.id confirmationPostId: targetPost.id})
})
.filter (question => { .filter (question => {
if (askedIds.has (question.id)) if (askedIds.has (question.id))
return false return false
@@ -2829,8 +2782,7 @@ const chooseFallbackQuestion = ({
materialIndex, materialIndex,
acceptedQuestions: [], acceptedQuestions: [],
mode: 'confirmation', mode: 'confirmation',
confirmationPostId: post.id confirmationPostId: post.id})))
})))
.slice (0, 32) .slice (0, 32)
const dynamicMatchIndex = new Map<string, Set<number>> () const dynamicMatchIndex = new Map<string, Set<number>> ()
const ranked = mergeQuestions ([ const ranked = mergeQuestions ([
@@ -2905,8 +2857,7 @@ const chooseFallbackQuestion = ({
const shouldEnterGuessPhase = ( const shouldEnterGuessPhase = (
reason: GuessReason | null, reason: GuessReason | null): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
(reason === 'hard_max_questions' (reason === 'hard_max_questions'
|| reason === 'winning_run_finished' || reason === 'winning_run_finished'
|| reason === 'question_count_checkpoint') || reason === 'question_count_checkpoint')
@@ -2914,15 +2865,13 @@ const shouldEnterGuessPhase = (
const isWinningRunActive = ( const isWinningRunActive = (
winningRunTargetId: number | null, winningRunTargetId: number | null,
winningRunStartAnswerCount: number | null, winningRunStartAnswerCount: number | null): boolean =>
): boolean =>
winningRunTargetId != null && winningRunStartAnswerCount != null winningRunTargetId != null && winningRunStartAnswerCount != null
const winningRunQuestionCount = ( const winningRunQuestionCount = (
answers: GekanatorAnswerLog[], answers: GekanatorAnswerLog[],
winningRunStartAnswerCount: number | null, winningRunStartAnswerCount: number | null): number => {
): number => {
if (winningRunStartAnswerCount == null) if (winningRunStartAnswerCount == null)
return 0 return 0
@@ -2962,8 +2911,7 @@ const nextQuestionPlanFor = (
matchIndex: GekanatorMatchIndex matchIndex: GekanatorMatchIndex
lastGuessQuestionCount: number lastGuessQuestionCount: number
winningRunTargetId: number | null winningRunTargetId: number | null
winningRunStartAnswerCount: number | null }, winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null
): { question: GekanatorQuestion | null
guess: Post | null guess: Post | null
guessReason: GuessReason | null guessReason: GuessReason | null
questionMode: QuestionMode questionMode: QuestionMode
@@ -3013,8 +2961,7 @@ const nextQuestionPlanFor = (
if ( if (
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
&& winningRunTargetId === nextWinningRunTargetId && winningRunTargetId === nextWinningRunTargetId
&& winningRunStartAnswerCount != null && winningRunStartAnswerCount != null)
)
return winningRunStartAnswerCount return winningRunStartAnswerCount
return answers.length return answers.length
@@ -3188,8 +3135,7 @@ const mascotStateFor = (
resultWon: boolean | null, resultWon: boolean | null,
eligiblePostCount: number, eligiblePostCount: number,
bestConfidencePercent: number, bestConfidencePercent: number,
winningRunActive: boolean, winningRunActive: boolean): MascotState => {
): MascotState => {
const resultPhase = const resultPhase =
phase === 'end' phase === 'end'
|| phase === 'review' || phase === 'review'
@@ -3210,13 +3156,11 @@ const mascotStateFor = (
if ( if (
winningRunActive winningRunActive
|| eligiblePostCount <= 2 || eligiblePostCount <= 2
|| bestConfidencePercent >= 70 || bestConfidencePercent >= 70)
)
return 'thinking_near' return 'thinking_near'
if ( if (
eligiblePostCount >= 15 eligiblePostCount >= 15
&& bestConfidencePercent < 45 && bestConfidencePercent < 45)
)
return 'thinking_far' return 'thinking_far'
return 'thinking_mid' return 'thinking_mid'
case 'guess': case 'guess':
@@ -3327,8 +3271,7 @@ const GekanatorBackdrop: FC<{
const settingsForMode = useCallback ( const settingsForMode = useCallback (
( (
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess'): { columns: number; rows: number; opacity: number } => {
): { columns: number; rows: number; opacity: number } => {
if (mode === 'winning_run' || mode === 'guess') if (mode === 'winning_run' || mode === 'guess')
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 } return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
@@ -3342,8 +3285,7 @@ const GekanatorBackdrop: FC<{
const scaleForMode = useCallback ( const scaleForMode = useCallback (
( (
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess',
displayedWinningCount: number, displayedWinningCount: number): number => {
): number => {
if (mode === 'guess') if (mode === 'guess')
return 8 return 8
@@ -3355,8 +3297,7 @@ const GekanatorBackdrop: FC<{
[]) [])
const postsForMode = useCallback (( const postsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess'): Post[] => {
): Post[] => {
if (mode === 'guess' && displayedGuess) if (mode === 'guess' && displayedGuess)
return [displayedGuess] return [displayedGuess]
if (mode === 'winning_run' && winningRunTargetPost) if (mode === 'winning_run' && winningRunTargetPost)
@@ -3366,8 +3307,7 @@ const GekanatorBackdrop: FC<{
const thumbnailsForMode = useCallback (( const thumbnailsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess', mode: 'normal' | 'winning_run' | 'guess',
count: number, count: number): string[] => {
): string[] => {
const modePosts = postsForMode (mode) const modePosts = postsForMode (mode)
if (modePosts.length === 0) if (modePosts.length === 0)
return [] return []
@@ -3734,8 +3674,7 @@ const GekanatorBackdrop: FC<{
const expectedAnswerFor = ( const expectedAnswerFor = (
question: GekanatorQuestion | undefined, question: GekanatorQuestion | undefined,
correctPost: Post | null, correctPost: Post | null): GekanatorAnswerValue | null =>
): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost) expectedAnswerForQuestion (question, correctPost)
@@ -4169,8 +4108,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (true) setSaved (true)
setSavedGameId (data.id) setSavedGameId (data.id)
setLearnedExampleCount (data.learnedExampleCount) setLearnedExampleCount (data.learnedExampleCount)
setResultWon (variables.guessedPostId === variables.correctPostId) setResultWon (variables.guessedPostId === variables.correctPostId)}})
}})
const questionSuggestionMutation = useMutation ({ const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion, mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: async data => { onSuccess: async data => {
@@ -4180,15 +4118,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('') setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes') setQuestionSuggestionAnswer ('yes')}})
}})
const extraQuestionAnswersMutation = useMutation ({ const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers, mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: async () => { onSuccess: async () => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setExtraQuestionState ('saved') setExtraQuestionState ('saved')
setPhase ('end') setPhase ('end')}})
}})
const resetExtraQuestionState = () => { const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState () const next = resettableExtraQuestionState ()
@@ -4330,8 +4266,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
!(allowPreQuestionRecovery) !(allowPreQuestionRecovery)
|| recoveredEligiblePosts.length === 0 || recoveredEligiblePosts.length === 0
|| recoveredEligiblePosts.length === 1 || recoveredEligiblePosts.length === 1)
)
return false return false
const nextQuestion = chooseQuestion ({ const nextQuestion = chooseQuestion ({
@@ -4493,8 +4428,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
!(nextPlan.question) !(nextPlan.question)
&& !(shouldEnterGuessPhase (nextPlan.guessReason)) && !(shouldEnterGuessPhase (nextPlan.guessReason))
&& recovered.eligiblePosts.length !== 1 && recovered.eligiblePosts.length !== 1)
)
{ {
const recoveredForQuestion = recoverQuestionState ({ const recoveredForQuestion = recoverQuestionState ({
nextAnswers, nextAnswers,
@@ -4602,8 +4536,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame) !(canPersistGame)
|| reviewGuessedPostId == null || reviewGuessedPostId == null
|| reviewCorrectPostId == null || reviewCorrectPostId == null
|| saveMutation.isPending || saveMutation.isPending)
)
return return
if (savedGameId != null) if (savedGameId != null)
@@ -4666,8 +4599,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame) !(canPersistGame)
|| savedGameId == null || savedGameId == null
|| extraQuestionAnswersMutation.isPending || extraQuestionAnswersMutation.isPending
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])))
)
return return
extraQuestionAnswersMutation.mutate ({ extraQuestionAnswersMutation.mutate ({
@@ -4870,8 +4802,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const answerExtraQuestion = ( const answerExtraQuestion = (
questionId: number, questionId: number,
value: GekanatorAnswerValue, value: GekanatorAnswerValue) => {
) => {
setExtraQuestionAnswers ({ setExtraQuestionAnswers ({
...extraQuestionAnswers, ...extraQuestionAnswers,
[String (questionId)]: value }) [String (questionId)]: value })
@@ -4909,8 +4840,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|| isLoading || isLoading
|| acceptedQuestionsLoading || acceptedQuestionsLoading
|| shouldEnterGuessPhase (questionPlan.guessReason) || shouldEnterGuessPhase (questionPlan.guessReason)
|| eligiblePosts.length === 1 || eligiblePosts.length === 1)
)
return return
const recovered = recoverQuestionState ({ const recovered = recoverQuestionState ({
@@ -4926,8 +4856,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
recovered.recoveryStepCount === recoveryStepCount recovered.recoveryStepCount === recoveryStepCount
&& recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size && recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size && recovered.softenedQuestionIds.size === softenedQuestionIds.size)
)
return return
setSoftenedQuestionIds (recovered.softenedQuestionIds) setSoftenedQuestionIds (recovered.softenedQuestionIds)
@@ -4954,15 +4883,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if ( if (
phase !== 'question' phase !== 'question'
|| isLoading || isLoading
|| acceptedQuestionsLoading || acceptedQuestionsLoading)
)
return return
if ( if (
currentQuestion currentQuestion
|| !(questionPlan.guess) || !(questionPlan.guess)
|| !(shouldEnterGuessPhase (questionPlan.guessReason)) || !(shouldEnterGuessPhase (questionPlan.guessReason)))
)
return return
setWinningRunTargetId (questionPlan.winningRunTargetId) setWinningRunTargetId (questionPlan.winningRunTargetId)
+2 -4
ファイルの表示
@@ -145,8 +145,7 @@ const DeerjikistDetailPage: FC = () => {
return rtn return rtn
})}/>)} })}/>)}
</FormField> </FormField>
</fieldset> </fieldset>))}
))}
<div className="py-3"> <div className="py-3">
<button <button
@@ -169,8 +168,7 @@ const DeerjikistDetailPage: FC = () => {
</button> </button>
</div> </div>
</form> </form>
</div> </div>)}
)}
</MainArea>) </MainArea>)
} }
+22 -15
ファイルの表示
@@ -45,14 +45,16 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
const { data: material, isError, isLoading } = useQuery ({ const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? ''), queryKey: materialsKeys.show (id ?? ''),
queryFn: () => fetchMaterial (id ?? ''), queryFn: () => fetchMaterial (id ?? ''),
enabled: id != null, enabled: id != null})
}) const materialTitle = material
? material.tag?.name ?? `素材 #${ material.id }`
: ''
useEffect (() => { useEffect (() => {
if (!(material)) if (!(material))
return return
setTag (material.tag.name) setTag (material.tag?.name ?? '')
setURL (material.url ?? '') setURL (material.url ?? '')
setExportPath (material.exportPaths.legacyDrive ?? '') setExportPath (material.exportPaths.legacyDrive ?? '')
if (material.file && material.contentType) if (material.file && material.contentType)
@@ -87,8 +89,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
onError: error => { onError: error => {
applyValidationError (error) applyValidationError (error)
toast ({ title: '更新失敗……', description: '入力を見直してください.' }) toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}, }})
})
const suppressMutation = useMutation ({ const suppressMutation = useMutation ({
mutationFn: async (reason: string) => mutationFn: async (reason: string) =>
@@ -102,8 +103,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
}, },
onError: () => { onError: () => {
toast ({ title: '抑止に失敗しました' }) toast ({ title: '抑止に失敗しました' })
}, }})
})
const handleSubmit = () => { const handleSubmit = () => {
clearValidationErrors () clearValidationErrors ()
@@ -124,24 +124,25 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
<MainArea> <MainArea>
{material && ( {material && (
<Helmet> <Helmet>
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title> <title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)} </Helmet>)}
{isLoading ? 'Loading...' : isError ? ( {isLoading ? 'Loading...' : isError ? (
<p className="text-red-600 dark:text-red-300"> <p className="text-red-600 dark:text-red-300">
</p> </p>) : material == null ? (
) : material == null ? (
<p className="text-stone-700 dark:text-stone-300"> <p className="text-stone-700 dark:text-stone-300">
</p> </p>) : (
) : (
<> <>
<PageTitle> <PageTitle>
{material.tag
? (
<TagLink <TagLink
tag={material.tag} tag={material.tag}
withWiki={false} withWiki={false}
withCount={false}/> withCount={false}/>)
: materialTitle}
</PageTitle> </PageTitle>
{material.fileSuppressedAt && ( {material.fileSuppressedAt && (
@@ -155,7 +156,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
{(!material.fileSuppressedAt && material.file && material.contentType) && ( {(!material.fileSuppressedAt && material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && ( (/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag.name || undefined}/>)) <img src={material.file} alt={material.tag?.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && ( || (/video\/.*/.test (material.contentType) && (
<video src={material.file} controls/>)) <video src={material.file} controls/>))
|| (/audio\/.*/.test (material.contentType) && ( || (/audio\/.*/.test (material.contentType) && (
@@ -163,9 +164,15 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
<TabGroup> <TabGroup>
<Tab name="Wiki"> <Tab name="Wiki">
{material.tag
? (
<WikiBody <WikiBody
title={material.tag.name} title={material.tag.name}
body={material.wikiPageBody ?? undefined}/> body={material.wikiPageBody ?? undefined}/>)
: (
<p className="text-stone-700 dark:text-stone-300">
</p>)}
</Tab> </Tab>
<Tab name="編輯"> <Tab name="編輯">
+419 -267
ファイルの表示
@@ -1,281 +1,280 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Fragment, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf' import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle' import Pagination from '@/components/common/Pagination'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config' import { API_BASE_URL, SITE_TITLE } from '@/config'
import { import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
fetchMaterials,
fetchMaterialTagByName,
parseMaterialFilter,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FC } from 'react' import type { FC, FormEvent } from 'react'
import type { Material, MaterialFilter, MaterialTagTree } from '@/types' import type {
FetchMaterialsParams,
Material,
MaterialIndexDirection,
MaterialIndexMediaKind,
MaterialIndexSort,
MaterialIndexSuppression,
MaterialIndexTagState,
MaterialIndexView } from '@/types'
const MATERIAL_FILTER_LABELS: Record<MaterialFilter, string> = { const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
present: '素材あり', image: '画像',
missing: '素材なし', video: '動画',
any: 'すべて', audio: '音声',
file_other: 'その他ファイル',
url_only: 'URL のみ',
suppressed: '抑止済み'}
const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
all: 'すべて',
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: 'URL のみ'}
const SORT_LABELS: Record<MaterialIndexSort, string> = {
created_at: '作成日時',
updated_at: '更新日時',
tag_name: 'タグ名',
media_kind: '種類',
file_byte_size: 'ファイルサイズ',
version_no: 'バージョン',
id: 'ID'}
const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
const next = value?.trim ()
if (next)
qs.set (key, next)
} }
const MaterialCard = ({ tag }: { tag: MaterialTagTree }) => { const parseOption = <T extends string> (
if (!(tag.material)) value: string | null,
return null allowed: readonly T[],
fallback: T): T => allowed.includes (value as T) ? value as T : fallback
return (
<PrefetchLink const fileSizeText = (bytes: number | null): string => {
to={`/materials/${ tag.material.id }`} if (bytes == null)
className="block h-40 w-40"> return ''
if (bytes < 1024)
return `${ bytes } B`
if (bytes < 1024 * 1024)
return `${ (bytes / 1024).toFixed (1) } KB`
return `${ (bytes / 1024 / 1024).toFixed (1) } MB`
}
const materialTitle = (material: Material): string =>
material.tag?.name ?? `素材 #${ material.id }`
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div <div
className={`h-full w-full overflow-hidden rounded-xl shadow text-center className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
content-center text-4xl ${ overflow-hidden rounded-lg border text-center shadow-sm ${
tag.material.fileSuppressedAt
? 'border-2 border-red-300 bg-red-50 text-base text-red-900 ' +
'dark:border-red-800 dark:bg-red-950 dark:text-red-100'
: 'bg-white text-stone-900 dark:bg-stone-900 dark:text-stone-100' }`}
style={{ fontFamily: 'Nikumaru' }}>
{tag.material.fileSuppressedAt
? <span></span>
: (tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/>
: <span></span>}
</div>
</PrefetchLink>)
}
const MaterialList = ({ materials }: { materials: Material[] }) => (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{materials.map (material => (
<article
key={material.id}
className={`rounded-2xl border p-4 shadow-sm ${
material.fileSuppressedAt material.fileSuppressedAt
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900 ' + ? [
'dark:bg-red-950 dark:text-red-100' 'border-red-300 bg-red-50 text-red-900 dark:border-red-800',
: 'border-stone-200 bg-white text-stone-900 dark:border-stone-700 ' + 'dark:bg-red-950 dark:text-red-100'].join (' ')
'dark:bg-stone-900 dark:text-stone-100'}`}> : [
<div className="flex items-start justify-between gap-3"> 'border-stone-200 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
{material.thumbnail
? <img src={material.thumbnail} alt="" className="h-full w-full object-contain"/>
: (
<span
className="px-2 text-2xl leading-tight"
style={{ fontFamily: material.thumbnailFallbackKind === 'tag_name'
? 'Nikumaru'
: undefined }}>
{material.thumbnailFallbackText}
</span>)}
</div>)
const MaterialCard: FC<{ material: Material }> = ({ material }) => (
<article className="w-[180px] justify-self-center">
<PrefetchLink to={`/materials/${ material.id }`} className="block">
<MaterialThumb material={material}/>
<div className="mt-2 w-[180px]">
<p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100">
{materialTitle (material)}
</p>
<p className="truncate text-xs text-stone-600 dark:text-stone-300">
{MEDIA_KIND_LABELS[material.mediaKind]} / {dateString (material.createdAt)}
</p>
</div>
</PrefetchLink>
</article>)
const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
<article
className={`rounded-lg border p-3 shadow-sm ${
material.fileSuppressedAt
? [
'border-red-200 bg-red-50 text-red-900 dark:border-red-900',
'dark:bg-red-950 dark:text-red-100'].join (' ')
: [
'border-stone-200 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ')}`}>
<div className="flex gap-3">
<MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2">
<div> <div>
<h2 className="font-medium text-stone-900 dark:text-stone-100"> <PrefetchLink
{material.tag?.name ?? '未分類素材'} to={`/materials/${ material.id }`}
</h2> className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{materialTitle (material)}
</PrefetchLink>
{material.fileSuppressedAt && ( {material.fileSuppressedAt && (
<p className="mt-1 text-sm text-red-700 dark:text-red-200"></p>)} <p className="mt-1 text-sm text-red-700 dark:text-red-200"></p>)}
</div> </div>
<PrefetchLink <dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
to={`/materials/${ material.id }`} <div>
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300"> <dt className="inline">: </dt>
<dd className="inline">{MEDIA_KIND_LABELS[material.mediaKind]}</dd>
</PrefetchLink>
</div> </div>
{material.exportPaths.legacyDrive && ( {material.fileByteSize != null && (
<p className="mt-3 break-all text-sm text-stone-600 dark:text-stone-300"> <div>
{material.exportPaths.legacyDrive} <dt className="inline">: </dt>
</p>)} <dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</article>))}
</div>)
const MaterialTagTreeView = ({ tag }: { tag: MaterialTagTree }) => (
<>
<PageTitle>
<TagLink
tag={tag}
withWiki={false}
withCount={false}
to={tag.material
? `/materials/${ tag.material.id }`
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
</PageTitle>
{(!tag.material && tag.hasMaterial !== true && tag.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
</PrefetchLink>
</div>)} </div>)}
{material.url && (
<MaterialCard tag={tag}/> <div>
<dt className="inline">URL: </dt>
<div className="ml-2 overflow-x-auto pb-2"> <dd className="inline break-all">{material.url}</dd>
{tag.children.map (c2 => (
<Fragment key={c2.id}>
<SectionTitle>
<TagLink
tag={c2}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
</SectionTitle>
{(!c2.material && c2.hasMaterial !== true && c2.category !== 'meme') && (
<div className="-mt-4">
<PrefetchLink to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
</PrefetchLink>
</div>)} </div>)}
<div>
<MaterialCard tag={c2}/> <dt className="inline">: </dt>
<dd className="inline">{dateString (material.createdAt)}</dd>
<div className="ml-2">
{c2.children.map (c3 => (
<Fragment key={c3.id}>
<SubsectionTitle>
<TagLink
tag={c3}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
</SubsectionTitle>
{(!c3.material && c3.hasMaterial !== true && c3.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (c3.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={c3}/>
</Fragment>))}
</div> </div>
</Fragment>))} </dl>
</div>
</>)
const MaterialSearchTop: FC<{
materialFilter: MaterialFilter
setMaterialFilter: (value: MaterialFilter) => void
tagName: string
setTagName: (value: string) => void
}> = ({ materialFilter, setMaterialFilter, tagName, setTagName }) => {
const navigate = useNavigate ()
const location = useLocation ()
const handleSearch = () => {
const qs = new URLSearchParams (location.search)
if (tagName.trim ())
qs.set ('tag', tagName.trim ())
else
qs.delete ('tag')
qs.delete ('unclassified')
qs.set ('material_filter', materialFilter)
navigate (`/materials?${ qs.toString () }`)
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<PageTitle></PageTitle>
<section className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-4">
<FormField label="タグ名検索">
{() => (
<TagInput
value={tagName}
setValue={setTagName}/>)}
</FormField>
<FormField label="素材状態">
{({ invalid }) => (
<select
value={materialFilter}
onChange={e => setMaterialFilter (e.target.value as MaterialFilter)}
className={inputClass (invalid)}>
{Object.entries (MATERIAL_FILTER_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>)}
</FormField>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSearch}
className="rounded-full bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400">
</button>
<PrefetchLink
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
className="rounded-full border border-stone-300 bg-white px-4 py-2
text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
</PrefetchLink>
<PrefetchLink
to="/materials/new"
className="rounded-full border border-stone-300 bg-white px-4 py-2
text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
</PrefetchLink>
<a
href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-stone-300 bg-white px-4 py-2
text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
ZIP
</a>
</div> </div>
</div> </div>
</section> </article>)
</div>)
}
const MaterialListPage: FC = () => { const MaterialListPage: FC = () => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const tagQuery = query.get ('tag') ?? '' const materialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
const unclassified = query.get ('unclassified') === '1'
const initialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
const [tagName, setTagName] = useState (tagQuery) const page = Number (query.get ('page') ?? 1)
const [materialFilter, setMaterialFilter] = useState<MaterialFilter> (initialFilter) const limit = Number (query.get ('limit') ?? 20)
const qQuery = query.get ('q') ?? query.get ('tag') ?? ''
const tagState = query.get ('unclassified') === '1'
? 'untagged'
: parseOption<MaterialIndexTagState> (
query.get ('tag_state'),
['all', 'tagged', 'untagged'],
'all')
const mediaKind = parseOption<MaterialIndexMediaKind> (
query.get ('media_kind'),
['all', 'image', 'video', 'audio', 'file_other', 'url_only'],
'all')
const suppression = parseOption<MaterialIndexSuppression> (
query.get ('suppression'),
['active', 'suppressed', 'all'],
'active')
const sort = parseOption<MaterialIndexSort> (
query.get ('sort'),
['created_at', 'updated_at', 'tag_name', 'media_kind', 'file_byte_size',
'version_no', 'id'],
'created_at')
const direction = parseOption<MaterialIndexDirection> (
query.get ('direction'),
['asc', 'desc'],
'desc')
const view = parseOption<MaterialIndexView> (query.get ('view'), ['card', 'list'], 'card')
const createdFromQuery = query.get ('created_from') ?? ''
const createdToQuery = query.get ('created_to') ?? ''
const updatedFromQuery = query.get ('updated_from') ?? ''
const updatedToQuery = query.get ('updated_to') ?? ''
const { const [q, setQ] = useState ('')
data: tag, const [tagStateInput, setTagStateInput] = useState<MaterialIndexTagState> ('all')
isLoading: tagLoading, const [mediaKindInput, setMediaKindInput] = useState<MaterialIndexMediaKind> ('all')
isError: tagError, const [suppressionInput, setSuppressionInput] =
} = useQuery ({ useState<MaterialIndexSuppression> ('active')
queryKey: materialsKeys.byTagName (tagQuery, initialFilter), const [createdFrom, setCreatedFrom] = useState<string | null> (null)
queryFn: () => fetchMaterialTagByName (tagQuery, initialFilter), const [createdTo, setCreatedTo] = useState<string | null> (null)
enabled: tagQuery !== '' && !unclassified, const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
}) const [updatedTo, setUpdatedTo] = useState<string | null> (null)
const { const keys: FetchMaterialsParams = {
data: unclassifiedData, q: qQuery,
isLoading: unclassifiedLoading, tagState,
isError: unclassifiedError, mediaKind,
} = useQuery ({ suppression,
queryKey: materialsKeys.unclassified ({ page: 1, limit: 50 }), createdFrom: createdFromQuery,
queryFn: () => fetchMaterials ({ page: 1, limit: 50, unclassified: true }), createdTo: createdToQuery,
enabled: unclassified, updatedFrom: updatedFromQuery,
}) updatedTo: updatedToQuery,
sort,
direction,
view,
page,
limit}
const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.index (keys),
queryFn: () => fetchMaterials (keys)})
const materials = data?.materials ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => { useEffect (() => {
setTagName (tagQuery) setQ (qQuery)
setMaterialFilter (initialFilter) setTagStateInput (tagState)
}, [initialFilter, tagQuery]) setMediaKindInput (mediaKind)
setSuppressionInput (suppression)
setCreatedFrom (createdFromQuery)
setCreatedTo (createdToQuery)
setUpdatedFrom (updatedFromQuery)
setUpdatedTo (updatedToQuery)
}, [createdFromQuery, createdToQuery, mediaKind, qQuery, suppression, tagState,
updatedFromQuery, updatedToQuery])
const search = (e: FormEvent) => {
e.preventDefault ()
const qs = new URLSearchParams ()
setIf (qs, 'q', q)
qs.set ('tag_state', tagStateInput)
qs.set ('media_kind', mediaKindInput)
qs.set ('suppression', suppressionInput)
setIf (qs, 'created_from', createdFrom)
setIf (qs, 'created_to', createdTo)
setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo)
qs.set ('sort', sort)
qs.set ('direction', direction)
qs.set ('view', view)
qs.set ('page', '1')
qs.set ('limit', String (limit))
qs.set ('material_filter', materialFilter)
navigate (`/materials?${ qs.toString () }`)
}
const updateQuery = (changes: Record<string, string>) => {
const qs = new URLSearchParams (location.search)
Object.entries (changes).forEach (([key, value]) => qs.set (key, value))
navigate (`/materials?${ qs.toString () }`)
}
return ( return (
<MainArea> <MainArea>
@@ -288,41 +287,194 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype'); src: url(${ nikumaru }) format('opentype');
}`} }`}
</style> </style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title> <title>{`素材一覧 | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
{unclassified ? ( <div className="space-y-5">
<> <div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle> <PageTitle></PageTitle>
<div className="-mt-2 mb-4"> <div className="flex flex-wrap gap-2">
<PrefetchLink
to="/materials/new"
className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm
text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
</PrefetchLink>
<a
href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-stone-300 bg-white px-4 py-2 text-sm
text-stone-900 hover:bg-stone-100 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
ZIP
</a>
</div>
</div>
{tagState === 'untagged' && (
<PrefetchLink <PrefetchLink
to={`/materials?material_filter=${ materialFilter }`} to={`/materials?material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
dark:text-sky-300">
</PrefetchLink> </PrefetchLink>)}
<form
onSubmit={search}
className="max-w-3xl rounded-lg border border-stone-200 bg-white p-4
text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100">
<div className="space-y-3">
<FormField label="検索">
{({ invalid }) => (
<input
type="search"
value={q}
onChange={e => setQ (e.target.value)}
placeholder="タグ名 / URL / ファイル名"
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="タグ状態">
{({ invalid }) => (
<select
value={tagStateInput}
onChange={e => setTagStateInput (
e.target.value as MaterialIndexTagState)}
className={inputClass (invalid)}>
<option value="all"></option>
<option value="tagged"></option>
<option value="untagged"></option>
</select>)}
</FormField>
<FormField label="メディア">
{({ invalid }) => (
<select
value={mediaKindInput}
onChange={e => setMediaKindInput (
e.target.value as MaterialIndexMediaKind)}
className={inputClass (invalid)}>
{Object.entries (MEDIA_FILTER_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>)}
</FormField>
<FormField label="抑止状態">
{({ invalid }) => (
<select
value={suppressionInput}
onChange={e => setSuppressionInput (
e.target.value as MaterialIndexSuppression)}
className={inputClass (invalid)}>
<option value="active"></option>
<option value="suppressed"></option>
<option value="all"></option>
</select>)}
</FormField>
<FormField label="作成日時">
{() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={createdFrom ?? undefined} onChange={setCreatedFrom}/>
<span></span>
<DateTimeField value={createdTo ?? undefined} onChange={setCreatedTo}/>
</div>)}
</FormField>
<FormField label="更新日時">
{() => (
<div className="flex flex-wrap items-center gap-2">
<DateTimeField value={updatedFrom ?? undefined} onChange={setUpdatedFrom}/>
<span></span>
<DateTimeField value={updatedTo ?? undefined} onChange={setUpdatedTo}/>
</div>)}
</FormField>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="submit"
className="rounded bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400">
</button>
</div>
</form>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => updateQuery ({ view: 'card' })}
className={`rounded-full border px-4 py-2 text-sm ${
view === 'card'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
</button>
<button
type="button"
onClick={() => updateQuery ({ view: 'list' })}
className={`rounded-full border px-4 py-2 text-sm ${
view === 'list'
? [
'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100'].join (' ')
: [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'].join (' ') }`}>
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<select
value={sort}
onChange={e => updateQuery ({ sort: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
{Object.entries (SORT_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>
<select
value={direction}
onChange={e => updateQuery ({ direction: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}>
<option value="desc"></option>
<option value="asc"></option>
</select>
</div>
</div>
{isLoading && <p>Loading...</p>}
{isError && (
<p className="text-red-600 dark:text-red-300"></p>)}
{(!isLoading && !isError && materials.length === 0) && (
<p></p>)}
{materials.length > 0 && (
view === 'card'
? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(196px,1fr))]
justify-items-center gap-4">
{materials.map (material => (
<MaterialCard key={material.id} material={material}/>))}
</div>)
: (
<div className="space-y-3">
{materials.map (material => (
<MaterialListItem key={material.id} material={material}/>))}
</div>))}
<Pagination page={page} totalPages={totalPages}/>
</div> </div>
{unclassifiedLoading && <p>Loading...</p>}
{unclassifiedError && (
<p className="text-red-600 dark:text-red-300"></p>)}
{unclassifiedData && unclassifiedData.materials.length === 0 && (
<p></p>)}
{unclassifiedData && unclassifiedData.materials.length > 0 && (
<MaterialList materials={unclassifiedData.materials}/>)}
</>) : tagQuery ? (
<>
{tagLoading && <p>Loading...</p>}
{tagError && (
<p className="text-red-600"></p>)}
{(!tagLoading && !tagError && !tag) && (
<p></p>)}
{tag && <MaterialTagTreeView tag={tag}/>}
</>) : (
<MaterialSearchTop
tagName={tagName}
setTagName={setTagName}
materialFilter={materialFilter}
setMaterialFilter={setMaterialFilter}/>)}
</MainArea>) </MainArea>)
} }
+1 -2
ファイルの表示
@@ -59,8 +59,7 @@ const MaterialNewPage: FC = () => {
onError: error => { onError: error => {
applyValidationError (error) applyValidationError (error)
toast ({ title: '送信失敗……', description: '入力を見直してください.' }) toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}, }})
})
const handleSubmit = () => { const handleSubmit = () => {
clearValidationErrors () clearValidationErrors ()
+2 -4
ファイルの表示
@@ -62,8 +62,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
toast ({ title: '失敗……', description: '通信に失敗しました……' }) toast ({ title: '失敗……', description: '通信に失敗しました……' })
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: postsKeys.root })} })
} })
useEffect (() => { useEffect (() => {
if (!(errorFlg)) if (!(errorFlg))
@@ -114,8 +113,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<PostList posts={[{ ...post, childPosts: [{ } as Post] }, <PostList posts={[{ ...post, childPosts: [{ } as Post] },
...post.childPosts!.map (p => ({ ...post.childPosts!.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/> ...p, parentPosts: [{ } as Post] }))]}/>
</div> </div>)}
)}
{(post.parentPosts ?? []).map (pp => { {(post.parentPosts ?? []).map (pp => {
const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`] const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`]
if (!(siblings)) if (!(siblings))
+14 -20
ファイルの表示
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, LoaderCircle, Pencil, X } from 'lucide-react' import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { Fragment, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@@ -87,8 +87,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
const defaultDirection = { const defaultDirection = {
name: 'asc', name: 'asc',
created_at: 'desc', created_at: 'desc',
updated_at: 'desc', updated_at: 'desc'} as const
} as const
const beginEdit = async (tag: NicoTag) => { const beginEdit = async (tag: NicoTag) => {
const editingTag = nicoTags.find (tag => tag.id === editingId) const editingTag = nicoTags.find (tag => tag.id === editingId)
@@ -99,15 +98,13 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
&& !(await dialogue.confirm ({ && !(await dialogue.confirm ({
title: '編集中の内容を破棄しますか?', title: '編集中の内容を破棄しますか?',
confirmText: '破棄', confirmText: '破棄',
variant: 'danger', variant: 'danger'})))
})))
return return
setEditingId (tag.id) setEditingId (tag.id)
setRawTags (rawTags => ({ setRawTags (rawTags => ({
...rawTags, ...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), [tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')}))
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] })) setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
} }
@@ -115,8 +112,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setEditingId (null) setEditingId (null)
setRawTags (rawTags => ({ setRawTags (rawTags => ({
...rawTags, ...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), [tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')}))
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] })) setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
} }
@@ -141,8 +137,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
...errors, ...errors,
[id]: validationError?.fieldErrors.tags [id]: validationError?.fieldErrors.tags
?? validationError?.baseErrors ?? validationError?.baseErrors
?? ['更新できませんでした.'], ?? ['更新できませんでした.']}))
}))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' }) toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
} }
finally finally
@@ -166,8 +161,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setRawTags (Object.fromEntries (data.tags.map (tag => [ setRawTags (Object.fromEntries (data.tags.map (tag => [
tag.id, tag.id,
tag.linkedTags.map (linkedTag => linkedTag.name).join (' '), tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')])))
])))
}, [data]) }, [data])
useEffect (() => { useEffect (() => {
@@ -282,7 +276,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
{nicoTags.map (tag => { {nicoTags.map (tag => {
const isEditing = editingId === tag.id const isEditing = editingId === tag.id
return [ return (
<Fragment key={tag.id}>
<tr <tr
key={tag.id} key={tag.id}
className={cn ( className={cn (
@@ -321,8 +316,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</button>)} </button>)}
</td>)} </td>)}
</tr>, </tr>
isEditing && ( {isEditing && (
<tr key={`${ tag.id }-edit`} <tr key={`${ tag.id }-edit`}
className="border-b border-rose-200 bg-rose-50 dark:border-rose-900 className="border-b border-rose-200 bg-rose-50 dark:border-rose-900
dark:bg-rose-950/30"> dark:bg-rose-950/30">
@@ -340,8 +335,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
placeholder="タグ名を空白または改行で区切って入力" placeholder="タグ名を空白または改行で区切って入力"
onChange={e => setRawTags (rawTags => ({ onChange={e => setRawTags (rawTags => ({
...rawTags, ...rawTags,
[tag.id]: e.target.value, [tag.id]: e.target.value}))}/>
}))}/>
<FieldError messages={errorsByTagId[tag.id]}/> <FieldError messages={errorsByTagId[tag.id]}/>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
@@ -368,8 +362,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</div> </div>
</div> </div>
</td> </td>
</tr>), </tr>)}
] </Fragment>)
})} })}
</tbody> </tbody>
</table> </table>
+2 -4
ファイルの表示
@@ -69,8 +69,7 @@ const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
const commentBox = ( const commentBox = (
comment: TheatreComment, comment: TheatreComment,
programme: TheatreProgramme | null = null, programme: TheatreProgramme | null = null): ReactNode[] =>
): ReactNode[] =>
[( [(
<div key={`${ comment.no }-content`} className="w-full"> <div key={`${ comment.no }-content`} className="w-full">
{comment.deleted {comment.deleted
@@ -120,8 +119,7 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' }, { tags, compact, flow = 'vertical' }) => {
) => {
const grouped = tagsByCategory (tags) const grouped = tagsByCategory (tags)
if (flow === 'horizontal') if (flow === 'horizontal')
+1 -2
ファイルの表示
@@ -15,5 +15,4 @@ export const useSharedTransitionStore = create<SharedTransitionState> (set => ({
set (state => { set (state => {
const next = { ...state.byLocationKey } const next = { ...state.byLocationKey }
delete next[locationKey] delete next[locationKey]
return { byLocationKey: next } return { byLocationKey: next }}) }))
}) }))
+5
ファイルの表示
@@ -77,7 +77,12 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
file: null, file: null,
url: null, url: null,
wikiPageBody: null, wikiPageBody: null,
thumbnail: null,
thumbnailFallbackText: 'テストタグ',
thumbnailFallbackKind: 'tag_name',
mediaKind: 'url_only',
contentType: null, contentType: null,
fileByteSize: null,
fileSuppressedAt: null, fileSuppressedAt: null,
fileSuppressionReason: null, fileSuppressionReason: null,
exportPaths: {}, exportPaths: {},
+46 -1
ファイルの表示
@@ -67,14 +67,59 @@ export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
export type MaterialFilter = 'present' | 'missing' | 'any' export type MaterialFilter = 'present' | 'missing' | 'any'
export type MaterialIndexDirection = 'asc' | 'desc'
export type MaterialIndexMediaKind =
| 'all'
| 'image'
| 'video'
| 'audio'
| 'file_other'
| 'url_only'
export type MaterialIndexSort =
| 'created_at'
| 'updated_at'
| 'tag_name'
| 'media_kind'
| 'file_byte_size'
| 'version_no'
| 'id'
export type MaterialIndexSuppression = 'active' | 'suppressed' | 'all'
export type MaterialIndexTagState = 'all' | 'tagged' | 'untagged'
export type MaterialIndexView = 'card' | 'list'
export type FetchMaterialsParams = {
q: string
tagState: MaterialIndexTagState
mediaKind: MaterialIndexMediaKind
suppression: MaterialIndexSuppression
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
sort: MaterialIndexSort
direction: MaterialIndexDirection
view: MaterialIndexView
page: number
limit: number }
export type Material = { export type Material = {
id: number id: number
versionNo: number versionNo: number
tag: Tag tag: Tag | null
file: string | null file: string | null
url: string | null url: string | null
wikiPageBody?: string | null wikiPageBody?: string | null
thumbnail: string | null
thumbnailFallbackText: string | null
thumbnailFallbackKind: 'tag_name' | 'created_at'
mediaKind: 'image' | 'video' | 'audio' | 'file_other' | 'url_only' | 'suppressed'
contentType: string | null contentType: string | null
fileByteSize: number | null
fileSuppressedAt: string | null fileSuppressedAt: string | null
fileSuppressionReason: string | null fileSuppressionReason: string | null
exportPaths: Record<string, string> exportPaths: Record<string, string>