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

このコミットが含まれているのは:
2026-06-22 12:39:17 +09:00
コミット 469228a6ed
162個のファイルの変更16567行の追加1180行の削除
+72 -16
ファイルの表示
@@ -4,7 +4,8 @@
These rules apply to work under `frontend/`.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, Framer Motion, Radix UI-style components, MDX, and Zustand.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS,
Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands
@@ -17,9 +18,11 @@ npm run lint
npm run preview
```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`.
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs
`node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added.
There is currently no `test` script in `package.json`. Do not run or report
`npm test` unless a test script is added.
After frontend changes, run:
@@ -30,20 +33,43 @@ npm run lint
If either command cannot be run or fails, report the exact command and failure.
Do not create, modify, or run tests unless the user explicitly asks for test
work. When the user asks for tests, keep working and rerun them until they
pass or the remaining failure is clearly blocked.
## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
`noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`,
`noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- Keep types explicit at module boundaries, API helpers, and exported utilities.
- Use `import type` for type-only imports.
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
- Preserve the repository's existing spacing style in TypeScript, including GNU-style spacing before call parentheses where it is already used.
- Preserve the repository's existing spacing style in TypeScript, including
GNU-style spacing before call parentheses where it is already used.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces.
- Use tabs only for leading indentation. Never replace spaces that occur after
a non-space character on the same line.
## React
- Use function components.
- Existing page components commonly export an anonymous function satisfying `FC`; match nearby file style when editing.
- Existing page components commonly export an anonymous function satisfying
`FC`; match nearby file style when editing.
- React hooks must be called unconditionally and at the top level of components or custom hooks.
- Gate editing and other privileged controls with shared permission helpers
such as `canEditContent`, instead of showing controls and relying only on a
later API failure.
- Keep page-level components under `src/pages`.
- Keep shared and feature components under `src/components`.
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
@@ -52,17 +78,23 @@ If either command cannot be run or fails, report the exact command and failure.
## TanStack Query
- Use `@tanstack/react-query` for server state.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there
instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as
`posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when
mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create
additional clients in feature code.
## API calls
- Use `src/lib/api.ts` for HTTP calls.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts non-blob responses to camelCase.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts
non-blob responses to camelCase.
- Send Rails snake_case params and request body keys where the backend expects them.
- Do not bypass the API wrapper unless there is a specific reason, such as a third-party request outside the Rails API.
- Do not bypass the API wrapper unless there is a specific reason, such as a
third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Imports and aliases
@@ -76,17 +108,41 @@ If either command cannot be run or fails, report the exact command and failure.
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- Reuse components from `src/components/common`, `src/components/layout`, and `src/components/ui` before adding new primitives.
- Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected.
- Prefer restrained, content-first UI chrome: avoid adding card backgrounds,
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
## TSX formatting
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Avoid reformatting unrelated JSX.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, and `eslint-plugin-react-refresh`.
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
and `eslint-plugin-react-refresh`.
- The hooks rules are enforced; fix hook ordering instead of disabling the rule.
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
- Build failures from unused locals or unused parameters are TypeScript errors, not lint-only issues.
- Build failures from unused locals or unused parameters are TypeScript
errors, not lint-only issues.
## Files to avoid in routine work
バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 559 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 146 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 1.2 MiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 188 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 201 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 196 KiB

バイナリファイルは表示されません.

変更後

幅:  |  高さ:  |  サイズ: 179 KiB

+5 -2
ファイルの表示
@@ -18,6 +18,7 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
@@ -62,8 +63,9 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
@@ -79,6 +81,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
@@ -158,4 +161,4 @@ const App: FC = () => {
</>)
}
export default App
export default App
+83
ファイルの表示
@@ -0,0 +1,83 @@
import { act, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import NicoViewer from '@/components/NicoViewer'
import type { NiconicoViewerHandle } from '@/types'
describe ('NicoViewer', () => {
afterEach (() => {
vi.useRealTimers ()
})
it ('does not time out after metadata reports a playable duration', () => {
vi.useFakeTimers ()
const onError = vi.fn ()
const onMetadataChange = vi.fn ()
const { container } = render (
<NicoViewer
id="sm12345"
width={640}
height={360}
onMetadataChange={onMetadataChange}
onError={onError}/>,
)
const iframe = container.querySelector ('iframe')
expect (iframe).not.toBeNull ()
fireEvent.load (iframe!)
act (() => {
window.dispatchEvent (new MessageEvent ('message', {
origin: 'https://embed.nicovideo.jp',
source: iframe!.contentWindow,
data: {
eventName: 'playerMetadataChange',
data: {
currentTime: 7,
duration: 120,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
},
},
}))
})
act (() => {
vi.advanceTimersByTime (8_000)
})
expect (onMetadataChange).toHaveBeenCalled ()
expect (onError).not.toHaveBeenCalled ()
})
it ('seeks with milliseconds', () => {
const ref = createRef<NiconicoViewerHandle> ()
const { container } = render (
<NicoViewer
ref={ref}
id="sm12345"
width={640}
height={360}/>,
)
const iframe = container.querySelector ('iframe')!
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
act (() => {
ref.current!.seek (7_000)
})
expect (postMessage).toHaveBeenCalledWith (
expect.objectContaining ({
eventName: 'seek',
data: { time: 7_000 },
}),
'https://embed.nicovideo.jp',
)
})
})
+86 -40
ファイルの表示
@@ -1,11 +1,11 @@
import { forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState } from 'react'
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState } from 'react'
import type { CSSProperties, ForwardedRef } from 'react'
@@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
type NiconicoPlayerMessage =
| { eventName: 'enterProgrammaticFullScreen' }
| { eventName: 'exitProgrammaticFullScreen' }
| { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
| { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
| { eventName: 'loadComplete'
playerId?: string
data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'playerMetadataChange'
playerId?: string
data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'
playerId?: string
data?: unknown }
| { eventName: 'error'
playerId?: string
data?: unknown
code?: string
message?: string }
type NiconicoCommand =
| { eventName: 'play'; sourceConnectorType: 1; playerId: string }
@@ -30,6 +40,7 @@ type NiconicoCommand =
data: { commentVisibility: boolean } }
const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
const LOAD_COMPLETE_TIMEOUT_MS = 8_000
type Props = {
id: string
@@ -37,14 +48,18 @@ type Props = {
height: number
style?: CSSProperties
onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void }
onMetadataChange?: (meta: NiconicoMetadata) => void
onError?: (data: unknown) => void }
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
const iframeRef = useRef<HTMLIFrameElement> (null)
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const playerId = useMemo (
() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`,
[id])
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
@@ -64,21 +79,39 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const styleFullScreen: CSSProperties =
fullScreen
? { top: 0,
left: landscape ? 0 : '100%',
position: 'fixed',
width: screenWidth,
height: screenHeight,
zIndex: 2_147_483_647,
maxWidth: 'none',
transformOrigin: '0% 0%',
transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%',
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
left: landscape ? 0 : '100%',
position: 'fixed',
width: screenWidth,
height: screenHeight,
zIndex: 2_147_483_647,
maxWidth: 'none',
transformOrigin: '0% 0%',
transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%',
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
: { }
const margedStyle: CSSProperties =
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
const clearLoadCompleteTimer = useCallback (() => {
if (!(loadCompleteTimerRef.current))
return
clearTimeout (loadCompleteTimerRef.current)
loadCompleteTimerRef.current = null
}, [])
const startLoadCompleteTimer = useCallback (() => {
clearLoadCompleteTimer ()
loadCompleteTimerRef.current = setTimeout (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
const postToPlayer = useCallback ((message: NiconicoCommand) => {
const win = iframeRef.current?.contentWindow
if (!(win))
@@ -96,7 +129,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
}, [playerId, postToPlayer])
const seek = useCallback ((time: number) => {
postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
postToPlayer (
{ eventName: 'seek', sourceConnectorType: 1, playerId,
data: { time } })
}, [playerId, postToPlayer])
const mute = useCallback (() => {
@@ -132,21 +167,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
useEffect (() => {
const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
if (!(iframeRef.current)
|| (event.source !== iframeRef.current.contentWindow)
|| (event.origin !== EMBED_ORIGIN))
return
|| (event.source !== iframeRef.current.contentWindow)
|| (event.origin !== EMBED_ORIGIN))
return
const data = event.data
if (!(data)
|| typeof data !== 'object'
|| !('eventName' in data))
return
return
if (('playerId' in data)
&& data.playerId
&& data.playerId !== playerId)
return
return
if (data.eventName === 'enterProgrammaticFullScreen')
{
@@ -162,24 +197,34 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'loadComplete')
{
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo)
return
}
if (data.eventName === 'playerMetadataChange')
{
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
clearLoadCompleteTimer ()
onMetadataChange?.(data.data)
return
}
if (data.eventName === 'error')
console.error ('niconico player error:', data)
{
clearLoadCompleteTimer ()
console.error ('niconico player error:', data)
onError?.(data)
}
}
addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage)
}, [onLoadComplete, onMetadataChange, playerId])
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => {
if (!(fullScreen))
@@ -192,7 +237,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const pollingResize = () => {
if (ended)
return
return
const isLandscape = innerWidth >= innerHeight
const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
@@ -206,9 +251,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const startPollingResize = () => {
if (requestAnimationFrame)
requestAnimationFrame (pollingResize)
requestAnimationFrame (pollingResize)
else
pollingResize ()
pollingResize ()
}
startPollingResize ()
@@ -231,9 +276,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
<iframe
ref={iframeRef}
src={src}
width={width}
height={height}
style={margedStyle}
allowFullScreen
allow="autoplay"/>)
width={width}
height={height}
style={margedStyle}
onLoad={startLoadCompleteTimer}
allowFullScreen
allow="autoplay"/>)
})
+46 -26
ファイルの表示
@@ -2,18 +2,23 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils'
import { inputClass, msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'originalCreatedAt'
const tagsToStr = (tags: TagWithSections[]): string => {
const result: Omit<TagWithSections, 'children'>[] = []
@@ -39,6 +44,8 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] =
@@ -51,6 +58,8 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
clearValidationErrors ()
try
{
const data = await updatePost (...args)
@@ -71,7 +80,14 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (response?.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
if (applyValidationError (e))
{
toast ({ description: '更新はできなかったよ……' })
return
}
toast ({ title: '失敗……', description: '入力を確認してください.' })
return
}
@@ -126,33 +142,38 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
return (
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タイトル */}
<div>
<Label></Label>
<input
type="text"
disabled={disabled}
className="w-full border rounded p-2"
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
</div>
<FormField label="タイトル">
{({ invalid }) => (
<input
type="text"
disabled={disabled}
className={inputClass (invalid)}
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)}
</FormField>
{/* 親投稿 */}
<div>
<Label>稿</Label>
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */}
<PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}/>
setTags={setTags}
errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
@@ -160,12 +181,11 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 送信 */}
<Button
type="submit"
disabled={disabled}>
<Button type="submit" disabled={disabled}>
</Button>
</form>)
+66 -1
ファイルの表示
@@ -8,12 +8,19 @@ const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/NicoViewer', () => ({
default: ({ id }: { id: string }) => <div>Nico:{id}</div>,
default: (props: { id: string }) => {
nicoViewer.props (props)
return <div>Nico:{props.id}</div>
},
}))
vi.mock ('react-youtube', () => ({
@@ -31,6 +38,64 @@ describe ('PostEmbed', () => {
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
})
it ('reports niconico metadata as milliseconds', () => {
const onVideoReady = vi.fn ()
const onPlaybackChange = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}
onPlaybackChange={onPlaybackChange}/>,
)
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledWith (120_000)
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
})
it ('reports niconico video readiness only once', () => {
const onVideoReady = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}/>,
)
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
title: '動画',
videoId: 'sm12345',
lengthInSeconds: 120,
thumbnailUrl: 'https://example.com/thumb.jpg',
description: '',
viewCount: 1,
commentCount: 2,
mylistCount: 3,
postedAt: '2026-01-02T03:04:05.000Z',
watchId: 12345,
})
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledTimes (1)
expect (onVideoReady).toHaveBeenCalledWith (120_000)
})
it ('embeds x/twitter status URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
+106 -6
ファイルの表示
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
@@ -8,17 +8,113 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react'
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
import type { YouTubePlayer } from 'react-youtube'
type YouTubeEvent<T = unknown> = {
data: T
target: YouTubePlayer }
type Props = {
ref?: RefObject<NiconicoViewerHandle | null>
post: Post
onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void }
onMetadataChange?: (meta: NiconicoMetadata) => void
onVideoReady?: (durationMs: number) => void
onPlaybackChange?: (currentTimeMs: number) => number | void
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => {
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue ()
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
if (niconicoVideoReadyRef.current
|| !(Number.isFinite (durationMs))
|| durationMs <= 0)
return
niconicoVideoReadyRef.current = true
onVideoReady?.(durationMs)
}, [onVideoReady])
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
const currentTime = await player.getCurrentTime ()
const currentTimeMs = currentTime * 1_000
const targetTimeMs = onPlaybackChange?.(currentTimeMs)
if (typeof targetTimeMs !== 'number')
return
if (Math.abs (currentTimeMs - targetTimeMs) > 5_000)
await player.seekTo (targetTimeMs / 1_000, true)
}, [onPlaybackChange])
const handleYoutubeReady = async (event: YouTubeEvent) => {
setYoutubePlayer (event.target)
try
{
await event.target.playVideo ()
const duration = await event.target.getDuration ()
const durationMs = duration * 1_000
onVideoReady?.(durationMs)
if (!(Number.isFinite (durationMs)) || durationMs <= 0)
return
await reportYoutubePlayback (event.target)
}
catch (error)
{
onError?.({ platform: 'youtube', error })
}
}
const handleYoutubeStateChange = (event: YouTubeEvent<number>) => {
void reportYoutubePlayback (event.target)
}
const handleYoutubeError = (event: YouTubeEvent<number>) => {
onError?.({ platform: 'youtube', code: event.data })
}
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
onLoadComplete?.(info)
}
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
notifyNiconicoVideoReady (meta.duration)
onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta)
}
useEffect (() => {
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
const timer = setInterval (
() => void reportYoutubePlayback (youtubePlayer),
1_000)
return () => clearInterval (timer)
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
@@ -38,8 +134,9 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
id={videoId}
width={640}
height={360}
onLoadComplete={onLoadComplete}
onMetadataChange={onMetadataChange}/>)
onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={handleNiconicoMetadataChange}
onError={onError}/>)
}
case 'twitter.com':
@@ -69,7 +166,10 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
mute: 0,
loop: 1,
width: '640',
height: '360' } }}/>)
height: '360' } }}
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
}
}
+33 -28
ファイルの表示
@@ -3,7 +3,7 @@
import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'
@@ -33,10 +33,11 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string
setTags: (tags: string) => void }
setTags: (tags: string) => void
errors?: string[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => {
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -73,30 +74,34 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => {
}
return (
<div className="relative w-full">
<Label></Label>
<TextArea
{...rest}
ref={ref}
value={tags}
onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
}}
onFocus={() => setFocused (true)}
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</div>)
<FormField className="relative w-full" label="タグ" messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
{...rest}
ref={ref}
value={tags}
aria-describedby={describedBy}
invalid={invalid}
onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
}}
onFocus={() => setFocused (true)}
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</>)}
</FormField>)
}
export default PostFormTagsArea
export default PostFormTagsArea
+75 -62
ファイルの表示
@@ -1,5 +1,5 @@
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import type { FC } from 'react'
@@ -9,68 +9,81 @@ type Props = {
originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void }
setOriginalCreatedBefore: (x: string | null) => void
errors?: string[] }
const PostOriginalCreatedTimeField: FC<Props> = ({ disabled,
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore }) => (
<div>
<Label></Label>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const PostOriginalCreatedTimeField: FC<Props> = (
{ disabled,
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props,
) => (
<FormField label="オリジナルの作成日時" messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</div>)
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
</Button>
</div>
</div>
export default PostOriginalCreatedTimeField
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
</Button>
</div>
</div>
</>)}
</FormField>)
export default PostOriginalCreatedTimeField
+15
ファイルの表示
@@ -18,6 +18,21 @@ describe ('TagLink', () => {
expect (screen.getByText ('4')).toBeInTheDocument ()
})
it ('does not append deprecated state to the rendered tag name', () => {
renderWithProviders (
<TagLink
tag={buildTag ({
name: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
})}
withWiki={false}
withCount={false}/>,
)
expect (screen.getByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
expect (screen.queryByText ('(廃止)')).not.toBeInTheDocument ()
})
it ('links wiki markers to the correct detail route', () => {
renderWithProviders (
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
+1 -1
ファイルの表示
@@ -128,4 +128,4 @@ const TagLink: FC<Props> = ({ tag,
</>)
}
export default TagLink
export default TagLink
+4 -2
ファイルの表示
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox'
@@ -110,11 +111,12 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white"/>
className={inputClass (false,
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}
export default TagSearch
export default TagSearch
+11 -10
ファイルの表示
@@ -55,12 +55,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>&thinsp;1&thinsp;</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
@@ -71,6 +65,9 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
@@ -132,8 +129,12 @@ const TopNav: FC<Props> = ({ user }) => {
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -244,9 +245,9 @@ const TopNav: FC<Props> = ({ user }) => {
<motion.div
key="submenu-shell"
layout
className="relative hidden md:block overflow-hidden
className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
@@ -257,7 +258,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}>
{moreVsbl
? (
menu.map ((item, i) => (
moreMenu.map ((item, i) => (
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3">
<motion.div
@@ -267,7 +268,7 @@ const TopNav: FC<Props> = ({ user }) => {
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } })}
className="z-10 h-full flex items-center px-3 font-bold w-24">
className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
</motion.div>
{item.subMenu
+15 -4
ファイルの表示
@@ -22,10 +22,11 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
value?: string
onChange?: (isoUTC: string | null) => void
className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void
invalid?: boolean }
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest }) => {
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid, ...rest }) => {
const [local, setLocal] = useState ('')
useEffect (() => {
@@ -35,9 +36,19 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest
return (
<input
{...rest}
className={cn ('border rounded p-2', className)}
className={cn ('border rounded p-2',
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none',
'focus:ring-2 focus:ring-red-200',
'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100']
: ['border-gray-300',
'focus:border-blue-500 focus:outline-none',
'focus:ring-2 focus:ring-blue-200']),
className)}
type="datetime-local"
value={local}
aria-invalid={invalid}
onChange={ev => {
const v = ev.target.value
setLocal (v)
@@ -46,4 +57,4 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest
onBlur={onBlur}/>)
}
export default DateTimeField
export default DateTimeField
+18
ファイルの表示
@@ -0,0 +1,18 @@
import type { FC } from 'react'
type Props = { id?: string
messages?: string[] }
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
if (!(messages) || messages.length === 0)
return null
return (
<ul id={id} className="mt-1 space-y-1 text-red-700 dark:text-red-300">
{messages.map ((message, i) => <li key={i}>{message}</li>)}
</ul>)
}
export default FieldError
+36
ファイルの表示
@@ -0,0 +1,36 @@
import { useId } from 'react'
import FieldError from '@/components/common/FieldError'
import Label from '@/components/common/Label'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type FieldState = { describedBy?: string
invalid: boolean }
type Props = {
children: (state: FieldState) => ReactNode
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
className?: string
label: ReactNode
messages?: string[] }
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
const id = useId ()
const invalid = messages != null && messages.length > 0
const errorId = invalid ? `${ id }-error` : undefined
return (
<div className={cn (className)}>
<Label checkBox={checkBox} invalid={invalid}>{label}</Label>
{children ({ describedBy: errorId, invalid })}
<FieldError id={errorId} messages={messages}/>
</div>)
}
export default FormField
+21 -14
ファイルの表示
@@ -1,32 +1,39 @@
import React from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = { children: React.ReactNode
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } }
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
invalid?: boolean }
const Label: FC<Props> = ({ children, checkBox }) => {
const Label: FC<Props> = ({ children, checkBox, invalid }: Props) => {
const labelClassName = cn ('block font-semibold mb-1',
invalid && 'text-red-700 dark:text-red-300')
if (!(checkBox))
{
return (
<label className="block font-semibold mb-1">
{children}
</label>)
<label className={labelClassName}>
{children}
</label>)
}
return (
<div className="flex gap-2 mb-1">
<label className="flex-1 block font-semibold">{children}</label>
<label className="flex items-center block gap-1">
<input type="checkbox"
checked={checkBox.checked}
onChange={checkBox.onChange}/>
{checkBox.label}
</label>
<label className="flex-1 block font-semibold">{children}</label>
<label className="flex items-center block gap-1">
<input type="checkbox"
checked={checkBox.checked}
onChange={checkBox.onChange}/>
{checkBox.label}
</label>
</div>)
}
export default Label
+11 -5
ファイルの表示
@@ -2,6 +2,7 @@ import { useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
@@ -9,10 +10,13 @@ import type { Tag } from '@/types'
type Props = {
value: string
setValue: (value: string) => void }
describedBy?: string
invalid?: boolean
value: string
setValue: (value: string) => void }
const TagInput: FC<Props> = ({ value, setValue }) => {
const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -85,12 +89,14 @@ const TagInput: FC<Props> = ({ value, setValue }) => {
<div className="relative">
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
className={inputClass (invalid)}/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
@@ -99,4 +105,4 @@ const TagInput: FC<Props> = ({ value, setValue }) => {
</div>)
}
export default TagInput
export default TagInput
+19 -3
ファイルの表示
@@ -1,9 +1,25 @@
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
import type { TextareaHTMLAttributes } from 'react'
type Props = TextareaHTMLAttributes<HTMLTextAreaElement>
type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }
export default forwardRef<HTMLTextAreaElement, Props> (({ ...props }, ref) => (
<textarea ref={ref} className="rounded border w-full p-2 h-32" {...props}/>))
export default forwardRef<HTMLTextAreaElement, Props> (
({ className, invalid = false, ...props }, ref) => (
<textarea
ref={ref}
aria-invalid={invalid}
className={cn ('rounded border w-full p-2 h-32',
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none focus:ring-2',
'focus:ring-red-200',
'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100']
: ['border-gray-300',
'focus:border-blue-500 focus:outline-none focus:ring-2',
'focus:ring-blue-200']),
className)}
{...props}/>))
+6 -3
ファイルの表示
@@ -64,11 +64,14 @@ export const apiPatch = async <T> (
): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async (
export const apiDelete = async <T = void> (
path: string,
opt?: Opt,
): Promise<void> => {
await client.delete (path, withUserCode (opt))
): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '')
return undefined as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
}
+79
ファイルの表示
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
const api = vi.hoisted (() => ({
isApiError: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('extractValidationError', () => {
it ('extracts field and base errors from 422 validation responses', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError<'name'> ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { name: ['名前は必須です.'] },
base_errors: ['全体エラー'],
},
},
})
expect (validationError).toEqual ({
message: '入力内容を確認してください.',
fieldErrors: { name: ['名前は必須です.'] },
baseErrors: ['全体エラー'],
})
})
it ('preserves dotted field keys for indexed form rows', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError<'deerjikists.0.platform'> ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
base_errors: [],
},
},
})
expect (validationError?.fieldErrors).toEqual ({
'deerjikists0Platform': ['プラットフォームを入力してください.'],
})
})
it ('does not treat 400 bad requests as form validation errors', async () => {
api.isApiError.mockReturnValueOnce (true)
const { extractValidationError } = await import ('@/lib/apiErrors')
const validationError = extractValidationError ({
response: {
status: 400,
data: {
type: 'bad_request',
message: 'リクエストが不正です.',
errors: {},
base_errors: ['リクエストが不正です.'],
},
},
})
expect (validationError).toBeNull ()
})
it ('ignores non-api errors', async () => {
api.isApiError.mockReturnValueOnce (false)
const { extractValidationError } = await import ('@/lib/apiErrors')
expect (extractValidationError (new Error ('network'))).toBeNull ()
})
})
+36
ファイルの表示
@@ -0,0 +1,36 @@
import toCamel from 'camelcase-keys'
import { isApiError } from '@/lib/api'
export type FieldErrors<T extends string = string> = Partial<Record<T, string[]>>
export type ValidationError<T extends string = string> =
{ message: string
fieldErrors: FieldErrors<T>
baseErrors: string[] }
type RawValidationError = { type?: string
message?: string
errors?: Record<string, string[]>
baseErrors?: string[] }
export const extractValidationError = <T extends string = string> (err: unknown) => {
if (!(isApiError (err)) || err.response?.status !== 422)
return null
const rawData = toCamel ((err.response.data ?? { }) as Record<string, unknown>,
{ deep: true }) as RawValidationError
const data: RawValidationError = {
type: rawData.type as string | undefined,
message: rawData.message as string | undefined,
errors: rawData.errors as Record<string, string[]> | undefined,
baseErrors: rawData.baseErrors as string[] | undefined }
if (data.type !== 'validation_error' && !(data.errors))
return null
return { message: data.message ?? '入力内容を確認してください.',
fieldErrors: (data.errors ?? { }) as FieldErrors<T>,
baseErrors: data.baseErrors ?? [] }
}
+523
ファイルの表示
@@ -0,0 +1,523 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet, apiPost } from '@/lib/api'
import {
buildGekanatorQuestions,
expectedAnswerForQuestion,
fetchGekanatorPosts,
fetchGekanatorQuestions,
learnedSemanticSideForPost,
questionIdForCondition,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
saveGekanatorGame,
saveGekanatorQuestionSuggestion,
} from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
StoredGekanatorQuestion,
} from '@/lib/gekanator'
import type { Post } from '@/types'
vi.mock('@/lib/api', () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}))
const mockedApiPost = vi.mocked(apiPost)
const mockedApiGet = vi.mocked(apiGet)
const post = (overrides: Partial<Post> = {}): Post => ({
id: 1,
versionNo: 1,
url: 'https://example.com/posts/1',
title: 'post title',
thumbnail: null,
thumbnailBase: null,
tags: [],
viewed: false,
related: [],
originalCreatedFrom: null,
originalCreatedBefore: null,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
uploadedUser: null,
...overrides,
})
describe('Gekanator API functions', () => {
it('returns posts from the Gekanator posts endpoint', async () => {
const posts = [post()]
mockedApiGet.mockResolvedValueOnce({ posts })
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
})
it('returns questions from the Gekanator questions endpoint', async () => {
const questions: StoredGekanatorQuestion[] = []
mockedApiGet.mockResolvedValueOnce({ questions })
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
})
})
describe('expectedAnswerForQuestion', () => {
it('returns a direct example answer when present', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'yes',
threshold: 0.65,
},
exampleAnswers: {
1: 'partial',
},
}
expect(expectedAnswerForQuestion(question, post({ id: 1 }))).toBe('partial')
})
it('returns the condition answer for the original post_similarity post', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'probably_no',
threshold: 0.65,
},
}
expect(expectedAnswerForQuestion(question, post({ id: 123 }))).toBe('probably_no')
})
it('returns null for an unrelated post_similarity post without examples', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'yes',
threshold: 0.65,
},
}
expect(expectedAnswerForQuestion(question, post({ id: 456 }))).toBeNull()
})
it('returns yes for a matching tag question', () => {
const question: StoredGekanatorQuestion = {
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
}
expect(
expectedAnswerForQuestion(
question,
post({
tags: [
{
id: 1,
name: '喜多郁代',
category: 'character',
aliases: [],
parents: [],
postCount: 1,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
deprecatedAt: null,
hasWiki: false,
hasDeerjikists: false,
materialId: null,
},
],
}),
),
).toBe('yes')
})
it('returns no for a non-matching tag question', () => {
const question: StoredGekanatorQuestion = {
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
}
expect(expectedAnswerForQuestion(question, post({ tags: [] }))).toBe('no')
})
it('ignores example answers for direct title facts', () => {
const question: StoredGekanatorQuestion = {
id: 'title:length-at-least:20',
text: 'タイトルは 20 文字以上?',
kind: 'title',
condition: {
type: 'title-length-at-least',
length: 20,
},
exampleAnswers: {
1: 'yes',
},
}
expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no')
})
it('returns yes for matching title-contains questions', () => {
const question: StoredGekanatorQuestion = {
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
condition: {
type: 'title-contains',
text: '結束バンド',
},
}
expect(expectedAnswerForQuestion(
question,
post({ title: '結束バンドのライブ' }),
)).toBe('yes')
expect(expectedAnswerForQuestion(
question,
post({ title: '後藤ひとりの休日' }),
)).toBe('no')
})
})
describe('learnedSemanticSideForPost', () => {
it('classifies post_similarity examples as positive, negative, or unknown', () => {
const question: StoredGekanatorQuestion = {
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 123,
answer: 'partial',
threshold: 0.65,
},
exampleAnswers: {
1: 'yes',
2: 'probably_no',
},
}
expect(learnedSemanticSideForPost(question, post({ id: 1 }))).toBe('positive')
expect(learnedSemanticSideForPost(question, post({ id: 2 }))).toBe('negative')
expect(learnedSemanticSideForPost(question, post({ id: 3 }))).toBe('unknown')
expect(learnedSemanticSideForPost(question, post({ id: 123 }))).toBe('positive')
})
})
describe('restoreGekanatorQuestion', () => {
it('uses default source and priority weight when omitted', () => {
const question = restoreGekanatorQuestion({
id: 'tag:character:喜多郁代',
text: '喜多ちゃんが関係してる?',
kind: 'tag',
condition: {
type: 'tag',
key: 'character:喜多郁代',
},
})
expect(question.source).toBe('default')
expect(question.priorityWeight).toBe(1)
})
it('tests a post_similarity question using direct examples', () => {
const question = restoreGekanatorQuestion({
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'yes',
threshold: 0.65,
},
exampleAnswers: {
1: 'yes',
2: 'no',
},
})
expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(false)
expect(question.test(post({ id: 3 }))).toBe(false)
})
it('tests a post_similarity question against its configured partial answer', () => {
const question = restoreGekanatorQuestion({
id: 'post-similarity:10',
text: '喜多ちゃんが泣いてる?',
kind: 'post_similarity',
source: 'user_suggested',
priorityWeight: 1.2,
condition: {
type: 'post-similarity',
postId: 999,
answer: 'partial',
threshold: 0.65,
},
exampleAnswers: {
1: 'partial',
2: 'yes',
},
})
expect(question.test(post({ id: 1 }))).toBe(true)
expect(question.test(post({ id: 2 }))).toBe(true)
})
it('normalizes legacy title-length-greater-than questions', () => {
const question = restoreGekanatorQuestion({
id: 'title:length-greater-than:20',
text: '題名が長めの投稿?',
kind: 'title',
condition: {
type: 'title-length-greater-than',
length: 20,
},
})
expect(question.id).toBe('title:length-at-least:21')
expect(question.condition).toEqual({
type: 'title-length-at-least',
length: 21,
})
expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false)
expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true)
})
it('restores title-contains questions with a title matcher', () => {
const question = restoreGekanatorQuestion({
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
condition: {
type: 'title-contains',
text: '結束バンド',
},
})
expect(question.test(post({ title: '結束バンドのライブ' }))).toBe(true)
expect(question.test(post({ title: '後藤ひとりの休日' }))).toBe(false)
})
})
describe('buildGekanatorQuestions', () => {
it('builds quantitative title length questions', () => {
const questions = buildGekanatorQuestions([
post({ id: 1, title: 'a' }),
post({ id: 2, title: 'bb' }),
post({ id: 3, title: 'ccc' }),
post({ id: 4, title: 'dddd' }),
])
const titleQuestion = questions.find(question =>
question.condition.type === 'title-length-at-least')
expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/)
expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/)
})
it('builds title-contains questions from repeated title words', () => {
const questions = buildGekanatorQuestions([
post({ id: 1, title: '結束バンド ライブ' }),
post({ id: 2, title: '結束バンド 新曲' }),
post({ id: 3, title: '後藤ひとり 練習' }),
post({ id: 4, title: '伊地知虹夏 練習' }),
])
const titleContainsQuestion = questions.find(question =>
question.condition.type === 'title-contains'
&& question.condition.text === '結束バンド')
expect(titleContainsQuestion).toMatchObject({
id: 'title:contains:結束バンド',
text: '題名に「結束バンド」が含まれる?',
kind: 'title',
source: 'default',
priorityWeight: .96,
})
expect(titleContainsQuestion?.test(post({ title: '結束バンドのライブ' }))).toBe(true)
expect(titleContainsQuestion?.test(post({ title: '廣井きくりのライブ' }))).toBe(false)
})
it('honors question caps and title-contains toggles', () => {
const posts = [
post({ id: 1, title: '結束バンド ライブ' }),
post({ id: 2, title: '結束バンド 新曲' }),
post({ id: 3, title: '後藤ひとり 練習' }),
post({ id: 4, title: '伊地知虹夏 練習' }),
]
const capped = buildGekanatorQuestions(posts, {
titleContainsCap: 1,
totalQuestionCap: 1,
})
const withoutTitleContains = buildGekanatorQuestions(posts, {
includeTitleContains: false,
})
expect(capped).toHaveLength(1)
expect(withoutTitleContains.some(question =>
question.condition.type === 'title-contains')).toBe(false)
})
})
describe('questionIdForCondition', () => {
it('builds stable ids for title-contains questions', () => {
expect(questionIdForCondition({
type: 'title-contains',
text: '結束バンド',
})).toBe('title:contains:結束バンド')
})
})
describe('Gekanator API writers', () => {
beforeEach(() => {
mockedApiPost.mockReset()
})
it('sends game results using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({ id: 100 })
const answers: GekanatorAnswerLog[] = [
{
questionId: 'tag:character:喜多郁代',
questionText: '喜多ちゃんが関係してる?',
questionCondition: {
type: 'tag',
key: 'character:喜多郁代',
},
questionMode: 'normal',
questionPurpose: 'effective_user_suggested',
effectiveQuestion: true,
learningQuestion: false,
answer: 'yes',
originalAnswer: 'partial',
},
]
await expect(
saveGekanatorGame({
guessedPostId: 1,
correctPostId: 2,
answers,
}),
).resolves.toEqual({ id: 100 })
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/games', {
guessed_post_id: 1,
correct_post_id: 2,
answers: [
{
question_id: 'tag:character:喜多郁代',
question_text: '喜多ちゃんが関係してる?',
question_condition: {
type: 'tag',
key: 'character:喜多郁代',
},
question_mode: 'normal',
question_purpose: 'effective_user_suggested',
effective_question: true,
learning_question: false,
answer: 'yes',
original_answer: 'partial',
},
],
})
})
it('sends question suggestions using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({
id: 10,
count: 1,
})
await expect(
saveGekanatorQuestionSuggestion({
gekanatorGameId: 100,
questionText: '喜多ちゃんが泣いてる?',
answer: 'yes',
}),
).resolves.toEqual({
id: 10,
count: 1,
})
expect(mockedApiPost).toHaveBeenCalledWith('/gekanator/question_suggestions', {
gekanator_game_id: 100,
question_text: '喜多ちゃんが泣いてる?',
answer: 'yes',
})
})
it('sends extra question answers using snake_case request keys', async () => {
mockedApiPost.mockResolvedValue({
count: 2,
})
await saveGekanatorExtraQuestionAnswers({
gameId: 100,
answers: [
{
questionId: 10,
answer: 'yes',
},
{
questionId: 11,
answer: 'probably_no',
},
],
})
expect(mockedApiPost).toHaveBeenCalledWith(
'/gekanator/games/100/extra_question_answers',
{
answers: [
{
question_id: 10,
answer: 'yes',
},
{
question_id: 11,
answer: 'probably_no',
},
],
},
)
})
})
+675
ファイルの表示
@@ -0,0 +1,675 @@
import { apiGet, apiPost } from '@/lib/api'
import type { Post } from '@/types'
export type GekanatorAnswerValue =
| 'yes'
| 'no'
| 'partial'
| 'probably_no'
| 'unknown'
export type LearnedSemanticSide =
| 'positive'
| 'negative'
| 'unknown'
export type GekanatorQuestionPurpose =
| 'effective_user_suggested'
| 'learning_user_suggested'
| 'normal'
export type GekanatorAnswerLog = {
questionId: string
questionText: string
questionCondition?: GekanatorQuestionCondition
questionMode?: 'normal' | 'winning_run'
questionPurpose?: GekanatorQuestionPurpose
effectiveQuestion?: boolean
learningQuestion?: boolean
answer: GekanatorAnswerValue
originalAnswer: GekanatorAnswerValue }
export type GekanatorQuestionKind =
| 'tag'
| 'source'
| 'title'
| 'original_date'
| 'post_similarity'
export type GekanatorQuestionSource =
| 'default'
| 'user_suggested'
| 'ai_generated'
| 'admin_curated'
export type GekanatorPerformanceMode = 'normal'
export type GekanatorQuestionCondition =
| { type: 'tag'; key: string }
| { type: 'source'; host: string }
| { type: 'original-year'; year: number }
| { type: 'original-month'; month: number }
| { type: 'original-month-day'; monthDay: string }
| { type: 'title-length-at-least'; length: number }
| { type: 'title-length-greater-than'; length: number }
| { type: 'title-has-ascii' }
| { type: 'title-contains'; text: string }
| {
type: 'post-similarity'
postId: number
answer: GekanatorAnswerValue
threshold: number
}
type NonPostSimilarityCondition = Exclude<
GekanatorQuestionCondition,
{ type: 'post-similarity' }
>
export type GekanatorExtraQuestion = {
id: number
text: string
source: GekanatorQuestionSource
priorityWeight: number }
export type StoredGekanatorQuestion = {
recordId?: number
id: string
text: string
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source?: GekanatorQuestionSource
priorityWeight?: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = {
recordId?: number
id: string
text: string
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source: GekanatorQuestionSource
priorityWeight: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
test: (post: Post) => boolean }
export type BuildGekanatorQuestionsOptions = {
includeTitleContains?: boolean
tagQuestionCap?: number
titleContainsCap?: number
totalQuestionCap?: number
}
export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition,
): GekanatorQuestionCondition => {
switch (condition.type)
{
case 'title-length-greater-than':
return {
type: 'title-length-at-least',
length: condition.length + 1 }
default:
return condition
}
}
export const titleLengthMinimumForCondition = (
condition: GekanatorQuestionCondition,
): number | null => {
switch (condition.type)
{
case 'title-length-at-least':
return condition.length
case 'title-length-greater-than':
return condition.length + 1
default:
return null
}
}
export const questionIdForCondition = (
condition: NonPostSimilarityCondition,
): string => {
switch (condition.type)
{
case 'tag':
return `tag:${ condition.key }`
case 'source':
return `source:${ condition.host }`
case 'original-year':
return `original-year:${ condition.year }`
case 'original-month':
return `original-month:${ condition.month }`
case 'original-month-day':
return `original-month-day:${ condition.monthDay }`
case 'title-length-at-least':
case 'title-length-greater-than':
return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }`
case 'title-has-ascii':
return 'title:ascii'
case 'title-contains':
return `title:contains:${ condition.text }`
}
}
const directExampleAnswerFor = (
question: StoredGekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
if (direct)
return direct
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
return question.condition.answer
return null
}
export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion,
): boolean =>
question.kind === 'post_similarity'
&& question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null,
): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial')
return 'positive'
if (answer === 'no' || answer === 'probably_no')
return 'negative'
return 'unknown'
}
const countBy = <T extends string | number> (values: T[]): Map<T, number> => {
const counts = new Map<T, number> ()
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
return counts
}
const median = (values: number[]): number => {
const sorted = [...values].sort ((a, b) => a - b)
return sorted[Math.floor (sorted.length / 2)] ?? 0
}
const hostOf = (post: Post): string | null => {
try
{
return new URL (post.url).hostname.replace (/^www\./, '')
}
catch
{
return null
}
}
const originalYearOf = (post: Post): number | null => {
const value = post.originalCreatedFrom || post.originalCreatedBefore
if (!(value))
return null
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return null
return date.getFullYear ()
}
const originalDateOf = (post: Post): Date | null => {
const value = post.originalCreatedFrom || post.originalCreatedBefore
if (!(value))
return null
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return null
return date
}
const originalMonthOf = (post: Post): number | null => {
const date = originalDateOf (post)
if (!(date))
return null
return date.getMonth () + 1
}
const originalMonthDayOf = (post: Post): string | null => {
const date = originalDateOf (post)
if (!(date))
return null
return `${ date.getMonth () + 1 }-${ date.getDate () }`
}
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
`${ category }:${ name }`
const tagFromQuestionKey = (key: string): { category: string; name: string } => {
const [category, ...rest] = key.split (':')
return { category: category ?? '', name: rest.join (':') }
}
const nicoTagLabel = (name: string): string => name.replace (/^nico:/, '')
const tagQuestionText = (category: string, label: string): string => {
switch (category)
{
case 'deerjikist':
return `作者・ニジラーとして「${ label }」に関係してゐる?`
case 'meme':
return `元ネタ・ミームとして「${ label }」に関係しさう?`
case 'character':
return `${ label }」といふキャラクターが関係してゐる?`
case 'material':
return `素材として「${ label }」に関係してゐる?`
case 'nico':
return `ニコニコに「${ label }」といふタグが付いてゐる?`
case 'general':
case 'meta':
default:
return `内容として「${ label }」に関係しさう?`
}
}
const questionableTag = (post: Post, key: string): boolean => {
const { category, name } = tagFromQuestionKey (key)
return (
post.tags.some (tag =>
tag.name === name
&& tag.category === category
&& !(tag.category === 'meta')
&& !(tag.name.includes ('タグ希望'))
&& !(tag.name.includes ('bot操作'))))
}
const questionMatches = (
post: Post,
question: StoredGekanatorQuestion,
): boolean => {
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return question.kind === 'post_similarity'
? learnedSemanticSideForAnswer (directAnswer) === 'positive'
: directAnswer === 'yes'
switch (question.condition.type)
{
case 'tag':
return questionableTag (post, question.condition.key)
case 'source':
return hostOf (post) === question.condition.host
case 'original-year':
return originalYearOf (post) === question.condition.year
case 'original-month':
return originalMonthOf (post) === question.condition.month
case 'original-month-day':
return originalMonthDayOf (post) === question.condition.monthDay
case 'title-length-at-least':
return (post.title?.length ?? 0) >= question.condition.length
case 'title-length-greater-than':
return (post.title?.length ?? 0) > question.condition.length
case 'title-has-ascii':
return /[A-Za-z0-9]/.test (post.title ?? '')
case 'title-contains':
return (post.title ?? '').includes (question.condition.text)
case 'post-similarity':
return false
}
}
export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(post))
return null
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return directAnswer
switch (question.condition.type)
{
case 'post-similarity':
if (question.condition.postId === post.id)
return question.condition.answer
return null
case 'tag':
case 'source':
case 'original-year':
case 'original-month':
case 'original-month-day':
case 'title-length-at-least':
case 'title-length-greater-than':
case 'title-has-ascii':
case 'title-contains':
return questionMatches (post, question) ? 'yes' : 'no'
}
}
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = {
...question,
recordId: question.recordId,
id: normalizedCondition.type === 'title-length-at-least'
? `title:length-at-least:${ normalizedCondition.length }`
: question.id,
condition: normalizedCondition,
source: question.source ?? 'default',
priorityWeight: question.priorityWeight ?? 1 }
return {
...normalizedQuestion,
test: (post: Post) => questionMatches (post, normalizedQuestion) }
}
export const storeGekanatorQuestion = (
question: GekanatorQuestion,
): StoredGekanatorQuestion => ({
id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }`
: question.id,
recordId: question.recordId,
text: question.text,
kind: question.kind,
condition: normalizeTitleLengthCondition (question.condition),
source: question.source,
priorityWeight: question.priorityWeight,
exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
return data.posts
}
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
return data.questions
}
export const fetchGekanatorExtraQuestions = async (
gameId: number,
nonce?: string,
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
return data.questions
}
export const buildGekanatorQuestions = (
posts: Post[],
options: BuildGekanatorQuestionsOptions = { },
): GekanatorQuestion[] => {
const {
includeTitleContains = true,
tagQuestionCap = 192,
titleContainsCap = 24,
totalQuestionCap = Number.POSITIVE_INFINITY,
} = options
const tagCounts = countBy (posts.flatMap (post =>
post.tags
.filter (tag =>
!(tag.category === 'meta')
&& !(tag.name.includes ('タグ希望'))
&& !(tag.name.includes ('bot操作')))
.map (tag => tagQuestionKey (tag))))
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
const originalYears = countBy (
posts
.map (originalYearOf)
.filter ((year): year is number => year != null))
const originalMonths = countBy (
posts
.map (originalMonthOf)
.filter ((month): month is number => month != null))
const originalMonthDays = countBy (
posts
.map (originalMonthDayOf)
.filter ((monthDay): monthDay is string => monthDay != null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const titleWordCounts =
includeTitleContains
? countBy (
posts.flatMap (post =>
Array.from (
new Set (
(post.title ?? '')
.match (
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu)
?? []))))
: new Map<string, number> ()
const usefulEntries = <T extends string | number> (
counts: Map<T, number>,
cap: number,
) =>
[...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
- Math.abs (posts.length / 2 - b[1]))
.slice (0, cap)
const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80))
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, tagQuestionCap)
.map (([key]) => {
const { category, name } = tagFromQuestionKey (String (key))
const label = category === 'nico' ? nicoTagLabel (name) : name
return {
id: `tag:${ key }`,
text: tagQuestionText (category, label),
kind: 'tag' as const,
condition: { type: 'tag' as const, key: String (key) },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => questionableTag (post, String (key)) }
})
const sourceQuestions = usefulEntries (hosts, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([host]) => ({
id: `source:${ host }`,
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
condition: { type: 'source' as const, host },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => hostOf (post) === host }))
const originalYearQuestions = usefulEntries (originalYears, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([year]) => ({
id: `original-year:${ year }`,
text: `オリジナルの投稿年は ${ year } 年?`,
kind: 'original_date' as const,
condition: { type: 'original-year' as const, year },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalYearOf (post) === year }))
const originalMonthQuestions = usefulEntries (originalMonths, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([month]) => ({
id: `original-month:${ month }`,
text: `オリジナルの投稿月は ${ month } 月?`,
kind: 'original_date' as const,
condition: { type: 'original-month' as const, month },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalMonthOf (post) === month }))
const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([monthDay]) => {
const [month, day] = String (monthDay).split ('-')
return {
id: `original-month-day:${ monthDay }`,
text: `オリジナルの投稿日は ${ month }${ day } 日?`,
kind: 'original_date' as const,
condition: { type: 'original-month-day' as const, monthDay: String (monthDay) },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => originalMonthDayOf (post) === monthDay }
})
const titleQuestions = [
{
id: `title:length-at-least:${ titleLengthMedian }`,
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
kind: 'title' as const,
condition: {
type: 'title-length-at-least' as const,
length: titleLengthMedian },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => (post.title?.length ?? 0) >= titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title' as const,
condition: { type: 'title-has-ascii' as const },
source: 'default' as const,
priorityWeight: 1,
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
.filter (question => {
const yes = posts.filter (post => question.test (post)).length
const no = posts.length - yes
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
})
const titleContainsQuestions =
includeTitleContains
? usefulEntries (titleWordCounts, titleContainsCap)
.filter (([word, count]) =>
String (word).length <= 24
&& count >= 2
&& count <= Math.max (2, posts.length * .7))
.slice (0, titleContainsCap)
.map (([word]) => ({
id: `title:contains:${ word }`,
text: `題名に「${ word }」が含まれる?`,
kind: 'title' as const,
condition: { type: 'title-contains' as const, text: String (word) },
source: 'default' as const,
priorityWeight: .96,
test: (post: Post) => (post.title ?? '').includes (String (word)) }))
: []
return [
...sourceQuestions,
...originalYearQuestions,
...originalMonthQuestions,
...originalMonthDayQuestions,
...titleQuestions,
...titleContainsQuestions,
...tagQuestions].slice (0, totalQuestionCap)
}
export const saveGekanatorGame = async ({
guessedPostId,
correctPostId,
answers,
}: {
guessedPostId: number
correctPostId: number
answers: GekanatorAnswerLog[]
}): Promise<{ id: number; learnedExampleCount: number }> =>
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
answers: answers.map (answer => ({
question_id: answer.questionId,
question_text: answer.questionText,
question_condition: answer.questionCondition ?? null,
question_mode: answer.questionMode,
question_purpose: answer.questionPurpose,
effective_question: answer.effectiveQuestion,
learning_question: answer.learningQuestion,
answer: answer.answer,
original_answer: answer.originalAnswer })) })
export const saveGekanatorQuestionSuggestion = async ({
gekanatorGameId,
existingQuestionId,
questionText,
answer,
}: {
gekanatorGameId: number
existingQuestionId?: number
questionText?: string
answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId,
existing_question_id: existingQuestionId,
question_text: questionText,
answer })
export const saveGekanatorExtraQuestionAnswers = async ({
gameId,
answers,
}: {
gameId: number
answers: { questionId: number; answer: GekanatorAnswerValue }[]
}) =>
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
answers: answers.map (item => ({
question_id: item.questionId,
answer: item.answer })) })
+259
ファイルの表示
@@ -0,0 +1,259 @@
import { describe, expect, it } from 'vitest'
import {
candidatePostsFor,
hardFilteredPostsForAnswer,
recoverCandidatePosts,
} from '@/lib/gekanatorCandidateRecovery'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
import type { RecoveredCandidateState } from '@/lib/gekanatorCandidateRecovery'
import type { Post } from '@/types'
const post = (id: number): Post => ({
id,
versionNo: 1,
url: `https://example.com/posts/${ id }`,
title: `post ${ id }`,
thumbnail: null,
thumbnailBase: null,
tags: [],
viewed: false,
related: [],
originalCreatedFrom: null,
originalCreatedBefore: null,
createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z',
uploadedUser: null,
})
const postSimilarityQuestion = (
id: string,
answers: Record<`${ number }`, GekanatorAnswerValue>,
): GekanatorQuestion => ({
id,
text: `${ id }?`,
kind: 'post_similarity',
condition: {
type: 'post-similarity',
postId: 9999,
answer: 'yes',
threshold: 0.65 },
source: 'user_suggested',
priorityWeight: 1,
exampleAnswers: answers,
test: candidate => answers[String (candidate.id) as `${ number }`] === 'yes',
})
const sourceQuestion = (
host: string,
): GekanatorQuestion => ({
id: `source:${ host }`,
text: `${ host }?`,
kind: 'source',
condition: {
type: 'source',
host },
source: 'default',
priorityWeight: 1,
test: candidate => new URL (candidate.url).hostname === host,
})
const answer = (
question: GekanatorQuestion,
value: GekanatorAnswerValue,
): GekanatorAnswerLog => ({
questionId: question.id,
questionText: question.text,
questionCondition: question.condition,
answer: value,
originalAnswer: value,
})
const recoveredState = (
answerCountAtRecovery: number,
scoreAtRecovery = 0,
): RecoveredCandidateState => ({
answerCountAtRecovery,
scoreAtRecovery,
})
describe('candidatePostsFor', () => {
it('does not hard-filter semantic post_similarity answers', () => {
const posts = [post (1), post (2), post (3)]
const oldQuestion = postSimilarityQuestion ('old', {
1: 'no',
2: 'yes',
3: 'yes',
})
const laterQuestion = postSimilarityQuestion ('later', {
1: 'no',
2: 'no',
3: 'yes',
})
const candidates = candidatePostsFor ({
posts,
questions: [oldQuestion, laterQuestion],
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([
[1, recoveredState (1)],
[3, recoveredState (1)],
]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
})
it('lets recovered candidates ignore old fact answers but not later fact answers', () => {
const posts = [
{ ...post (1), url: 'https://other.example/posts/1' },
post (2),
{ ...post (3), url: 'https://example.com/posts/3' },
]
const oldQuestion = sourceQuestion ('old.example.com')
const laterQuestion = sourceQuestion ('example.com')
const candidates = candidatePostsFor ({
posts,
questions: [oldQuestion, laterQuestion],
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([
[1, recoveredState (1)],
[3, recoveredState (1)],
]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([3])
})
it('does not let recovered candidates bypass explicit rejected posts', () => {
const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', {
1: 'yes',
2: 'yes',
})
const candidates = candidatePostsFor ({
posts,
questions: [question],
answers: [answer (question, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set ([1]),
recoveredCandidatePosts: new Map ([[1, recoveredState (1)]]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([2])
})
})
describe('hardFilteredPostsForAnswer', () => {
it('keeps the original pool for semantic post_similarity answers', () => {
const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', {
1: 'yes',
2: 'yes',
})
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'no',
})).toEqual (posts)
})
it('hard-filters fact answers only for yes and no', () => {
const posts = [
{ ...post (1), url: 'https://example.com/posts/1' },
{ ...post (2), url: 'https://other.example/posts/2' },
]
const question = sourceQuestion ('example.com')
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'yes',
}).map (candidate => candidate.id)).toEqual ([1])
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'no',
}).map (candidate => candidate.id)).toEqual ([2])
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'partial',
})).toEqual (posts)
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'probably_no',
})).toEqual (posts)
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'unknown',
})).toEqual (posts)
})
})
describe('recoverCandidatePosts', () => {
it('recovers high-score non-rejected, non-eligible candidates in staged batches', () => {
const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1))
const scores = new Map (posts.map (candidate => [candidate.id, candidate.id]))
const recovered = recoverCandidatePosts ({
posts,
scores,
rejectedPostIds: new Set ([10]),
recoveredCandidatePosts: new Map ([[8, recoveredState (1, 8)]]),
eligiblePostIds: new Set ([9]),
answerCountAtRecovery: 2,
recoveryStepCount: 0,
})
expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([8, 7, 6, 5, 4])
expect(recovered?.recoveredCandidatePosts.get (7)).toEqual ({
answerCountAtRecovery: 2,
scoreAtRecovery: 7,
})
})
it('does not add posts when recovered and eligible candidates already hit the target', () => {
const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1))
const scores = new Map (posts.map (candidate => [candidate.id, candidate.id]))
const recovered = recoverCandidatePosts ({
posts,
scores,
rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([
[1, recoveredState (1, 1)],
[2, recoveredState (1, 2)],
[3, recoveredState (1, 3)],
]),
eligiblePostIds: new Set ([4, 5, 6]),
answerCountAtRecovery: 2,
recoveryStepCount: 0,
})
expect(recovered?.recoveryStepCount).toBe (1)
expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])])
.toEqual ([1, 2, 3])
})
})
+159
ファイルの表示
@@ -0,0 +1,159 @@
import { isLearnedSemanticQuestion,
learnedSemanticSideForPost } from '@/lib/gekanator'
import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
export type RecoveredCandidatePost = {
postId: number
answerCountAtRecovery: number
scoreAtRecovery: number }
export type RecoveredCandidateState = {
answerCountAtRecovery: number
scoreAtRecovery: number }
const questionSupportsAnswerBasedHardFiltering = (question: GekanatorQuestion): boolean =>
!(isLearnedSemanticQuestion (question)
|| (question.kind === 'tag'
&& question.condition.type === 'tag'
&& !(question.condition.key.startsWith ('nico:'))))
export const candidatePostsFor = (
{ posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
recoveredCandidatePosts }: { posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
const recoveredCandidate = recoveredCandidatePosts.get (post.id)
return answers.every ((answer, index) => {
if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
return true
if (softenedQuestionIds.has (answer.questionId))
return true
const question = questionById.get (answer.questionId)
if (!(question))
return true
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return true
switch (answer.answer)
{
case 'yes':
case 'no':
{
const expected = learnedSemanticSideForPost (question, post)
return expected === 'unknown'
|| (answer.answer === 'yes' && expected === 'positive')
|| (answer.answer === 'no' && expected === 'negative')
}
default:
return true
}
})
})
}
export const hardFilteredPostsForAnswer = (
{ posts, question, answer }: { posts: Post[]
question: GekanatorQuestion
answer: GekanatorAnswerValue },
): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts
if (!(answer === 'yes' || answer === 'no'))
return posts
return posts.filter (post => {
const side = learnedSemanticSideForPost (question, post)
return side === 'unknown'
|| (answer === 'yes' && side === 'positive')
|| (answer === 'no' && side === 'negative')
})
}
const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', 'probably_no']
export const allConcreteAnswerOptionsExhausted = (
posts: Post[],
question: GekanatorQuestion | null,
): boolean => {
if (!(question))
return false
return concreteAnswerOptions.every (answer =>
hardFilteredPostsForAnswer ({ posts, question, answer }).length === 0)
}
const nextRecoveryTargetSize = (recoveryStepCount: number): number =>
6 * (2 ** recoveryStepCount)
export const recoverCandidatePosts = (
{ posts,
scores,
rejectedPostIds,
recoveredCandidatePosts,
eligiblePostIds,
answerCountAtRecovery,
recoveryStepCount }: { posts: Post[]
scores: Map<number, number>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number>
answerCountAtRecovery: number
recoveryStepCount: number },
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
const countedPostIds = new Set ([...eligiblePostIds, ...recovered.keys ()])
const addCount = targetSize - countedPostIds.size
if (addCount <= 0)
{
return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
}
const candidates =
posts
.filter (post => (!(rejectedPostIds.has (post.id))
&& !(eligiblePostIds.has (post.id))
&& !(recovered.has (post.id))))
.sort ((a, b) => ((scores.get (b.id) ?? Number.NEGATIVE_INFINITY)
- (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)))
.slice (0, addCount)
if (candidates.length === 0)
return null
candidates.forEach (post => recovered.set (post.id, {
answerCountAtRecovery,
scoreAtRecovery: scores.get (post.id) ?? 0 }))
return { recoveredCandidatePosts: recovered,
recoveryStepCount: recoveryStepCount + 1 }
}
+167
ファイルの表示
@@ -0,0 +1,167 @@
import { titleLengthMinimumForCondition } from '@/lib/gekanator'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
} from '@/lib/gekanator'
export const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
if (condition.type === 'original-month')
return condition.month
if (condition.type !== 'original-month-day')
return null
const month = Number (condition.monthDay.split ('-')[0])
return Number.isInteger (month) ? month : null
}
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
return false
switch (answer)
{
case 'yes':
return candidateLength <= previousLength
case 'no':
return candidateLength >= previousLength
default:
return false
}
}
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
})
const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.host === previous.host
default:
return false
}
}
const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.year === previous.year
default:
return false
}
}
const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
switch (answer)
{
case 'yes':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return true
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) !== previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month'
|| candidate.type === 'original-month-day'
return false
case 'no':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return candidate.month === previous.month
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) === previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month-day'
&& candidate.monthDay === previous.monthDay
return false
default:
return false
}
}
const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (!(answer === 'yes' || answer === 'no'))
return false
return isSourceFactBlocked (candidate, previous, answer)
|| isOriginalYearFactBlocked (candidate, previous, answer)
|| isOriginalMonthFactBlocked (candidate, previous, answer)
}
export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
if (previous === undefined)
return false
return isQuestionRedundantAfterAnswers (question, [answer])
|| isFactQuestionBlocked (question.condition, previous, answer.answer)
})
+28
ファイルの表示
@@ -10,6 +10,7 @@ const postsApi = vi.hoisted (() => ({
}))
const tagsApi = vi.hoisted (() => ({
fetchNicoTags: vi.fn (),
fetchTag: vi.fn (),
fetchTagByName: vi.fn (),
fetchTagChanges: vi.fn (),
@@ -37,6 +38,7 @@ describe ('prefetchForURL', () => {
postsApi.fetchPost.mockResolvedValue ({ id: 1 })
postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 })
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
tagsApi.fetchNicoTags.mockResolvedValue ({ tags: [], count: 0 })
tagsApi.fetchTag.mockResolvedValue ({ id: 1 })
tagsApi.fetchTagByName.mockResolvedValue (null)
tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 })
@@ -85,6 +87,32 @@ describe ('prefetchForURL', () => {
)
})
it ('prefetches nico tag indexes and their alias from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags/nico?name=source&linked_tag=destination'
+ '&link_status=linked&page=3&limit=10',
)
await prefetchForURL (qc (), 'http://localhost/nico/tags?page=2')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, {
name: 'source',
linkedTag: 'destination',
linkStatus: 'linked',
page: 3,
limit: 10,
order: 'updated_at:desc',
})
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, {
name: '',
linkedTag: '',
linkStatus: 'all',
page: 2,
limit: 20,
order: 'updated_at:desc',
})
})
it ('prefetches wiki show pages and related tag/post data', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({
id: 3,
+29 -2
ファイルの表示
@@ -3,7 +3,7 @@ import { match } from 'path-to-regexp'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiPage,
fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki'
@@ -17,6 +17,10 @@ const mWiki = match<{ title: string }> ('/wiki/:title')
const mTag = match<{ id: string }> ('/tags/:id')
const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
const title = url.searchParams.get ('title') ?? ''
@@ -156,13 +160,16 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
const createdTo = url.searchParams.get ('created_to') ?? ''
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
const updatedTo = url.searchParams.get ('updated_to') ?? ''
const deprecated = url.searchParams.has ('deprecated')
? boolFromQuery (url.searchParams.get ('deprecated'))
: null
const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20)
const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder
const keys = {
post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, page, limit, order }
updatedFrom, updatedTo, deprecated, page, limit, order }
await qc.prefetchQuery ({
queryKey: tagsKeys.index (keys),
@@ -170,6 +177,24 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
}
const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
const keys = {
name: url.searchParams.get ('name') ?? '',
linkedTag: url.searchParams.get ('linked_tag') ?? '',
linkStatus: (url.searchParams.get ('link_status') || 'all') as
'all' | 'linked' | 'unlinked',
page: Number (url.searchParams.get ('page') || 1),
limit: Number (url.searchParams.get ('limit') || 20),
order: (url.searchParams.get ('order') || 'updated_at:desc') as
'name:asc' | 'name:desc' | 'created_at:asc' | 'created_at:desc'
| 'updated_at:asc' | 'updated_at:desc' }
await qc.prefetchQuery ({
queryKey: tagsKeys.nicoIndex (keys),
queryFn: () => fetchNicoTags (keys) })
}
const prefetchTagShow: Prefetcher = async (qc, url) => {
const m = mTag (url.pathname)
if (!(m))
@@ -206,6 +231,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
&& Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow },
{ test: u => u.pathname === '/tags', run: prefetchTagsIndex },
{ test: u => ['/tags/nico', '/nico/tags'].includes (u.pathname),
run: prefetchNicoTagsIndex },
{ test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname))
&& Boolean (mTag (u.pathname))),
run: prefetchTagShow },
+10 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { FetchPostsParams, FetchTagsParams } from '@/types'
import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types'
export const postsKeys = {
root: ['posts'] as const,
@@ -8,9 +8,18 @@ export const postsKeys = {
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
['posts', 'changes', p] as const }
export const gekanatorKeys = {
root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const,
questions: () => ['gekanator', 'questions'] as const,
extraQuestions: (gameId: number, nonce: string) =>
['gekanator', 'games', gameId, 'extra-questions', nonce] as const }
export const tagsKeys = {
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
nicoRoot: ['tags', 'nico'] as const,
nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const,
show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const,
+15
ファイルの表示
@@ -20,6 +20,7 @@ const baseParams: FetchTagsParams = {
createdTo: '',
updatedFrom: '',
updatedTo: '',
deprecated: null,
page: 1,
limit: 30,
order: 'updated_at:desc',
@@ -57,6 +58,20 @@ describe ('tags API functions', () => {
)
})
it.each ([
[true, '1'],
[false, '0'],
] as const) ('maps deprecated=%s to %s', async (deprecated, expected) => {
api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 })
await fetchTags ({ ...baseParams, deprecated })
expect (api.apiGet).toHaveBeenCalledWith (
'/tags',
{ params: expect.objectContaining ({ deprecated: expected }) },
)
})
it ('returns null when tag fetches fail', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
+22 -3
ファイルの表示
@@ -1,11 +1,17 @@
import { apiGet } from '@/lib/api'
import type { Deerjikist, FetchTagsParams, Tag, TagVersion } from '@/types'
import type { Deerjikist,
FetchNicoTagsParams,
FetchTagsParams,
NicoTag,
Tag,
TagVersion } from '@/types'
export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, page, limit, order }: FetchTagsParams,
updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams,
): Promise<{ tags: Tag[]
count: number }> =>
await apiGet ('/tags', { params: {
@@ -18,11 +24,25 @@ export const fetchTags = async (
...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }),
...(deprecated != null && { deprecated: deprecated ? '1' : '0' }),
...(page && { page }),
...(limit && { limit }),
...(order && { order }) } })
export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
): Promise<{ tags: NicoTag[]
count: number }> =>
await apiGet ('/tags/nico', { params: {
page,
limit,
name,
linked_tag: linkedTag,
link_status: linkStatus === 'all' ? '' : linkStatus,
order } })
export const fetchTag = async (id: string): Promise<Tag | null> => {
try
{
@@ -46,7 +66,6 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => {
}
}
export const fetchTagChanges = async (
{ id, page, limit }: {
id?: string
+28
ファイルの表示
@@ -0,0 +1,28 @@
import { useState } from 'react'
import { extractValidationError } from '@/lib/apiErrors'
import type { FieldErrors } from '@/lib/apiErrors'
export const useValidationErrors = <T extends string> () => {
const [baseErrors, setBaseErrors] = useState<string[]> ([])
const [fieldErrors, setFieldErrors] = useState<FieldErrors<T>> ({ })
const clearValidationErrors = () => {
setBaseErrors ([])
setFieldErrors ({ })
}
const applyValidationError = (error: unknown): boolean => {
const validationError = extractValidationError<T> (error)
if (!(validationError))
return false
setBaseErrors (validationError.baseErrors)
setFieldErrors (validationError.fieldErrors)
return true
}
return { baseErrors, fieldErrors, clearValidationErrors, applyValidationError }
}
+20
ファイルの表示
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { canEditContent } from '@/lib/users'
import type { UserRole } from '@/types'
const userWithRole = (role: UserRole) => ({ role })
describe ('user permission helpers', () => {
it ('allows admins and members to edit content', () => {
expect (canEditContent (userWithRole ('admin'))).toBe (true)
expect (canEditContent (userWithRole ('member'))).toBe (true)
})
it ('does not allow guests or missing users to edit content', () => {
expect (canEditContent (userWithRole ('guest'))).toBe (false)
expect (canEditContent (null)).toBe (false)
expect (canEditContent (undefined)).toBe (false)
})
})
+8
ファイルの表示
@@ -0,0 +1,8 @@
import type { User, UserRole } from '@/types'
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = (
user: Pick<User, 'role'> | null | undefined,
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
+12
ファイルの表示
@@ -83,3 +83,15 @@ export const msToTime = (ms: number): string => {
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
: `${ min }:${ s.padStart (2, '0') }`)
}
export const inputClass = (invalid?: boolean, className?: string): string =>
cn ('w-full rounded border p-2',
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'placeholder:text-red-300',
'focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-200',
'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100']
: ['border-gray-300',
'focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200']),
className)
+240
ファイルの表示
@@ -0,0 +1,240 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
import type {
GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorQuestion,
GekanatorQuestionCondition,
} from '@/lib/gekanator'
const question = (
condition: GekanatorQuestionCondition,
): GekanatorQuestion => ({
id: `${ condition.type }:candidate`,
text: 'candidate?',
kind: condition.type === 'source'
? 'source'
: condition.type.startsWith ('original-')
? 'original_date'
: condition.type.startsWith ('title-')
? 'title'
: 'tag',
condition,
source: 'default',
priorityWeight: 1,
test: () => false,
})
const answer = (
condition: GekanatorQuestionCondition,
value: GekanatorAnswerValue,
): GekanatorAnswerLog => ({
questionId: 'previous',
questionText: 'previous?',
questionCondition: condition,
answer: value,
originalAnswer: value,
})
const blocked = (
candidate: GekanatorQuestionCondition,
previous: GekanatorQuestionCondition,
value: GekanatorAnswerValue,
): boolean =>
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)])
const gekanatorPageSource = readFileSync (
resolve (process.cwd (), 'src/pages/GekanatorPage.tsx'),
'utf8')
const gekanatorBackdropSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
const gekanatorChooseQuestionSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const chooseQuestion'),
gekanatorPageSource.indexOf ('const winningRunPriorityFor'))
const gekanatorFallbackQuestionSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const chooseFallbackQuestion'),
gekanatorPageSource.indexOf ('const shouldEnterGuessPhase'))
describe('GekanatorBackdrop regression structure', () => {
it('keeps displayedBackdropMode as the render-time source of truth', () => {
expect(gekanatorBackdropSource).not.toContain ('isLeavingGuessBackdrop')
expect(gekanatorBackdropSource).not.toContain ('renderBackdropMode')
expect(gekanatorBackdropSource).not.toContain ('renderWinningRunCount')
expect(gekanatorBackdropSource).not.toContain ('renderThumbnails')
expect(gekanatorBackdropSource).not.toContain ('renderIsCrossfading')
expect(gekanatorBackdropSource).toContain (
"const renderedSettings = settingsForMode (displayedBackdropMode)")
expect(gekanatorBackdropSource).toContain (
'scaleForMode (displayedBackdropMode, displayedWinningRunCount)')
expect(gekanatorBackdropSource).toContain (
"backdropMode === 'guess' || displayedBackdropMode === 'guess'")
})
it('does not split guess into a separate renderer or force a remount', () => {
expect(gekanatorBackdropSource).not.toContain ('renderStaticGuessBackdrop')
expect(gekanatorBackdropSource).not.toContain ('guessZoomAnimationKey')
expect(gekanatorBackdropSource).not.toContain ('shouldAnimateGuessZoomIn')
expect(gekanatorBackdropSource).not.toContain ('previousBackdropModeRef')
expect(gekanatorBackdropSource).not.toContain (
'if (isGuessPresentation && guessThumbnail)')
})
it('keeps tile keys independent from backdrop mode', () => {
expect(gekanatorBackdropSource).toContain ('key={duplicate}')
expect(gekanatorBackdropSource).toContain ('key={`${ duplicate }:${ index }`}')
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*mode/)
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*displayedBackdropMode/)
})
it('keeps guess on the shared scale, x, and y animation path', () => {
expect(gekanatorBackdropSource).toContain ('animate={{ scale: renderedScale')
expect(gekanatorBackdropSource).toContain (
"x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%'")
expect(gekanatorBackdropSource).toContain (
"y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%'")
})
})
describe('Gekanator question selection regression structure', () => {
it('prefers normal questions after user_suggested quota has been met', () => {
const normalFallbackIndex = gekanatorChooseQuestionSource.indexOf (
'else if (normalPool.length > 0)')
const effectiveFallbackIndex = gekanatorChooseQuestionSource.indexOf (
'else if (effectiveUserSuggestedPool.length > 0)')
expect(normalFallbackIndex).toBeGreaterThan(0)
expect(effectiveFallbackIndex).toBeGreaterThan(0)
expect(normalFallbackIndex).toBeLessThan(effectiveFallbackIndex)
})
it('does not let fallback questions bypass user_suggested purpose tracking', () => {
expect(gekanatorFallbackQuestionSource).toContain (
"question.source !== 'user_suggested'")
})
it('does not show a fixed extra-question count in the extra learning UI', () => {
expect(gekanatorPageSource).not.toContain ('追加で 2 問まで答えてください。')
expect(gekanatorPageSource).toContain ('追加で質問に答えてください。')
})
})
describe('isQuestionHardFilteredAfterAnswers', () => {
it('blocks only contradictory or redundant month questions after a yes answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'yes'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes'))
.toBe(false)
expect(blocked ({ type: 'original-year', year: 2024 }, previous, 'yes')).toBe(false)
expect(blocked ({ type: 'source', host: 'example.com' }, previous, 'yes')).toBe(false)
expect(blocked ({ type: 'tag', key: 'character:喜多郁代' }, previous, 'yes')).toBe(false)
})
it('blocks same-month facts after a no answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(true)
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'no')).toBe(false)
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '2-14' }, previous, 'no'))
.toBe(false)
})
it('blocks all month and month-day questions after a month-day yes answer', () => {
const previous: GekanatorQuestionCondition = {
type: 'original-month-day',
monthDay: '12-25',
}
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month', month: 2 }, previous, 'yes')).toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'yes'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'yes'))
.toBe(true)
})
it('blocks the same month-day only after a month-day no answer', () => {
const previous: GekanatorQuestionCondition = {
type: 'original-month-day',
monthDay: '12-25',
}
expect(blocked ({ type: 'original-month-day', monthDay: '12-25' }, previous, 'no'))
.toBe(true)
expect(blocked ({ type: 'original-month-day', monthDay: '12-26' }, previous, 'no'))
.toBe(false)
expect(blocked ({ type: 'original-month', month: 12 }, previous, 'no')).toBe(false)
})
it('blocks year and source as single-value facts', () => {
expect(blocked (
{ type: 'original-year', year: 2025 },
{ type: 'original-year', year: 2024 },
'yes',
)).toBe(true)
expect(blocked (
{ type: 'original-year', year: 2024 },
{ type: 'original-year', year: 2024 },
'no',
)).toBe(true)
expect(blocked (
{ type: 'source', host: 'b.example' },
{ type: 'source', host: 'a.example' },
'yes',
)).toBe(true)
expect(blocked (
{ type: 'source', host: 'b.example' },
{ type: 'source', host: 'a.example' },
'no',
)).toBe(false)
})
it('does not hard-filter partial, probably_no, or unknown fact answers', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
const candidate: GekanatorQuestionCondition = { type: 'original-month', month: 2 }
expect(blocked (candidate, previous, 'partial')).toBe(false)
expect(blocked (candidate, previous, 'probably_no')).toBe(false)
expect(blocked (candidate, previous, 'unknown')).toBe(false)
})
it('keeps title-length hard redundancy for yes and no only', () => {
const previous: GekanatorQuestionCondition = {
type: 'title-length-at-least',
length: 30,
}
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'yes'))
.toBe(true)
expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'yes'))
.toBe(false)
expect(blocked ({ type: 'title-length-at-least', length: 40 }, previous, 'no'))
.toBe(true)
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'no'))
.toBe(false)
expect(blocked ({ type: 'title-length-at-least', length: 20 }, previous, 'partial'))
.toBe(false)
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
+72
ファイルの表示
@@ -0,0 +1,72 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const tagsApi = vi.hoisted (() => ({
fetchDeerjikistsByTag: vi.fn (),
}))
const api = vi.hoisted (() => ({
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
const renderPage = () =>
renderWithProviders (
<Routes>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
</Routes>,
{ route: '/tags/7/deerjikists' },
)
describe ('DeerjikistDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows indexed validation errors returned for deerjikist rows', async () => {
tagsApi.fetchDeerjikistsByTag.mockResolvedValueOnce ({
tag: buildTag ({ id: 7, name: 'deerjika', category: 'deerjikist' }),
deerjikists: [{ platform: null, code: 'abc' }],
})
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
base_errors: [],
},
},
})
renderPage ()
await screen.findByDisplayValue ('abc')
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/tags/7/deerjikists',
[{ platform: null, code: 'abc' }],
)
})
expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument ()
expect (screen.getByRole ('combobox')).toHaveAttribute ('aria-invalid', 'true')
})
})
+55 -35
ファイルの表示
@@ -4,7 +4,8 @@ import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
@@ -12,12 +13,16 @@ import { PLATFORM_NAMES, PLATFORMS } from '@/consts'
import { apiPut } from '@/lib/api'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchDeerjikistsByTag } from '@/lib/tags'
import { cn } from '@/lib/utils'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
import type { Deerjikist, Platform } from '@/types'
type DeerjikistFormField =
'deerjikists' | `deerjikists${ number }Platform` | `deerjikists${ number }Code`
const DeerjikistDetailPage: FC = () => {
const { id } = useParams ()
@@ -32,11 +37,14 @@ const DeerjikistDetailPage: FC = () => {
const [data, setData] =
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<DeerjikistFormField> ()
const qc = useQueryClient ()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
clearValidationErrors ()
try
{
@@ -47,8 +55,9 @@ const DeerjikistDetailPage: FC = () => {
toast ({ description: '更新しました.' })
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
@@ -82,6 +91,9 @@ const DeerjikistDetailPage: FC = () => {
</PageTitle>
<form onSubmit={handleSubmit} className="my-4 space-y-2">
<FieldError messages={baseErrors}/>
<FieldError messages={fieldErrors.deerjikists}/>
{data.map ((datum, i) => (
<fieldset key={i} className="min-w-0 rounded-lg border border-gray-300
dark:border-gray-700 p-4">
@@ -97,40 +109,48 @@ const DeerjikistDetailPage: FC = () => {
</legend>
{/* プラットフォーム */}
<div>
<Label></Label>
<select
className="w-full border p-2 rounded"
disabled={disabled}
value={datum.platform ?? ''}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null }
return rtn
})}>
<option value="">&nbsp;</option>
{PLATFORMS.map (p => (
<option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>
</div>
<FormField
label="プラットフォーム"
messages={fieldErrors[`deerjikists${ i }Platform`]}>
{({ describedBy, invalid }) => (
<select
disabled={disabled}
value={datum.platform ?? ''}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i],
platform: (e.target.value || null) as Platform | null }
return rtn
})}>
<option value="">&nbsp;</option>
{PLATFORMS.map (p => (
<option key={p} value={p}>
{PLATFORM_NAMES[p]}
</option>))}
</select>)}
</FormField>
{/* コード */}
<div>
<Label></Label>
<input
type="text"
disabled={disabled}
className="w-full border p-2 rounded"
value={datum.code}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>
</div>
<FormField
label="コード"
messages={fieldErrors[`deerjikists${ i }Code`]}>
{({ describedBy, invalid }) => (
<input
type="text"
disabled={disabled}
value={datum.code}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onChange={e => setData (prev => {
const rtn = [...prev]
rtn[i] = { ...rtn[i], code: e.target.value }
return rtn
})}/>)}
</FormField>
</fieldset>
))}
+68 -46
ファイルの表示
@@ -4,7 +4,8 @@ import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import Label from '@/components/common/Label'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import TagInput from '@/components/common/TagInput'
@@ -13,6 +14,8 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react'
@@ -20,6 +23,8 @@ import type { Material, Tag } from '@/types'
type MaterialWithTag = Material & { tag: Tag }
type MaterialFormField = 'tag' | 'file' | 'url'
const MaterialDetailPage: FC = () => {
const { id } = useParams ()
@@ -31,8 +36,12 @@ const MaterialDetailPage: FC = () => {
const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
@@ -48,8 +57,9 @@ const MaterialDetailPage: FC = () => {
setMaterial (data)
toast ({ title: '更新成功!' })
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}
finally
@@ -118,54 +128,66 @@ const MaterialDetailPage: FC = () => {
<Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タグ */}
<div>
<Label></Label>
<TagInput value={tag} setValue={setTag}/>
</div>
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
invalid={invalid}
value={tag}
setValue={setTag}/>)}
</FormField>
{/* ファイル */}
<div>
<Label></Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</div>
<FormField label="ファイル" messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
<input
type="file"
accept="image/*,video/*,audio/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</>)}
</FormField>
{/* 参考 URL */}
<div>
<Label> URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="参考 URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */}
<Button
@@ -181,4 +203,4 @@ const MaterialDetailPage: FC = () => {
</MainArea>)
}
export default MaterialDetailPage
export default MaterialDetailPage
+36 -2
ファイルの表示
@@ -1,11 +1,13 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (),
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
@@ -16,6 +18,12 @@ vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('MaterialNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (false)
})
it ('initializes tag from query and submits form data', async () => {
api.apiPost.mockResolvedValueOnce ({})
@@ -35,4 +43,30 @@ describe ('MaterialNewPage', () => {
expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' })
})
it ('shows validation errors for file and url fields', async () => {
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: {
file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'],
},
base_errors: [],
},
},
})
renderWithProviders (<MaterialNewPage/>)
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '虹夏' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
})
})
+67 -46
ファイルの表示
@@ -2,8 +2,9 @@ import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
@@ -11,9 +12,13 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react'
type MaterialFormField = 'tag' | 'file' | 'url'
const MaterialNewPage: FC = () => {
const location = useLocation ()
@@ -27,8 +32,12 @@ const MaterialNewPage: FC = () => {
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
if (tag)
formData.append ('tag', tag)
@@ -44,8 +53,9 @@ const MaterialNewPage: FC = () => {
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}
finally
@@ -62,55 +72,66 @@ const MaterialNewPage: FC = () => {
<Form>
<PageTitle></PageTitle>
<FieldError messages={baseErrors}/>
{/* タグ */}
<div>
<Label></Label>
<TagInput value={tag} setValue={setTag}/>
</div>
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
invalid={invalid}
value={tag}
setValue={setTag}/>)}
</FormField>
{/* ファイル */}
<div>
<Label></Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</div>
<FormField label="ファイル" messages={fieldErrors.file}>
{({ describedBy, invalid }) => (
<>
<input
type="file"
accept="image/*,video/*,audio/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</>)}
</FormField>
{/* 参考 URL */}
<div>
<Label> URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="参考 URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 送信 */}
<Button
@@ -123,4 +144,4 @@ const MaterialNewPage: FC = () => {
</MainArea>)
}
export default MaterialNewPage
export default MaterialNewPage
+14 -14
ファイルの表示
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
@@ -29,23 +29,23 @@ const MaterialSearchPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2">
{/* タグ */}
<div>
<Label></Label>
<TagInput
value={tagName}
setValue={setTagName}/>
</div>
<FormField label="タグ">
{() => (
<TagInput
value={tagName}
setValue={setTagName}/>)}
</FormField>
{/* 親タグ */}
<div>
<Label></Label>
<TagInput
value={parentTagName}
setValue={setParentTagName}/>
</div>
<FormField label="親タグ">
{() => (
<TagInput
value={parentTagName}
setValue={setParentTagName}/>)}
</FormField>
</form>
</div>
</MainArea>)
}
export default MaterialSearchPage
export default MaterialSearchPage
+3 -2
ファイルの表示
@@ -3,7 +3,6 @@ import { motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import PostEditForm from '@/components/PostEditForm'
import PostEmbed from '@/components/PostEmbed'
import PostList from '@/components/PostList'
@@ -16,6 +15,7 @@ import { SITE_TITLE } from '@/config'
import { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
@@ -28,6 +28,7 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const { id } = useParams ()
const postId = String (id ?? '')
const postKey = postsKeys.show (postId)
@@ -164,7 +165,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
? <PostList posts={post.related}/>
: 'まだないよ(笑)'}
</Tab>
{['admin', 'member'].some (r => user?.role === r) && (
{editable && (
<Tab name="編輯">
<PostEditForm
post={post}
+41 -3
ファイルの表示
@@ -1,13 +1,14 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
@@ -18,6 +19,11 @@ vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('blocks guests', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
@@ -55,4 +61,36 @@ describe ('PostNewPage', () => {
expect (formData.get ('tags')).toBe ('tag1 tag2')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
})
it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true)
api.apiPost.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
base_errors: ['投稿内容を確認してください.'],
},
},
})
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
})
})
+93 -66
ファイルの表示
@@ -4,14 +4,18 @@ import { useNavigate } from 'react-router-dom'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react'
@@ -20,12 +24,18 @@ import type { User } from '@/types'
type Props = { user: User | null }
type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => {
const editable = ['admin', 'member'].some (r => user?.role === r)
const editable = canEditContent (user)
const navigate = useNavigate ()
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null)
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('')
@@ -43,6 +53,8 @@ const PostNewPage: FC<Props> = ({ user }) => {
const thumbnailPreviewRef = useRef ('')
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
formData.append ('title', title)
formData.append ('url', url)
@@ -61,8 +73,9 @@ const PostNewPage: FC<Props> = ({ user }) => {
toast ({ title: '投稿成功!' })
navigate ('/posts')
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
}
}
@@ -126,85 +139,99 @@ const PostNewPage: FC<Props> = ({ user }) => {
</Helmet>
<Form>
<PageTitle>稿</PageTitle>
<FieldError messages={baseErrors}/>
{/* URL */}
<div>
<Label>URL</Label>
<input type="url"
placeholder="例:https://www.nicovideo.jp/watch/..."
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"
onBlur={handleURLBlur}/>
</div>
<FormField label="URL" messages={fieldErrors.url}>
{({ describedBy, invalid }) => (
<input type="url"
placeholder="例:https://www.nicovideo.jp/watch/..."
value={url}
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onBlur={handleURLBlur}/>)}
</FormField>
{/* タイトル */}
<div>
<Label checkBox={{
label: '自動',
checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}>
</Label>
<input type="text"
className="w-full border rounded p-2"
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleAutoFlg}/>
</div>
<FormField
checkBox={{
label: '自動',
checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}
label="タイトル"
messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleAutoFlg}/>)}
</FormField>
{/* サムネール */}
<div>
<Label checkBox={{
label: '自動',
checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}>
</Label>
{thumbnailAutoFlg
? (thumbnailLoading
? <p className="text-gray-500 text-sm">Loading...</p>
: !(thumbnailPreview) && (
<p className="text-gray-500 text-sm">
URL
</p>))
: (
<input type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>)}
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>)}
</div>
<FormField
checkBox={{
label: '自動',
checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}
label="サムネール"
messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => (
<>
{thumbnailAutoFlg
? (thumbnailLoading
? <p className="text-gray-500 text-sm">Loading...</p>
: !(thumbnailPreview) && (
<p className="text-gray-500 text-sm">
URL
</p>))
: (
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>)}
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>)}
</>)}
</FormField>
{/* 親投稿 */}
<div>
<Label>稿</Label>
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="親投稿" messages={fieldErrors.parentPostIds}>
{({ describedBy, invalid }) => (
<input
type="text"
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/>
<PostFormTagsArea tags={tags} setTags={setTags} errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/>
setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/>
{/* 送信 */}
<Button onClick={handleSubmit}
+60 -52
ファイルの表示
@@ -8,7 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
@@ -16,7 +16,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -138,31 +138,33 @@ const PostSearchPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<div>
<Label></Label>
<input
type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="タイトル">
{({ invalid }) => (
<input
type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
{/* URL */}
<div>
<Label>URL</Label>
<input
type="text"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="URL">
{({ invalid }) => (
<input
type="text"
value={url}
onChange={e => setURL (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */}
<FormField label="タグ">
{() => (
<TagInput
value={tagsStr}
setValue={setTagsStr}/>)}
</FormField>
<div>
<Label></Label>
<TagInput
value={tagsStr}
setValue={setTagsStr}/>
<fieldset className="w-full my-2">
<label></label>
<label className="mx-2">
@@ -185,40 +187,46 @@ const PostSearchPage: FC = () => {
</div>
{/* オリジナルの投稿日時 */}
<div>
<Label>稿</Label>
<DateTimeField
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={originalCreatedTo ?? undefined}
onChange={setOriginalCreatedTo}/>
</div>
<FormField label="オリジナルの投稿日時">
{() => (
<>
<DateTimeField
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={originalCreatedTo ?? undefined}
onChange={setOriginalCreatedTo}/>
</>)}
</FormField>
{/* 投稿日時 */}
<div>
<Label>稿</Label>
<DateTimeField
value={createdFrom ?? undefined}
onChange={setCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={createdTo ?? undefined}
onChange={setCreatedTo}/>
</div>
<FormField label="投稿日時">
{() => (
<>
<DateTimeField
value={createdFrom ?? undefined}
onChange={setCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={createdTo ?? undefined}
onChange={setCreatedTo}/>
</>)}
</FormField>
{/* 更新日時 */}
<div>
<Label></Label>
<DateTimeField
value={updatedFrom ?? undefined}
onChange={setUpdatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={updatedTo ?? undefined}
onChange={setUpdatedTo}/>
</div>
<FormField label="更新日時">
{() => (
<>
<DateTimeField
value={updatedFrom ?? undefined}
onChange={setUpdatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={updatedTo ?? undefined}
onChange={setUpdatedTo}/>
</>)}
</FormField>
{/* 検索 */}
<div className="py-3">
+273
ファイルの表示
@@ -0,0 +1,273 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (),
...values,
category: 'nico',
linkedTags: values.linkedTags ?? [],
recentPostTagCreatedAt: values.recentPostTagCreatedAt ?? null,
})
const renderPage = (route = '/tags/nico') =>
renderWithProviders (
<NicoTagListPage user={buildUser ({ role: 'member' })}/>,
{ route },
)
describe ('NicoTagListPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
dialogue.confirm.mockResolvedValue (true)
Element.prototype.scrollIntoView = scrollIntoView
scrollIntoView.mockClear ()
})
it ('loads a filtered page from URL search parameters', async () => {
api.apiGet.mockResolvedValue ({
tags: [buildNicoTag ({
id: 1,
name: 'nico:linked',
createdAt: '2024-01-02T03:04:05Z',
recentPostTagCreatedAt: '2025-01-02T03:04:05Z',
updatedAt: '2026-01-02T03:04:05Z',
})],
count: 21,
})
renderPage (
'/tags/nico?name=linked&linked_tag=destination&link_status=linked'
+ '&page=2&order=name:asc',
)
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/nico',
{ params: {
page: 2,
limit: 20,
name: 'linked',
linked_tag: 'destination',
link_status: 'linked',
order: 'name:asc',
} },
)
})
expect (await screen.findByText ('21 件')).toBeInTheDocument ()
expect (screen.getByLabelText ('前のページ')).toBeInTheDocument ()
expect (screen.queryByText ('なし')).not.toBeInTheDocument ()
expect (screen.getByText (dateString ('2025-01-02T03:04:05Z'))).toBeInTheDocument ()
expect (screen.queryByText (dateString ('2026-01-02T03:04:05Z'))).not.toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'ニコニコタグ ▲' })).toHaveAttribute (
'href',
expect.stringContaining ('order=name%3Adesc'),
)
expect (screen.getByRole ('link', { name: '最初に記載された日時' })).toHaveAttribute (
'href',
expect.stringContaining ('order=created_at%3Adesc'),
)
expect (screen.getByRole ('link', { name: '最近記載された日時' })).toHaveAttribute (
'href',
expect.stringContaining ('order=updated_at%3Adesc'),
)
fireEvent.mouseEnter (screen.getByLabelText ('前のページ'))
await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico',
{ params: {
page: 1,
limit: 20,
name: 'linked',
linked_tag: 'destination',
link_status: 'linked',
order: 'name:asc',
} },
)
})
})
it ('scrolls to the table when moving between pages', async () => {
api.apiGet.mockResolvedValue ({
tags: [buildNicoTag ({ id: 1, name: 'nico:linked' })],
count: 21,
})
renderPage ()
fireEvent.click (await screen.findByLabelText ('次のページ'))
await waitFor (() => {
expect (scrollIntoView).toHaveBeenCalledWith ({ behavior: 'smooth' })
})
})
it ('navigates with submitted search conditions', async () => {
api.apiGet.mockResolvedValue ({ tags: [], count: 0 })
renderPage ()
fireEvent.change (screen.getByLabelText ('ニコニコタグ'), {
target: { value: 'source' },
})
fireEvent.change (screen.getByLabelText ('連携タグ'), {
target: { value: 'destination' },
})
fireEvent.change (screen.getByLabelText ('連携状態'), {
target: { value: 'unlinked' },
})
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico',
{ params: expect.objectContaining ({
page: 1,
name: 'source',
linked_tag: 'destination',
link_status: 'unlinked',
}) },
)
})
})
it ('updates links from a tag card', async () => {
api.apiGet
.mockResolvedValueOnce ({
tags: [buildNicoTag ({ id: 7, name: 'nico:source' })],
count: 1,
})
.mockResolvedValueOnce ({
tags: [
buildNicoTag ({
id: 7,
name: 'nico:source',
linkedTags: [buildTag ({ id: 8, name: '連携先' })],
}),
],
count: 1,
})
api.apiPut.mockResolvedValueOnce ([buildTag ({ id: 8, name: '連携先' })])
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '編集' }))
fireEvent.change (screen.getByLabelText ('連携する広場タグ'), {
target: { value: '連携先' },
})
fireEvent.click (screen.getByRole ('button', { name: '保存' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/tags/nico/7',
expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
})
expect (await screen.findByText ('1 件')).toBeInTheDocument ()
})
it ('asks before discarding changes when editing another tag', async () => {
api.apiGet.mockResolvedValueOnce ({
tags: [
buildNicoTag ({ id: 1, name: 'nico:first' }),
buildNicoTag ({ id: 2, name: 'nico:second' }),
],
count: 2,
})
renderPage ()
dialogue.confirm.mockResolvedValueOnce (false)
const editButtons = await screen.findAllByRole ('button', { name: '編集' })
fireEvent.click (editButtons[0])
fireEvent.change (screen.getByLabelText ('連携する広場タグ'), {
target: { value: '入力中' },
})
fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0])
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalledWith ({
title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
variant: 'danger',
})
})
expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1)
expect (screen.getByLabelText ('連携する広場タグ')).toHaveValue ('入力中')
})
it ('switches editing rows without confirmation when unchanged', async () => {
api.apiGet.mockResolvedValueOnce ({
tags: [
buildNicoTag ({ id: 1, name: 'nico:first' }),
buildNicoTag ({ id: 2, name: 'nico:second' }),
],
count: 2,
})
renderPage ()
const editButtons = await screen.findAllByRole ('button', { name: '編集' })
fireEvent.click (editButtons[0])
fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0])
expect (dialogue.confirm).not.toHaveBeenCalled ()
expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1)
})
it ('shows tags field validation errors inside the edited card', async () => {
api.apiGet.mockResolvedValueOnce ({
tags: [buildNicoTag ({ id: 7, name: 'nico:source' })],
count: 1,
})
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
errors: { tags: ['タグ名を確認してください.'] },
base_errors: [],
},
},
})
renderPage ()
fireEvent.click (await screen.findByRole ('button', { name: '編集' }))
fireEvent.click (screen.getByRole ('button', { name: '保存' }))
expect (await screen.findByText ('タグ名を確認してください.')).toBeInTheDocument ()
expect (screen.getByLabelText ('連携する広場タグ')).toHaveAttribute ('aria-invalid', 'true')
})
})
+342 -124
ファイルの表示
@@ -1,104 +1,179 @@
import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, LoaderCircle, Pencil, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import SortHeader from '@/components/SortHeader'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TextArea from '@/components/common/TextArea'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api'
import { apiPut } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchNicoTags } from '@/lib/tags'
import { cn, dateString, inputClass } from '@/lib/utils'
import { canEditContent } from '@/lib/users'
import type { NicoTag, Tag, User } from '@/types'
import type { FC, FormEvent } from 'react'
import type { FetchNicoTagsOrder, FetchNicoTagsOrderField, NicoTag, Tag, User } from '@/types'
type LinkStatus = 'all' | 'linked' | 'unlinked'
type Props = { user: User | null }
const setIf = (qs: URLSearchParams, key: string, value: string) => {
const trimmed = value.trim ()
if (trimmed)
qs.set (key, trimmed)
}
const NicoTagListPage: FC<Props> = ({ user }) => {
const [cursor, setCursor] = useState ('')
const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ })
const [loading, setLoading] = useState (false)
const [nicoTags, setNicoTags] = useState<NicoTag[]> ([])
const [rawTags, setRawTags] = useState<{ [key: number]: string }> ({ })
const dialogue = useDialogue ()
const location = useLocation ()
const navigate = useNavigate ()
const queryClient = useQueryClient ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const loaderRef = useRef<HTMLDivElement | null> (null)
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const qName = query.get ('name') ?? ''
const qLinkedTag = query.get ('linked_tag') ?? ''
const qLinkStatus = (query.get ('link_status') || 'all') as LinkStatus
const order = (query.get ('order') || 'updated_at:desc') as FetchNicoTagsOrder
const memberFlg = ['admin', 'member'].some (r => user?.role === r)
const [editingId, setEditingId] = useState<number | null> (null)
const [errorsByTagId, setErrorsByTagId] = useState<Record<number, string[]>> ({ })
const [linkStatus, setLinkStatus] = useState<LinkStatus> ('all')
const [linkedTag, setLinkedTag] = useState ('')
const [name, setName] = useState ('')
const [rawTags, setRawTags] = useState<Record<number, string>> ({ })
const [savingId, setSavingId] = useState<number | null> (null)
const applyLoadedTags = useCallback ((data: { tags: NicoTag[]; nextCursor: string },
withCursor: boolean) => {
setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags])
setCursor (data.nextCursor)
const keys = {
name: qName, linkedTag: qLinkedTag, linkStatus: qLinkStatus, page, limit, order }
const { data, isError, isLoading: loading } = useQuery ({
queryKey: tagsKeys.nicoIndex (keys),
queryFn: () => fetchNicoTags (keys) })
const nicoTags = data?.tags ?? []
const count = data?.count ?? 0
const newEditing = Object.fromEntries (data.tags.map (t => [t.id, false]))
setEditing (editing => ({ ...editing, ...newEditing }))
const editable = canEditContent (user)
const totalPages = Math.ceil (count / limit)
const newRawTags = Object.fromEntries (
data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')]))
setRawTags (rawTags => ({ ...rawTags, ...newRawTags }))
}, [])
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
const loadInitial = useCallback (async () => {
setLoading (true)
const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ('/tags/nico')
applyLoadedTags (data, false)
setLoading (false)
}, [applyLoadedTags])
const loadMore = useCallback (async () => {
setLoading (true)
const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> (
'/tags/nico', { params: { cursor } })
applyLoadedTags (data, true)
setLoading (false)
}, [applyLoadedTags, cursor])
const handleEdit = async (id: number) => {
if (editing[id])
{
const formData = new FormData
formData.append ('tags', rawTags[id])
const data = await apiPut<Tag[]> (`/tags/nico/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setNicoTags (nicoTags => {
nicoTags.find (t => t.id === id)!.linkedTags = data
return [...nicoTags]
})
setRawTags (rawTags => ({ ...rawTags, [id]: data.map (t => t.name).join (' ') }))
toast ({ title: '更新しました.' })
}
setEditing (editing => ({ ...editing, [id]: !(editing[id]) }))
const qs = new URLSearchParams ()
setIf (qs, 'name', name)
setIf (qs, 'linked_tag', linkedTag)
if (linkStatus !== 'all')
qs.set ('link_status', linkStatus)
qs.set ('page', '1')
qs.set ('limit', String (limit))
qs.set ('order', order)
navigate (`${ location.pathname }?${ qs.toString () }`)
}
useEffect(() => {
const observer = new IntersectionObserver (entries => {
if (entries[0].isIntersecting && !(loading) && cursor)
loadMore ()
}, { threshold: 1 })
const defaultDirection = {
name: 'asc',
created_at: 'desc',
updated_at: 'desc',
} as const
const target = loaderRef.current
if (target)
observer.observe (target)
const beginEdit = async (tag: NicoTag) => {
const editingTag = nicoTags.find (tag => tag.id === editingId)
const editingValue = editingTag?.linkedTags.map (tag => tag.name).join (' ') ?? ''
const editingChanged = editingId != null && rawTags[editingId] !== editingValue
return () => {
if (target)
observer.unobserve (target)
if (editingId != null && editingId !== tag.id && editingChanged
&& !(await dialogue.confirm ({
title: '編集中の内容を破棄しますか?',
confirmText: '破棄',
variant: 'danger',
})))
return
setEditingId (tag.id)
setRawTags (rawTags => ({
...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
}
const cancelEdit = (tag: NicoTag) => {
setEditingId (null)
setRawTags (rawTags => ({
...rawTags,
[tag.id]: tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
}))
setErrorsByTagId (errors => ({ ...errors, [tag.id]: [] }))
}
const saveLinks = async (id: number) => {
const formData = new FormData
formData.append ('tags', rawTags[id] ?? '')
setSavingId (id)
try
{
await apiPut<Tag[]> (`/tags/nico/${ id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setErrorsByTagId (errors => ({ ...errors, [id]: [] }))
setEditingId (null)
await queryClient.invalidateQueries ({ queryKey: tagsKeys.nicoRoot })
toast ({ description: '連携を更新しました.' })
}
}, [cursor, loadMore, loading])
catch (e)
{
const validationError = extractValidationError<'tags'> (e)
setErrorsByTagId (errors => ({
...errors,
[id]: validationError?.fieldErrors.tags
?? validationError?.baseErrors
?? ['更新できませんでした.'],
}))
toast ({ title: '更新失敗', description: '入力内容を確認してください.' })
}
finally
{
setSavingId (null)
}
}
useEffect (() => {
setNicoTags ([])
loadInitial ()
}, [loadInitial])
setName (qName)
setLinkedTag (qLinkedTag)
setLinkStatus (qLinkStatus)
setEditingId (null)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qLinkedTag, qLinkStatus, qName])
useEffect (() => {
if (!(data))
return
setRawTags (Object.fromEntries (data.tags.map (tag => [
tag.id,
tag.linkedTags.map (linkedTag => linkedTag.name).join (' '),
])))
}, [data])
useEffect (() => {
if (isError)
toast ({ title: '読込失敗', description: 'ニコニコ連携を読み込めませんでした.' })
}, [isError])
return (
<MainArea>
@@ -108,58 +183,201 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
<div className="max-w-xl">
<PageTitle></PageTitle>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-300">
</p>
<form onSubmit={handleSearch} className="space-y-2">
<FormField label="ニコニコタグ">
{({ invalid }) => (
<input
type="text"
aria-label="ニコニコタグ"
value={name}
onChange={e => setName (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="連携タグ">
{({ invalid }) => (
<input
type="text"
aria-label="連携タグ"
value={linkedTag}
onChange={e => setLinkedTag (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="連携状態">
{({ invalid }) => (
<select
aria-label="連携状態"
value={linkStatus}
onChange={e => setLinkStatus (e.target.value as LinkStatus)}
className={inputClass (invalid)}>
<option value="all"></option>
<option value="linked"></option>
<option value="unlinked"></option>
</select>)}
</FormField>
<div className="py-3">
<button
type="submit"
className="rounded bg-blue-500 px-4 py-2 text-white">
</button>
</div>
</form>
</div>
<div className="mt-4">
{nicoTags.length > 0 && (
<table className="table-auto w-full border-collapse mb-4">
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
{memberFlg && <th></th>}
</tr>
</thead>
<tbody>
{nicoTags.map ((tag, i) => (
<tr key={i} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink tag={tag} withWiki={false} withCount={false}/>
</td>
<td className="p-2">
{editing[tag.id]
? (
<TextArea value={rawTags[tag.id]} onChange={ev => {
setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value }))
}}/>)
: tag.linkedTags.map((lt, j) => (
<span key={j} className="mr-2">
<TagLink tag={lt}
linkFlg={false}
withCount={false}/>
</span>))}
</td>
{memberFlg && (
<td className="p-2">
<a href="#" onClick={ev => {
ev.preventDefault ()
handleEdit (tag.id)
}}>
{editing[tag.id]
? (
<span className="text-red-600 hover:text-red-400
dark:text-red-300 dark:hover:text-red-100">
</span>)
: <span></span>}
</a>
</td>)}
</tr>))}
</tbody>
</table>)}
{loading && 'Loading...'}
<div ref={loaderRef} className="h-12"></div>
</div>
{loading
? 'Loading...'
: (
<div className="mt-6">
<div className="mb-3 flex items-baseline justify-between gap-4">
<h2 className="text-lg font-bold"></h2>
<span className="text-sm text-gray-500 dark:text-gray-400">{count} </span>
</div>
{nicoTags.length > 0
? (
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] table-fixed border-collapse">
<colgroup>
<col className="w-64"/>
<col className="w-[48rem]"/>
<col className="w-56"/>
<col className="w-56"/>
{editable && <col className="w-24"/>}
</colgroup>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchNicoTagsOrderField>
by="name"
label="ニコニコタグ"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchNicoTagsOrderField>
by="created_at"
label="最初に記載された日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap">
<SortHeader<FetchNicoTagsOrderField>
by="updated_at"
label="最近記載された日時"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
{editable && <th className="p-2"></th>}
</tr>
</thead>
<tbody>
{nicoTags.map (tag => {
const isEditing = editingId === tag.id
return [
<tr
key={tag.id}
className={cn (
'border-b border-gray-200 dark:border-gray-700',
isEditing
? 'bg-rose-50 dark:bg-rose-950/30'
: 'even:bg-gray-100 dark:even:bg-gray-800')}>
<td className="p-2 align-top font-semibold">
<TagLink tag={tag} withWiki={false} withCount={false}/>
</td>
<td className="p-2 align-top">
{tag.linkedTags.map ((linkedTag, i) => (
<span key={linkedTag.id}>
{i > 0 && ' '}
<TagLink
tag={linkedTag}
linkFlg={false}
withCount={false}/>
</span>))}
</td>
<td className="p-2 align-top whitespace-nowrap">
{dateString (tag.createdAt)}
</td>
<td className="p-2 align-top whitespace-nowrap">
{tag.recentPostTagCreatedAt && dateString (tag.recentPostTagCreatedAt)}
</td>
{editable && (
<td className="p-2 text-right align-top">
{!(isEditing) && (
<button
type="button"
onClick={() => beginEdit (tag)}
className="inline-flex items-center gap-1 text-sm text-blue-700
hover:underline dark:text-blue-300">
<Pencil className="size-3.5"/>
</button>)}
</td>)}
</tr>,
isEditing && (
<tr key={`${ tag.id }-edit`}
className="border-b border-rose-200 bg-rose-50 dark:border-rose-900
dark:bg-rose-950/30">
<td colSpan={editable ? 5 : 4} className="p-3">
<div className="space-y-2">
<label htmlFor={`nico-links-${ tag.id }`}
className="block text-sm font-semibold">
</label>
<TextArea
id={`nico-links-${ tag.id }`}
value={rawTags[tag.id] ?? ''}
invalid={(errorsByTagId[tag.id] ?? []).length > 0}
className="min-h-24 resize-y"
placeholder="タグ名を空白または改行で区切って入力"
onChange={e => setRawTags (rawTags => ({
...rawTags,
[tag.id]: e.target.value,
}))}/>
<FieldError messages={errorsByTagId[tag.id]}/>
<div className="flex justify-end gap-2">
<button
type="button"
disabled={savingId === tag.id}
onClick={() => cancelEdit (tag)}
className="inline-flex items-center gap-1 rounded border
border-gray-300 px-3 py-1.5 text-sm
disabled:opacity-50 dark:border-gray-700">
<X className="size-3.5"/>
</button>
<button
type="button"
disabled={savingId === tag.id}
onClick={() => saveLinks (tag.id)}
className="inline-flex items-center gap-1 rounded bg-rose-700
px-3 py-1.5 text-sm text-white disabled:opacity-50">
{savingId === tag.id
? <LoaderCircle className="size-3.5 animate-spin"/>
: <Check className="size-3.5"/>}
</button>
</div>
</div>
</td>
</tr>),
]
})}
</tbody>
</table>
</div>)
: <p></p>}
<Pagination page={page} totalPages={totalPages}/>
</div>)}
</MainArea>)
}
+33 -3
ファイルの表示
@@ -1,6 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import { buildTag } from '@/test/factories'
@@ -11,7 +11,8 @@ const tagsApi = vi.hoisted (() => ({
}))
const api = vi.hoisted (() => ({
apiPut: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
@@ -28,9 +29,14 @@ const renderPage = () =>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
</Routes>,
{ route: '/tags/7' },
)
)
describe ('TagDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('loads and displays an editable tag', async () => {
tagsApi.fetchTag.mockResolvedValueOnce (
buildTag ({ id: 7, name: '虹夏', category: 'character', aliases: ['drums'] }),
@@ -68,4 +74,28 @@ describe ('TagDetailPage', () => {
expect (await screen.findByRole ('button', { name: '更新' })).toBeDisabled ()
})
it ('shows validation errors returned for tag fields', async () => {
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { category: ['ニコタグは変更できません.'] },
base_errors: [],
},
},
})
renderPage ()
await screen.findByDisplayValue ('old')
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
expect (await screen.findByText ('ニコタグは変更できません.')).toBeInTheDocument ()
expect (screen.getByRole ('combobox')).toHaveAttribute ('aria-invalid', 'true')
})
})
+82 -38
ファイルの表示
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import Label from '@/components/common/Label'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
@@ -11,12 +12,20 @@ import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn } from '@/lib/utils'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react'
import type { Category, Tag } from '@/types'
type TagFormField =
| 'name'
| 'category'
| 'aliases'
| 'parentTags'
| 'deprecated'
const TagDetailPage: FC = () => {
const { id } = useParams ()
@@ -31,18 +40,23 @@ const TagDetailPage: FC = () => {
const [category, setCategory] = useState<Category> ('general')
const [aliases, setAliases] = useState ('')
const [parentTags, setParentTags] = useState ('')
const [deprecated, setDeprecated] = useState (false)
const [disabled, setDisabled] = useState (true)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TagFormField> ()
const qc = useQueryClient ()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()
clearValidationErrors ()
const formData = new FormData
formData.append ('name', name)
formData.append ('category', category)
formData.append ('aliases', aliases)
formData.append ('parent_tags', parentTags)
formData.append ('deprecated', deprecated ? '1' : '0')
try
{
@@ -52,13 +66,15 @@ const TagDetailPage: FC = () => {
setCategory (data.category as Category)
setAliases (data.aliases.join (' '))
setParentTags (data.parents.map (t => t.name).join (' '))
setDeprecated (Boolean (data.deprecatedAt))
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
toast ({ description: '更新しました.' })
}
catch
catch (e)
{
applyValidationError (e)
toast ({ description: '更新に失敗しました.' })
}
}
@@ -74,6 +90,7 @@ const TagDetailPage: FC = () => {
setCategory (tag.category as Category)
setAliases (tag.aliases.join (' '))
setParentTags (tag.parents.map (t => t.name).join (' '))
setDeprecated (Boolean (tag.deprecatedAt))
setDisabled (tag.category === 'nico')
}, [tag])
@@ -89,57 +106,84 @@ const TagDetailPage: FC = () => {
</PageTitle>
<form onSubmit={handleSubmit} className="my-4 space-y-2">
<FieldError messages={baseErrors}/>
{/* 名称 */}
<div>
<Label></Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={name}
onChange={e => setName (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="名称" messages={fieldErrors.name}>
{({ describedBy, invalid }) => (
<>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={name}
onChange={e => setName (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
</>)}
</FormField>
{/* カテゴリ */}
<div>
<Label></Label>
<FormField label="カテゴリ" messages={fieldErrors.category}>
{({ describedBy, invalid }) => (
<select
disabled={disabled}
value={category ?? ''}
onChange={e => setCategory(e.target.value as Category)}
className="w-full border p-2 rounded">
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}>
{CATEGORIES.filter (cat => tag.category === 'nico' || cat !== 'nico')
.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
</option>))}
</select>
</div>
</select>)}
</FormField>
{/* 別名 */}
<div>
<Label></Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={aliases}
onChange={e => setAliases (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="別名" messages={fieldErrors.aliases}>
{({ describedBy, invalid }) => (
<>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={aliases}
onChange={e => setAliases (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
</>)}
</FormField>
{/* 上位タグ */}
<div>
<Label></Label>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={parentTags}
onChange={e => setParentTags (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="上位タグ" messages={fieldErrors.parentTags}>
{({ describedBy, invalid }) => (
<>
{/* TODO: 補完に対応させる */}
<input
type="text"
disabled={disabled}
value={parentTags}
onChange={e => setParentTags (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>
</>)}
</FormField>
<FormField label="廃止済" messages={fieldErrors.deprecated}>
{({ describedBy, invalid }) => (
<input
type="checkbox"
disabled={disabled}
checked={deprecated}
onChange={e => setDeprecated (e.target.checked)}
aria-describedby={describedBy}
aria-invalid={invalid}/>)}
</FormField>
<div className="py-3">
<button
+25 -6
ファイルの表示
@@ -20,17 +20,28 @@ import type { FC } from 'react'
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
<>
{(diff.prev && diff.prev !== diff.current) && (
{diff.prev !== diff.current
? (
<>
<del className="text-red-600 dark:text-red-400">
{diff.prev}
{diff.prev && <>{diff.prev}<br/></>}
</del>
{diff.current && <br/>}
</>)}
{diff.current}
<ins className="text-green-600 dark:text-green-400">
{diff.current}
</ins>
</>)
: diff.current}
</>)
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const renderStateDiff = (diff: { current: string | null; prev: string | null }) =>
renderDiff ({ current: tagStateLabel (diff.current),
prev: tagStateLabel (diff.prev) })
const TagHistoryPage: FC = () => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
@@ -72,6 +83,8 @@ const TagHistoryPage: FC = () => {
<col className="w-96"/>
{/* カテゴリ */}
<col className="w-96"/>
{/* 状態 */}
<col className="w-32"/>
{/* 別名 */}
<col className="w-[48rem]"/>
{/* 上位タグ */}
@@ -87,6 +100,7 @@ const TagHistoryPage: FC = () => {
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
@@ -106,6 +120,9 @@ const TagHistoryPage: FC = () => {
prev: (change.category.prev
&& CATEGORY_NAMES[change.category.prev]) })}
</td>
<td className="p-2 break-all">
{renderStateDiff (change.deprecatedAt)}
</td>
<td className="p-2">
{change.aliases.map ((tag, i) => (
tag.type === 'added'
@@ -178,6 +195,7 @@ const TagHistoryPage: FC = () => {
`/tags/${ change.tagId }`,
{ name: change.name.current,
category: change.category.current,
deprecated: change.deprecatedAt.current ? '1' : '0',
aliases:
change.aliases
.filter (t => t.type !== 'removed')
@@ -211,4 +229,5 @@ const TagHistoryPage: FC = () => {
</MainArea>)
}
export default TagHistoryPage
export default TagHistoryPage
+17 -3
ファイルの表示
@@ -14,13 +14,22 @@ vi.mock ('@/lib/tags', () => tagsApi)
describe ('TagListPage', () => {
it ('loads tags from URL filters and renders the results table', async () => {
tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [buildTag ({ id: 7, name: '虹夏', category: 'character', postCount: 99 })],
tags: [buildTag ({
id: 7,
name: '虹夏',
category: 'character',
postCount: 99,
deprecatedAt: '2026-06-01T00:00:00.000Z',
})],
count: 1,
})
renderWithProviders (
<TagListPage/>,
{ route: '/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5' },
{
route:
'/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5&deprecated=1',
},
)
await waitFor (() => {
@@ -30,6 +39,7 @@ describe ('TagListPage', () => {
category: 'character',
page: 3,
postCountGTE: 5,
deprecated: true,
}),
)
})
@@ -38,6 +48,8 @@ describe ('TagListPage', () => {
'/tags/7',
)
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
expect (screen.getAllByText ('廃止')).toHaveLength (2)
})
it ('navigates to a normalized search URL on submit', async () => {
@@ -46,7 +58,9 @@ describe ('TagListPage', () => {
renderWithProviders (<TagListPage/>, { route: '/tags' })
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
fireEvent.change (screen.getByRole ('combobox'), { target: { value: 'character' } })
fireEvent.change (screen.getAllByRole ('combobox')[0], {
target: { value: 'character' },
})
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
await waitFor (() => {
+102 -61
ファイルの表示
@@ -7,7 +7,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
@@ -15,7 +15,7 @@ import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { tagsKeys } from '@/lib/queryKeys'
import { fetchTags } from '@/lib/tags'
import { dateString } from '@/lib/utils'
import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -29,6 +29,13 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
}
const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const TagListPage: FC = () => {
const location = useLocation ()
@@ -48,6 +55,9 @@ const TagListPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? ''
const qDeprecated = query.has ('deprecated')
? boolFromQuery (query.get ('deprecated'))
: null
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
const [name, setName] = useState ('')
@@ -58,6 +68,7 @@ const TagListPage: FC = () => {
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
const [deprecated, setDeprecated] = useState<boolean | null> (null)
const keys = {
page, limit, order,
@@ -69,7 +80,8 @@ const TagListPage: FC = () => {
createdFrom: qCreatedFrom,
createdTo: qCreatedTo,
updatedFrom: qUpdatedFrom,
updatedTo: qUpdatedTo }
updatedTo: qUpdatedTo,
deprecated: qDeprecated }
const { data, isLoading: loading } = useQuery ({
queryKey: tagsKeys.index (keys),
queryFn: () => fetchTags (keys) })
@@ -85,10 +97,11 @@ const TagListPage: FC = () => {
setCreatedTo (qCreatedTo)
setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo)
setDeprecated (qDeprecated)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
qPostCountLTE, qUpdatedFrom, qUpdatedTo])
qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
@@ -104,6 +117,8 @@ const TagListPage: FC = () => {
setIf (qs, 'created_to', createdTo)
setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo)
if (deprecated != null)
qs.set ('deprecated', deprecated ? '1' : '0')
qs.set ('page', '1')
qs.set ('order', order)
@@ -127,71 +142,94 @@ const TagListPage: FC = () => {
<form onSubmit={handleSearch} className="space-y-2">
{/* 名前 */}
<div>
<Label></Label>
<input
type="text"
value={name}
onChange={e => setName (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="名前">
{({ invalid }) => (
<input
type="text"
value={name}
onChange={e => setName (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
{/* カテゴリ */}
<div>
<Label></Label>
<select
value={category ?? ''}
onChange={e => setCategory((e.target.value || null) as Category | null)}
className="w-full border p-2 rounded">
<option value="">&nbsp;</option>
{CATEGORIES.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
</option>))}
</select>
</div>
<FormField label="カテゴリ">
{({ invalid }) => (
<select
value={category ?? ''}
onChange={e => setCategory((e.target.value || null) as Category | null)}
className={inputClass (invalid)}>
<option value="">&nbsp;</option>
{CATEGORIES.map (cat => (
<option key={cat} value={cat}>
{CATEGORY_NAMES[cat]}
</option>))}
</select>)}
</FormField>
{/* 広場の投稿数 */}
<div>
<Label>稿</Label>
<input
type="number"
min="0"
value={postCountGTE < 0 ? 0 : String (postCountGTE)}
onChange={e => setPostCountGTE (Number (e.target.value || 0))}
className="border rounded p-2"/>
<span className="mx-1"></span>
<input
type="number"
min="0"
value={postCountLTE == null ? '' : String (postCountLTE)}
onChange={e => setPostCountLTE (e.target.value ? Number (e.target.value) : null)}
className="border rounded p-2"/>
</div>
<FormField label="広場の投稿数">
{({ invalid }) => (
<>
<input
type="number"
min="0"
value={postCountGTE < 0 ? 0 : String (postCountGTE)}
onChange={e => setPostCountGTE (Number (e.target.value || 0))}
className={inputClass (invalid, 'w-auto')}/>
<span className="mx-1"></span>
<input
type="number"
min="0"
value={postCountLTE == null ? '' : String (postCountLTE)}
onChange={e => setPostCountLTE (e.target.value
? Number (e.target.value)
: null)}
className={inputClass (invalid, 'w-auto')}/>
</>)}
</FormField>
{/* はじめて記載された日時 */}
<div>
<Label></Label>
<DateTimeField
value={createdFrom ?? undefined}
onChange={setCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={createdTo ?? undefined}
onChange={setCreatedTo}/>
</div>
<FormField label="はじめて記載された日時">
{() => (
<>
<DateTimeField
value={createdFrom ?? undefined}
onChange={setCreatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={createdTo ?? undefined}
onChange={setCreatedTo}/>
</>)}
</FormField>
{/* 定義の更新日時 */}
<div>
<Label></Label>
<DateTimeField
value={updatedFrom ?? undefined}
onChange={setUpdatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={updatedTo ?? undefined}
onChange={setUpdatedTo}/>
</div>
<FormField label="定義の更新日時">
{() => (
<>
<DateTimeField
value={updatedFrom ?? undefined}
onChange={setUpdatedFrom}/>
<span className="mx-1"></span>
<DateTimeField
value={updatedTo ?? undefined}
onChange={setUpdatedTo}/>
</>)}
</FormField>
<FormField label="状態">
{({ invalid }) => (
<select
value={deprecated == null ? '' : (deprecated ? '1' : '0')}
onChange={e => setDeprecated (
e.target.value === ''
? null
: e.target.value === '1')}
className={inputClass (invalid)}>
<option value="">&nbsp;</option>
<option value="0"></option>
<option value="1"></option>
</select>)}
</FormField>
<div className="py-3">
<button
@@ -211,6 +249,7 @@ const TagListPage: FC = () => {
<col className="w-72"/>
<col className="w-16"/>
<col className="w-48"/>
<col className="w-32"/>
<col className="w-72"/>
<col className="w-48"/>
<col className="w-56"/>
@@ -241,6 +280,7 @@ const TagListPage: FC = () => {
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap">
@@ -272,6 +312,7 @@ const TagListPage: FC = () => {
</td>
<td className="p-2 text-right">{row.postCount}</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</td>
<td className="p-2">{tagStateLabel (row.deprecatedAt)}</td>
<td className="p-2">{row.aliases.join (' ')}</td>
<td className="p-2">
{row.parents.map (t => (
+298
ファイルの表示
@@ -0,0 +1,298 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost,
buildTheatre,
buildTheatreComment,
buildTheatreInfo,
buildTheatrePostSelectionWeights,
buildTheatreProgramme,
buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
const api = vi.hoisted (() => ({
apiDelete: vi.fn (),
apiGet: vi.fn (),
apiPatch: vi.fn (),
apiPost: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (() => false),
}))
const postsApi = vi.hoisted (() => ({
fetchPost: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const postEmbed = vi.hoisted (() => ({
props: vi.fn (),
play: vi.fn (),
seek: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/PostEmbed', () => ({
default: (props: {
ref?: { current: unknown }
post: { title: string | null; url: string }
}) => {
postEmbed.props (props)
if (props.ref)
props.ref.current = {
play: postEmbed.play,
seek: postEmbed.seek,
}
return <div>Embed:{props.post.title || props.post.url}</div>
},
}))
vi.mock ('@/components/PostEditForm', () => ({
default: () => <div>Post edit form</div>,
}))
vi.mock ('framer-motion', () => ({
motion: {
aside: ({ children }: { children?: ReactNode }) => <aside>{children}</aside>,
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main>,
},
}))
const currentPost = buildPost ({
id: 10,
title: '上映中の投稿',
url: 'https://www.nicovideo.jp/watch/sm10',
})
const theatre = buildTheatre ({ id: 7, name: '上映室' })
const programme = buildTheatreProgramme ({
theatreId: 7,
position: 3,
post: currentPost,
})
const weights = buildTheatrePostSelectionWeights ({
lightestPosts: [{
post: currentPost,
penalty: 2,
weight: 0.5,
tags: [],
}],
})
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders (
<Routes>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
</Routes>,
{ route: '/theatres/7' },
)
const mockDefaultApi = () => {
api.apiGet.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7':
return Promise.resolve (theatre)
case '/theatres/7/comments':
return Promise.resolve ([
buildTheatreComment ({
theatreId: 7,
no: 2,
user: { id: 1, name: 'tester' },
content: '視聴コメント',
}),
])
case '/theatres/7/programmes':
return Promise.resolve ([programme])
case '/theatres/7/post_selection_weights':
return Promise.resolve (weights)
default:
return Promise.reject (new Error (`Unexpected GET ${ path }`))
}
})
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
case '/theatres/7/skip_vote':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 1,
requiredCount: 2,
watchingUsersCount: 1,
voted: true,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
api.apiDelete.mockResolvedValue (undefined)
api.apiPatch.mockResolvedValue (undefined)
api.apiPost.mockResolvedValue (undefined)
postsApi.fetchPost.mockResolvedValue (currentPost)
dialogue.confirm.mockResolvedValue (true)
}
describe ('TheatreDetailPage', () => {
beforeEach (() => {
vi.useRealTimers ()
vi.clearAllMocks ()
mockDefaultApi ()
})
it ('loads theatre state, comments, current post, programme history, and weights', async () => {
renderPage ()
expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument ()
expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument ()
expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument ()
expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument ()
expect (screen.getByText ('penalty 2')).toBeInTheDocument ()
await waitFor (() => {
expect (postsApi.fetchPost).toHaveBeenCalledWith ('10')
})
})
it ('votes to skip the current post', async () => {
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/theatres/7/skip_vote',
{ post_id: 10 },
)
})
expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' }))
.toBeInTheDocument ()
})
it ('does not seek to zero while applying video length from the player', async () => {
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
hostFlg: true,
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 7_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
const props = postEmbed.props.mock.calls.at (-1)![0]
act (() => {
props.onVideoReady (120_000)
props.onPlaybackChange (0)
})
const seekMs = postEmbed.seek.mock.calls[0][0]
expect (seekMs).toBeGreaterThanOrEqual (7_000)
expect (seekMs).toBeLessThan (10_000)
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
})
it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
hostFlg: true,
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 4_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching')
})
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledTimes (2)
}, { timeout: 2_500 })
expect (api.apiPatch).not.toHaveBeenCalledWith ('/theatres/7/next_post')
})
it ('deletes an owned comment after confirmation', async () => {
renderPage ()
fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0])
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled ()
})
await waitFor (() => {
expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2')
})
expect (await screen.findAllByText ('削除されました.')).toHaveLength (2)
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
+32 -2
ファイルの表示
@@ -1,12 +1,13 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SettingPage from '@/pages/users/SettingPage'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPut: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
@@ -23,6 +24,11 @@ vi.mock ('@/components/users/InheritDialogue', () => ({
}))
describe ('SettingPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows loading when user is absent', () => {
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
@@ -51,4 +57,28 @@ describe ('SettingPage', () => {
expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' })
})
it ('shows validation errors returned for the name field', async () => {
const user = buildUser ({ id: 11, name: 'old' })
api.isApiError.mockReturnValue (true)
api.apiPut.mockRejectedValueOnce ({
response: {
status: 422,
data: {
type: 'validation_error',
message: '入力内容を確認してください.',
errors: { name: ['名前は必須です.'] },
base_errors: [],
},
},
})
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } })
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument ()
expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true')
})
})
+22 -5
ファイルの表示
@@ -3,7 +3,9 @@ import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
@@ -13,22 +15,30 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { User } from '@/types'
type Props = { user: User | null
setUser: React.Dispatch<React.SetStateAction<User | null>> }
type UserFormField = 'name'
const SettingPage: FC<Props> = ({ user, setUser }) => {
const [name, setName] = useState ('')
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const [inheritVsbl, setInheritVsbl] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<UserFormField> ()
const handleSubmit = async () => {
if (!(user))
return
clearValidationErrors ()
const formData = new FormData
formData.append ('name', name)
@@ -40,8 +50,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setUser (user => ({ ...user, ...data }))
toast ({ title: '設定を更新しました.' })
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: 'しっぱい……' })
}
}
@@ -65,11 +76,16 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{user ? (
<>
<FieldError messages={baseErrors}/>
{/* 名前 */}
<div>
<Label></Label>
<FormField label="表示名" messages={fieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input type="text"
className="w-full border rounded p-2"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => setName (ev.target.value)}/>
@@ -77,7 +93,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</div>
</>)}
</FormField>
{/* 送信 */}
<Button onClick={handleSubmit}
+54
ファイルの表示
@@ -0,0 +1,54 @@
import { screen } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (),
}))
const tagsApi = vi.hoisted (() => ({
fetchTagByName: vi.fn (),
}))
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
}))
vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi)
describe ('WikiDetailPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
tagsApi.fetchTagByName.mockResolvedValueOnce (buildTag ({
name: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>,
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
)
const marker = await screen.findByText ('(廃止)')
const heading = marker.closest ('h1')
const link = screen.getByRole ('link', { name: '旧タグ' })
expect (heading).not.toBeNull ()
expect (heading!).toHaveTextContent ('旧タグ(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
})
})
+6 -2
ファイルの表示
@@ -39,6 +39,7 @@ const WikiDetailPage: FC = () => {
queryFn: () => fetchWikiPageByTitle (title, { version }) })
const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
@@ -88,7 +89,7 @@ const WikiDetailPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`${ title } Wiki | ${ SITE_TITLE }`}</title>
<title>{`${ effectiveTitle }${ deprecated ? '(廃止)' : '' } Wiki | ${ SITE_TITLE }`}</title>
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
</Helmet>
@@ -110,10 +111,13 @@ const WikiDetailPage: FC = () => {
<article className="prose dark:prose-invert mx-auto p-4">
<h1 className="prose-a:no-underline">
<TagLink tag={tag ?? defaultTag}
<TagLink tag={tag ?? { ...defaultTag,
name: effectiveTitle,
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
withWiki={false}
withCount={false}
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span></span>}
</h1>
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
+23
ファイルの表示
@@ -16,6 +16,7 @@ describe ('WikiDiffPage', () => {
api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3,
title: '差分対象',
deprecatedAt: null,
olderRevisionId: 1,
newerRevisionId: 2,
diff: [
@@ -43,4 +44,26 @@ describe ('WikiDiffPage', () => {
expect (screen.getByText ('added line')).toBeInTheDocument ()
expect (screen.getByText ('removed line')).toBeInTheDocument ()
})
it ('appends deprecated state to the wiki title', async () => {
api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3,
title: '廃止 Wiki',
deprecatedAt: '2026-06-01T00:00:00.000Z',
olderRevisionId: 1,
newerRevisionId: 2,
diff: [],
})
renderWithProviders (
<Routes>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
</Routes>,
{ route: '/wiki/3/diff?from=1&to=2' },
)
expect (await screen.findByRole ('heading', {
name: '廃止 Wiki(廃止)',
})).toBeInTheDocument ()
})
})
+5 -2
ファイルの表示
@@ -23,6 +23,9 @@ const WikiDiffPage: FC = () => {
const query = new URLSearchParams (location.search)
const from = query.get ('from')
const to = query.get ('to')
const displayTitle = diff
? `${ diff.title }${ diff.deprecatedAt != null ? '(廃止)' : '' }`
: ''
useEffect (() => {
void (async () => {
@@ -33,9 +36,9 @@ const WikiDiffPage: FC = () => {
return (
<MainArea>
<Helmet>
<title>{`Wiki 差分: ${ diff?.title } | ${ SITE_TITLE }`}</title>
<title>{`Wiki 差分: ${ displayTitle } | ${ SITE_TITLE }`}</title>
</Helmet>
<PageTitle>{diff?.title}</PageTitle>
<PageTitle>{displayTitle}</PageTitle>
<div className="prose mx-auto p-4">
{diff
? (
+32 -16
ファイルの表示
@@ -5,11 +5,16 @@ import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite'
import { useParams, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css'
@@ -22,9 +27,11 @@ const mdParser = new MarkdownIt
type Props = { user: User | null }
type WikiFormField = 'title' | 'body'
const WikiEditPage: FC<Props> = ({ user }) => {
const editable = ['admin', 'member'].some (r => user?.role === r)
const editable = canEditContent (user)
const { id } = useParams ()
@@ -35,8 +42,12 @@ const WikiEditPage: FC<Props> = ({ user }) => {
const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<WikiFormField> ()
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData ()
formData.append ('title', title)
formData.append ('body', body)
@@ -51,8 +62,9 @@ const WikiEditPage: FC<Props> = ({ user }) => {
toast ({ title: '投稿成功!' })
navigate (`/wiki/${ title }`)
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
}
}
@@ -83,24 +95,28 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{loading ? 'Loading...' : (
<>
<FieldError messages={baseErrors}/>
{/* タイトル */}
{/* TODO: タグ補完 */}
<div>
<label className="block font-semibold mb-1"></label>
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 本文 */}
<div>
<label className="block font-semibold mb-1"></label>
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>
</div>
<FormField label="本文" messages={fieldErrors.body}>
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
{/* 送信 */}
<button onClick={handleSubmit}
+38
ファイルの表示
@@ -0,0 +1,38 @@
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('WikiHistoryPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
api.apiGet.mockResolvedValueOnce ([{
revisionId: 2,
pred: 1,
succ: null,
wikiPage: {
id: 3,
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
},
user: { id: 4, name: 'tester' },
kind: 'content',
message: 'updated',
timestamp: '2026-06-02T00:00:00.000Z',
}])
renderWithProviders (<WikiHistoryPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
expect (marker.closest ('a')).toBeNull ()
})
})
+1
ファイルの表示
@@ -59,6 +59,7 @@ const WikiHistoryPage: FC = () => {
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
{change.wikiPage.title}
</PrefetchLink>
{change.wikiPage.deprecatedAt != null && <span></span>}
</td>
<td className="p-2">
{change.pred == null ? '新規' : '更新'}
+31 -16
ファイルの表示
@@ -6,10 +6,15 @@ import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite'
import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css'
@@ -20,9 +25,11 @@ const mdParser = new MarkdownIt
type Props = { user: User | null }
type WikiFormField = 'title' | 'body'
const WikiNewPage: FC<Props> = ({ user }) => {
const editable = ['admin', 'member'].some (r => user?.role === r)
const editable = canEditContent (user)
const location = useLocation ()
const navigate = useNavigate ()
@@ -32,8 +39,12 @@ const WikiNewPage: FC<Props> = ({ user }) => {
const [title, setTitle] = useState (titleQuery)
const [body, setBody] = useState ('')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<WikiFormField> ()
const handleSubmit = async () => {
clearValidationErrors ()
const formData = new FormData
formData.append ('title', title)
formData.append ('body', body)
@@ -45,8 +56,9 @@ const WikiNewPage: FC<Props> = ({ user }) => {
toast ({ title: '投稿成功!' })
navigate (`/wiki/${ data.title }`)
}
catch
catch (e)
{
applyValidationError (e)
toast ({ title: '投稿失敗', description: '入力を確認してください。' })
}
}
@@ -61,25 +73,28 @@ const WikiNewPage: FC<Props> = ({ user }) => {
</Helmet>
<div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2"> Wiki </h1>
<FieldError messages={baseErrors}/>
{/* タイトル */}
{/* TODO: タグ補完 */}
<div>
<label className="block font-semibold mb-1"></label>
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>
{/* 本文 */}
<div>
<label className="block font-semibold mb-1"></label>
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>
</div>
<FormField label="本文" messages={fieldErrors.body}>
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
{/* 送信 */}
<button onClick={handleSubmit}
+17
ファイルの表示
@@ -42,4 +42,21 @@ describe ('WikiSearchPage', () => {
})
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
})
it ('marks deprecated wiki tags in the result title', async () => {
api.apiGet.mockResolvedValueOnce ([
buildWikiPage ({
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}),
])
renderWithProviders (<WikiSearchPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
})
})
+17 -15
ファイルの表示
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { dateString } from '@/lib/utils'
import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent , FC } from 'react'
@@ -43,22 +44,22 @@ const WikiSearchPage: FC = () => {
<PageTitle>Wiki</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<div>
<label></label><br />
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className="border p-1 w-full" />
</div>
<FormField label="タイトル">
{({ invalid }) => (
<input type="text"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
{/* 内容 */}
<div>
<label></label><br />
<input type="text"
value={text}
onChange={e => setText (e.target.value)}
className="border p-1 w-full" />
</div>
<FormField label="内容">
{({ invalid }) => (
<input type="text"
value={text}
onChange={e => setText (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
{/* 検索 */}
<div className="py-3">
@@ -85,6 +86,7 @@ const WikiSearchPage: FC = () => {
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title}
</PrefetchLink>
{page.deprecatedAt != null && <span></span>}
</td>
<td className="p-2">
{dateString (page.updatedAt)}
+71 -1
ファイルの表示
@@ -1,9 +1,19 @@
import type { Material, Post, TagWithSections, User, WikiPage } from '@/types'
import type { Material,
Post,
TagWithSections,
Theatre,
TheatreComment,
TheatreInfo,
TheatrePostSelectionWeights,
TheatreProgramme,
User,
WikiPage } from '@/types'
export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSections => ({
id: 1,
name: 'テストタグ',
category: 'general',
deprecatedAt: null,
aliases: [],
parents: [],
postCount: 12,
@@ -50,6 +60,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
id: 1,
title: 'テストWiki',
deprecatedAt: null,
createdUserId: 1,
updatedUserId: 1,
createdAt: '2026-01-02T03:04:05.000Z',
@@ -74,3 +85,62 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
updatedByUser: { id: 2, name: 'updater' },
...overrides,
})
export const buildTheatre = (overrides: Partial<Theatre> = {}): Theatre => ({
id: 1,
name: 'テスト劇場',
opensAt: '2026-01-02T03:04:05.000Z',
closesAt: null,
createdByUser: { id: 1, name: 'creator' },
createdAt: '2026-01-02T03:04:05.000Z',
updatedAt: '2026-01-03T03:04:05.000Z',
...overrides,
})
export const buildTheatreInfo = (
overrides: Partial<TheatreInfo> = {},
): TheatreInfo => ({
hostFlg: false,
postId: null,
postStartedAt: null,
postElapsedMs: null,
watchingUsers: [],
skipVote: {
votesCount: 0,
requiredCount: 1,
watchingUsersCount: 0,
voted: false,
},
...overrides,
})
export const buildTheatreComment = (
overrides: Partial<TheatreComment> = {},
): TheatreComment => ({
theatreId: 1,
no: 1,
deleted: false,
user: { id: 1, name: 'tester' },
content: 'テストコメント',
createdAt: '2026-01-02T03:04:05.000Z',
...overrides,
} as TheatreComment)
export const buildTheatreProgramme = (
overrides: Partial<TheatreProgramme> = {},
): TheatreProgramme => ({
theatreId: 1,
position: 1,
post: buildPost (),
createdAt: '2026-01-02T03:04:05.000Z',
...overrides,
})
export const buildTheatrePostSelectionWeights = (
overrides: Partial<TheatrePostSelectionWeights> = {},
): TheatrePostSelectionWeights => ({
tagPenalties: [],
lightestPosts: [],
heaviestPosts: [],
...overrides,
})
+93 -20
ファイルの表示
@@ -39,18 +39,31 @@ export type FetchTagsOrderField =
| 'updated_at'
export type FetchTagsParams = {
post: number | null
name: string
category: Category | null
postCountGTE: number
postCountLTE: number | null
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
page: number
limit: number
order: FetchTagsOrder }
post: number | null
name: string
category: Category | null
postCountGTE: number
postCountLTE: number | null
createdFrom: string
createdTo: string
updatedFrom: string
updatedTo: string
deprecated: boolean | null
page: number
limit: number
order: FetchTagsOrder }
export type FetchNicoTagsParams = {
name: string
linkedTag: string
linkStatus: 'all' | 'linked' | 'unlinked'
page: number
limit: number
order: FetchNicoTagsOrder }
export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc' }`
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
export type Material = {
id: number
@@ -83,8 +96,9 @@ export type MenuVisibleItem = {
subMenu: SubMenuItem[] }
export type NicoTag = Tag & {
category: 'nico'
linkedTags: Tag[] }
category: 'nico'
linkedTags: Tag[]
recentPostTagCreatedAt: string | null }
export type NiconicoMetadata = {
currentTime: number
@@ -126,6 +140,10 @@ export type Post = {
title: string | null
thumbnail: string | null
thumbnailBase: string | null
postSimilarityEdges?: {
targetPostId: number
cos: number
}[]
tags: TagWithSections[]
parentPosts?: Post[]
childPosts?: Post[]
@@ -179,6 +197,7 @@ export type Tag = {
id: number
name: string
category: Category
deprecatedAt: string | null
aliases: string[]
parents: Tag[]
postCount: number
@@ -195,6 +214,7 @@ export type TagVersion = {
eventType: 'create' | 'update' | 'discard' | 'restore'
name: { current: string; prev: string | null }
category: { current: Category; prev: Category | null }
deprecatedAt: { current: string | null; prev: string | null }
aliases: { name: string; type: 'context' | 'added' | 'removed' }[]
parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[]
createdAt: string
@@ -213,13 +233,64 @@ export type Theatre = {
createdAt: string
updatedAt: string }
export type TheatreComment = {
theatreId: number,
no: number,
user: { id: number, name: string } | null
content: string
export type TheatreComment =
| { theatreId: number
no: number
deleted: false
user: { id: number, name: string } | null
content: string
createdAt: string }
| { theatreId: number
no: number
deleted: true
user: { id: number, name: string } | null
content: null,
createdAt: string }
export type TheatreProgramme = {
theatreId: number
position: number
post: Post
createdAt: string }
export type TheatreSkipVoteStatus = {
votesCount: number
requiredCount: number
watchingUsersCount: number
voted: boolean }
export type TheatreInfo = {
hostFlg: boolean
postId: number | null
postStartedAt: string | null
postElapsedMs: number | null
watchingUsers: Pick<User, 'id' | 'name'>[]
skipVote: TheatreSkipVoteStatus
skipped?: boolean }
export type TheatreSkipEvent = {
id: number
theatreId: number
post: Post
tags: Tag[]
programmePosition: number | null
createdAt: string }
export type TheatrePostWeight = {
post: Post
weight: number
penalty: number
tags: Tag[] }
export type TheatreTagPenalty = {
tag: Tag
penalty: number }
export type TheatrePostSelectionWeights = {
tagPenalties: TheatreTagPenalty[]
lightestPosts: TheatrePostWeight[]
heaviestPosts: TheatrePostWeight[] }
export type User = {
id: number
name: string | null
@@ -231,6 +302,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
export type WikiPage = {
id: number
title: string
deprecatedAt: string | null
createdUserId: number
updatedUserId: number
createdAt: string
@@ -244,7 +316,7 @@ export type WikiPageChange = {
revisionId: number
pred: number | null
succ: null
wikiPage: Pick<WikiPage, 'id' | 'title'>
wikiPage: Pick<WikiPage, 'id' | 'title' | 'deprecatedAt'>
user: Pick<User, 'id' | 'name'>
kind: 'content' | 'redirect'
message: string | null
@@ -253,6 +325,7 @@ export type WikiPageChange = {
export type WikiPageDiff = {
wikiPageId: number
title: string
deprecatedAt: string | null
olderRevisionId: number | null
newerRevisionId: number
diff: WikiPageDiffDiff[] }
+2 -1
ファイルの表示
@@ -27,5 +27,6 @@
"@/*": ["*"]
}
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}