| @@ -30,6 +30,8 @@ | |||||
| "devDependencies": { | "devDependencies": { | ||||
| "@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
| "@types/axios": "^0.9.36", | "@types/axios": "^0.9.36", | ||||
| "@types/markdown-it": "^14.1.2", | |||||
| "@types/node": "^24.0.13", | |||||
| "@types/react": "^19.1.2", | "@types/react": "^19.1.2", | ||||
| "@types/react-dom": "^19.1.2", | "@types/react-dom": "^19.1.2", | ||||
| "@types/react-router-dom": "^5.3.3", | "@types/react-router-dom": "^5.3.3", | ||||
| @@ -1993,6 +1995,24 @@ | |||||
| "dev": true, | "dev": true, | ||||
| "license": "MIT" | "license": "MIT" | ||||
| }, | }, | ||||
| "node_modules/@types/linkify-it": { | |||||
| "version": "5.0.0", | |||||
| "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", | |||||
| "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", | |||||
| "dev": true, | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/@types/markdown-it": { | |||||
| "version": "14.1.2", | |||||
| "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", | |||||
| "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", | |||||
| "dev": true, | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "@types/linkify-it": "^5", | |||||
| "@types/mdurl": "^2" | |||||
| } | |||||
| }, | |||||
| "node_modules/@types/mdast": { | "node_modules/@types/mdast": { | ||||
| "version": "4.0.4", | "version": "4.0.4", | ||||
| "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", | "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", | ||||
| @@ -2002,16 +2022,34 @@ | |||||
| "@types/unist": "*" | "@types/unist": "*" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/@types/mdurl": { | |||||
| "version": "2.0.0", | |||||
| "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", | |||||
| "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", | |||||
| "dev": true, | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/@types/ms": { | "node_modules/@types/ms": { | ||||
| "version": "2.1.0", | "version": "2.1.0", | ||||
| "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", | "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", | ||||
| "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", | "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", | ||||
| "license": "MIT" | "license": "MIT" | ||||
| }, | }, | ||||
| "node_modules/@types/node": { | |||||
| "version": "24.0.13", | |||||
| "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz", | |||||
| "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==", | |||||
| "dev": true, | |||||
| "license": "MIT", | |||||
| "dependencies": { | |||||
| "undici-types": "~7.8.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/@types/react": { | "node_modules/@types/react": { | ||||
| "version": "19.1.4", | "version": "19.1.4", | ||||
| "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", | "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz", | ||||
| "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", | "integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==", | ||||
| "dev": true, | |||||
| "license": "MIT", | "license": "MIT", | ||||
| "dependencies": { | "dependencies": { | ||||
| "csstype": "^3.0.2" | "csstype": "^3.0.2" | ||||
| @@ -2021,7 +2059,7 @@ | |||||
| "version": "19.1.5", | "version": "19.1.5", | ||||
| "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", | "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", | ||||
| "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", | "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", | ||||
| "devOptional": true, | |||||
| "dev": true, | |||||
| "license": "MIT", | "license": "MIT", | ||||
| "peerDependencies": { | "peerDependencies": { | ||||
| "@types/react": "^19.0.0" | "@types/react": "^19.0.0" | ||||
| @@ -2882,6 +2920,7 @@ | |||||
| "version": "3.1.3", | "version": "3.1.3", | ||||
| "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", | ||||
| "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", | "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", | ||||
| "dev": true, | |||||
| "license": "MIT" | "license": "MIT" | ||||
| }, | }, | ||||
| "node_modules/debug": { | "node_modules/debug": { | ||||
| @@ -6289,6 +6328,13 @@ | |||||
| "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", | "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", | ||||
| "license": "MIT" | "license": "MIT" | ||||
| }, | }, | ||||
| "node_modules/undici-types": { | |||||
| "version": "7.8.0", | |||||
| "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", | |||||
| "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", | |||||
| "dev": true, | |||||
| "license": "MIT" | |||||
| }, | |||||
| "node_modules/unified": { | "node_modules/unified": { | ||||
| "version": "11.0.5", | "version": "11.0.5", | ||||
| "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", | "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", | ||||
| @@ -31,6 +31,8 @@ | |||||
| "devDependencies": { | "devDependencies": { | ||||
| "@eslint/js": "^9.25.0", | "@eslint/js": "^9.25.0", | ||||
| "@types/axios": "^0.9.36", | "@types/axios": "^0.9.36", | ||||
| "@types/markdown-it": "^14.1.2", | |||||
| "@types/node": "^24.0.13", | |||||
| "@types/react": "^19.1.2", | "@types/react": "^19.1.2", | ||||
| "@types/react-dom": "^19.1.2", | "@types/react-dom": "^19.1.2", | ||||
| "@types/react-router-dom": "^5.3.3", | "@types/react-router-dom": "^5.3.3", | ||||
| @@ -56,7 +56,7 @@ export default () => { | |||||
| <> | <> | ||||
| <Router> | <Router> | ||||
| <div className="flex flex-col h-screen w-screen"> | <div className="flex flex-col h-screen w-screen"> | ||||
| <TopNav user={user} setUser={setUser} /> | |||||
| <TopNav user={user} /> | |||||
| <div className="flex flex-1"> | <div className="flex flex-1"> | ||||
| <Routes> | <Routes> | ||||
| <Route path="/" element={<Navigate to="/posts" replace />} /> | <Route path="/" element={<Navigate to="/posts" replace />} /> | ||||
| @@ -1,15 +1,17 @@ | |||||
| import React, { useRef, useLayoutEffect, useEffect, useState, CSSProperties } from 'react' | |||||
| import { Link, useNavigate, useLocation } from 'react-router-dom' | |||||
| import { useRef, useLayoutEffect, useEffect, useState } from 'react' | |||||
| type Props = { id: string, | type Props = { id: string, | ||||
| width: number, | |||||
| height: number, | |||||
| style?: CSSProperties } | |||||
| width: number, | |||||
| height: number, | |||||
| style?: CSSProperties } | |||||
| import type { CSSProperties } from 'react' | |||||
| const NicoViewer: React.FC = (props: Props) => { | |||||
| export default (props: Props) => { | |||||
| const { id, width, height, style = { } } = props | const { id, width, height, style = { } } = props | ||||
| const iframeRef = useRef<HTMLIFrameElement> (null) | const iframeRef = useRef<HTMLIFrameElement> (null) | ||||
| const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> () | const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> () | ||||
| const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> () | const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> () | ||||
| const [landscape, setLandscape] = useState<boolean> (false) | const [landscape, setLandscape] = useState<boolean> (false) | ||||
| @@ -18,97 +20,92 @@ const NicoViewer: React.FC = (props: Props) => { | |||||
| const src = `https://embed.nicovideo.jp/watch/${id}?persistence=1&oldScript=1&referer=&from=0&allowProgrammaticFullScreen=1`; | const src = `https://embed.nicovideo.jp/watch/${id}?persistence=1&oldScript=1&referer=&from=0&allowProgrammaticFullScreen=1`; | ||||
| const styleFullScreen: CSSProperties = fullScreen ? { | const styleFullScreen: CSSProperties = fullScreen ? { | ||||
| top: 0, | |||||
| left: landscape ? 0 : '100%', | |||||
| position: 'fixed', | |||||
| width: screenWidth, | |||||
| height: screenHeight, | |||||
| zIndex: 2147483647, | |||||
| maxWidth: 'none', | |||||
| transformOrigin: '0% 0%', | |||||
| transform: landscape ? 'none' : 'rotate(90deg)', | |||||
| WebkitTransformOrigin: '0% 0%', | |||||
| WebkitTransform: landscape ? 'none' : 'rotate(90deg)', | |||||
| } : {}; | |||||
| top: 0, | |||||
| left: landscape ? 0 : '100%', | |||||
| position: 'fixed', | |||||
| width: screenWidth, | |||||
| height: screenHeight, | |||||
| zIndex: 2147483647, | |||||
| maxWidth: 'none', | |||||
| transformOrigin: '0% 0%', | |||||
| transform: landscape ? 'none' : 'rotate(90deg)', | |||||
| WebkitTransformOrigin: '0% 0%', | |||||
| WebkitTransform: landscape ? 'none' : 'rotate(90deg)' } : {}; | |||||
| const margedStyle = { | const margedStyle = { | ||||
| border: 'none', | |||||
| maxWidth: '100%', | |||||
| ...style, | |||||
| ...styleFullScreen, | |||||
| }; | |||||
| border: 'none', | |||||
| maxWidth: '100%', | |||||
| ...style, | |||||
| ...styleFullScreen } | |||||
| useEffect(() => { | |||||
| useEffect (() => { | |||||
| const onMessage = (event: MessageEvent<any>) => { | const onMessage = (event: MessageEvent<any>) => { | ||||
| if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return; | |||||
| if (event.data.eventName === 'enterProgrammaticFullScreen') { | |||||
| setFullScreen(true); | |||||
| } else if (event.data.eventName === 'exitProgrammaticFullScreen') { | |||||
| setFullScreen(false); | |||||
| } | |||||
| }; | |||||
| if (!(iframeRef.current) | |||||
| || (event.source !== iframeRef.current.contentWindow)) | |||||
| return | |||||
| if (event.data.eventName === 'enterProgrammaticFullScreen') | |||||
| setFullScreen (true) | |||||
| else if (event.data.eventName === 'exitProgrammaticFullScreen') | |||||
| setFullScreen (false) | |||||
| } | |||||
| addEventListener('message', onMessage); | |||||
| addEventListener ('message', onMessage) | |||||
| return () => { | |||||
| removeEventListener('message', onMessage); | |||||
| }; | |||||
| }, []); | |||||
| return () => removeEventListener ('message', onMessage) | |||||
| }, []) | |||||
| useLayoutEffect(() => { | useLayoutEffect(() => { | ||||
| if (!(fullScreen)) | if (!(fullScreen)) | ||||
| return | return | ||||
| const initialScrollX = window.scrollX | |||||
| const initialScrollY = window.scrollY | |||||
| const initialScrollX = scrollX | |||||
| const initialScrollY = scrollY | |||||
| let timer: NodeJS.Timeout | let timer: NodeJS.Timeout | ||||
| let ended = false | let ended = false | ||||
| const pollingResize = () => { | const pollingResize = () => { | ||||
| if (ended) | if (ended) | ||||
| return | |||||
| return | |||||
| const landscape = window.innerWidth >= window.innerHeight | |||||
| const windowWidth = `${landscape ? window.innerWidth : window.innerHeight}px` | |||||
| const windowHeight = `${landscape ? window.innerHeight : window.innerWidth}px` | |||||
| const landscape = innerWidth >= innerHeight | |||||
| const windowWidth = `${landscape ? innerWidth : innerHeight}px` | |||||
| const windowHeight = `${landscape ? innerHeight : innerWidth}px` | |||||
| setLandScape (Landscape) | |||||
| setLandscape (landscape) | |||||
| setScreenWidth (windowWidth) | setScreenWidth (windowWidth) | ||||
| setScreenHeight (windowHeight) | setScreenHeight (windowHeight) | ||||
| timer = setTimeout (startPollingResize, 200) | timer = setTimeout (startPollingResize, 200) | ||||
| } | } | ||||
| const startPollingResize = () => { | const startPollingResize = () => { | ||||
| if (window.requestAnimationFrame) { | |||||
| window.requestAnimationFrame(pollingResize); | |||||
| } else { | |||||
| pollingResize(); | |||||
| } | |||||
| if (requestAnimationFrame) | |||||
| requestAnimationFrame (pollingResize) | |||||
| else | |||||
| pollingResize () | |||||
| } | } | ||||
| startPollingResize(); | |||||
| startPollingResize () | |||||
| return () => { | return () => { | ||||
| clearTimeout(timer); | |||||
| ended = true; | |||||
| window.scrollTo(initialScrollX, initialScrollY); | |||||
| }; | |||||
| }, [fullScreen]); | |||||
| clearTimeout (timer) | |||||
| ended = true | |||||
| scrollTo (initialScrollX, initialScrollY) | |||||
| } | |||||
| }, [fullScreen]) | |||||
| useEffect(() => { | |||||
| useEffect (() => { | |||||
| if (!(fullScreen)) | if (!(fullScreen)) | ||||
| return | return | ||||
| scrollTo (0, 0) | scrollTo (0, 0) | ||||
| }, [screenWidth, screenHeight, fullScreen]) | }, [screenWidth, screenHeight, fullScreen]) | ||||
| return <iframe ref={iframeRef} | |||||
| src={src} | |||||
| width={width} | |||||
| height={height} | |||||
| style={margedStyle} | |||||
| allowFullScreen | |||||
| allow="autoplay" /> | |||||
| return ( | |||||
| <iframe ref={iframeRef} | |||||
| src={src} | |||||
| width={width} | |||||
| height={height} | |||||
| style={margedStyle} | |||||
| allowFullScreen | |||||
| allow="autoplay" />) | |||||
| } | } | ||||
| export default NicoViewer | |||||
| @@ -1,11 +1,11 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import React, { useEffect, useState } from 'react' | |||||
| import { useState } from 'react' | |||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| import type { Post, Tag } from '@/types' | |||||
| import type { Post } from '@/types' | |||||
| type Props = { post: Post | type Props = { post: Post | ||||
| onSave: (newPost: Post) => void } | onSave: (newPost: Post) => void } | ||||
| @@ -19,9 +19,10 @@ export default ({ post, onSave }: Props) => { | |||||
| .join (' ')) | .join (' ')) | ||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const { data } = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags }, | |||||
| const res = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags }, | |||||
| { headers: { 'Content-Type': 'multipart/form-data', | { headers: { 'Content-Type': 'multipart/form-data', | ||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } ) | 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } ) | ||||
| const data = res.data as Post | |||||
| onSave ({ ...post, | onSave ({ ...post, | ||||
| title: data.title, | title: data.title, | ||||
| tags: data.tags } as Post) | tags: data.tags } as Post) | ||||
| @@ -1,24 +1,22 @@ | |||||
| import axios from 'axios' | |||||
| import React, { useEffect, useState } from 'react' | |||||
| import { Link, useParams } from 'react-router-dom' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { Link } from 'react-router-dom' | |||||
| import TagSearch from '@/components/TagSearch' | import TagSearch from '@/components/TagSearch' | ||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { CATEGORIES } from '@/consts' | import { CATEGORIES } from '@/consts' | ||||
| import type { Category, Post, Tag } from '@/types' | import type { Category, Post, Tag } from '@/types' | ||||
| type TagByCategory = { [key: Category]: Tag[] } | |||||
| type TagByCategory = { [key in Category]: Tag[] } | |||||
| type Props = { post: Post | null } | type Props = { post: Post | null } | ||||
| export default ({ post }: Props) => { | export default ({ post }: Props) => { | ||||
| const [tags, setTags] = useState<TagByCategory> ({ }) | |||||
| const [tags, setTags] = useState({ } as TagByCategory) | |||||
| const categoryNames: { [key: Category]: string } = { | |||||
| const categoryNames: Partial<{ [key in Category]: string }> = { | |||||
| general: '一般', | general: '一般', | ||||
| deerjikist: 'ニジラー', | deerjikist: 'ニジラー', | ||||
| nico: 'ニコニコタグ' } | nico: 'ニコニコタグ' } | ||||
| @@ -28,15 +26,15 @@ export default ({ post }: Props) => { | |||||
| return | return | ||||
| const fetchTags = () => { | const fetchTags = () => { | ||||
| const tagsTmp: TagByCategory = { } | |||||
| const tagsTmp = { } as TagByCategory | |||||
| for (const tag of post.tags) | for (const tag of post.tags) | ||||
| { | { | ||||
| if (!(tag.category in tagsTmp)) | if (!(tag.category in tagsTmp)) | ||||
| tagsTmp[tag.category] = [] | tagsTmp[tag.category] = [] | ||||
| tagsTmp[tag.category].push (tag) | tagsTmp[tag.category].push (tag) | ||||
| } | } | ||||
| for (const cat of Object.keys (tagsTmp)) | |||||
| tagsTmp[cat].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1) | |||||
| for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) | |||||
| tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) | |||||
| setTags (tagsTmp) | setTags (tagsTmp) | ||||
| } | } | ||||
| @@ -1,7 +1,9 @@ | |||||
| import React, { useEffect, useState } from 'react' | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import { Link, useNavigate, useLocation } from 'react-router-dom' | |||||
| import React, { useEffect, useState } from 'react' | |||||
| import { useNavigate, useLocation } from 'react-router-dom' | |||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| import TagSearchBox from './TagSearchBox' | import TagSearchBox from './TagSearchBox' | ||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| @@ -16,34 +18,34 @@ const TagSearch: React.FC = () => { | |||||
| const [activeIndex, setActiveIndex] = useState (-1) | const [activeIndex, setActiveIndex] = useState (-1) | ||||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | ||||
| const whenChanged = e => { | |||||
| setSearch (e.target.value) | |||||
| const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => { | |||||
| setSearch (ev.target.value) | |||||
| const q: string = e.target.value.split (' ').at (-1) | |||||
| const q = ev.target.value.trim ().split (' ').at (-1) | |||||
| if (!(q)) | if (!(q)) | ||||
| { | { | ||||
| setSuggestions ([]) | setSuggestions ([]) | ||||
| return | return | ||||
| } | } | ||||
| void (axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } }) | |||||
| .then (res => { | |||||
| setSuggestions (res.data) | |||||
| if (suggestions.length) | |||||
| setSuggestionsVsbl (true) | |||||
| })) | |||||
| const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } }) | |||||
| const data = res.data as Tag[] | |||||
| setSuggestions (data) | |||||
| if (suggestions.length) | |||||
| setSuggestionsVsbl (true) | |||||
| } | } | ||||
| const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |||||
| switch (e.key) | |||||
| const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => { | |||||
| switch (ev.key) | |||||
| { | { | ||||
| case 'ArrowDown': | case 'ArrowDown': | ||||
| e.preventDefault () | |||||
| ev.preventDefault () | |||||
| setActiveIndex (i => Math.min (i + 1, suggestions.length - 1)) | setActiveIndex (i => Math.min (i + 1, suggestions.length - 1)) | ||||
| setSuggestionsVsbl (true) | setSuggestionsVsbl (true) | ||||
| break | break | ||||
| case 'ArrowUp': | case 'ArrowUp': | ||||
| e.preventDefault () | |||||
| ev.preventDefault () | |||||
| setActiveIndex (i => Math.max (i - 1, -1)) | setActiveIndex (i => Math.max (i - 1, -1)) | ||||
| setSuggestionsVsbl (true) | setSuggestionsVsbl (true) | ||||
| break | break | ||||
| @@ -51,17 +53,17 @@ const TagSearch: React.FC = () => { | |||||
| case 'Enter': | case 'Enter': | ||||
| if (activeIndex < 0) | if (activeIndex < 0) | ||||
| break | break | ||||
| e.preventDefault () | |||||
| ev.preventDefault () | |||||
| const selected = suggestions[activeIndex] | const selected = suggestions[activeIndex] | ||||
| selected && handleTagSelect (selected) | selected && handleTagSelect (selected) | ||||
| break | break | ||||
| case 'Escape': | case 'Escape': | ||||
| e.preventDefault () | |||||
| ev.preventDefault () | |||||
| setSuggestionsVsbl (false) | setSuggestionsVsbl (false) | ||||
| break | break | ||||
| } | } | ||||
| if (e.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) | |||||
| if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) | |||||
| { | { | ||||
| navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`) | navigate (`/posts?${ (new URLSearchParams ({ tags: search })).toString () }`) | ||||
| setSuggestionsVsbl (false) | setSuggestionsVsbl (false) | ||||
| @@ -92,7 +94,7 @@ const TagSearch: React.FC = () => { | |||||
| onBlur={() => setSuggestionsVsbl (false)} | onBlur={() => setSuggestionsVsbl (false)} | ||||
| onKeyDown={handleKeyDown} | onKeyDown={handleKeyDown} | ||||
| className="w-full px-3 py-2 border rounded border-gray-600 bg-gray-800 text-white" /> | className="w-full px-3 py-2 border rounded border-gray-600 bg-gray-800 text-white" /> | ||||
| <TagSearchBox suggestions={suggestionsVsbl && suggestions.length && suggestions} | |||||
| <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} | |||||
| activeIndex={activeIndex} | activeIndex={activeIndex} | ||||
| onSelect={handleTagSelect} /> | onSelect={handleTagSelect} /> | ||||
| </div>) | </div>) | ||||
| @@ -1,7 +1,3 @@ | |||||
| import React, { useEffect, useState } from 'react' | |||||
| import axios from 'axios' | |||||
| import { Link, useNavigate, useLocation } from 'react-router-dom' | |||||
| import { API_BASE_URL } from '@/config' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| @@ -11,15 +7,10 @@ type Props = { suggestions: Tag[] | |||||
| onSelect: (tag: Tag) => void } | onSelect: (tag: Tag) => void } | ||||
| const TagSearchBox: React.FC = (props: Props) => { | |||||
| const { suggestions, activeIndex, onSelect } = props | |||||
| export default ({ suggestions, activeIndex, onSelect }: Props) => { | |||||
| if (!(suggestions.length)) | if (!(suggestions.length)) | ||||
| return null | return null | ||||
| const navigate = useNavigate () | |||||
| const location = useLocation () | |||||
| return ( | return ( | ||||
| <ul className="absolute left-0 right-0 z-50 w-full bg-gray-800 border border-gray-600 rounded shadow"> | <ul className="absolute left-0 right-0 z-50 w-full bg-gray-800 border border-gray-600 rounded shadow"> | ||||
| {suggestions.map ((tag, i) => ( | {suggestions.map ((tag, i) => ( | ||||
| @@ -31,10 +22,7 @@ const TagSearchBox: React.FC = (props: Props) => { | |||||
| onMouseDown={() => onSelect (tag)} | onMouseDown={() => onSelect (tag)} | ||||
| > | > | ||||
| {tag.name} | {tag.name} | ||||
| {<span className="ml-2 text-sm text-gray-400">{tag.count}</span>} | |||||
| {<span className="ml-2 text-sm text-gray-400">{tag.postCount}</span>} | |||||
| </li>))} | </li>))} | ||||
| </ul>) | </ul>) | ||||
| } | } | ||||
| export default TagSearchBox | |||||
| @@ -1,7 +1,6 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | |||||
| import React, { useEffect, useState } from 'react' | |||||
| import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { Link, useLocation, useNavigate } from 'react-router-dom' | |||||
| import TagSearch from '@/components/TagSearch' | import TagSearch from '@/components/TagSearch' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| @@ -1,7 +1,7 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import React, { useState, useEffect } from 'react' | |||||
| import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' | |||||
| import { useState, useEffect } from 'react' | |||||
| import { Link, useLocation, useNavigate } from 'react-router-dom' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| @@ -10,34 +10,34 @@ import { cn } from '@/lib/utils' | |||||
| import type { Tag, User, WikiPage } from '@/types' | import type { Tag, User, WikiPage } from '@/types' | ||||
| type Props = { user: User | null | |||||
| setUser: (user: User) => void } | |||||
| type Props = { user: User | null } | |||||
| const enum Menu { None, | |||||
| Post, | |||||
| User, | |||||
| Tag, | |||||
| Wiki } | |||||
| const Menu = { None: 'None', | |||||
| Post: 'Post', | |||||
| User: 'User', | |||||
| Tag: 'Tag', | |||||
| Wiki: 'Wiki' } as const | |||||
| type Menu = typeof Menu[keyof typeof Menu] | |||||
| export default ({ user, setUser }: Props) => { | |||||
| export default ({ user }: Props) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const [selectedMenu, setSelectedMenu] = useState<Menu> (Menu.None) | const [selectedMenu, setSelectedMenu] = useState<Menu> (Menu.None) | ||||
| const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) | const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) | ||||
| const [wikiSearch, setWikiSearch] = useState ('') | |||||
| const [activeIndex, setActiveIndex] = useState (-1) | |||||
| const [suggestions, setSuggestions] = useState<WikiPage[]> ([]) | |||||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||||
| const [tagSearch, setTagSearch] = useState ('') | |||||
| const [userSearch, setUserSearch] = useState ('') | |||||
| // const [wikiSearch, setWikiSearch] = useState ('') | |||||
| // const [activeIndex, setActiveIndex] = useState (-1) | |||||
| // const [suggestions, setSuggestions] = useState<WikiPage[]> ([]) | |||||
| // const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | |||||
| // const [tagSearch, setTagSearch] = useState ('') | |||||
| // const [userSearch, setUserSearch] = useState ('') | |||||
| const [postCount, setPostCount] = useState<number | null> (null) | const [postCount, setPostCount] = useState<number | null> (null) | ||||
| const MyLink = ({ to, title, menu, base }: { to: string | |||||
| title: string | |||||
| menu?: Menu | |||||
| base?: string }) => ( | |||||
| const MyLink = ({ to, title, base }: { to: string | |||||
| title: string | |||||
| base?: string }) => ( | |||||
| <Link to={to} className={cn ('hover:text-orange-500 h-full flex items-center', | <Link to={to} className={cn ('hover:text-orange-500 h-full flex items-center', | ||||
| (location.pathname.startsWith (base ?? to) | (location.pathname.startsWith (base ?? to) | ||||
| ? 'bg-gray-700 px-4 font-bold' | ? 'bg-gray-700 px-4 font-bold' | ||||
| @@ -45,55 +45,55 @@ export default ({ user, setUser }: Props) => { | |||||
| {title} | {title} | ||||
| </Link>) | </Link>) | ||||
| const whenTagSearchChanged = ev => { | |||||
| // TODO: 実装 | |||||
| setTagSearch (ev.target.value) | |||||
| const q: string = ev.target.value.split (' ').at (-1) | |||||
| if (!(q)) | |||||
| { | |||||
| setSuggestions ([]) | |||||
| return | |||||
| } | |||||
| } | |||||
| const whenWikiSearchChanged = ev => { | |||||
| // TODO: 実装 | |||||
| setWikiSearch (ev.target.value) | |||||
| const q: string = ev.target.value.split (' ').at (-1) | |||||
| if (!(q)) | |||||
| { | |||||
| setSuggestions ([]) | |||||
| return | |||||
| } | |||||
| } | |||||
| const whenUserSearchChanged = ev => { | |||||
| // TODO: 実装 | |||||
| setUserSearch (ev.target.value) | |||||
| const q: string = ev.target.value.split (' ').at (-1) | |||||
| if (!(q)) | |||||
| { | |||||
| setSuggestions ([]) | |||||
| return | |||||
| } | |||||
| } | |||||
| const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |||||
| if (e.key === 'Enter' && wikiSearch.length && (!(suggestionsVsbl) || activeIndex < 0)) | |||||
| { | |||||
| navigate (`/wiki/${ encodeURIComponent (wikiSearch) }`) | |||||
| setSuggestionsVsbl (false) | |||||
| } | |||||
| } | |||||
| const handleTagSelect = (tag: Tag) => { | |||||
| } | |||||
| // const whenTagSearchChanged = (ev: React.ChangeEvent<HTMLInputElement>) => { | |||||
| // // TODO: 実装 | |||||
| // | |||||
| // setTagSearch (ev.target.value) | |||||
| // | |||||
| // const q: string = ev.target.value.split (' ').at (-1) | |||||
| // if (!(q)) | |||||
| // { | |||||
| // // setSuggestions ([]) | |||||
| // return | |||||
| // } | |||||
| // } | |||||
| // const whenWikiSearchChanged = (ev: React.ChangeEvent<HTMLInputElement>) => { | |||||
| // // TODO: 実装 | |||||
| // | |||||
| // setWikiSearch (ev.target.value) | |||||
| // | |||||
| // const q: string = ev.target.value.split (' ').at (-1) | |||||
| // if (!(q)) | |||||
| // { | |||||
| // // setSuggestions ([]) | |||||
| // return | |||||
| // } | |||||
| // } | |||||
| // const whenUserSearchChanged = (ev: React.ChangeEvent<HTMLInputElement>) => { | |||||
| // // TODO: 実装 | |||||
| // | |||||
| // setUserSearch (ev.target.value) | |||||
| // | |||||
| // const q: string = ev.target.value.split (' ').at (-1) | |||||
| // if (!(q)) | |||||
| // { | |||||
| // // setSuggestions ([]) | |||||
| // return | |||||
| // } | |||||
| // } | |||||
| // const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => { | |||||
| // if (ev.key === 'Enter' && wikiSearch.length && (!(suggestionsVsbl) || activeIndex < 0)) | |||||
| // { | |||||
| // navigate (`/wiki/${ encodeURIComponent (wikiSearch) }`) | |||||
| // setSuggestionsVsbl (false) | |||||
| // } | |||||
| // } | |||||
| // const handleTagSelect = (tag: Tag) => { | |||||
| // } | |||||
| useEffect (() => { | useEffect (() => { | ||||
| const unsubscribe = WikiIdBus.subscribe (setWikiId) | const unsubscribe = WikiIdBus.subscribe (setWikiId) | ||||
| @@ -120,11 +120,13 @@ export default ({ user, setUser }: Props) => { | |||||
| void (async () => { | void (async () => { | ||||
| try | try | ||||
| { | { | ||||
| const { data: pageData } = await axios.get (`${ API_BASE_URL }/wiki/${ wikiId }`) | |||||
| const wikiPage: WikiPage = toCamel (pageData, { deep: true }) | |||||
| const pageRes = await axios.get (`${ API_BASE_URL }/wiki/${ wikiId }`) | |||||
| const pageData: any = pageRes.data | |||||
| const wikiPage = toCamel (pageData, { deep: true }) as WikiPage | |||||
| const { data: tagData } = await axios.get (`${ API_BASE_URL }/tags/name/${ wikiPage.title }`) | |||||
| const tag: Tag = toCamel (tagData, { deep: true }) | |||||
| const tagRes = await axios.get (`${ API_BASE_URL }/tags/name/${ wikiPage.title }`) | |||||
| const tagData: any = tagRes.data | |||||
| const tag = toCamel (tagData, { deep: true }) as Tag | |||||
| setPostCount (tag.postCount) | setPostCount (tag.postCount) | ||||
| } | } | ||||
| @@ -156,7 +158,7 @@ export default ({ user, setUser }: Props) => { | |||||
| {(() => { | {(() => { | ||||
| const className = 'bg-gray-700 text-white px-3 flex items-center w-full min-h-[40px]' | const className = 'bg-gray-700 text-white px-3 flex items-center w-full min-h-[40px]' | ||||
| const subClass = 'hover:text-orange-500 h-full flex items-center px-3' | const subClass = 'hover:text-orange-500 h-full flex items-center px-3' | ||||
| const inputBox = 'flex items-center px-3 mx-2' | |||||
| // const inputBox = 'flex items-center px-3 mx-2' | |||||
| const Separator = () => <span className="flex items-center px-2">|</span> | const Separator = () => <span className="flex items-center px-2">|</span> | ||||
| switch (selectedMenu) | switch (selectedMenu) | ||||
| { | { | ||||
| @@ -5,13 +5,13 @@ type TabProps = { name: string | |||||
| init?: boolean | init?: boolean | ||||
| children: React.ReactNode } | children: React.ReactNode } | ||||
| type Props = { children: React.ReactElement<TabProps>[] } | |||||
| type Props = { children: React.ReactNode } | |||||
| export const Tab = ({ children }: TabProps) => <>{children}</> | export const Tab = ({ children }: TabProps) => <>{children}</> | ||||
| export default ({ children }: Props) => { | export default ({ children }: Props) => { | ||||
| const tabs = React.Children.toArray (children) | |||||
| const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[] | |||||
| const [current, setCurrent] = useState<number> (() => { | const [current, setCurrent] = useState<number> (() => { | ||||
| const i = tabs.findIndex (tab => tab.props.init) | const i = tabs.findIndex (tab => tab.props.init) | ||||
| @@ -5,9 +5,7 @@ import { useState } from 'react' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { Dialog, | import { Dialog, | ||||
| DialogContent, | DialogContent, | ||||
| DialogDescription, | |||||
| DialogTitle, | |||||
| DialogTrigger } from '@/components/ui/dialog' | |||||
| DialogTitle } from '@/components/ui/dialog' | |||||
| import { Input } from '@/components/ui/input' | import { Input } from '@/components/ui/input' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| @@ -16,11 +14,10 @@ import type { User } from '@/types' | |||||
| type Props = { visible: boolean | type Props = { visible: boolean | ||||
| onVisibleChange: (visible: boolean) => void | onVisibleChange: (visible: boolean) => void | ||||
| user: User | null, | |||||
| setUser: (user: User) => void } | setUser: (user: User) => void } | ||||
| export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||||
| export default ({ visible, onVisibleChange, setUser }: Props) => { | |||||
| const [inputCode, setInputCode] = useState ('') | const [inputCode, setInputCode] = useState ('') | ||||
| const handleTransfer = async () => { | const handleTransfer = async () => { | ||||
| @@ -29,7 +26,8 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| const { data } = await axios.post (`${ API_BASE_URL }/users/verify`, { code: inputCode }) | |||||
| const res = await axios.post (`${ API_BASE_URL }/users/verify`, { code: inputCode }) | |||||
| const data = res.data as { valid: boolean; user: any } | |||||
| if (data.valid) | if (data.valid) | ||||
| { | { | ||||
| localStorage.setItem ('user_code', inputCode) | localStorage.setItem ('user_code', inputCode) | ||||
| @@ -3,9 +3,7 @@ import axios from 'axios' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { Dialog, | import { Dialog, | ||||
| DialogContent, | DialogContent, | ||||
| DialogDescription, | |||||
| DialogTitle, | |||||
| DialogTrigger } from '@/components/ui/dialog' | |||||
| DialogTitle } from '@/components/ui/dialog' | |||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| @@ -14,21 +12,25 @@ import type { User } from '@/types' | |||||
| type Props = { visible: boolean | type Props = { visible: boolean | ||||
| onVisibleChange: (visible: boolean) => void | onVisibleChange: (visible: boolean) => void | ||||
| user: User | null | user: User | null | ||||
| setUser: (user: User) => void } | |||||
| setUser: React.Dispatch<React.SetStateAction<User | null>> } | |||||
| export default ({ visible, onVisibleChange, user, setUser }: Props) => { | export default ({ visible, onVisibleChange, user, setUser }: Props) => { | ||||
| const handleChange = async () => { | const handleChange = async () => { | ||||
| if (!(user)) | |||||
| return | |||||
| if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) | if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) | ||||
| return | return | ||||
| const { data } = await axios.post (`${ API_BASE_URL }/users/code/renew`, { }, { headers: { | |||||
| const res = await axios.post (`${ API_BASE_URL }/users/code/renew`, { }, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | 'Content-Type': 'multipart/form-data', | ||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | ||||
| const data = res.data as { code: string } | |||||
| if (data.code) | if (data.code) | ||||
| { | { | ||||
| localStorage.setItem ('user_code', data.code) | localStorage.setItem ('user_code', data.code) | ||||
| setUser (user => ({ ...user, inheritanceCode: data.code })) | |||||
| setUser (user => ({ ...user, inheritanceCode: data.code } as User)) | |||||
| toast ({ title: '再発行しました.' }) | toast ({ title: '再発行しました.' }) | ||||
| } | } | ||||
| } | } | ||||
| @@ -1,5 +1,5 @@ | |||||
| // frontend/src/config.ts | // frontend/src/config.ts | ||||
| const ENV = 'development' | |||||
| const ENV: string = 'development' | |||||
| const config = { | const config = { | ||||
| API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002', | API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002', | ||||
| @@ -1,13 +1,13 @@ | |||||
| export const CATEGORIES = ['general', | export const CATEGORIES = ['general', | ||||
| 'character', | |||||
| 'deerjikist', | |||||
| 'meme', | |||||
| 'material', | |||||
| 'nico', | |||||
| 'meta'] as const | |||||
| 'character', | |||||
| 'deerjikist', | |||||
| 'meme', | |||||
| 'material', | |||||
| 'meta', | |||||
| 'nico'] as const | |||||
| export const USER_ROLES = ['admin', 'member', 'guest'] as const | export const USER_ROLES = ['admin', 'member', 'guest'] as const | ||||
| export const enum ViewFlagBehavior { OnShowedDetail = 1, | |||||
| OnClickedLink = 2, | |||||
| NotAuto = 3 } | |||||
| export const ViewFlagBehavior = { OnShowedDetail: 1, | |||||
| OnClickedLink: 2, | |||||
| NotAuto: 3 } as const | |||||
| @@ -1,8 +1,8 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import React, { useEffect, useState } from 'react' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link, useLocation, useParams } from 'react-router-dom' | |||||
| import { useParams } from 'react-router-dom' | |||||
| import TagDetailSidebar from '@/components/TagDetailSidebar' | import TagDetailSidebar from '@/components/TagDetailSidebar' | ||||
| import NicoViewer from '@/components/NicoViewer' | import NicoViewer from '@/components/NicoViewer' | ||||
| @@ -14,7 +14,7 @@ import { toast } from '@/components/ui/use-toast' | |||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { Post, Tag, User } from '@/types' | |||||
| import type { Post, User } from '@/types' | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| @@ -22,40 +22,44 @@ type Props = { user: User | null } | |||||
| export default ({ user }: Props) => { | export default ({ user }: Props) => { | ||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const location = useLocation () | |||||
| const [post, setPost] = useState<Post | null> (null) | const [post, setPost] = useState<Post | null> (null) | ||||
| const [editing, setEditing] = useState (true) | const [editing, setEditing] = useState (true) | ||||
| const changeViewedFlg = () => { | |||||
| if (post?.viewed) | |||||
| { | |||||
| void (axios.delete ( | |||||
| `${ API_BASE_URL }/posts/${ id }/viewed`, | |||||
| { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| .then (() => setPost (post => ({ ...post, viewed: false }))) | |||||
| .catch (() => toast ({ title: '失敗……', | |||||
| description: '通信に失敗しました……' }))) | |||||
| } | |||||
| else | |||||
| { | |||||
| void (axios.post ( | |||||
| `${ API_BASE_URL }/posts/${ id }/viewed`, | |||||
| { }, | |||||
| { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| .then (() => setPost (post => ({ ...post, viewed: true }))) | |||||
| .catch (() => toast ({ title: '失敗……', | |||||
| description: '通信に失敗しました……' }))) | |||||
| } | |||||
| const changeViewedFlg = async () => { | |||||
| const url = `${ API_BASE_URL }/posts/${ id }/viewed` | |||||
| const opt = { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } } | |||||
| try | |||||
| { | |||||
| if (post!.viewed) | |||||
| await axios.delete (url, opt) | |||||
| else | |||||
| await axios.post (url, { }, opt) | |||||
| // 通信に成功したら “閲覧済” をトグル | |||||
| setPost (post => ({ ...post!, viewed: !(post!.viewed) })) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ title: '失敗……', description: '通信に失敗しました……' }) | |||||
| } | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(id)) | if (!(id)) | ||||
| return | return | ||||
| void (axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: { | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| .then (res => setPost (toCamel (res.data, { deep: true }))) | |||||
| .catch (err => console.error ('うんち!', err))) | |||||
| void (async () => { | |||||
| try | |||||
| { | |||||
| const res = await axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: { | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| setPost (toCamel (res.data as any, { deep: true }) as Post) | |||||
| } | |||||
| catch (err) | |||||
| { | |||||
| console.error ('うんち!', err) | |||||
| } | |||||
| }) () | |||||
| }, [id]) | }, [id]) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -69,47 +73,46 @@ export default ({ user }: Props) => { | |||||
| const url = post ? new URL (post.url) : null | const url = post ? new URL (post.url) : null | ||||
| const nicoFlg = url?.hostname.split ('.').slice (-2).join ('.') === 'nicovideo.jp' | const nicoFlg = url?.hostname.split ('.').slice (-2).join ('.') === 'nicovideo.jp' | ||||
| const videoId = (nicoFlg | |||||
| ? url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)[0] | |||||
| : '') | |||||
| const match = nicoFlg ? url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/) : null | |||||
| const videoId = match?.[0] ?? '' | |||||
| const viewedClass = (post?.viewed | const viewedClass = (post?.viewed | ||||
| ? 'bg-blue-600 hover:bg-blue-700' | |||||
| : 'bg-gray-500 hover:bg-gray-600') | |||||
| ? 'bg-blue-600 hover:bg-blue-700' | |||||
| : 'bg-gray-500 hover:bg-gray-600') | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Helmet> | |||||
| {(post?.thumbnail || post?.thumbnailBase) && | |||||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase} />} | |||||
| {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | |||||
| </Helmet> | |||||
| <TagDetailSidebar post={post} /> | |||||
| <MainArea> | |||||
| {post | |||||
| ? ( | |||||
| <> | |||||
| {nicoFlg | |||||
| ? ( | |||||
| <NicoViewer id={videoId} | |||||
| width="640" | |||||
| height="360" />) | |||||
| : <img src={post.thumbnail} alt={post.url} className="mb-4 w-full" />} | |||||
| <Button onClick={changeViewedFlg} | |||||
| className={cn ('text-white', viewedClass)}> | |||||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||||
| </Button> | |||||
| <TabGroup> | |||||
| {(['admin', 'member'].some (r => r === user?.role) && editing) && ( | |||||
| <Tab name="編輯"> | |||||
| <PostEditForm post={post} | |||||
| onSave={newPost => { | |||||
| setPost (newPost) | |||||
| toast ({ description: '更新しました.' }) | |||||
| }} /> | |||||
| </Tab>)} | |||||
| </TabGroup> | |||||
| </>) | |||||
| : 'Loading...'} | |||||
| </MainArea> | |||||
| <Helmet> | |||||
| {(post?.thumbnail || post?.thumbnailBase) && | |||||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase} />} | |||||
| {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | |||||
| </Helmet> | |||||
| <TagDetailSidebar post={post} /> | |||||
| <MainArea> | |||||
| {post | |||||
| ? ( | |||||
| <> | |||||
| {nicoFlg | |||||
| ? ( | |||||
| <NicoViewer id={videoId} | |||||
| width={640} | |||||
| height={360} />) | |||||
| : <img src={post.thumbnail} alt={post.url} className="mb-4 w-full" />} | |||||
| <Button onClick={changeViewedFlg} | |||||
| className={cn ('text-white', viewedClass)}> | |||||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||||
| </Button> | |||||
| <TabGroup> | |||||
| {(['admin', 'member'].some (r => r === user?.role) && editing) && ( | |||||
| <Tab name="編輯"> | |||||
| <PostEditForm post={post} | |||||
| onSave={newPost => { | |||||
| setPost (newPost) | |||||
| toast ({ description: '更新しました.' }) | |||||
| }} /> | |||||
| </Tab>)} | |||||
| </TabGroup> | |||||
| </>) | |||||
| : 'Loading...'} | |||||
| </MainArea> | |||||
| </>) | </>) | ||||
| } | } | ||||
| @@ -1,6 +1,6 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import React, { useEffect, useRef, useState } from 'react' | |||||
| import { useEffect, useRef, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link, useLocation } from 'react-router-dom' | import { Link, useLocation } from 'react-router-dom' | ||||
| @@ -10,7 +10,7 @@ import TabGroup, { Tab } from '@/components/common/TabGroup' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| import type { Post, Tag, WikiPage } from '@/types' | |||||
| import type { Post, WikiPage } from '@/types' | |||||
| export default () => { | export default () => { | ||||
| @@ -21,13 +21,14 @@ export default () => { | |||||
| const loaderRef = useRef<HTMLDivElement | null> (null) | const loaderRef = useRef<HTMLDivElement | null> (null) | ||||
| const loadMore = async withCursor => { | |||||
| const loadMore = async (withCursor: boolean) => { | |||||
| setLoading (true) | setLoading (true) | ||||
| const res = await axios.get (`${ API_BASE_URL }/posts`, { | const res = await axios.get (`${ API_BASE_URL }/posts`, { | ||||
| params: { tags: tags.join (' '), | params: { tags: tags.join (' '), | ||||
| match: (anyFlg ? 'any' : 'all'), | match: (anyFlg ? 'any' : 'all'), | ||||
| ...(withCursor ? { cursor } : { }) } }) | ...(withCursor ? { cursor } : { }) } }) | ||||
| const data = toCamel (res.data, { deep: true }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as { posts: Post[] | |||||
| nextCursor: string } | |||||
| setPosts (posts => [...(withCursor ? posts : []), ...data.posts]) | setPosts (posts => [...(withCursor ? posts : []), ...data.posts]) | ||||
| setCursor (data.nextCursor) | setCursor (data.nextCursor) | ||||
| setLoading (false) | setLoading (false) | ||||
| @@ -48,7 +49,9 @@ export default () => { | |||||
| const target = loaderRef.current | const target = loaderRef.current | ||||
| target && observer.observe (target) | target && observer.observe (target) | ||||
| return () => target && observer.unobserve (target) | |||||
| return () => { | |||||
| target && observer.unobserve (target) | |||||
| } | |||||
| }, [loaderRef, loading]) | }, [loaderRef, loading]) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -62,8 +65,8 @@ export default () => { | |||||
| try | try | ||||
| { | { | ||||
| const tagName = tags[0] | const tagName = tags[0] | ||||
| const { data } = await axios.get (`${ API_BASE_URL }/wiki/title/${ tagName }`) | |||||
| setWikiPage (toCamel (data, { deep: true })) | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/title/${ tagName }`) | |||||
| setWikiPage (toCamel (res.data as any, { deep: true }) as WikiPage) | |||||
| } | } | ||||
| catch | catch | ||||
| { | { | ||||
| @@ -84,7 +87,7 @@ export default () => { | |||||
| </Helmet> | </Helmet> | ||||
| <TagSidebar posts={posts.slice (0, 20)} /> | <TagSidebar posts={posts.slice (0, 20)} /> | ||||
| <MainArea> | <MainArea> | ||||
| <TabGroup key={wikiPage}> | |||||
| <TabGroup> | |||||
| <Tab name="広場"> | <Tab name="広場"> | ||||
| {posts.length | {posts.length | ||||
| ? ( | ? ( | ||||
| @@ -93,9 +96,9 @@ export default () => { | |||||
| <Link to={`/posts/${ post.id }`} | <Link to={`/posts/${ post.id }`} | ||||
| key={post.id} | key={post.id} | ||||
| className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"> | className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"> | ||||
| <img src={post.thumbnail || post.thumbnailBase || null} | |||||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||||
| alt={post.title || post.url} | alt={post.title || post.url} | ||||
| title={post.title || post.url || null} | |||||
| title={post.title || post.url || undefined} | |||||
| className="object-none w-full h-full" /> | className="object-none w-full h-full" /> | ||||
| </Link>))} | </Link>))} | ||||
| </div>) | </div>) | ||||
| @@ -1,9 +1,8 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import React, { useEffect, useState, useRef } from 'react' | |||||
| import { useEffect, useState, useRef } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link, useLocation, useParams, useNavigate } from 'react-router-dom' | |||||
| import { useNavigate } from 'react-router-dom' | |||||
| import NicoViewer from '@/components/NicoViewer' | |||||
| import Form from '@/components/common/Form' | import Form from '@/components/common/Form' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| @@ -12,16 +11,8 @@ 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 { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| import { cn } from '@/lib/utils' | |||||
| import type { Post, Tag } from '@/types' | |||||
| type Props = { posts: Post[] | |||||
| setPosts: (posts: Post[]) => void } | |||||
| export default () => { | export default () => { | ||||
| const location = useLocation () | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| @@ -41,7 +32,8 @@ export default () => { | |||||
| formData.append ('title', title) | formData.append ('title', title) | ||||
| formData.append ('url', url) | formData.append ('url', url) | ||||
| formData.append ('tags', tags) | formData.append ('tags', tags) | ||||
| formData.append ('thumbnail', thumbnailFile) | |||||
| if (thumbnailFile) | |||||
| formData.append ('thumbnail', thumbnailFile) | |||||
| void (axios.post (`${ API_BASE_URL }/posts`, formData, { headers: { | void (axios.post (`${ API_BASE_URL }/posts`, formData, { headers: { | ||||
| 'Content-Type': 'multipart/form-data', | 'Content-Type': 'multipart/form-data', | ||||
| @@ -78,9 +70,10 @@ export default () => { | |||||
| const fetchTitle = async () => { | const fetchTitle = async () => { | ||||
| setTitle ('') | setTitle ('') | ||||
| setTitleLoading (true) | setTitleLoading (true) | ||||
| const { data } = await axios.get (`${ API_BASE_URL }/preview/title`, { | |||||
| const res = await axios.get (`${ API_BASE_URL }/preview/title`, { | |||||
| params: { url }, | params: { url }, | ||||
| headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | ||||
| const data = res.data as { title: string } | |||||
| setTitle (data.title || '') | setTitle (data.title || '') | ||||
| setTitleLoading (false) | setTitleLoading (false) | ||||
| } | } | ||||
| @@ -91,10 +84,11 @@ export default () => { | |||||
| setThumbnailLoading (true) | setThumbnailLoading (true) | ||||
| if (thumbnailPreview) | if (thumbnailPreview) | ||||
| URL.revokeObjectURL (thumbnailPreview) | URL.revokeObjectURL (thumbnailPreview) | ||||
| const { data } = await axios.get (`${ API_BASE_URL }/preview/thumbnail`, { | |||||
| const res = await axios.get (`${ API_BASE_URL }/preview/thumbnail`, { | |||||
| params: { url }, | params: { url }, | ||||
| headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' }, | headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' }, | ||||
| responseType: 'blob' }) | responseType: 'blob' }) | ||||
| const data = res.data as Blob | |||||
| const imageURL = URL.createObjectURL (data) | const imageURL = URL.createObjectURL (data) | ||||
| setThumbnailPreview (imageURL) | setThumbnailPreview (imageURL) | ||||
| setThumbnailFile (new File ([data], | setThumbnailFile (new File ([data], | ||||
| @@ -2,7 +2,6 @@ import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link } from 'react-router-dom' | |||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| @@ -20,7 +19,7 @@ export default () => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| void (async () => { | void (async () => { | ||||
| const res = await axios.get (`${ API_BASE_URL }/tags/nico`) | const res = await axios.get (`${ API_BASE_URL }/tags/nico`) | ||||
| const data = toCamel (res.data, { deep: true }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as { tags: NicoTag[] } | |||||
| setNicoTags (data.tags) | setNicoTags (data.tags) | ||||
| @@ -67,7 +66,7 @@ export default () => { | |||||
| : rawTags[tag.id]} | : rawTags[tag.id]} | ||||
| </td> | </td> | ||||
| <td> | <td> | ||||
| <a href="#" onClick={ev => { | |||||
| <a href="#" onClick={() => { | |||||
| setEditing (editing => ({ ...editing, [tag.id]: !(editing[tag.id]) })) | setEditing (editing => ({ ...editing, [tag.id]: !(editing[tag.id]) })) | ||||
| }}> | }}> | ||||
| {editing[tag.id] ? <span className="text-red-400">更新</span> : '編輯'} | {editing[tag.id] ? <span className="text-red-400">更新</span> : '編輯'} | ||||
| @@ -1,5 +1,4 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -16,7 +15,7 @@ import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import type { User } from '@/types' | import type { User } from '@/types' | ||||
| type Props = { user: User | null | type Props = { user: User | null | ||||
| setUser: (user: User) => void } | |||||
| setUser: React.Dispatch<React.SetStateAction<User | null>> } | |||||
| export default ({ user, setUser }: Props) => { | export default ({ user, setUser }: Props) => { | ||||
| @@ -33,9 +32,10 @@ export default ({ user, setUser }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| const { data } = await axios.put (`${ API_BASE_URL }/users/${ user.id }`, formData, { | |||||
| const res = await axios.put (`${ API_BASE_URL }/users/${ user.id }`, formData, { | |||||
| headers: { 'Content-Type': 'multipart/form-data', | headers: { 'Content-Type': 'multipart/form-data', | ||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | ||||
| const data = res.data as User | |||||
| setUser (user => ({ ...user, ...data })) | setUser (user => ({ ...user, ...data })) | ||||
| toast ({ title: '設定を更新しました.' }) | toast ({ title: '設定を更新しました.' }) | ||||
| } | } | ||||
| @@ -49,7 +49,7 @@ export default ({ user, setUser }: Props) => { | |||||
| if (!user) | if (!user) | ||||
| return | return | ||||
| setName (user.name) | |||||
| setName (user.name ?? '') | |||||
| }, [user]) | }, [user]) | ||||
| return ( | return ( | ||||
| @@ -108,7 +108,6 @@ export default ({ user, setUser }: Props) => { | |||||
| <InheritDialogue visible={inheritVsbl} | <InheritDialogue visible={inheritVsbl} | ||||
| onVisibleChange={setInheritVsbl} | onVisibleChange={setInheritVsbl} | ||||
| user={user} | |||||
| setUser={setUser} /> | setUser={setUser} /> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| @@ -14,7 +14,8 @@ import type { WikiPage } from '@/types' | |||||
| export default () => { | export default () => { | ||||
| const { title } = useParams () | |||||
| const params = useParams () | |||||
| const title = params.title ?? '' | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| @@ -27,45 +28,56 @@ export default () => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| if (/^\d+$/.test (title)) | if (/^\d+$/.test (title)) | ||||
| { | { | ||||
| void (axios.get (`${ API_BASE_URL }/wiki/${ title }`) | |||||
| .then (res => navigate (`/wiki/${ res.data.title }`, { replace: true }))) | |||||
| return | |||||
| void (async () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) | |||||
| const data = res.data as WikiPage | |||||
| navigate (`/wiki/${ data.title }`, { replace: true }) | |||||
| }) () | |||||
| return | |||||
| } | } | ||||
| void (axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, version && { params: { version } }) | |||||
| .then (res => { | |||||
| setWikiPage (toCamel (res.data, { deep: true })) | |||||
| WikiIdBus.set (res.data.id) | |||||
| }) | |||||
| .catch (() => setWikiPage (null))) | |||||
| void (async () => { | |||||
| try | |||||
| { | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, | |||||
| { params: { ...(version ? { version } : { }) } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as WikiPage | |||||
| setWikiPage (data) | |||||
| WikiIdBus.set (data.id) | |||||
| } | |||||
| catch | |||||
| { | |||||
| setWikiPage (null) | |||||
| } | |||||
| }) () | |||||
| return () => WikiIdBus.set (null) | return () => WikiIdBus.set (null) | ||||
| }, [title, location.search]) | }, [title, location.search]) | ||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| <Helmet> | |||||
| <title>{`${ title } Wiki | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| {(wikiPage && version) && ( | |||||
| <div className="text-sm flex gap-3 items-center justify-center border border-gray-700 rounded px-2 py-1 mb-4"> | |||||
| {wikiPage.pred ? ( | |||||
| <Link to={`/wiki/${ title }?version=${ wikiPage.pred }`}> | |||||
| < 古 | |||||
| </Link>) : <>(最古)</>} | |||||
| <Helmet> | |||||
| <title>{`${ title } Wiki | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| {(wikiPage && version) && ( | |||||
| <div className="text-sm flex gap-3 items-center justify-center border border-gray-700 rounded px-2 py-1 mb-4"> | |||||
| {wikiPage.pred ? ( | |||||
| <Link to={`/wiki/${ title }?version=${ wikiPage.pred }`}> | |||||
| < 古 | |||||
| </Link>) : <>(最古)</>} | |||||
| <span>{wikiPage.updatedAt}</span> | |||||
| <span>{wikiPage.updatedAt}</span> | |||||
| {wikiPage.succ ? ( | |||||
| <Link to={`/wiki/${ title }?version=${ wikiPage.succ }`}> | |||||
| 新 > | |||||
| </Link>) : <>(最新)</>} | |||||
| </div>)} | |||||
| <PageTitle>{title}</PageTitle> | |||||
| <div className="prose mx-auto p-4"> | |||||
| {wikiPage === undefined | |||||
| ? 'Loading...' | |||||
| : <WikiBody body={wikiPage?.body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ title })。`} />} | |||||
| </div> | |||||
| {wikiPage.succ ? ( | |||||
| <Link to={`/wiki/${ title }?version=${ wikiPage.succ }`}> | |||||
| 新 > | |||||
| </Link>) : <>(最新)</>} | |||||
| </div>)} | |||||
| <PageTitle>{title}</PageTitle> | |||||
| <div className="prose mx-auto p-4"> | |||||
| {wikiPage === undefined | |||||
| ? 'Loading...' | |||||
| : <WikiBody body={wikiPage?.body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ title })。`} />} | |||||
| </div> | |||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| @@ -2,7 +2,7 @@ import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link, useLocation, useParams } from 'react-router-dom' | |||||
| import { useLocation, useParams } from 'react-router-dom' | |||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| @@ -23,8 +23,10 @@ export default () => { | |||||
| const to = query.get ('to') | const to = query.get ('to') | ||||
| useEffect (() => { | useEffect (() => { | ||||
| void (axios.get (`${ API_BASE_URL }/wiki/${ id }/diff`, { params: { from, to } }) | |||||
| .then (res => setDiff (toCamel (res.data, { deep: true })))) | |||||
| void (async () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }/diff`, { params: { from, to } }) | |||||
| setDiff (toCamel (res.data as any, { deep: true }) as WikiPageDiff) | |||||
| }) () | |||||
| }, []) | }, []) | ||||
| return ( | return ( | ||||
| @@ -1,20 +1,17 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import MarkdownIt from 'markdown-it' | import MarkdownIt from 'markdown-it' | ||||
| import React, { useEffect, useState, useRef } from 'react' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import MdEditor from 'react-markdown-editor-lite' | import MdEditor from 'react-markdown-editor-lite' | ||||
| import { Link, useLocation, useParams, useNavigate } from 'react-router-dom' | |||||
| import { useParams, useNavigate } from 'react-router-dom' | |||||
| import NicoViewer from '@/components/NicoViewer' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { Button } from '@/components/ui/button' | |||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| import { cn } from '@/lib/utils' | |||||
| import 'react-markdown-editor-lite/lib/index.css' | import 'react-markdown-editor-lite/lib/index.css' | ||||
| import type { Tag } from '@/types' | |||||
| import type { WikiPage } from '@/types' | |||||
| const mdParser = new MarkdownIt | const mdParser = new MarkdownIt | ||||
| @@ -22,68 +19,71 @@ const mdParser = new MarkdownIt | |||||
| export default () => { | export default () => { | ||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const location = useLocation () | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| const [body, setBody] = useState ('') | const [body, setBody] = useState ('') | ||||
| const handleSubmit = () => { | |||||
| const handleSubmit = async () => { | |||||
| const formData = new FormData () | const formData = new FormData () | ||||
| formData.append ('title', title) | formData.append ('title', title) | ||||
| formData.append ('body', body) | formData.append ('body', body) | ||||
| void (axios.put (`${ API_BASE_URL }/wiki/${ id }`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| .then (res => { | |||||
| toast ({ title: '投稿成功!' }) | |||||
| navigate (`/wiki/${ title }`) | |||||
| }) | |||||
| .catch (e => toast ({ title: '投稿失敗', | |||||
| description: '入力を確認してください。' }))) | |||||
| try | |||||
| { | |||||
| await axios.put (`${ API_BASE_URL }/wiki/${ id }`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| toast ({ title: '投稿成功!' }) | |||||
| navigate (`/wiki/${ title }`) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ title: '投稿失敗', description: '入力を確認してください。' }) | |||||
| } | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| void (axios.get (`${ API_BASE_URL }/wiki/${ id }`) | |||||
| .then (res => { | |||||
| setTitle (res.data.title) | |||||
| setBody (res.data.body) | |||||
| })) | |||||
| void (async () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) | |||||
| const data = res.data as WikiPage | |||||
| setTitle (data.title) | |||||
| setBody (data.body) | |||||
| }) () | |||||
| }, [id]) | }, [id]) | ||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| <Helmet> | |||||
| <title>{`Wiki ページを編輯 | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||||
| <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | |||||
| <Helmet> | |||||
| <title>{`Wiki ページを編輯 | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||||
| <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | |||||
| {/* タイトル */} | |||||
| {/* 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> | |||||
| {/* タイトル */} | |||||
| {/* 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> | |||||
| {/* 本文 */} | |||||
| <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> | |||||
| {/* 本文 */} | |||||
| <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> | |||||
| {/* 送信 */} | |||||
| <button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| 追加 | |||||
| </button> | |||||
| </div> | |||||
| {/* 送信 */} | |||||
| <button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| 追加 | |||||
| </button> | |||||
| </div> | |||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| @@ -2,7 +2,7 @@ import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | import toCamel from 'camelcase-keys' | ||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link, useLocation, useParams } from 'react-router-dom' | |||||
| import { Link, useLocation } from 'react-router-dom' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| @@ -18,8 +18,11 @@ export default () => { | |||||
| const id = query.get ('id') | const id = query.get ('id') | ||||
| useEffect (() => { | useEffect (() => { | ||||
| void (axios.get (`${ API_BASE_URL }/wiki/changes`, id && { params: { id } }) | |||||
| .then (res => setChanges (toCamel (res.data, { deep: true })))) | |||||
| void (async () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/changes`, | |||||
| { params: { ...(id ? { id } : { }) } }) | |||||
| setChanges (toCamel (res.data as any, { deep: true }) as WikiPageChange[]) | |||||
| }) () | |||||
| }, [location.search]) | }, [location.search]) | ||||
| return ( | return ( | ||||
| @@ -1,20 +1,17 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import MarkdownIt from 'markdown-it' | import MarkdownIt from 'markdown-it' | ||||
| import React, { useEffect, useState, useRef } from 'react' | |||||
| import { useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import MdEditor from 'react-markdown-editor-lite' | import MdEditor from 'react-markdown-editor-lite' | ||||
| import { Link, useLocation, useParams, useNavigate } from 'react-router-dom' | |||||
| import { useLocation, useNavigate } from 'react-router-dom' | |||||
| import NicoViewer from '@/components/NicoViewer' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { Button } from '@/components/ui/button' | |||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| import { cn } from '@/lib/utils' | |||||
| import 'react-markdown-editor-lite/lib/index.css' | import 'react-markdown-editor-lite/lib/index.css' | ||||
| import type { Tag } from '@/types' | |||||
| import type { WikiPage } from '@/types' | |||||
| const mdParser = new MarkdownIt | const mdParser = new MarkdownIt | ||||
| @@ -29,54 +26,58 @@ export default () => { | |||||
| const [title, setTitle] = useState (titleQuery) | const [title, setTitle] = useState (titleQuery) | ||||
| const [body, setBody] = useState ('') | const [body, setBody] = useState ('') | ||||
| const handleSubmit = () => { | |||||
| const formData = new FormData () | |||||
| const handleSubmit = async () => { | |||||
| const formData = new FormData | |||||
| formData.append ('title', title) | formData.append ('title', title) | ||||
| formData.append ('body', body) | formData.append ('body', body) | ||||
| void (axios.post (`${ API_BASE_URL }/wiki`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| .then (res => { | |||||
| toast ({ title: '投稿成功!' }) | |||||
| navigate (`/wiki/${ res.data.title }`) | |||||
| }) | |||||
| .catch (e => toast ({ title: '投稿失敗', | |||||
| description: '入力を確認してください。' }))) | |||||
| try | |||||
| { | |||||
| const res = await axios.post (`${ API_BASE_URL }/wiki`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| const data = res.data as WikiPage | |||||
| toast ({ title: '投稿成功!' }) | |||||
| navigate (`/wiki/${ data.title }`) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ title: '投稿失敗', description: '入力を確認してください。' }) | |||||
| } | |||||
| } | } | ||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| <Helmet> | |||||
| <title>{`新規 Wiki ページ | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||||
| <h1 className="text-2xl font-bold mb-2">新規 Wiki ページ</h1> | |||||
| <Helmet> | |||||
| <title>{`新規 Wiki ページ | ${ SITE_TITLE }`}</title> | |||||
| </Helmet> | |||||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||||
| <h1 className="text-2xl font-bold mb-2">新規 Wiki ページ</h1> | |||||
| {/* タイトル */} | |||||
| {/* 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> | |||||
| {/* タイトル */} | |||||
| {/* 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> | |||||
| {/* 本文 */} | |||||
| <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> | |||||
| {/* 本文 */} | |||||
| <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> | |||||
| {/* 送信 */} | |||||
| <button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| 追加 | |||||
| </button> | |||||
| </div> | |||||
| {/* 送信 */} | |||||
| <button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| 追加 | |||||
| </button> | |||||
| </div> | |||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| @@ -8,22 +8,21 @@ import SectionTitle from '@/components/common/SectionTitle' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | import { API_BASE_URL, SITE_TITLE } from '@/config' | ||||
| import type { Category, WikiPage } from '@/types' | |||||
| import type { WikiPage } from '@/types' | |||||
| export default () => { | export default () => { | ||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| const [text, setText] = useState ('') | const [text, setText] = useState ('') | ||||
| const [category, setCategory] = useState<Category | null> (null) | |||||
| const [results, setResults] = useState<WikiPage[]> ([]) | const [results, setResults] = useState<WikiPage[]> ([]) | ||||
| const search = () => { | |||||
| void (axios.get (`${ API_BASE_URL }/wiki/search`, { params: { title } }) | |||||
| .then (res => setResults (toCamel (res.data, { deep: true })))) | |||||
| const search = async () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/search`, { params: { title } }) | |||||
| setResults (toCamel (res.data as any, { deep: true }) as WikiPage[]) | |||||
| } | } | ||||
| const handleSearch = (e: React.FormEvent) => { | |||||
| e.preventDefault () | |||||
| const handleSearch = (ev: React.FormEvent) => { | |||||
| ev.preventDefault () | |||||
| search () | search () | ||||
| } | } | ||||
| @@ -1,5 +1,5 @@ | |||||
| import { Route, | |||||
| createBrowserRouter, | |||||
| createRoutesFromElements } from 'react-router-dom' | |||||
| import App from '@/App' | |||||
| // import { Route, | |||||
| // createBrowserRouter, | |||||
| // createRoutesFromElements } from 'react-router-dom' | |||||
| // | |||||
| // import App from '@/App' | |||||
| @@ -1,4 +1,4 @@ | |||||
| import { CATEGORIES, USER_ROLES } from '@/consts' | |||||
| import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | |||||
| export type Category = typeof CATEGORIES[number] | export type Category = typeof CATEGORIES[number] | ||||
| @@ -28,9 +28,12 @@ export type User = { | |||||
| inheritanceCode: string | inheritanceCode: string | ||||
| role: UserRole } | role: UserRole } | ||||
| export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior] | |||||
| export type WikiPage = { | export type WikiPage = { | ||||
| id: number | id: number | ||||
| title: string | title: string | ||||
| body: string | |||||
| sha: string | sha: string | ||||
| pred?: string | pred?: string | ||||
| succ?: string | succ?: string | ||||
| @@ -1,9 +1,9 @@ | |||||
| { | { | ||||
| "compilerOptions": { | "compilerOptions": { | ||||
| "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | ||||
| "target": "ES2020", | |||||
| "target": "ES2022", | |||||
| "useDefineForClassFields": true, | "useDefineForClassFields": true, | ||||
| "lib": ["ES2020", "DOM", "DOM.Iterable"], | |||||
| "lib": ["ES2022", "DOM", "DOM.Iterable"], | |||||
| "module": "ESNext", | "module": "ESNext", | ||||
| "skipLibCheck": true, | "skipLibCheck": true, | ||||