@@ -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, | ||||