素材管理(#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
@@ -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>