Merge remote-tracking branch 'origin/main' into feature/351

このコミットが含まれているのは:
2026-07-02 01:55:35 +09:00
コミット 46de995f8d
98個のファイルの変更6862行の追加1175行の削除
+4 -1
ファイルの表示
@@ -14,8 +14,10 @@ import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
@@ -68,10 +70,11 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
+482 -75
ファイルの表示
@@ -1,23 +1,43 @@
import { Fragment, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api'
import TagLink from '@/components/TagLink'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { Tag } from '@/types'
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
type TagWithDepth = Tag & {
hasChildren: boolean
children: TagWithDepth[] }
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
const px = (value: string): number => {
const parsed = Number.parseFloat (value)
return Number.isFinite (parsed) ? parsed : 0
}
const verticalChrome = (el: HTMLElement): number => {
const style = window.getComputedStyle (el)
return (
px (style.paddingTop)
+ px (style.paddingBottom)
+ px (style.borderTopWidth)
+ px (style.borderBottomWidth))
}
const setChildrenById = (
tags: TagWithDepth[],
tags: MaterialSidebarTag[],
targetId: number,
children: TagWithDepth[],
): TagWithDepth[] => (
children: MaterialSidebarTag[],
): MaterialSidebarTag[] => (
tags.map (tag => {
if (tag.id === targetId)
return { ...tag, children }
@@ -25,75 +45,462 @@ const setChildrenById = (
if (tag.children.length === 0)
return tag
return { ...tag,
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
}))
const MaterialSidebar: FC = () => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
const materialPath = (
tagId: number,
materialFilter: MaterialFilter,
): string =>
`/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
+ `&material_filter=${ materialFilter }`
useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
ts.map (t => (
<Fragment key={t.id}>
<li>
<div className="flex">
<div className="flex-none w-4">
{t.hasChildren && (
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(tagFetchedFlags[t.id]))
{
try
{
const data =
await apiGet<TagWithDepth[]> (
'/tags/with-depth', { params: { parent: String (t.id) } })
setTags (prev => setChildrenById (prev, t.id, data))
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
}
catch
{
;
}
}
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
}}>
{openTags[t.id] ? <>&minus;</> : '+'}
</a>)}
</div>
<div className="flex-1 truncate">
<TagLink
tag={t}
nestLevel={nestLevel}
title={t.name}
withCount={false}
withWiki={false}
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
</div>
</div>
</li>
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
</Fragment>)))
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
id: tag.id,
name: tag.name,
category: tag.category,
deprecatedAt: tag.deprecated ? '' : null,
aliases: [],
parents: [],
postCount: 0,
createdAt: '',
updatedAt: '',
hasWiki: false,
materialId: null,
hasDeerjikists: false,
matchedAlias: null })
return (
<SidebarComponent>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
const tagSelectionShellClass = (selected: boolean): string =>
cn (selected
? ['rounded-md border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700',
'dark:border-sky-400 dark:bg-sky-950 dark:text-sky-100']
: 'px-2 py-1')
const updateMaterialFilterQuery = (
pathname: string,
locationSearch: string,
navigate: ReturnType<typeof useNavigate>,
materialFilter: MaterialFilter,
) => {
const qs = new URLSearchParams (locationSearch)
qs.set ('material_filter', materialFilter)
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
}
export default MaterialSidebar
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
onChange: (materialFilter: MaterialFilter) => void }> = (
{ materialFilter, onChange },
) => (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold"></label>
{FILTERS.map (value => (
<button
key={value}
type="button"
onClick={() => onChange (value)}
className={cn (
'rounded-full border px-3 py-1 text-sm',
(materialFilter === value
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}>
{FILTER_LABELS[value]}
</button>))}
</div>)
const MaterialTreeNode: FC<{
materialFilter: MaterialFilter
selectedTagId: number | null
nestLevel?: number
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
openTags: Record<number, boolean>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
tag: MaterialSidebarTag
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
setOpenTags, tag }) => {
const open = Boolean (openTags[tag.id])
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
if (open && data && tag.children.length === 0)
onChildren (tag.id, data)
}, [data, onChildren, open, tag.children.length, tag.id])
return (
<>
<li>
<div className="flex flex-center">
<div className="flex-none w-4 my-auto">
{tag.hasChildren && (
<button
type="button"
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
className="text-neutral-500 dark:text-stone-400">
{open ? <>&minus;</> : '+'}
</button>)}
</div>
<div className="min-w-0 flex-1 my-auto">
<div className={cn (tagSelectionShellClass (selectedTagId === tag.id),
'min-w-0 truncate')}>
<TagLink
tag={sidebarTagToTag (tag)}
nestLevel={nestLevel}
title={tag.name}
withCount={false}
withWiki={false}
to={materialPath (tag.id, materialFilter)}/>
</div>
</div>
</div>
</li>
{open && tag.children.length > 0 && (
<ul>
{tag.children.map (child => (
<MaterialTreeNode
key={child.id}
tag={child}
nestLevel={nestLevel + 1}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={onChildren}/>))}
</ul>)}
</>)
}
const MobileMaterialTreeNode: FC<{ depth?: number
availableInlineSizePx?: number | null
materialFilter: MaterialFilter
selectedTagId: number | null
onChildren: (tagId: number, children: MaterialSidebarTag[]) =>
void
openTags: Record<number, boolean>
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
tag: MaterialSidebarTag }> = (
{
depth = 0,
availableInlineSizePx = null,
materialFilter,
onChildren,
openTags,
selectedTagId,
setOpenTags,
tag,
},
) => {
const open = Boolean (openTags[tag.id])
const tagColumnRef = useRef<HTMLDivElement | null> (null)
const chipRef = useRef<HTMLDivElement | null> (null)
const buttonRef = useRef<HTMLButtonElement | null> (null)
const expansionSlotRef = useRef<HTMLDivElement | null> (null)
const expansionBorderRef = useRef<HTMLDivElement | null> (null)
const [tagChipInlineSizePx, setTagChipInlineSizePx] = useState<number | null> (null)
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
if (open && data && tag.children.length === 0)
onChildren (tag.id, data)
}, [data, onChildren, open, tag.children.length, tag.id])
useEffect (() => {
const tagColumn = tagColumnRef.current
const chip = chipRef.current
if (!(tagColumn) || !(chip))
return
const updateTagInlineSize = () => {
const buttonHeight = buttonRef.current?.offsetHeight ?? 0
const gap = tag.hasChildren ? px (window.getComputedStyle (tagColumn).rowGap) : 0
const columnHeight =
availableInlineSizePx == null
? tagColumn.clientHeight
: Math.min (tagColumn.clientHeight, availableInlineSizePx)
const nextChipInlineSize = Math.max (24, columnHeight - buttonHeight - gap)
const nextLinkInlineSize = Math.max (
16,
nextChipInlineSize - verticalChrome (chip),
)
setTagChipInlineSizePx (prev => prev === nextChipInlineSize ? prev : nextChipInlineSize)
setTagLinkInlineSizePx (prev => prev === nextLinkInlineSize ? prev : nextLinkInlineSize)
}
updateTagInlineSize ()
const resizeObserver = new ResizeObserver (() => {
updateTagInlineSize ()
})
resizeObserver.observe (tagColumn)
resizeObserver.observe (chip)
if (buttonRef.current)
resizeObserver.observe (buttonRef.current)
return () => {
resizeObserver.disconnect ()
}
}, [availableInlineSizePx, open, tag.hasChildren, tag.children.length])
useEffect (() => {
const expansionSlot = expansionSlotRef.current
const expansionBorder = expansionBorderRef.current
if (!(expansionSlot) || !(expansionBorder))
return
const updateChildInlineSize = () => {
const base =
availableInlineSizePx == null
? expansionSlot.clientHeight
: availableInlineSizePx
const chrome = verticalChrome (expansionSlot) + verticalChrome (expansionBorder)
const nextInlineSize = Math.max (24, base - chrome)
setChildAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
}
updateChildInlineSize ()
const resizeObserver = new ResizeObserver (() => {
updateChildInlineSize ()
})
resizeObserver.observe (expansionSlot)
resizeObserver.observe (expansionBorder)
return () => {
resizeObserver.disconnect ()
}
}, [availableInlineSizePx, open, tag.children.length])
return (
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
overflow-hidden">
<div
ref={tagColumnRef}
className="flex h-full min-h-0 max-h-full flex-col items-center gap-1
overflow-hidden">
<div
ref={chipRef}
className={cn (
tagSelectionShellClass (selectedTagId === tag.id),
'box-border rounded-xl border px-3 py-2 text-sm shadow-sm',
'min-h-0 overflow-hidden [max-inline-size:var(--tag-chip-inline-size)]',
'[max-height:var(--tag-chip-inline-size)]',
)}
style={{
writingMode: 'vertical-rl',
'--tag-chip-inline-size': (
tagChipInlineSizePx == null
? undefined
: `${ tagChipInlineSizePx }px`),
'--tag-link-inline-size': (
tagLinkInlineSizePx == null
? undefined
: `${ tagLinkInlineSizePx }px`),
} as CSSProperties}>
<TagLink
tag={sidebarTagToTag (tag)}
title={tag.name}
withCount={false}
withWiki={false}
to={materialPath (tag.id, materialFilter)}
className="block overflow-hidden text-ellipsis whitespace-nowrap
[max-inline-size:var(--tag-link-inline-size)]
[max-height:var(--tag-link-inline-size)]"/>
</div>
{tag.hasChildren && (
<button
ref={buttonRef}
type="button"
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
className="flex-none rounded-full border border-stone-300 bg-white
px-2 py-0.5 text-sm text-stone-700 dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100">
{open ? <>&minus;</> : '+'}
</button>)}
</div>
{open && tag.children.length > 0 && (
<div
ref={expansionSlotRef}
className={cn (
'h-full min-h-0 max-h-full overflow-hidden box-border',
depth === 0 ? 'pt-5' : 'pt-3')}>
<div
ref={expansionBorderRef}
className="relative max-h-full overflow-hidden rounded-2xl border border-stone-200
bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100">
<div className="flex h-full min-h-0 max-h-full flex-row-reverse items-start gap-2
overflow-hidden">
<span
aria-hidden="true"
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
{tag.children.map (child => (
<div
key={child.id}
className="relative h-full min-h-0 max-h-full overflow-hidden">
<MobileMaterialTreeNode
tag={child}
depth={depth + 1}
availableInlineSizePx={childAvailableInlineSizePx}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={onChildren}/>
</div>))}
</div>
</div>
</div>)}
</div>)
}
const MaterialSidebar: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
const qs = new URLSearchParams (location.search)
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const mobileRailRef = useRef<HTMLDivElement | null> (null)
const [mobileAvailableInlineSizePx, setMobileAvailableInlineSizePx] =
useState<number | null> (null)
const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
useEffect (() => {
setDesktopTags (rootTags)
}, [rootTags])
useEffect (() => {
const el = mobileRailRef.current
if (!(el))
return
requestAnimationFrame (() => {
el.scrollLeft = el.scrollWidth
})
}, [rootTags, materialFilter])
useEffect (() => {
const el = mobileRailRef.current
if (!(el))
return
const updateAvailableInlineSize = () => {
const nextInlineSize = Math.max (24, el.clientHeight)
setMobileAvailableInlineSizePx (prev => prev === nextInlineSize ? prev : nextInlineSize)
}
updateAvailableInlineSize ()
const resizeObserver = new ResizeObserver (() => {
updateAvailableInlineSize ()
})
resizeObserver.observe (el)
return () => {
resizeObserver.disconnect ()
}
}, [rootTags, materialFilter])
const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
setDesktopTags (prev => {
const base = prev.length > 0 ? prev : rootTags
return setChildrenById (base, tagId, children)
})
}
const handleFilterChange = (value: MaterialFilter) => {
setDesktopTags ([])
setOpenTags ({ })
updateMaterialFilterQuery (location.pathname, location.search, navigate, value)
}
const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => (
tags.map (tag => (
<MaterialTreeNode
key={tag.id}
tag={tag}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={setChildren}/>)))
return (
<>
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
dark:text-stone-100 md:hidden flex h-[25dvh] min-h-0 flex-col
overflow-hidden">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
<div
ref={mobileRailRef}
className="mt-3 min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className="flex min-w-max flex-row-reverse items-start gap-3 pb-1 h-full
min-h-0 max-h-full">
{visibleRootTags.map (tag => (
<MobileMaterialTreeNode
key={tag.id}
tag={tag}
availableInlineSizePx={mobileAvailableInlineSizePx}
materialFilter={materialFilter}
selectedTagId={selectedTagId}
openTags={openTags}
setOpenTags={setOpenTags}
onChildren={setChildren}/>))}
</div>
</div>
</div>
<div className="hidden md:block">
<SidebarComponent>
<div className="space-y-4">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && (
<ul>
{renderDesktopTree (visibleRootTags)}
</ul>)}
</div>
</SidebarComponent>
</div>
</>)
}
export default MaterialSidebar
+1 -2
ファイルの表示
@@ -107,8 +107,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
loadCompleteTimerRef.current = setTimeout (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
reason: 'niconico video length was not reported by embed'})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
+1 -2
ファイルの表示
@@ -19,8 +19,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props,
) => (
errors }: Props) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
+3 -6
ファイルの表示
@@ -37,8 +37,7 @@ const renderTagTree = (
path: string,
suppressClickRef: MutableRefObject<boolean>,
parentTagId?: number,
sp?: boolean,
): ReactNode[] => {
sp?: boolean): ReactNode[] => {
const key = `${ path }-${ tag.id }`
const self = (
@@ -130,8 +129,7 @@ const buildTagByCategory = (post: Post): TagByCategory => {
const changeCategory = async (
tagId: number,
category: Category,
): Promise<void> => {
category: Category): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category })
}
@@ -294,8 +292,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
addEventListener ('click', e => {
e.preventDefault ()
e.stopPropagation ()
suppressClickRef.current = false
}, { capture: true, once: true })
suppressClickRef.current = false}, { capture: true, once: true })
}}
onDragCancel={() => {
setActiveTagId (null)
+9 -8
ファイルの表示
@@ -28,11 +28,12 @@ type Props =
const TagLink: FC<Props> = ({ tag,
nestLevel = 0,
linkFlg = true,
withWiki = true,
withCount = true,
...props }) => {
nestLevel = 0,
linkFlg = true,
withWiki = true,
withCount = true,
className,
...props }) => {
const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -105,7 +106,7 @@ const TagLink: FC<Props> = ({ tag,
</span>)}
{tag.matchedAlias != null && (
<>
<span className={spanClass} {...props}>
<span className={cn (spanClass, className)} {...props}>
{tag.matchedAlias}
</span>
<> </>
@@ -114,12 +115,12 @@ const TagLink: FC<Props> = ({ tag,
? (
<PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
className={cn (linkClass, className)}
{...props}>
{tag.name}
</PrefetchLink>)
: (
<span className={spanClass}
<span className={cn (spanClass, className)}
{...props}>
{tag.name}
</span>)}
+34 -14
ファイルの表示
@@ -7,30 +7,36 @@ import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils'
import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { Menu, MenuVisibleItem, Tag, User } from '@/types'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null }
export const menuOutline = ({ tag, wikiId, user, pathName }: {
tag?: Tag | null
wikiId: number | null
user: User | null,
pathName: string }): Menu => {
const postCount = tag?.postCount ?? 0
export const menuOutline = (
{ tag, material, wikiId, user, pathName }: {
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null,
pathName: string },
): Menu => {
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName)
const materialFlg = /^\/materials\/\d+/.test (pathName)
return [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
@@ -49,12 +55,18 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: tagFlg },
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: false, subMenu: [
{ name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '抑止', to: '/materials/suppressions' },
{ name: '全体履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg },
{ name: `広場 (${ postCount || 0 })`,
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
visible: materialFlg && Boolean (material?.tag) },
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
@@ -119,15 +131,23 @@ const TopNav: FC<Props> = ({ user }) => {
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
const effectiveTitle = (tagFlg ? location.pathname.split ('/')[2] : wikiPage?.title) ?? ''
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
const effectiveTitle = (((tagFlg || materialFlg)
? location.pathname.split ('/')[2]
: wikiPage?.title)
?? '')
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
const { data: material } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: materialsKeys.show (effectiveTitle),
queryFn: () => fetchMaterial (effectiveTitle) })
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
+5 -3
ファイルの表示
@@ -1,12 +1,14 @@
import React from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = { children: React.ReactNode }
type Props = { children: React.ReactNode; className?: string }
const PageTitle: FC<Props> = ({ children }) => (
<h1 className="text-2xl font-bold mb-2">
const PageTitle: FC<Props> = ({ children, className, ...rest }) => (
<h1 className={cn ('text-2xl font-bold mb-2', className)} {...rest}>
{children}
</h1>)
+1 -2
ファイルの表示
@@ -16,8 +16,7 @@ const range = (start: number, end: number): number[] =>
const getPages = (
page: number,
total: number,
siblingCount: number,
): (number | '…')[] => {
siblingCount: number): (number | '…')[] => {
if (total <= 1)
return [1]
+1 -2
ファイルの表示
@@ -103,8 +103,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })
}) }), [push])
resolve: resolve as (value: string | null) => void })}) }), [push])
const active = queue[0]
+23 -31
ファイルの表示
@@ -10,41 +10,35 @@ const buttonVariants = cva (
'rounded-md text-sm font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
].join (' '),
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'].join (' '),
{
variants: {
variant: {
default:
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
default:
'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300',
destructive:
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
destructive:
'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600',
outline:
'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800',
outline:
'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800',
secondary:
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
secondary:
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
ghost:
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
ghost:
'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800',
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300',
},
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'}},
defaultVariants: {
variant: 'default',
size: 'default',
},
})
size: 'default'}})
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -57,13 +51,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>)
})
Button.displayName = "Button"
export { Button, buttonVariants }
+10 -20
ファイルの表示
@@ -22,11 +22,9 @@ const DialogOverlay = React.forwardRef<
ref={ref}
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",
className
)}
className)}
{...props}
/>
))
/>))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
@@ -62,8 +60,7 @@ const DialogContent = React.forwardRef<
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
</DialogPortal>))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
@@ -73,11 +70,9 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
className)}
{...props}
/>
)
/>)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
@@ -87,11 +82,9 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
className)}
{...props}
/>
)
/>)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
@@ -102,11 +95,9 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
className)}
{...props}
/>
))
/>))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
@@ -117,8 +108,7 @@ const DialogDescription = React.forwardRef<
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
/>))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
+12 -15
ファイルの表示
@@ -1,22 +1,19 @@
import * as React from "react"
import * as React from 'react'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
type={type}
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',
className)}
ref={ref}
{...props}
/>)
})
Input.displayName = 'Input'
export { Input }
+8 -11
ファイルの表示
@@ -1,9 +1,9 @@
"use client"
'use client'
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -11,19 +11,16 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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",
className
)}
'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)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
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>))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+115 -129
ファイルの表示
@@ -1,129 +1,115 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
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]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
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",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
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",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
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]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: { variant: "default" } }
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
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",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
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",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
+30 -31
ファイルの表示
@@ -1,31 +1,30 @@
'use client'
import { useToast } from '@/components/ui/use-toast'
import { Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport } from '@/components/ui/toast'
export const Toaster = () => {
const { toasts } = useToast ()
return (
<ToastProvider>
{toasts.map (({ id, title, description, action, ...props }) => (
<Toast key={id}
className="bg-gray-300/80 dark:bg-gray-700/80"
{...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>))}
<ToastViewport />
</ToastProvider>
)
}
'use client'
import { useToast } from '@/components/ui/use-toast'
import { Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport } from '@/components/ui/toast'
export const Toaster = () => {
const { toasts } = useToast ()
return (
<ToastProvider>
{toasts.map (({ id, title, description, action, ...props }) => (
<Toast key={id}
className="bg-gray-300/80 dark:bg-gray-700/80"
{...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>))}
<ToastViewport />
</ToastProvider>
)
+28 -42
ファイルの表示
@@ -58,8 +58,7 @@ const addToRemoveQueue = (toastId: string) => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
toastId: toastId})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
@@ -69,17 +68,14 @@ export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t)}
case "DISMISS_TOAST": {
const { toastId } = action
@@ -87,36 +83,31 @@ export const reducer = (state: State, action: Action): State => {
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false}
: t)}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
return {
...state,
toasts: []}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId)}
}
}
@@ -139,8 +130,7 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
toast: { ...props, id }})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
@@ -150,16 +140,13 @@ function toast({ ...props }: Toast) {
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
if (!open) dismiss()
}}})
return {
id: id,
dismiss,
update,
}
update}
}
function useToast() {
@@ -170,7 +157,7 @@ function useToast() {
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1)
}
}
}, [state])
@@ -178,8 +165,7 @@ function useToast() {
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })}
}
export { useToast, toast }
+1 -2
ファイルの表示
@@ -3,8 +3,7 @@ const ENV: string = 'development'
const config = {
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 SITE_TITLE = config.SITE_TITLE
+5 -10
ファイルの表示
@@ -10,8 +10,7 @@ export const CATEGORIES = [
'general',
'material',
'meta',
'nico',
] as const
'nico'] as const
export const CATEGORY_NAMES: Record<Category, string> = {
deerjikist: 'ニジラー',
@@ -20,16 +19,14 @@ export const CATEGORY_NAMES: Record<Category, string> = {
general: '一般',
material: '素材',
meta: 'メタタグ',
nico: 'ニコニコタグ',
} as const
nico: 'ニコニコタグ'} as const
export const FETCH_POSTS_ORDER_FIELDS = [
'title',
'url',
'original_created_at',
'created_at',
'updated_at',
] as const
'updated_at'] as const
export const PLATFORMS = ['nico', 'youtube'] as const
@@ -43,13 +40,11 @@ export const TAG_COLOUR = {
general: 'cyan',
material: 'orange',
meta: 'yellow',
nico: 'gray',
} as const satisfies Record<Category, string>
nico: 'gray'} as const satisfies Record<Category, string>
export const USER_ROLES = ['admin', 'member', 'guest'] as const
export const ViewFlagBehavior = {
OnShowedDetail: 1,
OnClickedLink: 2,
NotAuto: 3,
} as const
NotAuto: 3} as const
+6 -12
ファイルの表示
@@ -23,8 +23,7 @@ const apiP = async <T> (
method: 'post' | 'put' | 'patch',
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
@@ -34,8 +33,7 @@ const apiP = async <T> (
export const apiGet = async <T> (
path: string,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client.get (path, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
@@ -46,28 +44,24 @@ export const apiGet = async <T> (
export const apiPost = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('post', path, body, opt)
opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
export const apiPut = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('put', path, body, opt)
opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
export const apiPatch = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('patch', path, body, opt)
opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async <T = void> (
path: string,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '')
return undefined as T
+14 -28
ファイルの表示
@@ -104,8 +104,7 @@ export type BuildGekanatorQuestionsOptions = {
export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition,
): GekanatorQuestionCondition => {
condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
switch (condition.type)
{
case 'title-length-greater-than':
@@ -119,8 +118,7 @@ export const normalizeTitleLengthCondition = (
export const titleLengthMinimumForCondition = (
condition: GekanatorQuestionCondition,
): number | null => {
condition: GekanatorQuestionCondition): number | null => {
switch (condition.type)
{
case 'title-length-at-least':
@@ -134,8 +132,7 @@ export const titleLengthMinimumForCondition = (
export const questionIdForCondition = (
condition: NonPostSimilarityCondition,
): string => {
condition: NonPostSimilarityCondition): string => {
switch (condition.type)
{
case 'tag':
@@ -161,8 +158,7 @@ export const questionIdForCondition = (
const directExampleAnswerFor = (
question: StoredGekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
post: Post): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null
@@ -178,15 +174,13 @@ const directExampleAnswerFor = (
export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion,
): boolean =>
question: StoredGekanatorQuestion | GekanatorQuestion): boolean =>
question.kind === 'post_similarity'
&& question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null,
): LearnedSemanticSide => {
answer: GekanatorAnswerValue | null): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial')
return 'positive'
@@ -314,8 +308,7 @@ const questionableTag = (post: Post, key: string): boolean => {
const questionMatches = (
post: Post,
question: StoredGekanatorQuestion,
): boolean => {
question: StoredGekanatorQuestion): boolean => {
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return question.kind === 'post_similarity'
@@ -350,8 +343,7 @@ const questionMatches = (
export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): GekanatorAnswerValue | null => {
post: Post | null): GekanatorAnswerValue | null => {
if (!(question) || !(post))
return null
@@ -382,14 +374,12 @@ export const expectedAnswerForQuestion = (
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
post: Post | null): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => {
question: StoredGekanatorQuestion): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = {
...question,
@@ -408,8 +398,7 @@ export const restoreGekanatorQuestion = (
export const storeGekanatorQuestion = (
question: GekanatorQuestion,
): StoredGekanatorQuestion => ({
question: GekanatorQuestion): StoredGekanatorQuestion => ({
id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }`
: question.id,
@@ -436,8 +425,7 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
export const fetchGekanatorExtraQuestions = async (
gameId: number,
nonce?: string,
): Promise<GekanatorExtraQuestion[]> => {
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
@@ -447,8 +435,7 @@ export const fetchGekanatorExtraQuestions = async (
export const buildGekanatorQuestions = (
posts: Post[],
options: BuildGekanatorQuestionsOptions = { },
): GekanatorQuestion[] => {
options: BuildGekanatorQuestionsOptions = { }): GekanatorQuestion[] => {
const {
includeTitleContains = true,
tagQuestionCap = 192,
@@ -490,8 +477,7 @@ export const buildGekanatorQuestions = (
const usefulEntries = <T extends string | number> (
counts: Map<T, number>,
cap: number,
) =>
cap: number) =>
[...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
+4 -8
ファイルの表示
@@ -32,8 +32,7 @@ export const candidatePostsFor = (
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): Post[] => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
@@ -76,8 +75,7 @@ export const candidatePostsFor = (
export const hardFilteredPostsForAnswer = (
{ posts, question, answer }: { posts: Post[]
question: GekanatorQuestion
answer: GekanatorAnswerValue },
): Post[] => {
answer: GekanatorAnswerValue }): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts
@@ -98,8 +96,7 @@ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', '
export const allConcreteAnswerOptionsExhausted = (
posts: Post[],
question: GekanatorQuestion | null,
): boolean => {
question: GekanatorQuestion | null): boolean => {
if (!(question))
return false
@@ -125,8 +122,7 @@ export const recoverCandidatePosts = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number>
answerCountAtRecovery: number
recoveryStepCount: number },
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number }): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
+8 -16
ファイルの表示
@@ -8,8 +8,7 @@ import type {
export const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
condition: GekanatorQuestion['condition']): number | null => {
if (condition.type === 'original-month')
return condition.month
@@ -24,8 +23,7 @@ export const monthForCondition = (
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
@@ -45,8 +43,7 @@ const isTitleLengthContradiction = (
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
@@ -56,8 +53,7 @@ const isQuestionRedundantAfterAnswers = (
const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source')
return false
@@ -76,8 +72,7 @@ const isSourceFactBlocked = (
const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false
@@ -96,8 +91,7 @@ const isOriginalYearFactBlocked = (
const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
switch (answer)
{
case 'yes':
@@ -143,8 +137,7 @@ const isOriginalMonthFactBlocked = (
const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (!(answer === 'yes' || answer === 'no'))
return false
@@ -156,8 +149,7 @@ const isFactQuestionBlocked = (
export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
const previous = answer.questionCondition
if (previous === undefined)
return false
+139
ファイルの表示
@@ -0,0 +1,139 @@
import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api'
import type { Material,
MaterialIndexResponse,
MaterialVersion,
FetchMaterialsParams,
MaterialFilter,
MaterialSyncSuppression,
MaterialSidebarTag,
MaterialTagTree } from '@/types'
export type FetchMaterialTreeParams = {
parentId?: number | null
materialFilter: MaterialFilter }
export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] }
export type MaterialChangesResponse = {
versions: MaterialVersion[]
count: number }
const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
export const parseMaterialFilter = (
value: unknown,
fallback: MaterialFilter = 'present',
): MaterialFilter =>
typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter)
? value as MaterialFilter
: fallback
export const fetchMaterials = async (
{ q, tagState, mediaKind, createdFrom, createdTo,
updatedFrom, updatedTo, sort, direction, page,
tagId, includeDescendants, groupBy,
limit }: FetchMaterialsParams,
): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: {
...(q && { q }),
tag_state: tagState,
media_kind: mediaKind,
...(tagId != null && { tag_id: tagId }),
...(includeDescendants && { include_descendants: '1' }),
...(groupBy !== 'none' && { group_by: groupBy }),
...(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> => {
try
{
return await apiGet (`/materials/${ id }`)
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return null
throw error
}
}
export const fetchMaterialChanges = async (
{ materialId, tag, eventType, page, limit }: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { params: {
...(materialId && { material_id: materialId }),
...(tag && { tag }),
...(eventType && { event_type: eventType }),
page,
limit} })
export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { params: {
...(parentId != null && { parent: String (parentId) }),
material_filter: materialFilter} })
export const fetchMaterialTagByName = async (
name: string,
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
try
{
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
{ params: { material_filter: materialFilter } })
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return null
throw error
}
}
export const createMaterial = async (formData: FormData): Promise<Material> =>
await apiPost ('/materials', formData)
export const updateMaterial = async (
id: string,
formData: FormData): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData)
export const fetchMaterialSyncSuppressions =
async (): Promise<MaterialSyncSuppressionResponse> =>
await apiGet ('/materials/suppressions')
export const createMaterialSyncSuppression = async (
payload: {
sourceKind: string
sourceUri?: string
drivePath?: string
driveFileId?: string
reason: string
}): Promise<MaterialSyncSuppression> =>
await apiPost ('/materials/suppressions', {
source_kind: payload.sourceKind,
source_uri: payload.sourceUri,
drive_path: payload.drivePath,
drive_file_id: payload.driveFileId,
reason: payload.reason})
+3 -6
ファイルの表示
@@ -5,8 +5,7 @@ import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
): Promise<{
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
posts: Post[]
count: number }> =>
await apiGet ('/posts', { params: {
@@ -33,8 +32,7 @@ export const fetchPostChanges = async (
post?: string
tag?: string
page: number
limit: number },
): Promise<{
limit: number }): Promise<{
versions: PostVersion[]
count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }),
@@ -53,8 +51,7 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }
) =>
merge?: boolean }) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ title: post.title,
+28 -1
ファイルの表示
@@ -1,4 +1,10 @@
import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types'
import type {
FetchNicoTagsParams,
FetchMaterialsParams,
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
} from '@/types'
export const postsKeys = {
root: ['posts'] as const,
@@ -25,6 +31,27 @@ export const tagsKeys = {
['tags', 'changes', p] as const,
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
export const materialsKeys = {
root: ['materials'] as const,
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
changes: (p: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}) => ['materials', 'changes', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const,
suppressions: () => ['materials', 'suppressions'] as const,
tree: (p: {
parentId?: number | null
materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const,
unclassified: (p: { page?: number; limit?: number } = { }) =>
['materials', 'unclassified', p] as const}
export const wikiKeys = {
root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
+4 -8
ファイルの表示
@@ -11,8 +11,7 @@ import type { Deerjikist,
export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams,
): Promise<{ tags: Tag[]
page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
count: number }> =>
await apiGet ('/tags', { params: {
...(post != null && { post }),
@@ -31,8 +30,7 @@ export const fetchTags = async (
export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
): Promise<{ tags: NicoTag[]
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[]
count: number }> =>
await apiGet ('/tags/nico', { params: {
page,
@@ -70,14 +68,12 @@ export const fetchTagChanges = async (
{ id, page, limit }: {
id?: string
page: number
limit: number },
): Promise<{
limit: number }): Promise<{
versions: TagVersion[]
count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })
export const fetchDeerjikistsByTag = async (
id: string,
): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> =>
id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
await apiGet (`/tags/${ id }/deerjikists`)
+1 -2
ファイルの表示
@@ -4,5 +4,4 @@ const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = (
user: Pick<User, 'role'> | null | undefined,
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
user: Pick<User, 'role'> | null | undefined): 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 = (
d: string | Date,
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null,
): string =>
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
toDate (d).toLocaleString (
'ja-JP-u-ca-japanese',
{ era: 'long',
@@ -28,8 +27,7 @@ export const dateString = (
export const originalCreatedAtString = (
f: string | Date | null,
b: string | Date | null,
): string => {
b: string | Date | null): string => {
const from = f ? toDate (f) : null
const before = b ? toDate (b) : null
+3 -6
ファイルの表示
@@ -4,22 +4,19 @@ import type { WikiPage } from '@/types'
export const fetchWikiPages = async (
{ title }: { title?: string },
): Promise<WikiPage[]> =>
{ title }: { title?: string }): Promise<WikiPage[]> =>
await apiGet ('/wiki', { params: { title } })
export const fetchWikiPage = async (
id: string,
{ version }: { version?: string },
): Promise<WikiPage> =>
{ version }: { version?: string }): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
export const fetchWikiPageByTitle = async (
title: string,
{ version }: { version?: string },
): Promise<WikiPage | null> => {
{ version }: { version?: string }): Promise<WikiPage | null> => {
try
{
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
+73 -146
ファイルの表示
@@ -238,8 +238,7 @@ const createGameSeed = (): string => {
const normalizeStoredQuestionId = (
questionId: string,
condition?: GekanatorQuestionCondition,
): string => {
condition?: GekanatorQuestionCondition): string => {
if (condition?.type === 'title-length-greater-than')
return `title:length-at-least:${ condition.length + 1 }`
@@ -312,8 +311,7 @@ const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
const shouldReplaceMergedQuestion = (
current: GekanatorQuestion | undefined,
candidate: GekanatorQuestion,
): boolean => {
candidate: GekanatorQuestion): boolean => {
if (!(current))
return true
@@ -408,8 +406,7 @@ const loadRecentGames = (): RecentGameSummary[] => {
const storeRecentGameSummary = (
summary: RecentGameSummary,
): RecentGameSummary[] => {
summary: RecentGameSummary): RecentGameSummary[] => {
const next =
[summary,
...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt
@@ -459,8 +456,7 @@ const resettableExtraQuestionState = (): {
const recoveredCandidateMapFromStored = (
items: RecoveredCandidatePost[],
scores: [number, number][],
): Map<number, RecoveredCandidateState> => {
scores: [number, number][]): Map<number, RecoveredCandidateState> => {
const storedScores = new Map (scores)
return new Map (items.map (item => [item.postId, {
@@ -470,8 +466,7 @@ const recoveredCandidateMapFromStored = (
const storedRecoveredCandidatesFromMap = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>,
): RecoveredCandidatePost[] =>
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): RecoveredCandidatePost[] =>
[...recoveredCandidatePosts.entries ()]
.map (([postId, recoveredCandidate]) => ({
postId,
@@ -513,8 +508,7 @@ const distributionEntropy = (weights: number[]): number =>
const questionCategoryPenalty = (
question: GekanatorQuestion,
answerCount: number,
repeatPenalty: number,
): number => {
repeatPenalty: number): number => {
const earlyFactor = Math.max (0, (3 - answerCount) / 3)
const titleLengthPenalty = (() => {
if (titleLengthMinimumForCondition (question.condition) == null)
@@ -553,8 +547,7 @@ const relatedPostIdsOf = (post: Post): number[] => {
const userPriorWeightsFor = (
posts: Post[],
recentGames: RecentGameSummary[],
): Map<number, number> => {
recentGames: RecentGameSummary[]): Map<number, number> => {
const postById = new Map (posts.map (post => [post.id, post]))
const weights = new Map<number, number> ()
const addWeight = (postId: number, weight: number) => {
@@ -581,14 +574,12 @@ const userPriorWeightsFor = (
const answerWeightFor = (
questionId: string,
softenedQuestionIds: Set<string>,
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
softenedQuestionIds: Set<string>): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
const scoreWeightForAnswer = (
answer: GekanatorAnswerLog,
softenedQuestionIds: Set<string>,
): number =>
softenedQuestionIds: Set<string>): number =>
answerWeightFor (answer.questionId, softenedQuestionIds)
* (
answer.questionPurpose === 'learning_user_suggested'
@@ -638,8 +629,7 @@ const titleTermPattern =
const addPostIdToIndex = <K extends string | number> (
index: Map<K, Set<number>>,
key: K,
postId: number,
) => {
postId: number) => {
const current = index.get (key)
if (current)
{
@@ -652,8 +642,7 @@ const addPostIdToIndex = <K extends string | number> (
const buildMaterialIndex = (
posts: Post[],
): GekanatorQuestionMaterialIndex => {
posts: Post[]): GekanatorQuestionMaterialIndex => {
const postById = new Map<number, Post> ()
const tagKeysByPostId = new Map<number, string[]> ()
const postIdsByTagKey = new Map<string, Set<number>> ()
@@ -784,8 +773,7 @@ const originalDateQuestionTextFor = (
condition: Extract<
GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' }
>,
): string => {
>): string => {
switch (condition.type)
{
case 'original-year':
@@ -852,8 +840,7 @@ const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:'))
const isUserSuggestedLearnedSemanticQuestion = (
question: GekanatorQuestion,
): boolean => isLearnedSemanticQuestion (question)
question: GekanatorQuestion): boolean => isLearnedSemanticQuestion (question)
type LearnedSemanticCandidateStats = {
@@ -872,8 +859,7 @@ const learnedSemanticStatsForCandidateIds = (
question }: {
candidateIds: number[]
posts: Post[]
question: GekanatorQuestion },
): LearnedSemanticCandidateStats => {
question: GekanatorQuestion }): LearnedSemanticCandidateStats => {
const candidateIdSet = new Set (candidateIds)
const positiveIds = new Set<number> ()
const negativeIds = new Set<number> ()
@@ -915,8 +901,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
question }: {
candidateIds: number[]
posts: Post[]
question: GekanatorQuestion },
): boolean => {
question: GekanatorQuestion }): boolean => {
if (!(isUserSuggestedLearnedSemanticQuestion (question)))
return false
@@ -936,8 +921,7 @@ const learnedSemanticQuestionIsEffectiveForCandidateIds = (
const directSemanticAnswerForPost = (
question: GekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
post: Post): GekanatorAnswerValue | null => {
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct)
return direct
@@ -956,8 +940,7 @@ const learnedSemanticLearningValueForTopPosts = (
question: GekanatorQuestion
learningTargetPosts: Post[]
candidateIds: number[]
posts: Post[] },
): { missingTopCount: number
posts: Post[] }): { missingTopCount: number
knownCount: number
hasLearningValue: boolean } => {
const missingTopCount =
@@ -1000,8 +983,7 @@ const learningTargetPostsForCandidates = ({
const questionPurposeCountsFor = (
answers: GekanatorAnswerLog[],
): {
answers: GekanatorAnswerLog[]): {
effectiveUserSuggestedCount: number
learningUserSuggestedCount: number
normalQuestionCount: number
@@ -1041,8 +1023,7 @@ const questionPurposeCountsFor = (
const learnedSemanticNarrowPenaltyForStats = (
candidateCount: number,
stats: LearnedSemanticCandidateStats,
): number => {
stats: LearnedSemanticCandidateStats): number => {
const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08)
return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0
}
@@ -1050,8 +1031,7 @@ const learnedSemanticNarrowPenaltyForStats = (
const learnedSemanticScoreDeltaForExpectedAnswer = (
userAnswer: GekanatorAnswerValue,
expectedAnswer: GekanatorAnswerValue | null,
): number => {
expectedAnswer: GekanatorAnswerValue | null): number => {
switch (userAnswer)
{
case 'yes':
@@ -1089,8 +1069,7 @@ const learnedSemanticScoreDeltaForExpectedAnswer = (
const scoreDropDeltaForRecoveredPost = (
postId: number,
totalScore: number,
recoveredCandidatePosts: Map<number, RecoveredCandidateState>,
): number => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): number => {
const recoveredCandidate = recoveredCandidatePosts.get (postId)
if (recoveredCandidate == null)
return totalScore
@@ -1114,8 +1093,7 @@ const postPassesScoreDrop = (
recoveredCandidatePosts }: {
postId: number
scores: Map<number, number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): boolean => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): boolean => {
if (!(activeCandidateScoreDropEnabled (scores)))
return true
@@ -1129,8 +1107,7 @@ const postPassesScoreDrop = (
// `post_similarities` is the score-propagation graph, not the question kind.
const questionUsesPostSimilarityPropagationGraphForScoring = (
question: GekanatorQuestion,
): boolean =>
question: GekanatorQuestion): boolean =>
(question.kind === 'post_similarity'
&& !(isUserSuggestedLearnedSemanticQuestion (question)))
|| (question.kind === 'tag'
@@ -1139,8 +1116,7 @@ const questionUsesPostSimilarityPropagationGraphForScoring = (
const questionSupportsAnswerBasedHardFiltering = (
question: GekanatorQuestion,
): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
question: GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
&& !(isUserSuggestedLearnedSemanticQuestion (question))
@@ -1160,8 +1136,7 @@ const usesLearnedTagExamples = (question: GekanatorQuestion): boolean =>
const searchedQuestionsFor = (
questions: GekanatorQuestion[],
search: string,
): GekanatorQuestion[] => {
search: string): GekanatorQuestion[] => {
const needle = search.trim ()
if (!(needle))
return []
@@ -1235,8 +1210,7 @@ type QuestionMatchResolver = {
const buildGekanatorMatchIndex = (
posts: Post[],
questions: GekanatorQuestion[],
): GekanatorMatchIndex => new Map (
questions: GekanatorQuestion[]): GekanatorMatchIndex => new Map (
questions.map (question => [
question.id,
new Set (
@@ -1285,8 +1259,7 @@ const matchingPostIdsForQuestion = ({
const positiveMatchingPostIdsForQuestion = (
resolver: QuestionMatchResolver,
): Set<number> => {
resolver: QuestionMatchResolver): Set<number> => {
if (isUserSuggestedLearnedSemanticQuestion (resolver.question))
{
const cached = resolver.dynamicMatchIndex?.get (resolver.question.id)
@@ -1356,8 +1329,7 @@ const matchingWeightInCandidates = (
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex },
): number => {
dynamicMatchIndex?: GekanatorMatchIndex }): number => {
const matched = positiveMatchingPostIdsForQuestion ({
posts,
materialIndex,
@@ -1380,8 +1352,7 @@ const signatureForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
question: GekanatorQuestion
dynamicMatchIndex?: GekanatorMatchIndex },
): string => {
dynamicMatchIndex?: GekanatorMatchIndex }): string => {
if (isUserSuggestedLearnedSemanticQuestion (question))
{
const postById = new Map (posts.map (post => [post.id, post]))
@@ -1421,8 +1392,7 @@ const postIdsForHardAnswer = (
posts: Post[]
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex
dynamicMatchIndex?: GekanatorMatchIndex },
): number[] => {
dynamicMatchIndex?: GekanatorMatchIndex }): number[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return candidateIds
@@ -1562,8 +1532,7 @@ const buildIndexedQuestion = (
text: string
kind: GekanatorQuestionKind
priorityWeight: number
materialIndex: GekanatorQuestionMaterialIndex },
): GekanatorQuestion => ({
materialIndex: GekanatorQuestionMaterialIndex }): GekanatorQuestion => ({
id: questionIdForCondition (condition),
text,
kind,
@@ -1578,8 +1547,7 @@ const buildIndexedQuestion = (
const rankedEntriesForCounts = <T extends string | number> (
{ counts, total, cap }: { counts: Map<T, number>
total: number
cap: number },
): [T, number][] =>
cap: number }): [T, number][] =>
([...counts.entries ()]
.filter (([, count]) => count > 0 && count < total)
.sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1]))
@@ -1594,8 +1562,7 @@ const buildQuestionsForCandidateIds = (
materialIndex: GekanatorQuestionMaterialIndex
acceptedQuestions: GekanatorQuestion[]
mode?: QuestionBuildMode
confirmationPostId?: number | null },
): GekanatorQuestion[] => {
confirmationPostId?: number | null }): GekanatorQuestion[] => {
const total = candidateIds.length
const confirmationPost = (() => {
if (confirmationPostId == null)
@@ -1652,8 +1619,7 @@ const buildQuestionsForCandidateIds = (
condition: Extract<
GekanatorQuestionCondition,
{ type: 'original-year' | 'original-month' | 'original-month-day' }
>,
): GekanatorQuestion => {
>): GekanatorQuestion => {
const priorityWeight = (() => {
if (condition.type === 'original-year')
return 1.04
@@ -1673,8 +1639,7 @@ const buildQuestionsForCandidateIds = (
const specialMonthDays = rankedEntriesForCounts ({
counts: monthDayCounts,
total,
cap: factCap
}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
if (mode === 'split')
{
@@ -2124,8 +2089,7 @@ type ExclusiveConditionGroup =
const exclusiveConditionGroupFor = (
condition: GekanatorQuestion['condition'],
): ExclusiveConditionGroup | null => {
condition: GekanatorQuestion['condition']): ExclusiveConditionGroup | null => {
switch (condition.type)
{
case 'original-month':
@@ -2144,8 +2108,7 @@ const exclusiveConditionGroupFor = (
const sameConditionValue = (
left: GekanatorQuestion['condition'],
right: GekanatorQuestion['condition'],
): boolean => {
right: GekanatorQuestion['condition']): boolean => {
const leftTitleLength = titleLengthMinimumForCondition (left)
const rightTitleLength = titleLengthMinimumForCondition (right)
if (leftTitleLength != null || rightTitleLength != null)
@@ -2187,8 +2150,7 @@ const sameConditionValue = (
const isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
previous: GekanatorQuestion['condition']): boolean => {
const candidateMonth = monthForCondition (candidate)
const previousMonth = monthForCondition (previous)
if (candidateMonth == null || previousMonth == null)
@@ -2204,8 +2166,7 @@ const isMonthCrossMatch = (
const isExclusiveContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
): boolean => {
previous: GekanatorQuestion['condition']): boolean => {
const candidateGroup = exclusiveConditionGroupFor (candidate)
const previousGroup = exclusiveConditionGroupFor (previous)
@@ -2242,16 +2203,14 @@ const contradictionPenaltyFor = ({
case 'no':
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
)
|| isMonthCrossMatch (question.condition, previous))
return sum + 40
return sum
case 'probably_no':
if (
sameConditionValue (question.condition, previous)
|| isMonthCrossMatch (question.condition, previous)
)
|| isMonthCrossMatch (question.condition, previous))
return sum + 20
return sum
@@ -2281,8 +2240,7 @@ const chooseQuestion = (
recentFirstQuestionPenaltyById: Map<string, number>
userPriorWeights: Map<number, number>
materialIndex: GekanatorQuestionMaterialIndex
matchIndex: GekanatorMatchIndex },
): QuestionSelection | null => {
matchIndex: GekanatorMatchIndex }): QuestionSelection | null => {
const dynamicMatchIndex = new Map<string, Set<number>> ()
const invertedSignature = (signature: string): string =>
@@ -2327,8 +2285,7 @@ const chooseQuestion = (
const rank = (
questionsToRank: GekanatorQuestion[],
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 candidateById = new Map (candidates.map (item => [item.post.id, item.post]))
const candidateIds = candidates.map (item => item.post.id)
@@ -2613,8 +2570,7 @@ const chooseQuestion = (
if (
effectiveRatio < targetEffectiveUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& effectiveUserSuggestedPool.length > 0
)
&& effectiveUserSuggestedPool.length > 0)
{
selectedPool = effectiveUserSuggestedPool
selectedPurpose = 'effective_user_suggested'
@@ -2622,8 +2578,7 @@ const chooseQuestion = (
else if (
learningRatio < targetLearningUserSuggestedQuestionRatio
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
&& learningUserSuggestedPool.length > 0
)
&& learningUserSuggestedPool.length > 0)
{
selectedPool = learningUserSuggestedPool
selectedPurpose = 'learning_user_suggested'
@@ -2683,8 +2638,7 @@ const chooseQuestion = (
const winningRunPriorityFor = (
expected: GekanatorAnswerValue,
): number | null => {
expected: GekanatorAnswerValue): number | null => {
if (expected === 'yes')
return 0
if (expected === 'partial')
@@ -2719,8 +2673,7 @@ const chooseWinningRunQuestion = ({
materialIndex,
acceptedQuestions,
mode: 'confirmation',
confirmationPostId: targetPost.id
})
confirmationPostId: targetPost.id})
.filter (question => {
if (askedIds.has (question.id))
return false
@@ -2829,8 +2782,7 @@ const chooseFallbackQuestion = ({
materialIndex,
acceptedQuestions: [],
mode: 'confirmation',
confirmationPostId: post.id
})))
confirmationPostId: post.id})))
.slice (0, 32)
const dynamicMatchIndex = new Map<string, Set<number>> ()
const ranked = mergeQuestions ([
@@ -2905,8 +2857,7 @@ const chooseFallbackQuestion = ({
const shouldEnterGuessPhase = (
reason: GuessReason | null,
): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
reason: GuessReason | null): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
(reason === 'hard_max_questions'
|| reason === 'winning_run_finished'
|| reason === 'question_count_checkpoint')
@@ -2914,15 +2865,13 @@ const shouldEnterGuessPhase = (
const isWinningRunActive = (
winningRunTargetId: number | null,
winningRunStartAnswerCount: number | null,
): boolean =>
winningRunStartAnswerCount: number | null): boolean =>
winningRunTargetId != null && winningRunStartAnswerCount != null
const winningRunQuestionCount = (
answers: GekanatorAnswerLog[],
winningRunStartAnswerCount: number | null,
): number => {
winningRunStartAnswerCount: number | null): number => {
if (winningRunStartAnswerCount == null)
return 0
@@ -2962,8 +2911,7 @@ const nextQuestionPlanFor = (
matchIndex: GekanatorMatchIndex
lastGuessQuestionCount: number
winningRunTargetId: number | null
winningRunStartAnswerCount: number | null },
): { question: GekanatorQuestion | null
winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null
guess: Post | null
guessReason: GuessReason | null
questionMode: QuestionMode
@@ -3013,8 +2961,7 @@ const nextQuestionPlanFor = (
if (
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
&& winningRunTargetId === nextWinningRunTargetId
&& winningRunStartAnswerCount != null
)
&& winningRunStartAnswerCount != null)
return winningRunStartAnswerCount
return answers.length
@@ -3188,8 +3135,7 @@ const mascotStateFor = (
resultWon: boolean | null,
eligiblePostCount: number,
bestConfidencePercent: number,
winningRunActive: boolean,
): MascotState => {
winningRunActive: boolean): MascotState => {
const resultPhase =
phase === 'end'
|| phase === 'review'
@@ -3210,13 +3156,11 @@ const mascotStateFor = (
if (
winningRunActive
|| eligiblePostCount <= 2
|| bestConfidencePercent >= 70
)
|| bestConfidencePercent >= 70)
return 'thinking_near'
if (
eligiblePostCount >= 15
&& bestConfidencePercent < 45
)
&& bestConfidencePercent < 45)
return 'thinking_far'
return 'thinking_mid'
case 'guess':
@@ -3327,8 +3271,7 @@ const GekanatorBackdrop: FC<{
const settingsForMode = useCallback (
(
mode: 'normal' | 'winning_run' | 'guess',
): { columns: number; rows: number; opacity: number } => {
mode: 'normal' | 'winning_run' | 'guess'): { columns: number; rows: number; opacity: number } => {
if (mode === 'winning_run' || mode === 'guess')
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
@@ -3342,8 +3285,7 @@ const GekanatorBackdrop: FC<{
const scaleForMode = useCallback (
(
mode: 'normal' | 'winning_run' | 'guess',
displayedWinningCount: number,
): number => {
displayedWinningCount: number): number => {
if (mode === 'guess')
return 8
@@ -3355,8 +3297,7 @@ const GekanatorBackdrop: FC<{
[])
const postsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess',
): Post[] => {
mode: 'normal' | 'winning_run' | 'guess'): Post[] => {
if (mode === 'guess' && displayedGuess)
return [displayedGuess]
if (mode === 'winning_run' && winningRunTargetPost)
@@ -3366,8 +3307,7 @@ const GekanatorBackdrop: FC<{
const thumbnailsForMode = useCallback ((
mode: 'normal' | 'winning_run' | 'guess',
count: number,
): string[] => {
count: number): string[] => {
const modePosts = postsForMode (mode)
if (modePosts.length === 0)
return []
@@ -3734,8 +3674,7 @@ const GekanatorBackdrop: FC<{
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): GekanatorAnswerValue | null =>
correctPost: Post | null): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost)
@@ -4169,8 +4108,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (true)
setSavedGameId (data.id)
setLearnedExampleCount (data.learnedExampleCount)
setResultWon (variables.guessedPostId === variables.correctPostId)
}})
setResultWon (variables.guessedPostId === variables.correctPostId)}})
const questionSuggestionMutation = useMutation ({
mutationFn: saveGekanatorQuestionSuggestion,
onSuccess: async data => {
@@ -4180,15 +4118,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
}})
setQuestionSuggestionAnswer ('yes')}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: async () => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setExtraQuestionState ('saved')
setPhase ('end')
}})
setPhase ('end')}})
const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState ()
@@ -4330,8 +4266,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
!(allowPreQuestionRecovery)
|| recoveredEligiblePosts.length === 0
|| recoveredEligiblePosts.length === 1
)
|| recoveredEligiblePosts.length === 1)
return false
const nextQuestion = chooseQuestion ({
@@ -4493,8 +4428,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
!(nextPlan.question)
&& !(shouldEnterGuessPhase (nextPlan.guessReason))
&& recovered.eligiblePosts.length !== 1
)
&& recovered.eligiblePosts.length !== 1)
{
const recoveredForQuestion = recoverQuestionState ({
nextAnswers,
@@ -4602,8 +4536,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame)
|| reviewGuessedPostId == null
|| reviewCorrectPostId == null
|| saveMutation.isPending
)
|| saveMutation.isPending)
return
if (savedGameId != null)
@@ -4666,8 +4599,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
!(canPersistGame)
|| savedGameId == null
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
)
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])))
return
extraQuestionAnswersMutation.mutate ({
@@ -4870,8 +4802,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const answerExtraQuestion = (
questionId: number,
value: GekanatorAnswerValue,
) => {
value: GekanatorAnswerValue) => {
setExtraQuestionAnswers ({
...extraQuestionAnswers,
[String (questionId)]: value })
@@ -4909,8 +4840,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|| isLoading
|| acceptedQuestionsLoading
|| shouldEnterGuessPhase (questionPlan.guessReason)
|| eligiblePosts.length === 1
)
|| eligiblePosts.length === 1)
return
const recovered = recoverQuestionState ({
@@ -4926,8 +4856,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
recovered.recoveryStepCount === recoveryStepCount
&& recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size
)
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size)
return
setSoftenedQuestionIds (recovered.softenedQuestionIds)
@@ -4954,15 +4883,13 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
if (
phase !== 'question'
|| isLoading
|| acceptedQuestionsLoading
)
|| acceptedQuestionsLoading)
return
if (
currentQuestion
|| !(questionPlan.guess)
|| !(shouldEnterGuessPhase (questionPlan.guessReason))
)
|| !(shouldEnterGuessPhase (questionPlan.guessReason)))
return
setWinningRunTargetId (questionPlan.winningRunTargetId)
+2 -10
ファイルの表示
@@ -1,6 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
@@ -79,11 +78,6 @@ const DeerjikistDetailPage: FC = () => {
return (
<MainArea>
<Helmet>
<meta name="robots" content="noindex"/>
{tag && <title>{tag.name} | </title>}
</Helmet>
{(loading || !(tag)) ? 'Loading...' : (
<div className="max-w-xl">
<PageTitle>
@@ -151,8 +145,7 @@ const DeerjikistDetailPage: FC = () => {
return rtn
})}/>)}
</FormField>
</fieldset>
))}
</fieldset>))}
<div className="py-3">
<button
@@ -175,8 +168,7 @@ const DeerjikistDetailPage: FC = () => {
</button>
</div>
</form>
</div>
)}
</div>)}
</MainArea>)
}
+29 -4
ファイルの表示
@@ -7,8 +7,9 @@ import { buildMaterial, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPut: vi.fn (),
apiGet: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const wikiApi = vi.hoisted (() => ({
@@ -26,7 +27,7 @@ vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () =>
renderWithProviders (
<Routes>
<Route path="/materials/:id" element={<MaterialDetailPage/>}/>
<Route path="/materials/:id" element={<MaterialDetailPage user={null}/>}/>
</Routes>,
{ route: '/materials/8' },
)
@@ -34,7 +35,11 @@ const renderPage = () =>
describe ('MaterialDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([])
api.apiGet.mockImplementation (async (path: string) =>
path === '/tags/autocomplete'
? []
: buildMaterial ({ id: 8 }))
api.isApiError.mockReturnValue (false)
wikiApi.fetchWikiPages.mockResolvedValue ([])
vi.stubGlobal ('fetch', vi.fn (async () => ({
blob: async () => new Blob (['image'], { type: 'image/png' }),
@@ -73,6 +78,7 @@ describe ('MaterialDetailPage', () => {
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'new' } })
fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
fireEvent.change (textboxes[2], { target: { value: '素材/new.png' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => {
@@ -81,6 +87,25 @@ describe ('MaterialDetailPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
})
it ('shows not found when material API returns 404', async () => {
api.isApiError.mockReturnValue (true)
api.apiGet.mockRejectedValueOnce ({ response: { status: 404 } })
renderPage ()
expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument ()
})
it ('shows an error for non-404 material API failures', async () => {
api.isApiError.mockReturnValue (false)
api.apiGet.mockRejectedValueOnce (new Error ('network error'))
renderPage ()
expect (await screen.findByText ('素材の取得に失敗しました.')).toBeInTheDocument ()
})
})
+105 -85
ファイルの表示
@@ -1,3 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
@@ -13,124 +14,130 @@ import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api'
import {
fetchMaterial,
updateMaterial,
} from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react'
import type { Material, Tag } from '@/types'
type MaterialWithTag = Material & { tag: Tag }
type MaterialFormField = 'tag' | 'file' | 'url'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC = () => {
const { id } = useParams ()
const qc = useQueryClient ()
const [exportPath, setExportPath] = useState ('')
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [loading, setLoading] = useState (false)
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)
try
{
setSending (true)
const data = await apiPut<Material> (`/materials/${ id }`, formData)
setMaterial (data)
toast ({ title: '更新成功!' })
}
catch (e)
{
applyValidationError (e)
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? ''),
queryFn: () => fetchMaterial (id ?? ''),
enabled: id != null})
const materialTitle = material
? material.tag?.name ?? `素材 #${ material.id }`
: ''
useEffect (() => {
if (!(id))
if (!(material))
return
void (async () => {
try
setTag (material.tag?.name ?? '')
setURL (material.url ?? '')
setExportPath (material.exportPaths.legacyDrive ?? '')
if (material.file && material.contentType)
{
setLoading (true)
const data = await apiGet<MaterialWithTag> (`/materials/${ id }`)
setMaterial (data)
setTag (data.tag.name)
if (data.file && data.contentType)
{
setFilePreview (data.file)
setFile (new File ([await (await fetch (data.file)).blob ()],
data.file,
{ type: data.contentType }))
}
setURL (data.url ?? '')
setFilePreview (material.file)
setFile (null)
}
finally
{
setLoading (false)
}
}) ()
}, [id])
}, [material])
const invalidateMaterialQueries = async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
}
const updateMutation = useMutation ({
mutationFn: async () => {
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await updateMaterial (id ?? '', formData)
},
onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? ''), data)
await invalidateMaterialQueries ()
toast ({ title: '更新成功!' })
},
onError: error => {
applyValidationError (error)
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}})
const handleSubmit = () => {
clearValidationErrors ()
updateMutation.mutate ()
}
return (
<MainArea>
{material && (
<Helmet>
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
<title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)}
{loading ? 'Loading...' : (material && (
{isLoading ? 'Loading...' : isError ? (
<p className="text-red-600 dark:text-red-300">
</p>) : material == null ? (
<p className="text-stone-700 dark:text-stone-300">
</p>) : (
<>
<PageTitle>
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>
{material.tag
? (
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>)
: materialTitle}
</PageTitle>
{(material.file && 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 src={material.file} controls/>))
|| (/audio\/.*/.test (material.contentType) && (
<audio src={material.file} controls/>)))}
<TabGroup>
<Tab name="Wiki">
<WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>
</Tab>
{material.tag && (
<Tab name="Wiki">
<WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>
</Tab>)}
<Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4">
<div className="max-w-wl space-y-4 pt-2">
<FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
@@ -140,7 +147,6 @@ const MaterialDetailPage: FC = () => {
setValue={setTag}/>)}
</FormField>
{/* ファイル */}
<FormField label="ファイル" messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
@@ -150,9 +156,10 @@ const MaterialDetailPage: FC = () => {
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
const nextFile = e.target.files?.[0]
setFile (nextFile ?? null)
setFilePreview (
nextFile ? URL.createObjectURL (nextFile) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
@@ -177,7 +184,6 @@ const MaterialDetailPage: FC = () => {
</>)}
</FormField>
{/* 参考 URL */}
<FormField label="参考 URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
@@ -189,17 +195,31 @@ const MaterialDetailPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
<FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleSubmit}
className="rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={updateMutation.isPending}>
</Button>
</div>
</div>
</Tab>
</TabGroup>
</>))}
</>)}
</MainArea>)
}
+189
ファイルの表示
@@ -0,0 +1,189 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => {
const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search)
const materialId = query.get ('material_id') ?? ''
const tag = query.get ('tag') ?? ''
const eventType = query.get ('event_type') ?? ''
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const [materialIdInput, setMaterialIdInput] = useState (materialId)
const [tagInput, setTagInput] = useState (tag)
const [eventTypeInput, setEventTypeInput] = useState (eventType)
useEffect (() => {
setMaterialIdInput (materialId)
setTagInput (tag)
setEventTypeInput (eventType)
}, [eventType, materialId, tag])
const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.changes ({
...(materialId && { materialId }),
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }),
queryFn: () => fetchMaterialChanges ({
...(materialId && { materialId }),
...(tag && { tag }),
...(eventType && { eventType }),
page,
limit }) })
const versions = data?.versions ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
const handleSearch = (event: FormEvent) => {
event.preventDefault ()
const qs = new URLSearchParams ()
if (materialIdInput.trim ())
qs.set ('material_id', materialIdInput.trim ())
if (tagInput.trim ())
qs.set ('tag', tagInput.trim ())
if (eventTypeInput.trim ())
qs.set ('event_type', eventTypeInput.trim ())
qs.set ('page', '1')
qs.set ('limit', String (limit))
navigate (`/materials/changes?${ qs.toString () }`)
}
return (
<MainArea>
<Helmet>
<title>{`素材履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="space-y-5">
<PageTitle></PageTitle>
<form
onSubmit={handleSearch}
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="素材 ID">
{({ invalid }) => (
<input
type="text"
value={materialIdInput}
onChange={e => setMaterialIdInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="タグ名">
{({ invalid }) => (
<input
type="text"
value={tagInput}
onChange={e => setTagInput (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
</div>
<button
type="submit"
className="mt-4 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">
</button>
</form>
{isLoading
? 'Loading...'
: isError
? (
<p className="text-red-600 dark:text-red-300">
</p>)
: (
<>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] table-fixed border-collapse">
<colgroup>
<col className="w-48"/>
<col className="w-28"/>
<col className="w-24"/>
<col className="w-64"/>
<col className="w-64"/>
<col className="w-80"/>
<col className="w-80"/>
<col className="w-48"/>
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"> ID</th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left">URL</th>
<th className="p-2 text-left">export_path</th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{versions.map (version => (
<tr
key={version.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">{dateString (version.createdAt)}</td>
<td className="p-2">
<PrefetchLink to={`/materials/${ version.materialId }`}>
#{version.materialId}
</PrefetchLink>
</td>
<td className="p-2">{version.versionNo}</td>
<td className="p-2 break-all">{version.tagName}</td>
<td className="p-2 break-all">{version.fileFilename}</td>
<td className="p-2 break-all">{version.url}</td>
<td className="p-2 break-all">
{version.exportPathsJson.legacy_drive}
</td>
<td className="p-2">
{version.createdByUser
? version.createdByUser.name || `#${ version.createdByUser.id }`
: 'bot 操作'}
</td>
</tr>))}
</tbody>
</table>
</div>
<Pagination page={page} totalPages={totalPages}/>
</>)}
</div>
</MainArea>)
}
export default MaterialHistoryPage
+151 -33
ファイルの表示
@@ -1,5 +1,5 @@
import { screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import { buildMaterial, buildTag } from '@/test/factories'
@@ -12,51 +12,169 @@ const api = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
describe ('MaterialListPage', () => {
it ('shows the empty selection guide without a tag query', () => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' })
expect (screen.getByText ('左のリストから照会したいタグを選択してください。')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '素材を新規追加する' })).toHaveAttribute (
'href',
'/materials/new',
)
beforeEach (() => {
vi.clearAllMocks ()
api.apiGet.mockResolvedValue ({ materials: [], count: 0 })
})
it ('loads materials for a tag query', async () => {
const tag = {
...buildTag ({
id: 4,
name: '素材タグ',
category: 'material',
}),
material: buildMaterial ({ id: 8, contentType: 'image/png', file: 'image.png' }),
children: [],
}
api.apiGet.mockResolvedValueOnce (tag)
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag=%E7%B4%A0%E6%9D%90' })
it ('loads the material index on the initial page', async () => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/name/%E7%B4%A0%E6%9D%90/materials',
'/materials',
{ params: {
tag_state: 'all',
media_kind: 'all',
sort: 'created_at',
direction: 'desc',
page: 1,
limit: 20,
} },
)
})
expect (await screen.findByRole ('link', { name: '素材タグ' })).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '' })).toHaveAttribute ('href', '/materials/8')
expect (await screen.findByText ('素材はありません。')).toBeInTheDocument ()
})
it ('offers adding a missing non-meme material', async () => {
it ('shows materials in the default card view', async () => {
api.apiGet.mockResolvedValueOnce ({
...buildTag ({ name: '未登録', category: 'material' }),
material: null,
children: [],
materials: [
buildMaterial ({
id: 8,
tag: buildTag ({ name: '素材タグ', category: 'material' }),
thumbnail: 'thumb.png',
mediaKind: 'image',
contentType: 'image/png',
}),
],
count: 1,
})
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag=x' })
renderWithProviders (<MaterialListPage/>, { route: '/materials' })
expect (await screen.findByRole ('link', { name: '追加' })).toHaveAttribute (
expect ((await screen.findByText ('素材タグ')).closest ('a'))
.toHaveAttribute ('href', '/materials/8')
expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument ()
})
it ('submits list filters as material index params', async () => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' })
fireEvent.change (screen.getByPlaceholderText ('タグ名 / URL / ファイル名'), {
target: { value: '虹夏' },
})
const selects = screen.getAllByRole ('combobox')
fireEvent.change (selects[0], {
target: { value: 'untagged' },
})
fireEvent.change (selects[1], {
target: { value: 'image' },
})
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/materials',
expect.objectContaining ({ params: expect.objectContaining ({
q: '虹夏',
tag_state: 'untagged',
media_kind: 'image',
}) }),
)
})
})
it ('keeps a route back to the material search top for untagged materials', async () => {
renderWithProviders (
<MaterialListPage/>,
{ route: '/materials?tag_state=untagged&material_filter=any' },
)
expect (await screen.findByRole (
'link',
{ name: '素材検索トップへ戻る' },
)).toHaveAttribute ('href', '/materials?material_filter=any')
})
it ('uses list view for detailed material rows', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [
buildMaterial ({
id: 9,
tag: buildTag ({ name: '詳細素材', category: 'material' }),
mediaKind: 'file_other',
fileByteSize: 2048,
}),
],
count: 1,
})
renderWithProviders (<MaterialListPage/>, { route: '/materials?view=list' })
expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute (
'href',
'/materials/new?tag=%E6%9C%AA%E7%99%BB%E9%8C%B2',
'/materials/9',
)
expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
})
it ('shows the selected tag scope and tag-specific add links', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [
buildMaterial ({
id: 10,
tag: buildTag ({ id: 30, name: '泣き', category: 'material' }),
}),
],
count: 1,
tagScope: {
tag: { id: 20, name: '伊地知ニジカ', category: 'material' },
includeDescendants: true,
},
groups: [
{
key: 'tag:30',
tag: { id: 30, name: '泣き', category: 'material' },
materialIds: [10],
count: 1,
},
],
})
renderWithProviders (
<MaterialListPage/>,
{ route: '/materials?tag_id=20&include_descendants=1&group_by=parent_tag' },
)
expect (await screen.findByText (
(_, element) => element?.textContent === '伊地知ニジカ 配下の素材を表示中',
)).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute (
'href',
'/materials?tag_id=30&include_descendants=1&group_by=parent_tag&material_filter=present',
)
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
)
})
it ('shows a clear link when tag_id does not resolve to tag_scope', async () => {
api.apiGet.mockResolvedValueOnce ({
materials: [],
count: 0,
tagScope: null,
groups: [],
})
renderWithProviders (<MaterialListPage/>, { route: '/materials?tag_id=999' })
expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href',
'/materials?material_filter=present',
)
})
})
+564 -125
ファイルの表示
@@ -1,75 +1,395 @@
import { Fragment, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FC } from 'react'
import type { FC, FormEvent } from 'react'
import type { Material, Tag } from '@/types'
import type { FetchMaterialsParams,
Material,
MaterialFilter,
MaterialIndexGroup,
MaterialIndexGroupBy,
MaterialIndexDirection,
MaterialIndexMediaKind,
MaterialIndexSort,
MaterialIndexTagState,
MaterialIndexView } from '@/types'
type TagWithMaterial = Omit<Tag, 'children'> & {
children: TagWithMaterial[]
material: Material | null }
const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: 'URL のみ'}
const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
all: 'すべて',
image: '画像',
video: '動画',
audio: '音声',
file_other: 'その他ファイル',
url_only: '外部リンクのみ'}
const SORT_LABELS: Record<MaterialIndexSort, string> = {
created_at: '作成日時',
updated_at: '更新日時',
tag_name: 'タグ名',
media_kind: '種類',
file_byte_size: '容量',
version_no: '版',
id: 'Id.'}
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
if (!(tag.material))
return
return (
<PrefetchLink
to={`/materials/${ tag.material.id }`}
className="block w-40 h-40">
<div
className="w-full h-full overflow-hidden rounded-xl shadow
text-center content-center text-4xl"
style={{ fontFamily: 'Nikumaru' }}>
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/>
: <span></span>}
</div>
</PrefetchLink>)
const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
const next = value?.trim ()
if (next)
qs.set (key, next)
}
const MaterialListPage: FC = () => {
const [loading, setLoading] = useState (false)
const [tag, setTag] = useState<TagWithMaterial | null> (null)
const parseOption = <T extends string> (
value: string | null,
allowed: readonly T[],
fallback: T): T => allowed.includes (value as T) ? value as T : fallback
const fileSizeText = (bytes: number | null): string => {
if (bytes == null)
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 groupedTagPath = (
tagId: number,
materialFilter: MaterialFilter,
): string =>
`/materials?tag_id=${ tagId }&include_descendants=1&group_by=parent_tag`
+ `&material_filter=${ materialFilter }`
const materialNewPath = (
tagName: string,
returnTo: string,
): string =>
`/materials/new?tag=${ encodeURIComponent (tagName) }`
+ `&return_to=${ encodeURIComponent (returnTo) }`
const clearedTagSelectionPath = (
locationSearch: string,
materialFilter: MaterialFilter,
): string => {
const qs = new URLSearchParams (locationSearch)
qs.delete ('tag_id')
qs.delete ('include_descendants')
qs.delete ('group_by')
qs.delete ('page')
qs.set ('material_filter', materialFilter)
return `/materials?${ qs.toString () }`
}
const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
<div
className={`flex aspect-square h-[180px] w-[180px] items-center justify-center
overflow-hidden rounded-lg border border-stone-200 bg-white text-center
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100`}>
{material.thumbnail
? <img src={material.thumbnail} alt="" className="block h-full w-full object-cover"/>
: (
<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 border-stone-200 bg-white p-3 text-stone-900
shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
<div className="flex gap-3">
<MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2">
<div>
<PrefetchLink
to={`/materials/${ material.id }`}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300">
{materialTitle (material)}
</PrefetchLink>
</div>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
<div>
<dt className="inline">: </dt>
<dd className="inline">{MEDIA_KIND_LABELS[material.mediaKind]}</dd>
</div>
{material.fileByteSize != null && (
<div>
<dt className="inline">: </dt>
<dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</div>)}
{material.url && (
<div>
<dt className="inline">URL: </dt>
<dd className="inline break-all">{material.url}</dd>
</div>)}
<div>
<dt className="inline">: </dt>
<dd className="inline">{dateString (material.createdAt)}</dd>
</div>
</dl>
</div>
</div>
</article>)
const GroupHeading: FC<{
count: number
materialFilter: MaterialFilter
title: string
tagId: number
}> = ({ count, materialFilter, title, tagId }) => (
<div className="flex items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700">
<PrefetchLink
to={groupedTagPath (tagId, materialFilter)}
className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300 w-full">
{title}
</PrefetchLink>
<div className="text-right w-auto">
<span className="text-nowrap text-sm text-stone-600 dark:text-stone-300">
{count}
</span>
</div>
</div>)
const MaterialListPage: FC = () => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const navigate = useNavigate ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const materialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const qQuery = query.get ('q') ?? ''
const tagIdQuery = Number (query.get ('tag_id') ?? 0)
const tagId = tagIdQuery > 0 ? tagIdQuery : null
const includeDescendants = query.get ('include_descendants') === '1'
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 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 groupByQuery = parseOption<MaterialIndexGroupBy> (
query.get ('group_by'),
['none', 'parent_tag'],
'none')
const groupBy = tagId == null ? 'none' : groupByQuery
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 [q, setQ] = useState ('')
const [tagStateInput, setTagStateInput] = useState<MaterialIndexTagState> ('all')
const [mediaKindInput, setMediaKindInput] = useState<MaterialIndexMediaKind> ('all')
const [groupByInput, setGroupByInput] = useState<MaterialIndexGroupBy> ('none')
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
const keys: FetchMaterialsParams = {
q: qQuery,
tagState,
mediaKind,
tagId,
includeDescendants,
groupBy,
createdFrom: createdFromQuery,
createdTo: createdToQuery,
updatedFrom: updatedFromQuery,
updatedTo: updatedToQuery,
sort,
direction,
view,
page,
limit}
const { data, isLoading, isError } = useQuery ({
queryKey: materialsKeys.index (keys),
queryFn: () => fetchMaterials (keys)})
const tagScope = data?.tagScope ?? null
const materials = data?.materials ?? []
const groups = data?.groups ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
const materialsById = useMemo (
() => new Map (materials.map (material => [material.id, material])),
[materials],
)
const groupedMaterialIds = useMemo (
() => new Set (groups.flatMap (group => group.materialIds)),
[groups],
)
const ungroupedMaterials = useMemo (
() => materials.filter (material => !groupedMaterialIds.has (material.id)),
[groupedMaterialIds, materials],
)
useEffect (() => {
if (!(tagQuery))
{
setTag (null)
return
}
setQ (qQuery)
setTagStateInput (tagState)
setMediaKindInput (mediaKind)
setGroupByInput (groupBy)
setCreatedFrom (createdFromQuery)
setCreatedTo (createdToQuery)
setUpdatedFrom (updatedFromQuery)
setUpdatedTo (updatedToQuery)
}, [createdFromQuery, createdToQuery, groupBy, mediaKind, qQuery, tagState,
updatedFromQuery, updatedToQuery])
void (async () => {
try
{
setLoading (true)
setTag (
await apiGet<TagWithMaterial> (
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
}
finally
{
setLoading (false)
}
}) ()
}, [location.search, tagQuery])
const search = (e: FormEvent) => {
e.preventDefault ()
const qs = new URLSearchParams ()
setIf (qs, 'q', q)
qs.set ('tag_state', tagStateInput)
qs.set ('media_kind', mediaKindInput)
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 ('group_by', tagId == null ? 'none' : groupByInput)
qs.set ('view', view)
qs.set ('page', '1')
qs.set ('limit', String (limit))
qs.set ('material_filter', materialFilter)
if (tagId != null)
qs.set ('tag_id', String (tagId))
if (includeDescendants)
qs.set ('include_descendants', '1')
navigate (`/materials?${ qs.toString () }`)
}
const updateQuery = (changes: Record<string, string>) => {
const qs = new URLSearchParams (location.search)
Object.entries (changes).forEach (([key, value]) => {
if (value === '')
qs.delete (key)
else
qs.set (key, value)
})
navigate (`/materials?${ qs.toString () }`)
}
const renderMaterialCollection = (rows: Material[]) =>
view === 'card'
? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(196px,1fr))]
justify-items-center gap-4">
{rows.map (material => (
<MaterialCard key={material.id} material={material}/>))}
</div>)
: (
<div className="space-y-3">
{rows.map (material => (
<MaterialListItem key={material.id} material={material}/>))}
</div>)
const renderGroupedMaterials = (materialGroups: MaterialIndexGroup[]) => (
<div className="space-y-6">
{materialGroups.map (group => {
const groupMaterials =
group.materialIds
.map (materialId => materialsById.get (materialId))
.filter ((material): material is Material => material != null)
if (groupMaterials.length === 0)
return null
return (
<section key={group.key} className="space-y-3">
<GroupHeading
tagId={group.tag.id}
title={group.tag.name}
count={group.count}
materialFilter={materialFilter}/>
{renderMaterialCollection (groupMaterials)}
</section>)
})}
{ungroupedMaterials.length > 0 && (
<section className="space-y-3">
<div className="border-b border-stone-200 pb-2 dark:border-stone-700">
<span className="font-medium text-stone-900 dark:text-stone-100">
</span>
</div>
{renderMaterialCollection (ungroupedMaterials)}
</section>)}
</div>)
return (
<MainArea>
@@ -82,86 +402,205 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
<title>{`素材管理 | ${ SITE_TITLE }`}</title>
</Helmet>
{loading ? 'Loading...' : (
tag
? (
<>
<PageTitle>
<TagLink
tag={tag}
withWiki={false}
withCount={false}
to={tag.material
? `/materials/${ tag.material.id }`
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
</PageTitle>
{(!(tag.material) && tag.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
</PrefetchLink>
</div>)}
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle className="my-auto"></PageTitle>
<div className="flex flex-wrap gap-2">
{/* TODO: 局所出力を可能にする */}
{/* <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>
<MaterialCard tag={tag}/>
{tagScope && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900
dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100">
<span>
{tagScope.tag.name}
{tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'}
</span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
</PrefetchLink>
</div>)}
{(tagId != null && tagScope == null) && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border
border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<span></span>
<PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2
text-amber-800 dark:text-amber-200">
</PrefetchLink>
</div>)}
<div className="ml-2">
{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.category !== 'meme') && (
<div className="-mt-4">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
</PrefetchLink>
</div>)}
{tagState === 'untagged' && (
<PrefetchLink
to={`/materials?material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
</PrefetchLink>)}
<MaterialCard tag={c2}/>
<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>
<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.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${
encodeURIComponent (c3.name) }`}>
</PrefetchLink>
</div>)}
<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>
<MaterialCard tag={c3}/>
</Fragment>))}
</div>
</Fragment>))}
</div>
</>)
: (
<>
<p></p>
<p></p>
<ul>
<li><PrefetchLink to="/materials/new"></PrefetchLink></li>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
</ul>
</>))}
<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="作成日時">
{() => (
<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>
{(tagScope && ['character', 'material'].includes (tagScope.tag.category)) && (
<>
<PrefetchLink
to={materialNewPath (tagScope.tag.name, location.pathname + location.search)}
className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300">
</PrefetchLink>
</>)}
</p>)}
{materials.length > 0 && (
groupBy === 'parent_tag' && groups.length > 0
? renderGroupedMaterials (groups)
: renderMaterialCollection (materials))}
<Pagination page={page} totalPages={totalPages}/>
</div>
</MainArea>)
}
+52
ファイルの表示
@@ -1,5 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes, useLocation } from 'react-router-dom'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render'
@@ -17,6 +18,11 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const LocationView = () => {
const location = useLocation ()
return <div>{location.pathname + location.search}</div>
}
describe ('MaterialNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
@@ -69,4 +75,50 @@ describe ('MaterialNewPage', () => {
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
})
it ('returns only to safe material URLs after successful creation', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=%2Fmaterials%3Ftag_id%3D20',
},
)
fireEvent.change (screen.getAllByRole ('textbox')[1], {
target: { value: 'https://example.com/ref' },
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument ()
})
it ('ignores unsafe return_to values after successful creation', async () => {
api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (
<Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/>
</Routes>,
{
route:
'/materials/new?tag=%E8%99%B9%E5%A4%8F'
+ '&return_to=https%3A%2F%2Fexample.com%2Fevil',
},
)
fireEvent.change (screen.getAllByRole ('textbox')[1], {
target: { value: 'https://example.com/ref' },
})
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials')).toBeInTheDocument ()
})
})
+48 -30
ファイルの表示
@@ -1,3 +1,4 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
@@ -11,57 +12,60 @@ import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import { createMaterial } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url'
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => {
const qc = useQueryClient ()
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const returnToQuery = query.get ('return_to') ?? ''
const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
const navigate = useNavigate ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
const [exportPath, setExportPath] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => {
const createMutation = useMutation ({
mutationFn: async () => {
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)
formData.append ('export_paths[legacy_drive]', exportPath)
return await createMaterial (formData)
},
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' })
navigate (safeReturnTo || '/materials')
},
onError: error => {
applyValidationError (error)
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}})
const handleSubmit = () => {
clearValidationErrors ()
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)
try
{
setSending (true)
await apiPost ('/materials', formData)
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
}
catch (e)
{
applyValidationError (e)
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
createMutation.mutate ()
}
return (
@@ -133,11 +137,25 @@ const MaterialNewPage: FC = () => {
className={inputClass (invalid)}/>)}
</FormField>
<FormField
label="ZIP 出力パス"
messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => (
<input
type="text"
value={exportPath}
onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
disabled={createMutation.isPending}>
</Button>
</Form>
-51
ファイルの表示
@@ -1,51 +0,0 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import type { FC, FormEvent } from 'react'
const MaterialSearchPage: FC = () => {
const [tagName, setTagName] = useState ('')
const [parentTagName, setParentTagName] = useState ('')
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
}
return (
<MainArea>
<Helmet>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タグ */}
<FormField label="タグ">
{() => (
<TagInput
value={tagName}
setValue={setTagName}/>)}
</FormField>
{/* 親タグ */}
<FormField label="親タグ">
{() => (
<TagInput
value={parentTagName}
setValue={setParentTagName}/>)}
</FormField>
</form>
</div>
</MainArea>)
}
export default MaterialSearchPage
+202
ファイルの表示
@@ -0,0 +1,202 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { createMaterialSyncSuppression, fetchMaterialSyncSuppressions } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
import type { MaterialSyncSuppressionSourceKind } from '@/types'
const SOURCE_KIND_LABELS: Record<MaterialSyncSuppressionSourceKind, string> = {
uri: 'URI',
google_drive_path: 'Google Drive ファイル',
google_drive_path_prefix: 'Google Drive フォルダ',
google_drive_file: 'Google Drive ファイル Id.',
legacy_drive_path: '汎用ファイル',
legacy_drive_path_prefix: '汎用フォルダ' }
const REASONS = [
'copyright_high_risk',
'copyright_takedown',
'adult_or_sensitive',
'personal_information',
'malware_or_dangerous_file',
'duplicate_or_low_quality',
'source_owner_request',
'other'] as const
type MaterialSyncSuppressionReason = typeof REASONS[number]
const REASON_NAMES: Record<MaterialSyncSuppressionReason, string> = {
['copyright_high_risk']: '著作権への懸念',
['copyright_takedown']: '著作者からの申出',
['adult_or_sensitive']: '成人向け',
['personal_information']: '個人情報',
['malware_or_dangerous_file']: '危険なソフトウェア',
['duplicate_or_low_quality']: '重複',
['source_owner_request']: '同期元管理者からの申出',
['other']: 'その他' } as const
const reasonName = (reason: string): string =>
REASON_NAMES[reason as MaterialSyncSuppressionReason] ?? reason
const MaterialSyncSuppressionsPage: FC = () => {
const qc = useQueryClient ()
const [sourceKind, setSourceKind] =
useState<MaterialSyncSuppressionSourceKind> ('uri')
const [sourceUri, setSourceUri] = useState ('')
const [drivePath, setDrivePath] = useState ('')
const [driveFileId, setDriveFileId] = useState ('')
const [reason, setReason] = useState<MaterialSyncSuppressionReason> (REASONS[0])
const { data, isError, isLoading } = useQuery ({
queryKey: materialsKeys.suppressions (),
queryFn: fetchMaterialSyncSuppressions})
const createMutation = useMutation ({
mutationFn: async () =>
await createMaterialSyncSuppression ({
sourceKind,
sourceUri,
drivePath,
driveFileId,
reason}),
onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions () })
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
setSourceUri ('')
setDrivePath ('')
setDriveFileId ('')
toast ({ title: '同期元抑止を登録しました' })
},
onError: () => {
toast ({ title: '同期元抑止の登録に失敗しました' })
}})
const handleSubmit = (event: FormEvent) => {
event.preventDefault ()
createMutation.mutate ()
}
const suppressions = data?.suppressions ?? []
return (
<MainArea>
<Helmet>
<title>{`素材同期抑止 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="space-y-5">
<PageTitle></PageTitle>
<form
onSubmit={handleSubmit}
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 }) => (
<select
value={sourceKind}
onChange={e => setSourceKind (
e.target.value as MaterialSyncSuppressionSourceKind)}
className={inputClass (invalid)}>
{Object.entries (SOURCE_KIND_LABELS).map (([value, label]) => (
<option key={value} value={value}>
{label}
</option>))}
</select>)}
</FormField>
<FormField label="同期元 URI">
{({ invalid }) => (
<input
type="text"
value={sourceUri}
onChange={e => setSourceUri (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive パス">
{({ invalid }) => (
<input
type="text"
value={drivePath}
onChange={e => setDrivePath (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="Google Drive ファイル Id.">
{({ invalid }) => (
<input
type="text"
value={driveFileId}
onChange={e => setDriveFileId (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="事由">
{({ invalid }) => (
<select
value={reason}
onChange={e => setReason (e.target.value as MaterialSyncSuppressionReason)}
className={inputClass (invalid)}>
{REASONS.map (value => (
<option key={value} value={value}>
{REASON_NAMES[value]}
</option>))}
</select>)}
</FormField>
</div>
<Button
type="submit"
className="mt-4 rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400"
disabled={createMutation.isPending}>
</Button>
</form>
{isLoading && <p>Loading...</p>}
{isError && (
<p className="text-red-600 dark:text-red-300">
</p>)}
<div className="space-y-3">
{suppressions.map (suppression => (
<article
key={suppression.id}
className="rounded-lg border border-stone-200 bg-white p-3
text-stone-900 shadow-sm dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100">
<div className="font-medium">
{SOURCE_KIND_LABELS[suppression.sourceKind]}
</div>
<div className="break-all text-sm text-stone-700 dark:text-stone-300">
{suppression.normalizedSourceKey}
</div>
<div className="mt-2 text-sm text-stone-600 dark:text-stone-400">
: {reasonName (suppression.reason)} /
: {dateString (suppression.createdAt)}
</div>
</article>))}
</div>
</div>
</MainArea>)
}
export default MaterialSyncSuppressionsPage
+2 -4
ファイルの表示
@@ -62,8 +62,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
toast ({ title: '失敗……', description: '通信に失敗しました……' })
},
onSuccess: () => {
qc.invalidateQueries ({ queryKey: postsKeys.root })
} })
qc.invalidateQueries ({ queryKey: postsKeys.root })} })
useEffect (() => {
if (!(errorFlg))
@@ -114,8 +113,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
<PostList posts={[{ ...post, childPosts: [{ } as Post] },
...post.childPosts!.map (p => ({
...p, parentPosts: [{ } as Post] }))]}/>
</div>
)}
</div>)}
{(post.parentPosts ?? []).map (pp => {
const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`]
if (!(siblings))
+2 -2
ファイルの表示
@@ -15,7 +15,7 @@ import { SITE_TITLE } from '@/config'
import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn, dateString, msToTime, originalCreatedAtString } from '@/lib/utils'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react'
@@ -94,7 +94,7 @@ const PostHistoryPage: FC = () => {
const duration =
change.videoMs.current == null
? null
: msToTime (change.videoMs.current)
: String (change.videoMs.current / 1_000)
const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, duration, parentPostIds,
+14 -20
ファイルの表示
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
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 { useLocation, useNavigate } from 'react-router-dom'
@@ -87,8 +87,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
const defaultDirection = {
name: 'asc',
created_at: 'desc',
updated_at: 'desc',
} as const
updated_at: 'desc'} as const
const beginEdit = async (tag: NicoTag) => {
const editingTag = nicoTags.find (tag => tag.id === editingId)
@@ -99,15 +98,13 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
&& !(await dialogue.confirm ({
title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
variant: 'danger',
})))
variant: 'danger'})))
return
setEditingId (tag.id)
setRawTags (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]: [] }))
}
@@ -115,8 +112,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setEditingId (null)
setRawTags (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]: [] }))
}
@@ -141,8 +137,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
...errors,
[id]: validationError?.fieldErrors.tags
?? validationError?.baseErrors
?? ['更新できませんでした.'],
}))
?? ['更新できませんでした.']}))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
@@ -166,8 +161,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setRawTags (Object.fromEntries (data.tags.map (tag => [
tag.id,
tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
])))
tag.linkedTags.map (linkedTag => linkedTag.name).join (' ')])))
}, [data])
useEffect (() => {
@@ -282,7 +276,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
{nicoTags.map (tag => {
const isEditing = editingId === tag.id
return [
return (
<Fragment key={tag.id}>
<tr
key={tag.id}
className={cn (
@@ -321,8 +316,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</button>)}
</td>)}
</tr>,
isEditing && (
</tr>
{isEditing && (
<tr key={`${ tag.id }-edit`}
className="border-b border-rose-200 bg-rose-50 dark:border-rose-900
dark:bg-rose-950/30">
@@ -340,8 +335,7 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
placeholder="タグ名を空白または改行で区切って入力"
onChange={e => setRawTags (rawTags => ({
...rawTags,
[tag.id]: e.target.value,
}))}/>
[tag.id]: e.target.value}))}/>
<FieldError messages={errorsByTagId[tag.id]}/>
<div className="flex justify-end gap-2">
<button
@@ -368,8 +362,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
</div>
</div>
</td>
</tr>),
]
</tr>)}
</Fragment>)
})}
</tbody>
</table>
+52 -1
ファイルの表示
@@ -1,9 +1,10 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost,
buildTag,
buildTheatre,
buildTheatreComment,
buildTheatreInfo,
@@ -96,6 +97,9 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
{ route: '/theatres/7' },
)
const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => {
api.apiGet.mockImplementation ((path: string) => {
switch (path)
@@ -245,6 +249,53 @@ describe ('TheatreDetailPage', () => {
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
})
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
const parentTag = buildTag ({
id: 11,
name: '親タグ',
category: 'general',
children: [childTag],
})
const duplicateParentTag = buildTag ({
id: 13,
name: '別親タグ',
category: 'general',
children: [childTag],
})
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
...currentPost,
tags: [parentTag, duplicateParentTag],
}))
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)
fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])
await waitFor (() => {
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
.toHaveLength (1)
})
})
it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => {
switch (path)
+53 -11
ファイルの表示
@@ -69,8 +69,7 @@ const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
const commentBox = (
comment: TheatreComment,
programme: TheatreProgramme | null = null,
): ReactNode[] =>
programme: TheatreProgramme | null = null): ReactNode[] =>
[(
<div key={`${ comment.no }-content`} className="w-full">
{comment.deleted
@@ -103,6 +102,28 @@ const compareTagName = (a: Tag, b: Tag): number =>
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
const flattenTags = (tags: Tag[]): Tag[] => {
const flattened: Tag[] = []
const seen = new Set<number> ()
const visit = (tag: Tag) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
flattened.push (tag)
for (const child of tag.children ?? [])
visit (child)
}
for (const tag of tags)
visit (tag)
return flattened
}
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
const grouped: Partial<Record<Category, Tag[]>> = { }
@@ -119,16 +140,39 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
}
const renderReadonlyTagTree = (
tag: Tag,
nestLevel: number,
path: string,
ancestors: Set<number> = new Set<number> (),
): ReactNode[] => {
const key = `${ path }-${ tag.id }`
const nextAncestors = new Set (ancestors)
nextAncestors.add (tag.id)
return [
(
<li key={key} className="text-left leading-tight">
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
</li>),
...((tag.children ?? [])
.filter (child => !(nextAncestors.has (child.id)))
.sort (compareTagName)
.flatMap (child =>
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
}
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' },
) => {
const grouped = tagsByCategory (tags)
{ tags, compact, flow = 'vertical' }) => {
const horizontalGrouped = tagsByCategory (flattenTags (tags))
const verticalGrouped = tagsByCategory (tags)
if (flow === 'horizontal')
{
return (
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
{CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => (
<li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
@@ -138,7 +182,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
return (
<div className="space-y-3">
{CATEGORIES.map (cat => {
const rows = grouped[cat] ?? []
const rows = verticalGrouped[cat] ?? []
if (rows.length === 0)
return null
@@ -148,10 +192,8 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{CATEGORY_NAMES[cat]}
</div>
<ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.map (tag => (
<li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
{rows.flatMap (tag =>
renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
</ul>
</div>)
})}
+1 -2
ファイルの表示
@@ -15,5 +15,4 @@ export const useSharedTransitionStore = create<SharedTransitionState> (set => ({
set (state => {
const next = { ...state.byLocationKey }
delete next[locationKey]
return { byLocationKey: next }
}) }))
return { byLocationKey: next }}) }))
+23 -10
ファイルの表示
@@ -74,16 +74,29 @@ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
})
export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
id: 1,
tag: buildTag (),
file: null,
url: null,
wikiPageBody: null,
contentType: null,
createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z',
updatedByUser: { id: 2, name: 'updater' },
id: 1,
versionNo: 1,
tag: buildTag (),
file: null,
url: null,
sourceKind: null,
sourceUri: null,
sourcePath: null,
sourceFileId: null,
normalizedSourceKey: null,
wikiPageBody: null,
thumbnail: null,
thumbnailFallbackText: 'テストタグ',
thumbnailFallbackKind: 'tag_name',
mediaKind: 'url_only',
contentType: null,
fileByteSize: null,
exportPaths: {},
exportItems: [],
createdAt: '2026-01-02T03:04:05.000Z',
createdByUser: { id: 1, name: 'creator' },
updatedAt: '2026-01-03T03:04:05.000Z',
updatedByUser: { id: 2, name: 'updater' },
...overrides,
})
+158 -10
ファイルの表示
@@ -65,17 +65,165 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc'
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
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 MaterialIndexTagState = 'all' | 'tagged' | 'untagged'
export type MaterialIndexGroupBy = 'none' | 'parent_tag'
export type MaterialIndexView = 'card' | 'list'
export type MaterialTagScope = {
tag: {
id: number
name: string
category: Category
}
includeDescendants: boolean
}
export type FetchMaterialsParams = {
q: string
tagState: MaterialIndexTagState
mediaKind: MaterialIndexMediaKind
tagId: number | null
includeDescendants: boolean
groupBy: MaterialIndexGroupBy
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
sort: MaterialIndexSort
direction: MaterialIndexDirection
view: MaterialIndexView
page: number
limit: number }
export type Material = {
id: number
tag: Tag
file: string | null
url: string | null
wikiPageBody?: string | null
contentType: string | null
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
id: number
versionNo: number
tag: Tag | null
file: string | null
url: string | null
sourceKind: string | null
sourceUri: string | null
sourcePath: string | null
sourceFileId: string | null
normalizedSourceKey: string | null
wikiPageBody?: string | null
thumbnail: string | null
thumbnailFallbackText: string | null
thumbnailFallbackKind: 'tag_name' | 'created_at'
mediaKind: 'image' | 'video' | 'audio' | 'file_other' | 'url_only'
contentType: string | null
fileByteSize: number | null
exportPaths: Record<string, string>
exportItems: MaterialExportItem[]
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
export type MaterialSyncSuppressionSourceKind =
| 'uri'
| 'google_drive_path'
| 'google_drive_path_prefix'
| 'google_drive_file'
| 'legacy_drive_path'
| 'legacy_drive_path_prefix'
export type MaterialSyncSuppression = {
id: number
sourceKind: MaterialSyncSuppressionSourceKind
sourceUri: string | null
drivePath: string | null
driveFileId: string | null
normalizedSourceKey: string
reason: string
createdByUser: { id: number; name: string } | null
createdAt: string
updatedAt: string }
export type MaterialSidebarTag = {
id: number
name: string
category: Category
deprecated: boolean
hasChildren: boolean
hasMaterial: boolean
children: MaterialSidebarTag[] }
export type MaterialTagTree = Omit<Tag, 'children'> & {
hasChildren: boolean
hasMaterial: boolean
children: MaterialTagTree[]
material?: Material | null }
export type MaterialExportItem = {
id: number
profile: string
exportPath: string
enabled: boolean }
export type MaterialIndexGroup = {
key: string
tag: {
id: number
name: string
category: Category
}
materialIds: number[]
count: number }
export type MaterialIndexResponse = {
materials: Material[]
count: number
groups?: MaterialIndexGroup[]
tagScope?: MaterialTagScope | null
}
export type MaterialVersion = {
id: number
materialId: number
versionNo: number
eventType: 'create' | 'update' | 'discard' | 'restore'
tagId: number | null
tagName: string | null
tagCategory: Category | null
url: string | null
sourceKind: string | null
sourceUri: string | null
sourcePath: string | null
sourceFileId: string | null
normalizedSourceKey: string | null
fileBlobId: number | null
fileFilename: string | null
fileContentType: string | null
fileByteSize: number | null
fileSha256: string | null
exportPathsJson: Record<string, string>
discardedAt: string | null
createdByUser: { id: number; name: string | null } | null
createdAt: string }
export type Menu = MenuItem[]