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