素材管理(#99) (#303)

#99

#99

#99

#99

#99

#99

#99

#99

#99

#99

Co-authored-by: miteruzo <miteruzo@naver.com>
Reviewed-on: #303
This commit was merged in pull request #303.
This commit is contained in:
2026-04-07 07:44:50 +09:00
parent 2adff3966a
commit 7b15cb2c5a
33 changed files with 1742 additions and 192 deletions
+13 -2
View File
@@ -10,6 +10,11 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage'
@@ -41,7 +46,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
return (
<LayoutGroup id="gallery-shared">
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
@@ -51,6 +56,12 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
@@ -120,7 +131,7 @@ export default (() => {
<>
<RouteBlockerOverlay/>
<BrowserRouter>
<div className="flex flex-col h-screen w-screen">
<div className="flex flex-col h-dvh w-screen">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</div>
@@ -0,0 +1,97 @@
import { Fragment, useEffect, useState } from 'react'
import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api'
import type { FC, ReactNode } from 'react'
import type { Tag } from '@/types'
type TagWithDepth = Tag & {
hasChildren: boolean
children: TagWithDepth[] }
const setChildrenById = (
tags: TagWithDepth[],
targetId: number,
children: TagWithDepth[],
): TagWithDepth[] => (
tags.map (tag => {
if (tag.id === targetId)
return { ...tag, children }
if (tag.children.length === 0)
return tag
return { ...tag,
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
}))
export default (() => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
ts.map (t => (
<Fragment key={t.id}>
<li>
<div className="flex">
<div className="flex-none w-4">
{t.hasChildren && (
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(tagFetchedFlags[t.id]))
{
try
{
const data =
await apiGet<TagWithDepth[]> (
'/tags/with-depth', { params: { parent: String (t.id) } })
setTags (prev => setChildrenById (prev, t.id, data))
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
}
catch
{
;
}
}
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
}}>
{openTags[t.id] ? <>&minus;</> : '+'}
</a>)}
</div>
<div className="flex-1 truncate">
<TagLink
tag={t}
nestLevel={nestLevel}
title={t.name}
withCount={false}
withWiki={false}
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
</div>
</div>
</li>
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
</Fragment>)))
return (
<SidebarComponent>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
}) satisfies FC
+38 -59
View File
@@ -1,8 +1,5 @@
import { useEffect, useState } from 'react'
import PrefetchLink from '@/components/PrefetchLink'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react'
@@ -13,8 +10,7 @@ type CommonProps = {
tag: Tag
nestLevel?: number
withWiki?: boolean
withCount?: boolean
prefetch?: boolean }
withCount?: boolean }
type PropsWithLink =
& CommonProps
@@ -36,37 +32,7 @@ export default (({ tag,
linkFlg = true,
withWiki = true,
withCount = true,
prefetch = false,
...props }: Props) => {
const [havingWiki, setHavingWiki] = useState (true)
const wikiExists = async (tag: Tag) => {
if ('hasWiki' in tag)
{
setHavingWiki (tag.hasWiki)
return
}
const tagName = (tag as Tag).name
try
{
await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`)
setHavingWiki (true)
}
catch
{
setHavingWiki (false)
}
}
useEffect (() => {
if (!(linkFlg) || !(withWiki))
return
wikiExists (tag)
}, [tag.name, linkFlg, withWiki])
const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -79,19 +45,39 @@ export default (({ tag,
<>
{(linkFlg && withWiki) && (
<span className="mr-1">
{havingWiki
{(tag.materialId != null || tag.hasWiki)
? (
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
?
</PrefetchLink>)
tag.materialId == null
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>))
: (
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>)}
['character', 'material'].includes (tag.category)
? (
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } 素材情報が存在しません.`}>
!
</PrefetchLink>)
: (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
!
</PrefetchLink>))}
</span>)}
{nestLevel > 0 && (
<span
@@ -108,19 +94,12 @@ export default (({ tag,
</>)}
{linkFlg
? (
prefetch
? <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>
: <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
<PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
: (
<span className={spanClass}
{...props}>
+1 -1
View File
@@ -66,7 +66,7 @@ export default (({ posts, onClick }: Props) => {
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<motion.div layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} prefetch onClick={onClick}/>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>
</li>))) : [])}
</ul>
+10 -3
View File
@@ -70,20 +70,27 @@ export default (({ user }: Props) => {
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '投稿追加', to: '/posts/new' },
{ name: '追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: true },
{ name: 'マスタ', to: '/tags' },
{ name: '別名タグ', to: '/tags/aliases', visible: false },
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
{ name: '素材', to: '/materials', subMenu: [
{ name: '一覧', to: '/materials' },
// { name: '検索', to: '/materials/search' },
{ name: '追加', to: '/materials/new' },
// { name: '履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>&thinsp;1&thinsp;</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }] },
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' },
@@ -0,0 +1,97 @@
import { useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
import type { Tag } from '@/types'
type Props = {
value: string
setValue: (value: string) => void }
export default (({ value, setValue }: Props) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}
return (
<div className="relative">
<input
type="text"
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}) satisfies FC<Props>
+3 -1
View File
@@ -8,6 +8,8 @@ type Props = {
export default (({ children, className }: Props) => (
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
<main className={cn ('flex-1 overflow-y-auto p-4',
'md:h-[calc(100dvh-88px)] md:overflow-y-auto',
className)}>
{children}
</main>)) satisfies FC<Props>
@@ -1,9 +1,29 @@
import React from 'react'
import { Helmet } from 'react-helmet-async'
type Props = { children: React.ReactNode }
import type { FC, ReactNode } from 'react'
type Props = { children: ReactNode }
export default ({ children }: Props) => (
<div className="p-4 w-full md:w-64 md:h-full">
export default (({ children }: Props) => (
<div
className="p-4 w-full md:w-64 md:h-full
md:h-[calc(100dvh-88px)] md:overflow-y-auto
sidebar">
<Helmet>
<style>
{`
.sidebar
{
direction: rtl;
}
.sidebar > *
{
direction: ltr;
}`}
</style>
</Helmet>
{children}
</div>)
</div>)) satisfies FC<Props>
@@ -0,0 +1,12 @@
import { Outlet } from 'react-router-dom'
import MaterialSidebar from '@/components/MaterialSidebar'
import type { FC } from 'react'
export default (() => (
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
<MaterialSidebar/>
<Outlet/>
</div>)) satisfies FC
@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api'
import type { FC } from 'react'
import type { Material, Tag } from '@/types'
type MaterialWithTag = Material & { tag: Tag }
export default (() => {
const { id } = useParams ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [loading, setLoading] = useState (false)
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')
const handleSubmit = async () => {
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)
try
{
setSending (true)
const data = await apiPut<Material> (`/materials/${ id }`, formData)
setMaterial (data)
toast ({ title: '更新成功!' })
}
catch
{
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}
useEffect (() => {
if (!(id))
return
void (async () => {
try
{
setLoading (true)
const data = await apiGet<MaterialWithTag> (`/materials/${ id }`)
setMaterial (data)
setTag (data.tag.name)
if (data.file && data.contentType)
{
setFilePreview (data.file)
setFile (new File ([await (await fetch (data.file)).blob ()],
data.file,
{ type: data.contentType }))
}
setURL (data.url ?? '')
}
finally
{
setLoading (false)
}
}) ()
}, [id])
return (
<MainArea>
{material && (
<Helmet>
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)}
{loading ? 'Loading...' : (material && (
<>
<PageTitle>
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>
</PageTitle>
{(material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && (
<video src={material.file} controls/>))
|| (/audio\/.*/.test (material.contentType) && (
<audio src={material.file} controls/>)))}
<TabGroup>
<Tab name="Wiki">
<WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>
</Tab>
<Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4">
{/* タグ */}
<div>
<Label></Label>
<TagInput value={tag} setValue={setTag}/>
</div>
{/* ファイル */}
<div>
<Label></Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</div>
{/* 参考 URL */}
<div>
<Label> URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
</div>
</Tab>
</TabGroup>
</>))}
</MainArea>)
}) satisfies FC
@@ -0,0 +1,166 @@
import { Fragment, useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import type { FC } from 'react'
import type { Material, Tag } from '@/types'
type TagWithMaterial = Omit<Tag, 'children'> & {
children: TagWithMaterial[]
material: Material | null }
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
if (!(tag.material))
return
return (
<PrefetchLink
to={`/materials/${ tag.material.id }`}
className="block w-40 h-40">
<div
className="w-full h-full overflow-hidden rounded-xl shadow
text-center content-center text-4xl"
style={{ fontFamily: 'Nikumaru' }}>
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/>
: <span></span>}
</div>
</PrefetchLink>)
}
export default (() => {
const [loading, setLoading] = useState (false)
const [tag, setTag] = useState<TagWithMaterial | null> (null)
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
useEffect (() => {
if (!(tagQuery))
{
setTag (null)
return
}
void (async () => {
try
{
setLoading (true)
setTag (
await apiGet<TagWithMaterial> (
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
}
finally
{
setLoading (false)
}
}) ()
}, [location.search])
return (
<MainArea>
<Helmet>
<style>
{`
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
</Helmet>
{loading ? 'Loading...' : (
tag
? (
<>
<PageTitle>
<TagLink
tag={tag}
withWiki={false}
withCount={false}
to={tag.material
? `/materials/${ tag.material.id }`
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
</PageTitle>
{(!(tag.material) && tag.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={tag}/>
<div className="ml-2">
{tag.children.map (c2 => (
<Fragment key={c2.id}>
<SectionTitle>
<TagLink
tag={c2}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
</SectionTitle>
{(!(c2.material) && c2.category !== 'meme') && (
<div className="-mt-4">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={c2}/>
<div className="ml-2">
{c2.children.map (c3 => (
<Fragment key={c3.id}>
<SubsectionTitle>
<TagLink
tag={c3}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
</SubsectionTitle>
{(!(c3.material) && c3.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${
encodeURIComponent (c3.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={c3}/>
</Fragment>))}
</div>
</Fragment>))}
</div>
</>)
: (
<>
<p></p>
<p></p>
<ul>
<li><PrefetchLink to="/materials/new"></PrefetchLink></li>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
</ul>
</>))}
</MainArea>)
}) satisfies FC
@@ -0,0 +1,124 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import Form from '@/components/common/Form'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import type { FC } from 'react'
export default (() => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const navigate = useNavigate ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
const handleSubmit = async () => {
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)
try
{
setSending (true)
await apiPost ('/materials', formData)
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
}
catch
{
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}
return (
<MainArea>
<Helmet>
<title>{`素材追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<Form>
<PageTitle></PageTitle>
{/* タグ */}
<div>
<Label></Label>
<TagInput value={tag} setValue={setTag}/>
</div>
{/* ファイル */}
<div>
<Label></Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</div>
{/* 参考 URL */}
<div>
<Label> URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
</Form>
</MainArea>)
}) satisfies FC
@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import type { FC, FormEvent } from 'react'
export default (() => {
const [tagName, setTagName] = useState ('')
const [parentTagName, setParentTagName] = useState ('')
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
}
return (
<MainArea>
<Helmet>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タグ */}
<div>
<Label></Label>
<TagInput
value={tagName}
setValue={setTagName}/>
</div>
{/* 親タグ */}
<div>
<Label></Label>
<TagInput
value={parentTagName}
setValue={setParentTagName}/>
</div>
</form>
</div>
</MainArea>)
}) satisfies FC
+1 -1
View File
@@ -93,7 +93,7 @@ export default (({ user }: Props) => {
: 'bg-gray-500 hover:bg-gray-600')
return (
<div className="md:flex md:flex-1">
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
<Helmet>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
+1 -1
View File
@@ -69,7 +69,7 @@ export default (() => {
}, [location.search])
return (
<div className="md:flex md:flex-1" ref={containerRef}>
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]" ref={containerRef}>
<Helmet>
<title>
{tags.length
+6 -82
View File
@@ -7,24 +7,22 @@ import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink'
import TagSearchBox from '@/components/TagSearchBox'
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
import type { FC, FormEvent } from 'react'
import type { FetchPostsOrder,
FetchPostsOrderField,
FetchPostsParams,
Tag } from '@/types'
FetchPostsParams } from '@/types'
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
@@ -57,14 +55,11 @@ export default (() => {
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
const [activeIndex, setActiveIndex] = useState (-1)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const [tagsStr, setTagsStr] = useState ('')
const [title, setTitle] = useState ('')
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
@@ -103,58 +98,6 @@ export default (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search])
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setTagsStr (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}
const search = async () => {
const qs = new URLSearchParams ()
setIf (qs, 'tags', tagsStr)
@@ -172,15 +115,6 @@ export default (() => {
navigate (`${ location.pathname }?${ qs.toString () }`)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = tagsStr.split (' ')
parts[parts.length - 1] = tag.name
setTagsStr (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
search ()
@@ -223,21 +157,11 @@ export default (() => {
</div>
{/* タグ */}
<div className="relative">
<div>
<Label></Label>
<input
type="text"
<TagInput
value={tagsStr}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
setValue={setTagsStr}/>
<fieldset className="w-full my-2">
<label></label>
<label className="mx-2">
+13
View File
@@ -49,6 +49,18 @@ export type FetchTagsParams = {
limit: number
order: FetchTagsOrder }
export type Material = {
id: number
tag: Tag
file: string | null
url: string | null
wikiPageBody?: string | null
contentType: string | null
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
export type Menu = MenuItem[]
export type MenuItem = {
@@ -129,6 +141,7 @@ export type Tag = {
createdAt: string
updatedAt: string
hasWiki: boolean
materialId: number
children?: Tag[]
matchedAlias?: string | null }