| @@ -18,12 +18,13 @@ npm install | |||||
| npm run dev | npm run dev | ||||
| npm run build | npm run build | ||||
| npm run lint | npm run lint | ||||
| npm test | |||||
| npm run test | |||||
| npm run test:run | |||||
| ``` | ``` | ||||
| ### Full verification | ### Full verification | ||||
| ```sh | ```sh | ||||
| cd backend && bundle exec rspec | cd backend && bundle exec rspec | ||||
| cd ../frontend && npm run build && npm run lint | |||||
| cd ../frontend && npm run test:run && npm run build && npm run lint | |||||
| ``` | ``` | ||||
| @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' | |||||
| import tseslint from 'typescript-eslint' | import tseslint from 'typescript-eslint' | ||||
| export default tseslint.config( | export default tseslint.config( | ||||
| { ignores: ['dist'] }, | |||||
| { ignores: ['dist', 'tailwind.config.js'] }, | |||||
| { | { | ||||
| extends: [js.configs.recommended, ...tseslint.configs.recommended], | extends: [js.configs.recommended, ...tseslint.configs.recommended], | ||||
| files: ['**/*.{ts,tsx}'], | files: ['**/*.{ts,tsx}'], | ||||
| @@ -19,10 +19,7 @@ export default tseslint.config( | |||||
| }, | }, | ||||
| rules: { | rules: { | ||||
| ...reactHooks.configs.recommended.rules, | ...reactHooks.configs.recommended.rules, | ||||
| 'react-refresh/only-export-components': [ | |||||
| 'warn', | |||||
| { allowConstantExport: true }, | |||||
| ], | |||||
| 'react-refresh/only-export-components': 'off', | |||||
| }, | }, | ||||
| }, | }, | ||||
| ) | ) | ||||
| @@ -8,6 +8,8 @@ | |||||
| "build": "tsc -b && vite build", | "build": "tsc -b && vite build", | ||||
| "postbuild": "node scripts/generate-sitemap.js", | "postbuild": "node scripts/generate-sitemap.js", | ||||
| "lint": "eslint .", | "lint": "eslint .", | ||||
| "test": "vitest", | |||||
| "test:run": "vitest run", | |||||
| "preview": "vite preview" | "preview": "vite preview" | ||||
| }, | }, | ||||
| "dependencies": { | "dependencies": { | ||||
| @@ -45,6 +47,10 @@ | |||||
| "devDependencies": { | "devDependencies": { | ||||
| "@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
| "@tailwindcss/typography": "^0.5.19", | "@tailwindcss/typography": "^0.5.19", | ||||
| "@testing-library/dom": "^10.4.1", | |||||
| "@testing-library/jest-dom": "^6.9.1", | |||||
| "@testing-library/react": "^16.3.2", | |||||
| "@testing-library/user-event": "^14.6.1", | |||||
| "@types/axios": "^0.14.4", | "@types/axios": "^0.14.4", | ||||
| "@types/markdown-it": "^14.1.2", | "@types/markdown-it": "^14.1.2", | ||||
| "@types/mdx": "^2.0.13", | "@types/mdx": "^2.0.13", | ||||
| @@ -58,11 +64,13 @@ | |||||
| "eslint-plugin-react-hooks": "^5.2.0", | "eslint-plugin-react-hooks": "^5.2.0", | ||||
| "eslint-plugin-react-refresh": "^0.4.19", | "eslint-plugin-react-refresh": "^0.4.19", | ||||
| "globals": "^16.0.0", | "globals": "^16.0.0", | ||||
| "jsdom": "^28.1.0", | |||||
| "postcss": "^8.5.3", | "postcss": "^8.5.3", | ||||
| "tailwindcss": "^3.4.13", | "tailwindcss": "^3.4.13", | ||||
| "typescript": "~5.8.3", | "typescript": "~5.8.3", | ||||
| "typescript-eslint": "^8.30.1", | "typescript-eslint": "^8.30.1", | ||||
| "vite": "^6.3.5" | |||||
| "vite": "^6.3.5", | |||||
| "vitest": "^4.1.5" | |||||
| }, | }, | ||||
| "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.", | "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.", | ||||
| "main": "eslint.config.js", | "main": "eslint.config.js", | ||||
| @@ -93,7 +93,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => { | |||||
| } | } | ||||
| export default (() => { | |||||
| const App: FC = () => { | |||||
| const [user, setUser] = useState<User | null> (null) | const [user, setUser] = useState<User | null> (null) | ||||
| const [status, setStatus] = useState (200) | const [status, setStatus] = useState (200) | ||||
| @@ -156,4 +156,6 @@ export default (() => { | |||||
| </DialogueProvider> | </DialogueProvider> | ||||
| </BrowserRouter> | </BrowserRouter> | ||||
| </>) | </>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default App | |||||
| @@ -19,7 +19,7 @@ type Props = { | |||||
| sp?: boolean } | sp?: boolean } | ||||
| export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => { | |||||
| const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { | |||||
| const dndId = `tag-node:${ pathKey }` | const dndId = `tag-node:${ pathKey }` | ||||
| const downPosRef = useRef<{ x: number; y: number } | null> (null) | const downPosRef = useRef<{ x: number; y: number } | null> (null) | ||||
| @@ -96,4 +96,6 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: | |||||
| <TagLink tag={tag} nestLevel={nestLevel}/> | <TagLink tag={tag} nestLevel={nestLevel}/> | ||||
| </motion.div> | </motion.div> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default DraggableDroppableTagRow | |||||
| @@ -10,7 +10,7 @@ import type { FC } from 'react' | |||||
| type Props = { status: number } | type Props = { status: number } | ||||
| export default (({ status }: Props) => { | |||||
| const ErrorScreen: FC<Props> = ({ status }) => { | |||||
| const [message, rightMsg, leftMsg]: [string, string, string] = (() => { | const [message, rightMsg, leftMsg]: [string, string, string] = (() => { | ||||
| switch (status) | switch (status) | ||||
| { | { | ||||
| @@ -58,4 +58,6 @@ export default (({ status }: Props) => { | |||||
| <p className="mr-[-.5em]">{message}</p> | <p className="mr-[-.5em]">{message}</p> | ||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default ErrorScreen | |||||
| @@ -31,7 +31,7 @@ const setChildrenById = ( | |||||
| })) | })) | ||||
| export default (() => { | |||||
| const MaterialSidebar: FC = () => { | |||||
| const [tags, setTags] = useState<TagWithDepth[]> ([]) | const [tags, setTags] = useState<TagWithDepth[]> ([]) | ||||
| const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) | const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) | ||||
| const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) | const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) | ||||
| @@ -94,4 +94,6 @@ export default (() => { | |||||
| {renderTags (tags)} | {renderTags (tags)} | ||||
| </ul> | </ul> | ||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MaterialSidebar | |||||
| @@ -1,9 +1,11 @@ | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| export default (() => ( | |||||
| const MenuSeparator: FC = () => ( | |||||
| <> | <> | ||||
| <span className="hidden md:inline flex items-center px-2">|</span> | <span className="hidden md:inline flex items-center px-2">|</span> | ||||
| <hr className="block md:hidden w-full opacity-25 | <hr className="block md:hidden w-full opacity-25 | ||||
| border-t border-black dark:border-white"/> | border-t border-black dark:border-white"/> | ||||
| </>)) satisfies FC | |||||
| </>) | |||||
| export default MenuSeparator | |||||
| @@ -6,6 +6,7 @@ import Label from '@/components/common/Label' | |||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | import { useDialogue } from '@/components/dialogues/DialogueProvider' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { isApiError } from '@/lib/api' | |||||
| import { updatePost } from '@/lib/posts' | import { updatePost } from '@/lib/posts' | ||||
| import type { FC, FormEvent } from 'react' | import type { FC, FormEvent } from 'react' | ||||
| @@ -32,7 +33,7 @@ type Props = { post: Post | |||||
| onSave: (newPost: Post) => void } | onSave: (newPost: Post) => void } | ||||
| export default (({ post, onSave }: Props) => { | |||||
| const PostEditForm: FC<Props> = ({ post, onSave }) => { | |||||
| const [disabled, setDisabled] = useState (false) | const [disabled, setDisabled] = useState (false) | ||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | const [originalCreatedBefore, setOriginalCreatedBefore] = | ||||
| useState<string | null> (post.originalCreatedBefore) | useState<string | null> (post.originalCreatedBefore) | ||||
| @@ -62,7 +63,7 @@ export default (({ post, onSave }: Props) => { | |||||
| } | } | ||||
| catch (e) | catch (e) | ||||
| { | { | ||||
| const response = (e as any)?.response | |||||
| const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined | |||||
| if (response?.status !== 409) | if (response?.status !== 409) | ||||
| { | { | ||||
| @@ -164,4 +165,6 @@ export default (({ post, onSave }: Props) => { | |||||
| 更新 | 更新 | ||||
| </Button> | </Button> | ||||
| </form>) | </form>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default PostEditForm | |||||
| @@ -16,8 +16,9 @@ type Props = { | |||||
| onMetadataChange?: (meta: NiconicoMetadata) => void } | onMetadataChange?: (meta: NiconicoMetadata) => void } | ||||
| export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => { | |||||
| const dialogue = useDialogue () | const dialogue = useDialogue () | ||||
| const [framed, setFramed] = useState (false) | |||||
| const url = new URL (post.url) | const url = new URL (post.url) | ||||
| @@ -44,7 +45,7 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| case 'twitter.com': | case 'twitter.com': | ||||
| case 'x.com': | case 'x.com': | ||||
| { | { | ||||
| const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/) | |||||
| const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/) | |||||
| const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) | const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) | ||||
| if (!(mUserId) || !(mStatusId)) | if (!(mUserId) || !(mStatusId)) | ||||
| break | break | ||||
| @@ -72,8 +73,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| } | } | ||||
| } | } | ||||
| const [framed, setFramed] = useState (false) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| {framed | {framed | ||||
| @@ -101,4 +100,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| </a> | </a> | ||||
| </div>)} | </div>)} | ||||
| </>) | </>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default PostEmbed | |||||
| @@ -36,7 +36,7 @@ type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | ' | |||||
| setTags: (tags: string) => void } | setTags: (tags: string) => void } | ||||
| export default (({ tags, setTags, ...rest }: Props) => { | |||||
| const PostFormTagsArea: FC<Props> = ({ tags, setTags, ...rest }) => { | |||||
| const ref = useRef<HTMLTextAreaElement> (null) | const ref = useRef<HTMLTextAreaElement> (null) | ||||
| const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | ||||
| @@ -97,4 +97,6 @@ export default (({ tags, setTags, ...rest }: Props) => { | |||||
| activeIndex={-1} | activeIndex={-1} | ||||
| onSelect={handleTagSelect}/>)} | onSelect={handleTagSelect}/>)} | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default PostFormTagsArea | |||||
| @@ -14,7 +14,7 @@ type Props = { posts: Post[] | |||||
| onClick?: (event: MouseEvent<HTMLElement>) => void } | onClick?: (event: MouseEvent<HTMLElement>) => void } | ||||
| export default (({ posts, onClick }: Props) => { | |||||
| const PostList: FC<Props> = ({ posts, onClick }) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) | const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) | ||||
| @@ -70,4 +70,6 @@ export default (({ posts, onClick }: Props) => { | |||||
| </PrefetchLink>) | </PrefetchLink>) | ||||
| })} | })} | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default PostList | |||||
| @@ -12,11 +12,11 @@ type Props = { | |||||
| setOriginalCreatedBefore: (x: string | null) => void } | setOriginalCreatedBefore: (x: string | null) => void } | ||||
| export default (({ disabled, | |||||
| const PostOriginalCreatedTimeField: FC<Props> = ({ disabled, | |||||
| originalCreatedFrom, | originalCreatedFrom, | ||||
| setOriginalCreatedFrom, | setOriginalCreatedFrom, | ||||
| originalCreatedBefore, | originalCreatedBefore, | ||||
| setOriginalCreatedBefore }: Props) => ( | |||||
| setOriginalCreatedBefore }) => ( | |||||
| <div> | <div> | ||||
| <Label>オリジナルの作成日時</Label> | <Label>オリジナルの作成日時</Label> | ||||
| <div className="my-1 flex"> | <div className="my-1 flex"> | ||||
| @@ -71,4 +71,6 @@ export default (({ disabled, | |||||
| </Button> | </Button> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div>)) satisfies FC<Props> | |||||
| </div>) | |||||
| export default PostOriginalCreatedTimeField | |||||
| @@ -13,7 +13,7 @@ export const useOverlayStore = create<OverlayStore> (set => ({ | |||||
| setActive: v => set ({ active: v }) })) | setActive: v => set ({ active: v }) })) | ||||
| export default (() => { | |||||
| const RouteBlockerOverlay: FC = () => { | |||||
| const active = useOverlayStore (s => s.active) | const active = useOverlayStore (s => s.active) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -43,4 +43,6 @@ export default (() => { | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default RouteBlockerOverlay | |||||
| @@ -151,7 +151,7 @@ const DropSlot = ({ cat }: { cat: Category }) => { | |||||
| type Props = { post: Post; sp?: boolean } | type Props = { post: Post; sp?: boolean } | ||||
| export default (({ post, sp }: Props) => { | |||||
| const TagDetailSidebar: FC<Props> = ({ post, sp }) => { | |||||
| sp = Boolean (sp) | sp = Boolean (sp) | ||||
| const qc = useQueryClient () | const qc = useQueryClient () | ||||
| @@ -376,4 +376,6 @@ export default (({ post, sp }: Props) => { | |||||
| </DragOverlay> | </DragOverlay> | ||||
| </DndContext> | </DndContext> | ||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TagDetailSidebar | |||||
| @@ -27,12 +27,12 @@ type Props = | |||||
| | PropsWithoutLink | | PropsWithoutLink | ||||
| export default (({ tag, | |||||
| const TagLink: FC<Props> = ({ tag, | |||||
| nestLevel = 0, | nestLevel = 0, | ||||
| linkFlg = true, | linkFlg = true, | ||||
| withWiki = true, | withWiki = true, | ||||
| withCount = true, | withCount = true, | ||||
| ...props }: Props) => { | |||||
| ...props }) => { | |||||
| const spanClass = cn ( | const spanClass = cn ( | ||||
| `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | ||||
| `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | ||||
| @@ -126,4 +126,6 @@ export default (({ tag, | |||||
| {withCount && ( | {withCount && ( | ||||
| <span className="ml-1">{tag.postCount}</span>)} | <span className="ml-1">{tag.postCount}</span>)} | ||||
| </>) | </>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TagLink | |||||
| @@ -12,7 +12,7 @@ import type { ChangeEvent, FC, KeyboardEvent } from 'react' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| export default (() => { | |||||
| const TagSearch: FC = () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -115,4 +115,6 @@ export default (() => { | |||||
| activeIndex={activeIndex} | activeIndex={activeIndex} | ||||
| onSelect={handleTagSelect}/> | onSelect={handleTagSelect}/> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default TagSearch | |||||
| @@ -10,7 +10,7 @@ type Props = { suggestions: Tag[] | |||||
| onSelect: (tag: Tag) => void } | onSelect: (tag: Tag) => void } | ||||
| export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||||
| const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => { | |||||
| if (suggestions.length === 0) | if (suggestions.length === 0) | ||||
| return | return | ||||
| @@ -26,4 +26,6 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => { | |||||
| <TagLink tag={tag} linkFlg={false} withWiki={false}/> | <TagLink tag={tag} linkFlg={false} withWiki={false}/> | ||||
| </li>))} | </li>))} | ||||
| </ul>) | </ul>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TagSearchBox | |||||
| @@ -19,7 +19,7 @@ type Props = { posts: Post[] | |||||
| onClick?: (event: MouseEvent<HTMLElement>) => void } | onClick?: (event: MouseEvent<HTMLElement>) => void } | ||||
| export default (({ posts, onClick }: Props) => { | |||||
| const TagSidebar: FC<Props> = ({ posts, onClick }) => { | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const [tagsVsbl, setTagsVsbl] = useState (false) | const [tagsVsbl, setTagsVsbl] = useState (false) | ||||
| @@ -126,4 +126,6 @@ export default (({ posts, onClick }: Props) => { | |||||
| {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} | ||||
| </a> | </a> | ||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TagSidebar | |||||
| @@ -26,7 +26,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| pathName: string }): Menu => { | pathName: string }): Menu => { | ||||
| const postCount = tag?.postCount ?? 0 | const postCount = tag?.postCount ?? 0 | ||||
| const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) | |||||
| const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) | |||||
| const wikiTitle = pathName.split ('/')[2] ?? '' | const wikiTitle = pathName.split ('/')[2] ?? '' | ||||
| const tagFlg = /^\/tags\/\d+/.test (pathName) | const tagFlg = /^\/tags\/\d+/.test (pathName) | ||||
| @@ -80,7 +80,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| } | } | ||||
| export default (({ user }: Props) => { | |||||
| const TopNav: FC<Props> = ({ user }) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const dirRef = useRef<(-1) | 1> (1) | const dirRef = useRef<(-1) | 1> (1) | ||||
| @@ -159,12 +159,12 @@ export default (({ user }: Props) => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| const unsubscribe = WikiIdBus.subscribe (setWikiId) | const unsubscribe = WikiIdBus.subscribe (setWikiId) | ||||
| return () => unsubscribe () | return () => unsubscribe () | ||||
| }, [activeIdx]) | |||||
| }, []) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| setMenuOpen (false) | setMenuOpen (false) | ||||
| setOpenItemIdx (activeIdx) | setOpenItemIdx (activeIdx) | ||||
| }, [location]) | |||||
| }, [activeIdx, location]) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -433,4 +433,6 @@ export default (({ user }: Props) => { | |||||
| </motion.div>)} | </motion.div>)} | ||||
| </AnimatePresence> | </AnimatePresence> | ||||
| </>) | </>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TopNav | |||||
| @@ -10,7 +10,7 @@ type Props = { user: User | null, | |||||
| sp?: boolean } | sp?: boolean } | ||||
| export default (({ user, sp }: Props) => { | |||||
| const TopNavUser: FC<Props> = ({ user, sp }) => { | |||||
| if (!(user)) | if (!(user)) | ||||
| return | return | ||||
| @@ -28,4 +28,6 @@ export default (({ user, sp }: Props) => { | |||||
| {user.name || '名もなきニジラー'} | {user.name || '名もなきニジラー'} | ||||
| </PrefetchLink> | </PrefetchLink> | ||||
| </>) | </>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TopNavUser | |||||
| @@ -5,7 +5,7 @@ type Props = { | |||||
| statusId: string } | statusId: string } | ||||
| export default (({ userId, statusId }: Props) => { | |||||
| const TwitterEmbed: FC<Props> = ({ userId, statusId }) => { | |||||
| const now = (new Date).toLocaleDateString () | const now = (new Date).toLocaleDateString () | ||||
| return ( | return ( | ||||
| @@ -18,4 +18,6 @@ export default (({ userId, statusId }: Props) => { | |||||
| </blockquote> | </blockquote> | ||||
| <script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/> | <script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TwitterEmbed | |||||
| @@ -25,7 +25,7 @@ const mdComponents = { a: (({ href, children }) => ( | |||||
| </a>))) } as const satisfies Components | </a>))) } as const satisfies Components | ||||
| export default (({ title, body }: Props) => { | |||||
| const WikiBody: FC<Props> = ({ title, body }) => { | |||||
| const { data } = useQuery ({ | const { data } = useQuery ({ | ||||
| enabled: Boolean (body), | enabled: Boolean (body), | ||||
| queryKey: wikiKeys.index ({ }), | queryKey: wikiKeys.index ({ }), | ||||
| @@ -39,4 +39,6 @@ export default (({ title, body }: Props) => { | |||||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | ||||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | ||||
| </ReactMarkdown>) | </ReactMarkdown>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default WikiBody | |||||
| @@ -25,7 +25,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & { | |||||
| onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | ||||
| export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||||
| const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, ...rest }) => { | |||||
| const [local, setLocal] = useState ('') | const [local, setLocal] = useState ('') | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -44,4 +44,6 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||||
| onChange?.(v ? (new Date (v)).toISOString () : null) | onChange?.(v ? (new Date (v)).toISOString () : null) | ||||
| }} | }} | ||||
| onBlur={onBlur}/>) | onBlur={onBlur}/>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default DateTimeField | |||||
| @@ -3,7 +3,9 @@ import type { FC, ReactNode } from 'react' | |||||
| type Props = { children: ReactNode } | type Props = { children: ReactNode } | ||||
| export default (({ children }: Props) => ( | |||||
| const Form: FC<Props> = ({ children }) => ( | |||||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | <div className="max-w-xl mx-auto p-4 space-y-4"> | ||||
| {children} | {children} | ||||
| </div>)) satisfies FC<Props> | |||||
| </div>) | |||||
| export default Form | |||||
| @@ -1,12 +1,14 @@ | |||||
| import React from 'react' | import React from 'react' | ||||
| import type { FC } from 'react' | |||||
| type Props = { children: React.ReactNode | type Props = { children: React.ReactNode | ||||
| checkBox?: { label: string | checkBox?: { label: string | ||||
| checked: boolean | checked: boolean | ||||
| onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } } | onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } } | ||||
| export default ({ children, checkBox }: Props) => { | |||||
| const Label: FC<Props> = ({ children, checkBox }) => { | |||||
| if (!(checkBox)) | if (!(checkBox)) | ||||
| { | { | ||||
| return ( | return ( | ||||
| @@ -26,3 +28,5 @@ export default ({ children, checkBox }: Props) => { | |||||
| </label> | </label> | ||||
| </div>) | </div>) | ||||
| } | } | ||||
| export default Label | |||||
| @@ -0,0 +1,15 @@ | |||||
| import { render, screen } from '@testing-library/react' | |||||
| import { describe, expect, it } from 'vitest' | |||||
| import PageTitle from '@/components/common/PageTitle' | |||||
| describe ('PageTitle', () => { | |||||
| it ('renders children as a level 1 heading', () => { | |||||
| render (<PageTitle>Test title</PageTitle>) | |||||
| const heading = screen.getByRole ('heading', { level: 1 }) | |||||
| expect (heading.textContent).toBe ('Test title') | |||||
| }) | |||||
| }) | |||||
| @@ -1,9 +1,13 @@ | |||||
| import React from 'react' | import React from 'react' | ||||
| import type { FC } from 'react' | |||||
| type Props = { children: React.ReactNode } | type Props = { children: React.ReactNode } | ||||
| export default ({ children }: Props) => ( | |||||
| const PageTitle: FC<Props> = ({ children }) => ( | |||||
| <h1 className="text-2xl font-bold mb-2"> | <h1 className="text-2xl font-bold mb-2"> | ||||
| {children} | {children} | ||||
| </h1>) | </h1>) | ||||
| export default PageTitle | |||||
| @@ -48,7 +48,7 @@ const getPages = ( | |||||
| } | } | ||||
| export default (({ page, totalPages, siblingCount = 3 }) => { | |||||
| const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const buildTo = (p: number) => { | const buildTo = (p: number) => { | ||||
| @@ -124,4 +124,6 @@ export default (({ page, totalPages, siblingCount = 3 }) => { | |||||
| </>)} | </>)} | ||||
| </div> | </div> | ||||
| </nav>) | </nav>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default Pagination | |||||
| @@ -5,7 +5,9 @@ import type { ComponentPropsWithoutRef, FC } from 'react' | |||||
| type Props = ComponentPropsWithoutRef<'h2'> | type Props = ComponentPropsWithoutRef<'h2'> | ||||
| export default (({ children, className, ...rest }: Props) => ( | |||||
| const SectionTitle: FC<Props> = ({ children, className, ...rest }) => ( | |||||
| <h2 {...rest} className={cn ('text-xl my-4', className)}> | <h2 {...rest} className={cn ('text-xl my-4', className)}> | ||||
| {children} | {children} | ||||
| </h2>)) satisfies FC<Props> | |||||
| </h2>) | |||||
| export default SectionTitle | |||||
| @@ -1,9 +1,13 @@ | |||||
| import React from 'react' | import React from 'react' | ||||
| import type { FC } from 'react' | |||||
| type Props = { children: React.ReactNode } | type Props = { children: React.ReactNode } | ||||
| export default ({ children }: Props) => ( | |||||
| const SubsectionTitle: FC<Props> = ({ children }) => ( | |||||
| <h3 className="my-2"> | <h3 className="my-2"> | ||||
| {children} | {children} | ||||
| </h3>) | </h3>) | ||||
| export default SubsectionTitle | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import React, { useState } from 'react' | import React, { useState } from 'react' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| @@ -10,7 +12,7 @@ type Props = { children: React.ReactNode } | |||||
| export const Tab = ({ children }: TabProps) => <>{children}</> | export const Tab = ({ children }: TabProps) => <>{children}</> | ||||
| export default ({ children }: Props) => { | |||||
| const TabGroup: FC<Props> = ({ children }) => { | |||||
| const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[] | const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[] | ||||
| const [current, setCurrent] = useState<number> (() => { | const [current, setCurrent] = useState<number> (() => { | ||||
| @@ -37,3 +39,5 @@ export default ({ children }: Props) => { | |||||
| </div> | </div> | ||||
| </div>) | </div>) | ||||
| } | } | ||||
| export default TabGroup | |||||
| @@ -12,7 +12,7 @@ type Props = { | |||||
| value: string | value: string | ||||
| setValue: (value: string) => void } | setValue: (value: string) => void } | ||||
| export default (({ value, setValue }: Props) => { | |||||
| const TagInput: FC<Props> = ({ value, setValue }) => { | |||||
| const [activeIndex, setActiveIndex] = useState (-1) | const [activeIndex, setActiveIndex] = useState (-1) | ||||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | const [suggestions, setSuggestions] = useState<Tag[]> ([]) | ||||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | ||||
| @@ -62,9 +62,12 @@ export default (({ value, setValue }: Props) => { | |||||
| case 'Enter': | case 'Enter': | ||||
| if (activeIndex < 0) | if (activeIndex < 0) | ||||
| break | break | ||||
| ev.preventDefault () | |||||
| const selected = suggestions[activeIndex] | |||||
| selected && handleTagSelect (selected) | |||||
| { | |||||
| ev.preventDefault () | |||||
| const selected = suggestions[activeIndex] | |||||
| if (selected) | |||||
| handleTagSelect (selected) | |||||
| } | |||||
| break | break | ||||
| case 'Escape': | case 'Escape': | ||||
| @@ -94,4 +97,6 @@ export default (({ value, setValue }: Props) => { | |||||
| activeIndex={activeIndex} | activeIndex={activeIndex} | ||||
| onSelect={handleTagSelect}/> | onSelect={handleTagSelect}/> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default TagInput | |||||
| @@ -57,7 +57,7 @@ let nextDialogueId = 1 | |||||
| type Props = { children: ReactNode } | type Props = { children: ReactNode } | ||||
| export default (({ children }: Props) => { | |||||
| const DialogueProvider: FC<Props> = ({ children }) => { | |||||
| const [queue, setQueue] = useState<DialogueRequest[]> ([]) | const [queue, setQueue] = useState<DialogueRequest[]> ([]) | ||||
| const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => { | const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => { | ||||
| @@ -174,7 +174,7 @@ export default (({ children }: Props) => { | |||||
| </DialogContent>)} | </DialogContent>)} | ||||
| </Dialog> | </Dialog> | ||||
| </DialogueContext.Provider>) | </DialogueContext.Provider>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export const useDialogue = () => { | export const useDialogue = () => { | ||||
| @@ -185,3 +185,5 @@ export const useDialogue = () => { | |||||
| return dialogue | return dialogue | ||||
| } | } | ||||
| export default DialogueProvider | |||||
| @@ -9,10 +9,12 @@ type Props = { | |||||
| className?: string } | className?: string } | ||||
| export default (({ children, className }: Props) => ( | |||||
| const MainArea: FC<Props> = ({ children, className }) => ( | |||||
| <motion.main | <motion.main | ||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | transition={{ layout: { duration: .2, ease: 'easeOut' } }} | ||||
| className={cn ('flex-1 overflow-y-auto p-4', className)} | className={cn ('flex-1 overflow-y-auto p-4', className)} | ||||
| layout="position"> | layout="position"> | ||||
| {children} | {children} | ||||
| </motion.main>)) satisfies FC<Props> | |||||
| </motion.main>) | |||||
| export default MainArea | |||||
| @@ -6,7 +6,7 @@ import type { FC, ReactNode } from 'react' | |||||
| type Props = { children: ReactNode } | type Props = { children: ReactNode } | ||||
| export default (({ children }: Props) => ( | |||||
| const SidebarComponent: FC<Props> = ({ children }) => ( | |||||
| <motion.div | <motion.div | ||||
| layout="position" | layout="position" | ||||
| transition={{ layout: { duration: .2, ease: 'easeOut' } }} | transition={{ layout: { duration: .2, ease: 'easeOut' } }} | ||||
| @@ -27,4 +27,6 @@ export default (({ children }: Props) => ( | |||||
| </Helmet> | </Helmet> | ||||
| {children} | {children} | ||||
| </motion.div>)) satisfies FC<Props> | |||||
| </motion.div>) | |||||
| export default SidebarComponent | |||||
| @@ -18,13 +18,6 @@ type ToasterToast = ToastProps & { | |||||
| action?: ToastActionElement | action?: ToastActionElement | ||||
| } | } | ||||
| const actionTypes = { | |||||
| ADD_TOAST: "ADD_TOAST", | |||||
| UPDATE_TOAST: "UPDATE_TOAST", | |||||
| DISMISS_TOAST: "DISMISS_TOAST", | |||||
| REMOVE_TOAST: "REMOVE_TOAST", | |||||
| } as const | |||||
| let count = 0 | let count = 0 | ||||
| function genId() { | function genId() { | ||||
| @@ -32,23 +25,21 @@ function genId() { | |||||
| return count.toString() | return count.toString() | ||||
| } | } | ||||
| type ActionType = typeof actionTypes | |||||
| type Action = | type Action = | ||||
| | { | | { | ||||
| type: ActionType["ADD_TOAST"] | |||||
| type: "ADD_TOAST" | |||||
| toast: ToasterToast | toast: ToasterToast | ||||
| } | } | ||||
| | { | | { | ||||
| type: ActionType["UPDATE_TOAST"] | |||||
| type: "UPDATE_TOAST" | |||||
| toast: Partial<ToasterToast> | toast: Partial<ToasterToast> | ||||
| } | } | ||||
| | { | | { | ||||
| type: ActionType["DISMISS_TOAST"] | |||||
| type: "DISMISS_TOAST" | |||||
| toastId?: ToasterToast["id"] | toastId?: ToasterToast["id"] | ||||
| } | } | ||||
| | { | | { | ||||
| type: ActionType["REMOVE_TOAST"] | |||||
| type: "REMOVE_TOAST" | |||||
| toastId?: ToasterToast["id"] | toastId?: ToasterToast["id"] | ||||
| } | } | ||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | import { useDialogue } from '@/components/dialogues/DialogueProvider' | ||||
| @@ -18,7 +20,7 @@ type Props = { visible: boolean | |||||
| setUser: (user: User) => void } | setUser: (user: User) => void } | ||||
| export default ({ visible, onVisibleChange, setUser }: Props) => { | |||||
| const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => { | |||||
| const dialogue = useDialogue () | const dialogue = useDialogue () | ||||
| const [inputCode, setInputCode] = useState ('') | const [inputCode, setInputCode] = useState ('') | ||||
| @@ -68,3 +70,5 @@ export default ({ visible, onVisibleChange, setUser }: Props) => { | |||||
| </DialogContent> | </DialogContent> | ||||
| </Dialog>) | </Dialog>) | ||||
| } | } | ||||
| export default InheritDialogue | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | import { useDialogue } from '@/components/dialogues/DialogueProvider' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { Dialog, | import { Dialog, | ||||
| @@ -17,7 +19,7 @@ type Props = { visible: boolean | |||||
| setUser: React.Dispatch<React.SetStateAction<User | null>> } | setUser: React.Dispatch<React.SetStateAction<User | null>> } | ||||
| export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||||
| const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => { | |||||
| const dialogue = useDialogue () | const dialogue = useDialogue () | ||||
| const handleChange = async () => { | const handleChange = async () => { | ||||
| @@ -69,3 +71,5 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||||
| </DialogContent> | </DialogContent> | ||||
| </Dialog>) | </Dialog>) | ||||
| } | } | ||||
| export default UserCodeDialogue | |||||
| @@ -28,7 +28,7 @@ const apiP = async <T> ( | |||||
| const res = await client[method] (path, body ?? { }, withUserCode (opt)) | const res = await client[method] (path, body ?? { }, withUserCode (opt)) | ||||
| if (opt?.responseType === 'blob') | if (opt?.responseType === 'blob') | ||||
| return res.data as T | return res.data as T | ||||
| return toCamel (res.data as any, { deep: true }) as T | |||||
| return toCamel (res.data as Record<string, unknown>, { deep: true }) as T | |||||
| } | } | ||||
| @@ -39,7 +39,7 @@ export const apiGet = async <T> ( | |||||
| const res = await client.get (path, withUserCode (opt)) | const res = await client.get (path, withUserCode (opt)) | ||||
| if (opt?.responseType === 'blob') | if (opt?.responseType === 'blob') | ||||
| return res.data as T | return res.data as T | ||||
| return toCamel (res.data as any, { deep: true }) as T | |||||
| return toCamel (res.data as Record<string, unknown>, { deep: true }) as T | |||||
| } | } | ||||
| @@ -72,4 +72,5 @@ export const apiDelete = async ( | |||||
| } | } | ||||
| export const isApiError = (err: unknown): err is AxiosError => axios.isAxiosError (err) | |||||
| export const isApiError = <T = unknown> (err: unknown): err is AxiosError<T> => | |||||
| axios.isAxiosError (err) | |||||
| @@ -38,7 +38,7 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) | |||||
| let last = 0 | let last = 0 | ||||
| const parts: RootContent[] = [] | const parts: RootContent[] = [] | ||||
| while (m = re.exec (value)) | |||||
| while ((m = re.exec (value)) !== null) | |||||
| { | { | ||||
| const start = m.index | const start = m.index | ||||
| const end = start + m[0].length | const end = start + m[0].length | ||||
| @@ -70,7 +70,7 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) | |||||
| } | } | ||||
| } | } | ||||
| const maybeChidren = (node as any).children | |||||
| const maybeChidren = 'children' in node ? node.children : undefined | |||||
| if (Array.isArray (maybeChidren)) | if (Array.isArray (maybeChidren)) | ||||
| { | { | ||||
| const parent = node as Parent | const parent = node as Parent | ||||
| @@ -1,4 +1,8 @@ | |||||
| import type { FC } from 'react' | |||||
| import ErrorScreen from '@/components/ErrorScreen' | import ErrorScreen from '@/components/ErrorScreen' | ||||
| export default () => <ErrorScreen status={403}/> | |||||
| const Forbidden: FC = () => <ErrorScreen status={403}/> | |||||
| export default Forbidden | |||||
| @@ -11,7 +11,7 @@ import type { FC } from 'react' | |||||
| import type { User } from '@/types' | import type { User } from '@/types' | ||||
| export default (() => { | |||||
| const MorePage: FC = () => { | |||||
| const menu = menuOutline ( | const menu = menuOutline ( | ||||
| { tag: null, wikiId: null, user: { } as User, pathName: location.pathname }) | { tag: null, wikiId: null, user: { } as User, pathName: location.pathname }) | ||||
| @@ -43,4 +43,6 @@ export default (() => { | |||||
| </section>))} | </section>))} | ||||
| </div>))} | </div>))} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MorePage | |||||
| @@ -1,4 +1,8 @@ | |||||
| import type { FC } from 'react' | |||||
| import ErrorScreen from '@/components/ErrorScreen' | import ErrorScreen from '@/components/ErrorScreen' | ||||
| export default () => <ErrorScreen status={404}/> | |||||
| const NotFound: FC = () => <ErrorScreen status={404}/> | |||||
| export default NotFound | |||||
| @@ -1,4 +1,8 @@ | |||||
| import type { FC } from 'react' | |||||
| import ErrorScreen from '@/components/ErrorScreen' | import ErrorScreen from '@/components/ErrorScreen' | ||||
| export default () => <ErrorScreen status={503}/> | |||||
| const ServiceUnavailable: FC = () => <ErrorScreen status={503}/> | |||||
| export default ServiceUnavailable | |||||
| @@ -1,5 +1,5 @@ | |||||
| import { useQuery, useQueryClient } from '@tanstack/react-query' | import { useQuery, useQueryClient } from '@tanstack/react-query' | ||||
| import { useEffect, useState } from 'react' | |||||
| import { useEffect, useMemo, useState } from 'react' | |||||
| import { useParams } from 'react-router-dom' | import { useParams } from 'react-router-dom' | ||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| @@ -18,7 +18,7 @@ import type { FC, FormEvent } from 'react' | |||||
| import type { Deerjikist, Platform } from '@/types' | import type { Deerjikist, Platform } from '@/types' | ||||
| export default (() => { | |||||
| const DeerjikistDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const tagId = String (id ?? '') | const tagId = String (id ?? '') | ||||
| const tagKey = tagsKeys.deerjikists (tagId) | const tagKey = tagsKeys.deerjikists (tagId) | ||||
| @@ -26,7 +26,7 @@ export default (() => { | |||||
| const { data: qData, isLoading: loading } = | const { data: qData, isLoading: loading } = | ||||
| useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) | useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) | ||||
| const tag = qData?.tag | const tag = qData?.tag | ||||
| const deerjikists = qData?.deerjikists ?? [] | |||||
| const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData]) | |||||
| const [data, setData] = | const [data, setData] = | ||||
| useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([]) | useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([]) | ||||
| @@ -152,4 +152,6 @@ export default (() => { | |||||
| </div> | </div> | ||||
| )} | )} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default DeerjikistDetailPage | |||||
| @@ -5,8 +5,10 @@ import MaterialSidebar from '@/components/MaterialSidebar' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| export default (() => ( | |||||
| const MaterialBasePage: FC = () => ( | |||||
| <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | ||||
| <MaterialSidebar/> | <MaterialSidebar/> | ||||
| <Outlet/> | <Outlet/> | ||||
| </div>)) satisfies FC | |||||
| </div>) | |||||
| export default MaterialBasePage | |||||
| @@ -21,7 +21,7 @@ import type { Material, Tag } from '@/types' | |||||
| type MaterialWithTag = Material & { tag: Tag } | type MaterialWithTag = Material & { tag: Tag } | ||||
| export default (() => { | |||||
| const MaterialDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const [file, setFile] = useState<File | null> (null) | const [file, setFile] = useState<File | null> (null) | ||||
| @@ -179,4 +179,6 @@ export default (() => { | |||||
| </TabGroup> | </TabGroup> | ||||
| </>))} | </>))} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MaterialDetailPage | |||||
| @@ -41,7 +41,7 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { | |||||
| } | } | ||||
| export default (() => { | |||||
| const MaterialListPage: FC = () => { | |||||
| const [loading, setLoading] = useState (false) | const [loading, setLoading] = useState (false) | ||||
| const [tag, setTag] = useState<TagWithMaterial | null> (null) | const [tag, setTag] = useState<TagWithMaterial | null> (null) | ||||
| @@ -69,7 +69,7 @@ export default (() => { | |||||
| setLoading (false) | setLoading (false) | ||||
| } | } | ||||
| }) () | }) () | ||||
| }, [location.search]) | |||||
| }, [location.search, tagQuery]) | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -163,4 +163,6 @@ export default (() => { | |||||
| </ul> | </ul> | ||||
| </>))} | </>))} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MaterialListPage | |||||
| @@ -15,7 +15,7 @@ import { apiPost } from '@/lib/api' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| export default (() => { | |||||
| const MaterialNewPage: FC = () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const tagQuery = query.get ('tag') ?? '' | const tagQuery = query.get ('tag') ?? '' | ||||
| @@ -121,4 +121,6 @@ export default (() => { | |||||
| </Button> | </Button> | ||||
| </Form> | </Form> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MaterialNewPage | |||||
| @@ -10,7 +10,7 @@ import { SITE_TITLE } from '@/config' | |||||
| import type { FC, FormEvent } from 'react' | import type { FC, FormEvent } from 'react' | ||||
| export default (() => { | |||||
| const MaterialSearchPage: FC = () => { | |||||
| const [tagName, setTagName] = useState ('') | const [tagName, setTagName] = useState ('') | ||||
| const [parentTagName, setParentTagName] = useState ('') | const [parentTagName, setParentTagName] = useState ('') | ||||
| @@ -46,4 +46,6 @@ export default (() => { | |||||
| </form> | </form> | ||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default MaterialSearchPage | |||||
| @@ -13,6 +13,7 @@ import MainArea from '@/components/layout/MainArea' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { isApiError } from '@/lib/api' | |||||
| import { fetchPost, toggleViewedFlg } from '@/lib/posts' | import { fetchPost, toggleViewedFlg } from '@/lib/posts' | ||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | import { postsKeys, tagsKeys } from '@/lib/queryKeys' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| @@ -26,7 +27,7 @@ import type { NiconicoViewerHandle, Post, User } from '@/types' | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| export default (({ user }: Props) => { | |||||
| const PostDetailPage: FC<Props> = ({ user }) => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const postId = String (id ?? '') | const postId = String (id ?? '') | ||||
| const postKey = postsKeys.show (postId) | const postKey = postsKeys.show (postId) | ||||
| @@ -44,16 +45,16 @@ export default (({ user }: Props) => { | |||||
| const changeViewedFlg = useMutation ({ | const changeViewedFlg = useMutation ({ | ||||
| mutationFn: async () => { | mutationFn: async () => { | ||||
| const cur = qc.getQueryData<any> (postKey) | |||||
| const cur = qc.getQueryData<Post> (postKey) | |||||
| const next = !(cur?.viewed) | const next = !(cur?.viewed) | ||||
| await toggleViewedFlg (postId, next) | await toggleViewedFlg (postId, next) | ||||
| return next | return next | ||||
| }, | }, | ||||
| onMutate: async () => { | onMutate: async () => { | ||||
| await qc.cancelQueries ({ queryKey: postKey }) | await qc.cancelQueries ({ queryKey: postKey }) | ||||
| const prev = qc.getQueryData<any> (postKey) | |||||
| const prev = qc.getQueryData<Post> (postKey) | |||||
| qc.setQueryData (postKey, | qc.setQueryData (postKey, | ||||
| (cur: any) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) | |||||
| (cur: Post | undefined) => cur ? { ...cur, viewed: !(cur.viewed) } : cur) | |||||
| return { prev } | return { prev } | ||||
| }, | }, | ||||
| onError: (...[, , ctx]) => { | onError: (...[, , ctx]) => { | ||||
| @@ -69,7 +70,7 @@ export default (({ user }: Props) => { | |||||
| if (!(errorFlg)) | if (!(errorFlg)) | ||||
| return | return | ||||
| const code = (error as any)?.response.status ?? (error as any)?.status | |||||
| const code = isApiError (error) ? error.response?.status : undefined | |||||
| if (code) | if (code) | ||||
| setStatus (code) | setStatus (code) | ||||
| }, [errorFlg, error]) | }, [errorFlg, error]) | ||||
| @@ -169,9 +170,9 @@ export default (({ user }: Props) => { | |||||
| <Tab name="編輯"> | <Tab name="編輯"> | ||||
| <PostEditForm | <PostEditForm | ||||
| post={post} | post={post} | ||||
| onSave={newPost => { | |||||
| onSave={newPost => { | |||||
| qc.setQueryData (postsKeys.show (postId), | qc.setQueryData (postsKeys.show (postId), | ||||
| (prev: any) => newPost ?? prev) | |||||
| (prev: Post | undefined) => newPost ?? prev) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | qc.invalidateQueries ({ queryKey: postsKeys.root }) | ||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | qc.invalidateQueries ({ queryKey: tagsKeys.root }) | ||||
| }}/> | }}/> | ||||
| @@ -185,4 +186,6 @@ export default (({ user }: Props) => { | |||||
| {post && <TagDetailSidebar post={post} sp/>} | {post && <TagDetailSidebar post={post} sp/>} | ||||
| </div> | </div> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default PostDetailPage | |||||
| @@ -35,7 +35,7 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||||
| </>) | </>) | ||||
| export default (() => { | |||||
| const PostHistoryPage: FC = () => { | |||||
| const dialogue = useDialogue () | const dialogue = useDialogue () | ||||
| const location = useLocation () | const location = useLocation () | ||||
| @@ -48,11 +48,11 @@ export default (() => { | |||||
| // 投稿列の結合で使用 | // 投稿列の結合で使用 | ||||
| let rowsCnt: number | let rowsCnt: number | ||||
| const { data: tag } = | |||||
| tagId | |||||
| ? useQuery ({ queryKey: tagsKeys.show (tagId), | |||||
| queryFn: () => fetchTag (tagId) }) | |||||
| : { data: null } | |||||
| const tagQueryId = tagId ?? '' | |||||
| const { data: tag } = useQuery ({ | |||||
| enabled: Boolean (tagId), | |||||
| queryKey: tagsKeys.show (tagQueryId), | |||||
| queryFn: () => fetchTag (tagQueryId) }) | |||||
| const { data, isLoading: loading } = useQuery ({ | const { data, isLoading: loading } = useQuery ({ | ||||
| queryKey: postsKeys.changes ({ ...(id && { post: id }), | queryKey: postsKeys.changes ({ ...(id && { post: id }), | ||||
| @@ -290,4 +290,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </>)} | </>)} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default PostHistoryPage | |||||
| @@ -1,5 +1,5 @@ | |||||
| import { useQuery } from '@tanstack/react-query' | import { useQuery } from '@tanstack/react-query' | ||||
| import { useLayoutEffect, useRef, useState } from 'react' | |||||
| import { useLayoutEffect, useMemo, useRef, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useLocation } from 'react-router-dom' | import { useLocation } from 'react-router-dom' | ||||
| @@ -20,7 +20,7 @@ import type { FC } from 'react' | |||||
| import type { WikiPage } from '@/types' | import type { WikiPage } from '@/types' | ||||
| export default (() => { | |||||
| const PostListPage: FC = () => { | |||||
| const containerRef = useRef<HTMLDivElement | null> (null) | const containerRef = useRef<HTMLDivElement | null> (null) | ||||
| const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) | ||||
| @@ -30,7 +30,7 @@ export default (() => { | |||||
| const tagsQuery = query.get ('tags') ?? '' | const tagsQuery = query.get ('tags') ?? '' | ||||
| const anyFlg = query.get ('match') === 'any' | const anyFlg = query.get ('match') === 'any' | ||||
| const match = anyFlg ? 'any' : 'all' | const match = anyFlg ? 'any' : 'all' | ||||
| const tags = tagsQuery.split (' ').filter (e => e !== '') | |||||
| const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery]) | |||||
| const tagsKey = tags.join (' ') | const tagsKey = tags.join (' ') | ||||
| const page = Number (query.get ('page') ?? 1) | const page = Number (query.get ('page') ?? 1) | ||||
| const limit = Number (query.get ('limit') ?? 20) | const limit = Number (query.get ('limit') ?? 20) | ||||
| @@ -66,7 +66,7 @@ export default (() => { | |||||
| ; | ; | ||||
| } | } | ||||
| }) () | }) () | ||||
| }, [location.search]) | |||||
| }, [location.search, tags]) | |||||
| return ( | return ( | ||||
| <div | <div | ||||
| @@ -76,7 +76,7 @@ export default (() => { | |||||
| <title> | <title> | ||||
| {tags.length | {tags.length | ||||
| ? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }` | ? `${ tags.join (anyFlg ? ' or ' : ' and ') } | ${ SITE_TITLE }` | ||||
| : `${ SITE_TITLE } 〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`} | |||||
| : `${ SITE_TITLE }\u3000〜 ぼざろクリーチャーシリーズ綜合リンク集サイト`} | |||||
| </title> | </title> | ||||
| </Helmet> | </Helmet> | ||||
| @@ -112,4 +112,6 @@ export default (() => { | |||||
| </TabGroup> | </TabGroup> | ||||
| </MainArea> | </MainArea> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default PostListPage | |||||
| @@ -1,4 +1,4 @@ | |||||
| import { useEffect, useState, useRef } from 'react' | |||||
| import { useCallback, useEffect, useState, useRef } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useNavigate } from 'react-router-dom' | import { useNavigate } from 'react-router-dom' | ||||
| @@ -21,9 +21,8 @@ import type { User } from '@/types' | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| export default (({ user }: Props) => { | |||||
| if (!(['admin', 'member'].some (r => user?.role === r))) | |||||
| return <Forbidden/> | |||||
| const PostNewPage: FC<Props> = ({ user }) => { | |||||
| const editable = ['admin', 'member'].some (r => user?.role === r) | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -41,6 +40,7 @@ export default (({ user }: Props) => { | |||||
| const [url, setURL] = useState ('') | const [url, setURL] = useState ('') | ||||
| const previousURLRef = useRef ('') | const previousURLRef = useRef ('') | ||||
| const thumbnailPreviewRef = useRef ('') | |||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const formData = new FormData | const formData = new FormData | ||||
| @@ -67,16 +67,6 @@ export default (({ user }: Props) => { | |||||
| } | } | ||||
| } | } | ||||
| useEffect (() => { | |||||
| if (titleAutoFlg && url) | |||||
| fetchTitle () | |||||
| }, [titleAutoFlg]) | |||||
| useEffect (() => { | |||||
| if (thumbnailAutoFlg && url) | |||||
| fetchThumbnail () | |||||
| }, [thumbnailAutoFlg]) | |||||
| const handleURLBlur = () => { | const handleURLBlur = () => { | ||||
| if (!(url) || url === previousURLRef.current) | if (!(url) || url === previousURLRef.current) | ||||
| return | return | ||||
| @@ -88,20 +78,20 @@ export default (({ user }: Props) => { | |||||
| previousURLRef.current = url | previousURLRef.current = url | ||||
| } | } | ||||
| const fetchTitle = async () => { | |||||
| const fetchTitle = useCallback (async () => { | |||||
| setTitle ('') | setTitle ('') | ||||
| setTitleLoading (true) | setTitleLoading (true) | ||||
| const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) | const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) | ||||
| setTitle (data.title || '') | setTitle (data.title || '') | ||||
| setTitleLoading (false) | setTitleLoading (false) | ||||
| } | |||||
| }, [url]) | |||||
| const fetchThumbnail = async () => { | |||||
| const fetchThumbnail = useCallback (async () => { | |||||
| setThumbnailPreview ('') | setThumbnailPreview ('') | ||||
| setThumbnailFile (null) | setThumbnailFile (null) | ||||
| setThumbnailLoading (true) | setThumbnailLoading (true) | ||||
| if (thumbnailPreview) | |||||
| URL.revokeObjectURL (thumbnailPreview) | |||||
| if (thumbnailPreviewRef.current) | |||||
| URL.revokeObjectURL (thumbnailPreviewRef.current) | |||||
| const data = await apiGet<Blob> ('/preview/thumbnail', | const data = await apiGet<Blob> ('/preview/thumbnail', | ||||
| { params: { url }, responseType: 'blob' }) | { params: { url }, responseType: 'blob' }) | ||||
| const imageURL = URL.createObjectURL (data) | const imageURL = URL.createObjectURL (data) | ||||
| @@ -110,7 +100,24 @@ export default (({ user }: Props) => { | |||||
| 'thumbnail.png', | 'thumbnail.png', | ||||
| { type: data.type || 'image/png' })) | { type: data.type || 'image/png' })) | ||||
| setThumbnailLoading (false) | setThumbnailLoading (false) | ||||
| } | |||||
| }, [url]) | |||||
| useEffect (() => { | |||||
| thumbnailPreviewRef.current = thumbnailPreview | |||||
| }, [thumbnailPreview]) | |||||
| useEffect (() => { | |||||
| if (titleAutoFlg && url) | |||||
| fetchTitle () | |||||
| }, [fetchTitle, titleAutoFlg, url]) | |||||
| useEffect (() => { | |||||
| if (thumbnailAutoFlg && url) | |||||
| fetchThumbnail () | |||||
| }, [fetchThumbnail, thumbnailAutoFlg, url]) | |||||
| if (!(editable)) | |||||
| return <Forbidden/> | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -207,4 +214,6 @@ export default (({ user }: Props) => { | |||||
| </Button> | </Button> | ||||
| </Form> | </Form> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default PostNewPage | |||||
| @@ -32,7 +32,7 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => { | |||||
| } | } | ||||
| export default (() => { | |||||
| const PostSearchPage: FC = () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -96,7 +96,8 @@ export default (() => { | |||||
| setUpdatedTo (qUpdatedTo) | setUpdatedTo (qUpdatedTo) | ||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | |||||
| }, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom, | |||||
| qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL]) | |||||
| const search = async () => { | const search = async () => { | ||||
| const qs = new URLSearchParams () | const qs = new URLSearchParams () | ||||
| @@ -336,4 +337,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </div>) : '結果ないよ(笑)')} | </div>) : '結果ないよ(笑)')} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default PostSearchPage | |||||
| @@ -1,4 +1,6 @@ | |||||
| import { useEffect, useRef, useState } from 'react' | |||||
| import type { FC } from 'react' | |||||
| import { useCallback, useEffect, useRef, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| @@ -14,7 +16,7 @@ import type { NicoTag, Tag, User } from '@/types' | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| export default ({ user }: Props) => { | |||||
| const NicoTagListPage: FC<Props> = ({ user }) => { | |||||
| const [cursor, setCursor] = useState ('') | const [cursor, setCursor] = useState ('') | ||||
| const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | ||||
| const [loading, setLoading] = useState (false) | const [loading, setLoading] = useState (false) | ||||
| @@ -25,12 +27,8 @@ export default ({ user }: Props) => { | |||||
| const memberFlg = ['admin', 'member'].some (r => user?.role === r) | const memberFlg = ['admin', 'member'].some (r => user?.role === r) | ||||
| const loadMore = async (withCursor: boolean) => { | |||||
| setLoading (true) | |||||
| const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ( | |||||
| '/tags/nico', { params: withCursor ? { cursor } : { } }) | |||||
| const applyLoadedTags = useCallback ((data: { tags: NicoTag[]; nextCursor: string }, | |||||
| withCursor: boolean) => { | |||||
| setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) | setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) | ||||
| setCursor (data.nextCursor) | setCursor (data.nextCursor) | ||||
| @@ -40,9 +38,26 @@ export default ({ user }: Props) => { | |||||
| const newRawTags = Object.fromEntries ( | const newRawTags = Object.fromEntries ( | ||||
| data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) | data.tags.map (t => [t.id, t.linkedTags.map (lt => lt.name).join (' ')])) | ||||
| setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) | setRawTags (rawTags => ({ ...rawTags, ...newRawTags })) | ||||
| }, []) | |||||
| const loadInitial = useCallback (async () => { | |||||
| setLoading (true) | |||||
| const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ('/tags/nico') | |||||
| applyLoadedTags (data, false) | |||||
| setLoading (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) => { | const handleEdit = async (id: number) => { | ||||
| if (editing[id]) | if (editing[id]) | ||||
| @@ -67,7 +82,7 @@ export default ({ user }: Props) => { | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const observer = new IntersectionObserver (entries => { | const observer = new IntersectionObserver (entries => { | ||||
| if (entries[0].isIntersecting && !(loading) && cursor) | if (entries[0].isIntersecting && !(loading) && cursor) | ||||
| loadMore (true) | |||||
| loadMore () | |||||
| }, { threshold: 1 }) | }, { threshold: 1 }) | ||||
| const target = loaderRef.current | const target = loaderRef.current | ||||
| @@ -78,12 +93,12 @@ export default ({ user }: Props) => { | |||||
| if (target) | if (target) | ||||
| observer.unobserve (target) | observer.unobserve (target) | ||||
| } | } | ||||
| }, [loaderRef, loading]) | |||||
| }, [cursor, loadMore, loading]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| setNicoTags ([]) | setNicoTags ([]) | ||||
| loadMore (false) | |||||
| }, []) | |||||
| loadInitial () | |||||
| }, [loadInitial]) | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -147,3 +162,5 @@ export default ({ user }: Props) => { | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default NicoTagListPage | |||||
| @@ -18,7 +18,7 @@ import type { FC, FormEvent } from 'react' | |||||
| import type { Category, Tag } from '@/types' | import type { Category, Tag } from '@/types' | ||||
| export default (() => { | |||||
| const TagDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const tagId = String (id ?? '') | const tagId = String (id ?? '') | ||||
| const tagKey = tagsKeys.show (tagId) | const tagKey = tagsKeys.show (tagId) | ||||
| @@ -155,4 +155,6 @@ export default (() => { | |||||
| </form> | </form> | ||||
| </div>)} | </div>)} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default TagDetailPage | |||||
| @@ -31,7 +31,7 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||||
| </>) | </>) | ||||
| export default (() => { | |||||
| const TagHistoryPage: FC = () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const id = query.get ('id') | const id = query.get ('id') | ||||
| @@ -209,4 +209,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </>)} | </>)} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default TagHistoryPage | |||||
| @@ -29,7 +29,7 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => { | |||||
| } | } | ||||
| export default (() => { | |||||
| const TagListPage: FC = () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -87,7 +87,8 @@ export default (() => { | |||||
| setUpdatedTo (qUpdatedTo) | setUpdatedTo (qUpdatedTo) | ||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | |||||
| }, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE, | |||||
| qPostCountLTE, qUpdatedFrom, qUpdatedTo]) | |||||
| const handleSearch = (e: FormEvent) => { | const handleSearch = (e: FormEvent) => { | ||||
| e.preventDefault () | e.preventDefault () | ||||
| @@ -296,4 +297,6 @@ export default (() => { | |||||
| <Pagination page={page} totalPages={totalPages}/> | <Pagination page={page} totalPages={totalPages}/> | ||||
| </div>) : '結果ないよ(笑)')} | </div>) : '結果ないよ(笑)')} | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default TagListPage | |||||
| @@ -9,7 +9,7 @@ import TagDetailSidebar from '@/components/TagDetailSidebar' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api' | |||||
| import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' | |||||
| import { fetchPost } from '@/lib/posts' | import { fetchPost } from '@/lib/posts' | ||||
| import { dateString } from '@/lib/utils' | import { dateString } from '@/lib/utils' | ||||
| @@ -34,11 +34,12 @@ const INITIAL_THEATRE_INFO = | |||||
| watchingUsers: [] as { id: number; name: string }[] } as const | watchingUsers: [] as { id: number; name: string }[] } as const | ||||
| export default (() => { | |||||
| const TheatreDetailPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const commentsRef = useRef<HTMLDivElement> (null) | const commentsRef = useRef<HTMLDivElement> (null) | ||||
| const embedRef = useRef<NiconicoViewerHandle> (null) | const embedRef = useRef<NiconicoViewerHandle> (null) | ||||
| const loadingRef = useRef (false) | |||||
| const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) | const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) | ||||
| const videoLengthRef = useRef (0) | const videoLengthRef = useRef (0) | ||||
| const lastCommentNoRef = useRef (0) | const lastCommentNoRef = useRef (0) | ||||
| @@ -53,6 +54,10 @@ export default (() => { | |||||
| const [post, setPost] = useState<Post | null> (null) | const [post, setPost] = useState<Post | null> (null) | ||||
| const [videoLength, setVideoLength] = useState (0) | const [videoLength, setVideoLength] = useState (0) | ||||
| useEffect (() => { | |||||
| loadingRef.current = loading | |||||
| }, [loading]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| theatreInfoRef.current = theatreInfo | theatreInfoRef.current = theatreInfo | ||||
| }, [theatreInfo]) | }, [theatreInfo]) | ||||
| @@ -87,7 +92,7 @@ export default (() => { | |||||
| } | } | ||||
| catch (error) | catch (error) | ||||
| { | { | ||||
| setStatus ((error as any)?.response.status ?? 200) | |||||
| setStatus (isApiError (error) ? error.response?.status ?? 200 : 200) | |||||
| } | } | ||||
| }) () | }) () | ||||
| @@ -160,7 +165,7 @@ export default (() => { | |||||
| }, [id]) | }, [id]) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) | |||||
| if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) | |||||
| return | return | ||||
| let cancelled = false | let cancelled = false | ||||
| @@ -338,4 +343,6 @@ export default (() => { | |||||
| {post && <TagDetailSidebar post={post} sp/>} | {post && <TagDetailSidebar post={post} sp/>} | ||||
| </div> | </div> | ||||
| </div>) | </div>) | ||||
| }) satisfies FC | |||||
| } | |||||
| export default TheatreDetailPage | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -18,7 +20,7 @@ type Props = { user: User | null | |||||
| setUser: React.Dispatch<React.SetStateAction<User | null>> } | setUser: React.Dispatch<React.SetStateAction<User | null>> } | ||||
| export default ({ user, setUser }: Props) => { | |||||
| const SettingPage: FC<Props> = ({ user, setUser }) => { | |||||
| const [name, setName] = useState ('') | const [name, setName] = useState ('') | ||||
| const [userCodeVsbl, setUserCodeVsbl] = useState (false) | const [userCodeVsbl, setUserCodeVsbl] = useState (false) | ||||
| const [inheritVsbl, setInheritVsbl] = useState (false) | const [inheritVsbl, setInheritVsbl] = useState (false) | ||||
| @@ -110,3 +112,5 @@ export default ({ user, setUser }: Props) => { | |||||
| setUser={setUser}/> | setUser={setUser}/> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default SettingPage | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useQuery } from '@tanstack/react-query' | import { useQuery } from '@tanstack/react-query' | ||||
| import { useEffect, useMemo } from 'react' | import { useEffect, useMemo } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -19,7 +21,7 @@ import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| export default () => { | |||||
| const WikiDetailPage: FC = () => { | |||||
| const params = useParams () | const params = useParams () | ||||
| const title = params.title ?? '' | const title = params.title ?? '' | ||||
| @@ -126,3 +128,5 @@ export default () => { | |||||
| </article> | </article> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default WikiDetailPage | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useLocation, useParams } from 'react-router-dom' | import { useLocation, useParams } from 'react-router-dom' | ||||
| @@ -11,7 +13,7 @@ import { cn } from '@/lib/utils' | |||||
| import type { WikiPageDiff } from '@/types' | import type { WikiPageDiff } from '@/types' | ||||
| export default () => { | |||||
| const WikiDiffPage: FC = () => { | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const location = useLocation () | const location = useLocation () | ||||
| @@ -26,7 +28,7 @@ export default () => { | |||||
| void (async () => { | void (async () => { | ||||
| setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { params: { from, to } })) | setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { params: { from, to } })) | ||||
| }) () | }) () | ||||
| }, []) | |||||
| }, [from, id, to]) | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -46,3 +48,5 @@ export default () => { | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default WikiDiffPage | |||||
| @@ -23,9 +23,8 @@ const mdParser = new MarkdownIt | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| export default (({ user }: Props) => { | |||||
| if (!(['admin', 'member'].some (r => user?.role === r))) | |||||
| return <Forbidden/> | |||||
| const WikiEditPage: FC<Props> = ({ user }) => { | |||||
| const editable = ['admin', 'member'].some (r => user?.role === r) | |||||
| const { id } = useParams () | const { id } = useParams () | ||||
| @@ -59,6 +58,9 @@ export default (({ user }: Props) => { | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(editable)) | |||||
| return | |||||
| void (async () => { | void (async () => { | ||||
| setLoading (true) | setLoading (true) | ||||
| const data = await apiGet<WikiPage> (`/wiki/${ id }`) | const data = await apiGet<WikiPage> (`/wiki/${ id }`) | ||||
| @@ -66,7 +68,10 @@ export default (({ user }: Props) => { | |||||
| setBody (data.body) | setBody (data.body) | ||||
| setLoading (false) | setLoading (false) | ||||
| }) () | }) () | ||||
| }, [id]) | |||||
| }, [editable, id]) | |||||
| if (!(editable)) | |||||
| return <Forbidden/> | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -105,4 +110,6 @@ export default (({ user }: Props) => { | |||||
| </>)} | </>)} | ||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC<Props> | |||||
| } | |||||
| export default WikiEditPage | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useLocation } from 'react-router-dom' | import { useLocation } from 'react-router-dom' | ||||
| @@ -12,7 +14,7 @@ import { dateString } from '@/lib/utils' | |||||
| import type { WikiPageChange } from '@/types' | import type { WikiPageChange } from '@/types' | ||||
| export default () => { | |||||
| const WikiHistoryPage: FC = () => { | |||||
| const [changes, setChanges] = useState<WikiPageChange[]> ([]) | const [changes, setChanges] = useState<WikiPageChange[]> ([]) | ||||
| const location = useLocation () | const location = useLocation () | ||||
| @@ -23,7 +25,7 @@ export default () => { | |||||
| void (async () => { | void (async () => { | ||||
| setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { params: id ? { id } : { } })) | setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { params: id ? { id } : { } })) | ||||
| }) () | }) () | ||||
| }, [location.search]) | |||||
| }, [id, location.search]) | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -73,3 +75,5 @@ export default () => { | |||||
| </table> | </table> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default WikiHistoryPage | |||||
| @@ -1,3 +1,5 @@ | |||||
| import type { FC } from 'react' | |||||
| import MarkdownIt from 'markdown-it' | import MarkdownIt from 'markdown-it' | ||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -19,9 +21,8 @@ const mdParser = new MarkdownIt | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| export default ({ user }: Props) => { | |||||
| if (!(['admin', 'member'].some (r => user?.role === r))) | |||||
| return <Forbidden/> | |||||
| const WikiNewPage: FC<Props> = ({ user }) => { | |||||
| const editable = ['admin', 'member'].some (r => user?.role === r) | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -50,6 +51,9 @@ export default ({ user }: Props) => { | |||||
| } | } | ||||
| } | } | ||||
| if (!(editable)) | |||||
| return <Forbidden/> | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| <Helmet> | <Helmet> | ||||
| @@ -85,3 +89,5 @@ export default ({ user }: Props) => { | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default WikiNewPage | |||||
| @@ -8,12 +8,12 @@ import { SITE_TITLE } from '@/config' | |||||
| import { apiGet } from '@/lib/api' | import { apiGet } from '@/lib/api' | ||||
| import { dateString } from '@/lib/utils' | import { dateString } from '@/lib/utils' | ||||
| import type { FormEvent } from 'react' | |||||
| import type { FormEvent , FC } from 'react' | |||||
| import type { WikiPage } from '@/types' | import type { WikiPage } from '@/types' | ||||
| export default () => { | |||||
| const WikiSearchPage: FC = () => { | |||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| const [text, setText] = useState ('') | const [text, setText] = useState ('') | ||||
| const [results, setResults] = useState<WikiPage[]> ([]) | const [results, setResults] = useState<WikiPage[]> ([]) | ||||
| @@ -28,7 +28,9 @@ export default () => { | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| search () | |||||
| void (async () => { | |||||
| setResults (await apiGet ('/wiki', { params: { title: '' } })) | |||||
| }) () | |||||
| }, []) | }, []) | ||||
| return ( | return ( | ||||
| @@ -93,3 +95,5 @@ export default () => { | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| export default WikiSearchPage | |||||
| @@ -0,0 +1 @@ | |||||
| import '@testing-library/jest-dom/vitest' | |||||
| @@ -21,5 +21,5 @@ | |||||
| "noFallthroughCasesInSwitch": true, | "noFallthroughCasesInSwitch": true, | ||||
| "noUncheckedSideEffectImports": true | "noUncheckedSideEffectImports": true | ||||
| }, | }, | ||||
| "include": ["vite.config.ts"] | |||||
| "include": ["vite.config.ts", "vitest.config.ts"] | |||||
| } | } | ||||
| @@ -0,0 +1,12 @@ | |||||
| import mdx from '@mdx-js/rollup' | |||||
| import react from '@vitejs/plugin-react' | |||||
| import path from 'path' | |||||
| import { defineConfig } from 'vitest/config' | |||||
| export default defineConfig ({ | |||||
| plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()], | |||||
| resolve: { alias: { '@': path.resolve (__dirname, './src') } }, | |||||
| test: { environment: 'jsdom', | |||||
| setupFiles: './src/test/setup.ts', | |||||
| globals: false } }) | |||||