このコミットが含まれているのは:
@@ -132,6 +132,10 @@ npm run preview
|
|||||||
- Tabs are only for leading indentation, never for spaces after non-space text.
|
- Tabs are only for leading indentation, never for spaces after non-space text.
|
||||||
- TypeScript and TSX imports may stay on one line if they remain within the
|
- TypeScript and TSX imports may stay on one line if they remain within the
|
||||||
line limit; do not expand short type-only imports mechanically.
|
line limit; do not expand short type-only imports mechanically.
|
||||||
|
- In TypeScript and TSX, when breaking a line at an operator, break before the
|
||||||
|
operator and put the operator at the beginning of the next line. A trailing
|
||||||
|
operator at end of line is unacceptable. This rule does not apply to Ruby,
|
||||||
|
where it can change the syntactic structure.
|
||||||
- In TypeScript and TSX, when a function takes one destructured object
|
- In TypeScript and TSX, when a function takes one destructured object
|
||||||
argument plus an inline type, prefer this shape when it fits locally:
|
argument plus an inline type, prefer this shape when it fits locally:
|
||||||
|
|
||||||
@@ -231,6 +235,22 @@ const value =
|
|||||||
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
|
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
|
||||||
under `frontend/src/components` unless existing patterns point elsewhere.
|
under `frontend/src/components` unless existing patterns point elsewhere.
|
||||||
- Match existing Tailwind, component, and import alias conventions.
|
- Match existing Tailwind, component, and import alias conventions.
|
||||||
|
- `<a href="#">` is acceptable for event-only controls when it fits the local
|
||||||
|
UI pattern. Do not use `<a>` for internal navigation or other non-external
|
||||||
|
links.
|
||||||
|
- Internal links must use `PrefetchLink`.
|
||||||
|
- External links must use `<a>` with `target="_blank"`.
|
||||||
|
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
|
||||||
|
explicit readable `text-*` color and dark-mode counterparts such as
|
||||||
|
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
|
||||||
|
- Do not rely on inherited text color for light backgrounds. This is especially
|
||||||
|
important for chips, cards, buttons, and panels that may inherit white text in
|
||||||
|
dark mode.
|
||||||
|
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
|
||||||
|
make dense controls wrap or scroll intentionally, and keep tag/filter
|
||||||
|
controls usable without horizontal page overflow.
|
||||||
|
- For mobile horizontal scrollers, make the scroll direction and item sizing
|
||||||
|
explicit, and ensure chip text remains readable in both light and dark modes.
|
||||||
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
|
- In TypeScript and TSX, prefer direct comparison operators such as `===` and
|
||||||
`!==` over negating a comparison like `!(a === b)`.
|
`!==` over negating a comparison like `!(a === b)`.
|
||||||
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
|
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
tag_id = params[:tag_id].presence
|
tag_id = params[:tag_id].presence
|
||||||
parent_id = params[:parent_id].presence
|
parent_id = params[:parent_id].presence
|
||||||
|
unclassified = bool?(:unclassified)
|
||||||
|
|
||||||
q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file
|
q = Material.includes(:tag, :created_by_user, :material_export_items).with_attached_file
|
||||||
|
if unclassified
|
||||||
|
q = q.where(tag_id: nil)
|
||||||
|
else
|
||||||
q = q.where(tag_id:) if tag_id
|
q = q.where(tag_id:) if tag_id
|
||||||
q = q.where(parent_id:) if parent_id
|
q = q.where(parent_id:) if parent_id
|
||||||
|
end
|
||||||
|
|
||||||
count = q.count
|
count = q.count
|
||||||
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
|
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ class TagsController < ApplicationController
|
|||||||
def with_depth
|
def with_depth
|
||||||
parent_tag_id = params[:parent].to_i
|
parent_tag_id = params[:parent].to_i
|
||||||
parent_tag_id = nil if parent_tag_id <= 0
|
parent_tag_id = nil if parent_tag_id <= 0
|
||||||
|
material_filter = material_filter_param(default: 'any')
|
||||||
|
|
||||||
graph = build_with_depth_graph
|
graph = build_with_depth_graph(material_filter)
|
||||||
|
|
||||||
tag_ids =
|
tag_ids =
|
||||||
if parent_tag_id
|
if parent_tag_id
|
||||||
@@ -92,19 +93,9 @@ class TagsController < ApplicationController
|
|||||||
visible_root_tag_ids(graph)
|
visible_root_tag_ids(graph)
|
||||||
end
|
end
|
||||||
|
|
||||||
tags =
|
render json: tag_ids
|
||||||
Tag
|
.sort_by { |tag_id| graph[:tags_by_id][tag_id][:name] }
|
||||||
.joins(:tag_name)
|
.map { |tag_id| with_depth_lightweight_repr(tag_id, graph) }
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
|
||||||
.where(id: tag_ids)
|
|
||||||
.order('tag_names.name')
|
|
||||||
.distinct
|
|
||||||
.to_a
|
|
||||||
|
|
||||||
render json: tags.map { |tag|
|
|
||||||
TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?,
|
|
||||||
children: [])
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def autocomplete
|
def autocomplete
|
||||||
@@ -229,13 +220,16 @@ class TagsController < ApplicationController
|
|||||||
def materials_by_name
|
def materials_by_name
|
||||||
name = params[:name].to_s.strip
|
name = params[:name].to_s.strip
|
||||||
return render_bad_request('name は必須です.') if name.blank?
|
return render_bad_request('name は必須です.') if name.blank?
|
||||||
|
material_filter = material_filter_param(default: 'any')
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
tag = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||||
.find_by(tag_names: { name: })
|
.find_by(tag_names: { name: })
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
|
|
||||||
render json: build_tag_children(tag)
|
graph = build_with_depth_graph(material_filter)
|
||||||
|
|
||||||
|
render json: build_tag_children(tag, graph:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_all
|
def update_all
|
||||||
@@ -348,7 +342,14 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_with_depth_graph
|
def material_filter_param default:
|
||||||
|
value = params[:material_filter].to_s.presence
|
||||||
|
return default unless ['present', 'missing', 'any'].include?(value)
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_with_depth_graph material_filter
|
||||||
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
|
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
|
||||||
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
|
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
|
||||||
|
|
||||||
@@ -361,20 +362,26 @@ class TagsController < ApplicationController
|
|||||||
parent_ids_by_child_id.keys +
|
parent_ids_by_child_id.keys +
|
||||||
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
|
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
|
||||||
|
|
||||||
tags_by_id = Tag.where(id: tag_ids)
|
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
|
||||||
.pluck(:id, :category, :deprecated_at)
|
|
||||||
.each_with_object({ }) do |(id, category, deprecated_at), h|
|
tags_by_id = Tag.joins(:tag_name)
|
||||||
h[id] = { category:, deprecated: deprecated_at.present? }
|
.where(id: tag_ids)
|
||||||
|
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
|
||||||
|
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
|
||||||
|
h[id] = { name:, category:, deprecated: deprecated_at.present?,
|
||||||
|
has_material: material_tag_ids.include?(id) }
|
||||||
end
|
end
|
||||||
|
|
||||||
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
|
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
|
||||||
visible_child_tag_ids_by_parent_id: { } }
|
visible_child_tag_ids_by_parent_id: { },
|
||||||
|
visible_subtree_by_tag_id: { },
|
||||||
|
material_filter: }
|
||||||
end
|
end
|
||||||
|
|
||||||
def visible_root_tag_ids graph
|
def visible_root_tag_ids graph
|
||||||
graph[:tags_by_id].filter_map do |tag_id, attrs|
|
graph[:tags_by_id].filter_map do |tag_id, _attrs|
|
||||||
next unless with_depth_visible_tag?(attrs)
|
|
||||||
next unless visible_root_tag?(tag_id, graph)
|
next unless visible_root_tag?(tag_id, graph)
|
||||||
|
next unless visible_subtree?(tag_id, graph)
|
||||||
|
|
||||||
tag_id
|
tag_id
|
||||||
end
|
end
|
||||||
@@ -428,18 +435,76 @@ class TagsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
visible_ids << tag_id if with_depth_visible_tag?(tag)
|
if with_depth_visible_tag?(tag, graph[:material_filter])
|
||||||
|
visible_ids << tag_id
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_depth_visible_tag? tag
|
visible_ids << tag_id if graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
|
||||||
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
|
visible_subtree?(child_tag_id, graph)
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_tag_children tag
|
def with_depth_visible_tag? tag, material_filter
|
||||||
|
return false unless tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
|
||||||
|
|
||||||
|
case material_filter
|
||||||
|
when 'present'
|
||||||
|
tag[:has_material]
|
||||||
|
when 'missing'
|
||||||
|
!tag[:has_material]
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_subtree? tag_id, graph
|
||||||
|
cache = graph[:visible_subtree_by_tag_id]
|
||||||
|
return cache[tag_id] if cache.key?(tag_id)
|
||||||
|
|
||||||
|
tag = graph[:tags_by_id][tag_id]
|
||||||
|
return cache[tag_id] = false unless tag
|
||||||
|
|
||||||
|
if tag[:deprecated]
|
||||||
|
return cache[tag_id] =
|
||||||
|
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
|
||||||
|
visible_subtree?(child_tag_id, graph)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
cache[tag_id] =
|
||||||
|
with_depth_visible_tag?(tag, graph[:material_filter]) ||
|
||||||
|
graph[:children_by_parent_id][tag_id].any? { |child_tag_id|
|
||||||
|
visible_subtree?(child_tag_id, graph)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_depth_lightweight_repr tag_id, graph
|
||||||
|
tag = graph[:tags_by_id][tag_id]
|
||||||
|
|
||||||
|
{ id: tag_id,
|
||||||
|
name: tag[:name],
|
||||||
|
category: tag[:category],
|
||||||
|
deprecated: tag[:deprecated],
|
||||||
|
has_material: tag[:has_material],
|
||||||
|
has_children: visible_child_tag_ids(tag_id, graph).present?,
|
||||||
|
children: [] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_tag_children tag, graph: nil
|
||||||
material = tag.materials.first
|
material = tag.materials.first
|
||||||
|
tag_graph = graph && graph[:tags_by_id][tag.id]
|
||||||
|
material =
|
||||||
|
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
|
||||||
|
|
||||||
|
children = tag.children.sort_by(&:name)
|
||||||
|
if graph
|
||||||
|
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
|
||||||
|
end
|
||||||
|
|
||||||
TagRepr.base(tag).merge(
|
TagRepr.base(tag).merge(
|
||||||
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
|
children: children.map { build_tag_children(_1, graph:) },
|
||||||
|
has_material: tag_graph ? tag_graph[:has_material] : material.present?,
|
||||||
material: material && MaterialRepr.base(material, host: request.base_url))
|
material: material && MaterialRepr.base(material, host: request.base_url))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react'
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import { apiGet } from '@/lib/api'
|
import { materialsKeys } from '@/lib/queryKeys'
|
||||||
|
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
||||||
|
|
||||||
type TagWithDepth = Tag & {
|
const FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
|
||||||
hasChildren: boolean
|
|
||||||
children: TagWithDepth[] }
|
const FILTER_LABELS: Record<MaterialFilter, string> = {
|
||||||
|
present: '素材あり',
|
||||||
|
missing: '素材なし',
|
||||||
|
any: 'すべて',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const setChildrenById = (
|
const setChildrenById = (
|
||||||
tags: TagWithDepth[],
|
tags: MaterialSidebarTag[],
|
||||||
targetId: number,
|
targetId: number,
|
||||||
children: TagWithDepth[],
|
children: MaterialSidebarTag[],
|
||||||
): TagWithDepth[] => (
|
): MaterialSidebarTag[] => (
|
||||||
tags.map (tag => {
|
tags.map (tag => {
|
||||||
if (tag.id === targetId)
|
if (tag.id === targetId)
|
||||||
return { ...tag, children }
|
return { ...tag, children }
|
||||||
@@ -25,75 +33,305 @@ const setChildrenById = (
|
|||||||
if (tag.children.length === 0)
|
if (tag.children.length === 0)
|
||||||
return tag
|
return tag
|
||||||
|
|
||||||
return { ...tag,
|
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
|
||||||
children: (setChildrenById (tag.children, targetId, children)
|
|
||||||
.filter (t => t.category !== 'meme' || t.hasChildren)) }
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
const MaterialSidebar: FC = () => {
|
const materialPath = (
|
||||||
const [tags, setTags] = useState<TagWithDepth[]> ([])
|
tagName: string,
|
||||||
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
|
materialFilter: MaterialFilter,
|
||||||
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
|
): string => `/materials?tag=${ encodeURIComponent (tagName) }&material_filter=${ materialFilter }`
|
||||||
|
|
||||||
|
|
||||||
|
const sidebarTagToTag = (tag: MaterialSidebarTag): Tag => ({
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
category: tag.category,
|
||||||
|
deprecatedAt: tag.deprecated ? '' : null,
|
||||||
|
aliases: [],
|
||||||
|
parents: [],
|
||||||
|
postCount: 0,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
hasWiki: false,
|
||||||
|
materialId: null,
|
||||||
|
hasDeerjikists: false,
|
||||||
|
matchedAlias: null })
|
||||||
|
|
||||||
|
|
||||||
|
const updateMaterialFilterQuery = (
|
||||||
|
pathname: string,
|
||||||
|
locationSearch: string,
|
||||||
|
navigate: ReturnType<typeof useNavigate>,
|
||||||
|
materialFilter: MaterialFilter,
|
||||||
|
) => {
|
||||||
|
const qs = new URLSearchParams (locationSearch)
|
||||||
|
qs.set ('material_filter', materialFilter)
|
||||||
|
navigate (`${ pathname }${ qs.toString () ? `?${ qs.toString () }` : '' }`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MaterialFilterButtons: FC<{
|
||||||
|
materialFilter: MaterialFilter
|
||||||
|
onChange: (materialFilter: MaterialFilter) => void
|
||||||
|
}> = ({ materialFilter, onChange }) => (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FILTERS.map (value => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange (value)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-sm ${
|
||||||
|
materialFilter === value
|
||||||
|
? 'border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400 ' +
|
||||||
|
'dark:bg-sky-950 dark:text-sky-100'
|
||||||
|
: 'border-neutral-300 bg-white text-neutral-700 dark:border-stone-700 ' +
|
||||||
|
'dark:bg-stone-900 dark:text-stone-200' }`}>
|
||||||
|
{FILTER_LABELS[value]}
|
||||||
|
</button>))}
|
||||||
|
</div>)
|
||||||
|
|
||||||
|
|
||||||
|
const MaterialTreeNode: FC<{
|
||||||
|
materialFilter: MaterialFilter
|
||||||
|
nestLevel?: number
|
||||||
|
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
|
||||||
|
openTags: Record<number, boolean>
|
||||||
|
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||||||
|
tag: MaterialSidebarTag
|
||||||
|
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, setOpenTags, tag }) => {
|
||||||
|
const open = Boolean (openTags[tag.id])
|
||||||
|
const { data } = useQuery ({
|
||||||
|
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||||
|
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||||
|
enabled: open && tag.hasChildren && tag.children.length === 0,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
void (async () => {
|
if (open && data && tag.children.length === 0)
|
||||||
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
|
onChildren (tag.id, data)
|
||||||
.filter (t => t.category !== 'meme' || t.hasChildren))
|
}, [data, onChildren, open, tag.children.length, tag.id])
|
||||||
}) ()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
|
return (
|
||||||
ts.map (t => (
|
<Fragment>
|
||||||
<Fragment key={t.id}>
|
|
||||||
<li>
|
<li>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-none w-4">
|
<div className="flex-none w-4">
|
||||||
{t.hasChildren && (
|
{tag.hasChildren && (
|
||||||
<a
|
<button
|
||||||
href="#"
|
type="button"
|
||||||
onClick={async e => {
|
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||||||
e.preventDefault ()
|
className="text-neutral-500 dark:text-stone-400">
|
||||||
if (!(tagFetchedFlags[t.id]))
|
{open ? <>−</> : '+'}
|
||||||
{
|
</button>)}
|
||||||
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] ? <>−</> : '+'}
|
|
||||||
</a>)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 truncate">
|
<div className="flex-1 truncate">
|
||||||
<TagLink
|
<TagLink
|
||||||
tag={t}
|
tag={sidebarTagToTag (tag)}
|
||||||
nestLevel={nestLevel}
|
nestLevel={nestLevel}
|
||||||
title={t.name}
|
title={tag.name}
|
||||||
withCount={false}
|
withCount={false}
|
||||||
withWiki={false}
|
withWiki={false}
|
||||||
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
|
to={materialPath (tag.name, materialFilter)}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
|
{open && tag.children.length > 0 && (
|
||||||
</Fragment>)))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarComponent>
|
|
||||||
<ul>
|
<ul>
|
||||||
{renderTags (tags)}
|
{tag.children.map (child => (
|
||||||
</ul>
|
<MaterialTreeNode
|
||||||
</SidebarComponent>)
|
key={child.id}
|
||||||
|
tag={child}
|
||||||
|
nestLevel={nestLevel + 1}
|
||||||
|
materialFilter={materialFilter}
|
||||||
|
openTags={openTags}
|
||||||
|
setOpenTags={setOpenTags}
|
||||||
|
onChildren={onChildren}/>))}
|
||||||
|
</ul>)}
|
||||||
|
</Fragment>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MobileMaterialTreeNode: FC<{
|
||||||
|
depth?: number
|
||||||
|
materialFilter: MaterialFilter
|
||||||
|
onChildren: (tagId: number, children: MaterialSidebarTag[]) => void
|
||||||
|
openTags: Record<number, boolean>
|
||||||
|
setOpenTags: Dispatch<SetStateAction<Record<number, boolean>>>
|
||||||
|
tag: MaterialSidebarTag
|
||||||
|
}> = ({ depth = 0, materialFilter, onChildren, openTags, setOpenTags, tag }) => {
|
||||||
|
const open = Boolean (openTags[tag.id])
|
||||||
|
const { data } = useQuery ({
|
||||||
|
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||||
|
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||||
|
enabled: open && tag.hasChildren && tag.children.length === 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (open && data && tag.children.length === 0)
|
||||||
|
onChildren (tag.id, data)
|
||||||
|
}, [data, onChildren, open, tag.children.length, tag.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row-reverse items-start gap-2">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="rounded-xl border border-stone-300 bg-white px-3 py-2 text-sm
|
||||||
|
text-stone-900 shadow-sm dark:border-stone-700 dark:bg-stone-900
|
||||||
|
dark:text-stone-100"
|
||||||
|
style={{ writingMode: 'vertical-rl' }}>
|
||||||
|
<TagLink
|
||||||
|
tag={sidebarTagToTag (tag)}
|
||||||
|
title={tag.name}
|
||||||
|
withCount={false}
|
||||||
|
withWiki={false}
|
||||||
|
to={materialPath (tag.name, materialFilter)}/>
|
||||||
|
</div>
|
||||||
|
{tag.hasChildren && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenTags (prev => ({ ...prev, [tag.id]: !prev[tag.id] }))}
|
||||||
|
className="rounded-full border border-stone-300 bg-white px-2 py-0.5
|
||||||
|
text-sm text-stone-700 dark:border-stone-700
|
||||||
|
dark:bg-stone-900 dark:text-stone-100">
|
||||||
|
{open ? <>−</> : '+'}
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
{open && tag.children.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="relative flex flex-row-reverse items-start gap-2 rounded-2xl border
|
||||||
|
border-stone-200 bg-stone-100/70 py-2 pl-2 pr-2 text-stone-900
|
||||||
|
dark:border-stone-700 dark:bg-stone-900/70 dark:text-stone-100"
|
||||||
|
style={{ marginTop: `${ depth === 0 ? 1.25 : .75 }rem` }}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -right-3 top-4 h-px w-3 bg-stone-300 dark:bg-stone-600"/>
|
||||||
|
{tag.children.map (child => (
|
||||||
|
<div key={child.id} className="relative">
|
||||||
|
<MobileMaterialTreeNode
|
||||||
|
tag={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
materialFilter={materialFilter}
|
||||||
|
openTags={openTags}
|
||||||
|
setOpenTags={setOpenTags}
|
||||||
|
onChildren={onChildren}/>
|
||||||
|
</div>))}
|
||||||
|
</div>)}
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MaterialSidebar: FC = () => {
|
||||||
|
const location = useLocation ()
|
||||||
|
const navigate = useNavigate ()
|
||||||
|
const qs = new URLSearchParams (location.search)
|
||||||
|
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'present')
|
||||||
|
|
||||||
|
const [desktopTags, setDesktopTags] = useState<MaterialSidebarTag[]> ([])
|
||||||
|
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
|
||||||
|
const mobileRailRef = useRef<HTMLDivElement | null> (null)
|
||||||
|
|
||||||
|
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
||||||
|
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
|
||||||
|
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setDesktopTags (rootTags)
|
||||||
|
}, [rootTags])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const el = mobileRailRef.current
|
||||||
|
if (!(el))
|
||||||
|
return
|
||||||
|
|
||||||
|
requestAnimationFrame (() => {
|
||||||
|
el.scrollLeft = el.scrollWidth
|
||||||
|
})
|
||||||
|
}, [rootTags, materialFilter])
|
||||||
|
|
||||||
|
const visibleRootTags = desktopTags.length > 0 ? desktopTags : rootTags
|
||||||
|
|
||||||
|
const setChildren = (tagId: number, children: MaterialSidebarTag[]) => {
|
||||||
|
setDesktopTags (prev => {
|
||||||
|
const base = prev.length > 0 ? prev : rootTags
|
||||||
|
return setChildrenById (base, tagId, children)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = (value: MaterialFilter) => {
|
||||||
|
setDesktopTags ([])
|
||||||
|
setOpenTags ({ })
|
||||||
|
updateMaterialFilterQuery (location.pathname, location.search, navigate, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDesktopTree = (tags: MaterialSidebarTag[]): ReactNode => (
|
||||||
|
tags.map (tag => (
|
||||||
|
<MaterialTreeNode
|
||||||
|
key={tag.id}
|
||||||
|
tag={tag}
|
||||||
|
materialFilter={materialFilter}
|
||||||
|
openTags={openTags}
|
||||||
|
setOpenTags={setOpenTags}
|
||||||
|
onChildren={setChildren}/>)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="border-b bg-stone-50 p-3 dark:border-stone-700 dark:bg-stone-950
|
||||||
|
dark:text-stone-100 md:hidden">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium text-stone-700 dark:text-stone-200">タグ一覧</span>
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
|
||||||
|
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
|
||||||
|
未分類素材
|
||||||
|
</PrefetchLink>
|
||||||
|
</div>
|
||||||
|
<MaterialFilterButtons
|
||||||
|
materialFilter={materialFilter}
|
||||||
|
onChange={handleFilterChange}/>
|
||||||
|
<div ref={mobileRailRef} className="mt-3 overflow-x-auto">
|
||||||
|
<div className="flex min-w-max flex-row-reverse gap-3 pb-1">
|
||||||
|
{visibleRootTags.map (tag => (
|
||||||
|
<MobileMaterialTreeNode
|
||||||
|
key={tag.id}
|
||||||
|
tag={tag}
|
||||||
|
materialFilter={materialFilter}
|
||||||
|
openTags={openTags}
|
||||||
|
setOpenTags={setOpenTags}
|
||||||
|
onChildren={setChildren}/>))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<SidebarComponent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<MaterialFilterButtons
|
||||||
|
materialFilter={materialFilter}
|
||||||
|
onChange={handleFilterChange}/>
|
||||||
|
<div>
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
|
||||||
|
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
|
||||||
|
未分類素材
|
||||||
|
</PrefetchLink>
|
||||||
|
</div>
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
|
||||||
|
{isError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-300">
|
||||||
|
タグ一覧の取得に失敗しました.
|
||||||
|
</p>)}
|
||||||
|
{(!isLoading && !isError) && (
|
||||||
|
<ul>
|
||||||
|
{renderDesktopTree (visibleRootTags)}
|
||||||
|
</ul>)}
|
||||||
|
</div>
|
||||||
|
</SidebarComponent>
|
||||||
|
</div>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default MaterialSidebar
|
export default MaterialSidebar
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
apiGet,
|
||||||
|
isApiError,
|
||||||
|
apiPatch,
|
||||||
|
apiPost,
|
||||||
|
apiPut,
|
||||||
|
} from '@/lib/api'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Material,
|
||||||
|
MaterialFilter,
|
||||||
|
MaterialSidebarTag,
|
||||||
|
MaterialTagTree,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export type FetchMaterialsParams = {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
tagId?: number | null
|
||||||
|
parentId?: number | null
|
||||||
|
unclassified?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchMaterialTreeParams = {
|
||||||
|
parentId?: number | null
|
||||||
|
materialFilter: MaterialFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaterialIndexResponse = {
|
||||||
|
materials: Material[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
|
||||||
|
|
||||||
|
|
||||||
|
export const parseMaterialFilter = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: MaterialFilter = 'present',
|
||||||
|
): MaterialFilter =>
|
||||||
|
typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter)
|
||||||
|
? value as MaterialFilter
|
||||||
|
: fallback
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchMaterials = async (
|
||||||
|
{ page, limit, tagId, parentId, unclassified }: FetchMaterialsParams,
|
||||||
|
): Promise<MaterialIndexResponse> =>
|
||||||
|
await apiGet ('/materials', { params: {
|
||||||
|
...(page != null && { page }),
|
||||||
|
...(limit != null && { limit }),
|
||||||
|
...(tagId != null && { tag_id: tagId }),
|
||||||
|
...(parentId != null && { parent_id: parentId }),
|
||||||
|
...(unclassified && { unclassified: '1' }),
|
||||||
|
} })
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchMaterial = async (id: string): Promise<Material | null> => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await apiGet (`/materials/${ id }`)
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (isApiError (error) && error.response?.status === 404)
|
||||||
|
return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchMaterialTagTree = async (
|
||||||
|
{ parentId, materialFilter }: FetchMaterialTreeParams,
|
||||||
|
): Promise<MaterialSidebarTag[]> =>
|
||||||
|
await apiGet ('/tags/with-depth', { params: {
|
||||||
|
...(parentId != null && { parent: String (parentId) }),
|
||||||
|
material_filter: materialFilter,
|
||||||
|
} })
|
||||||
|
|
||||||
|
|
||||||
|
export const fetchMaterialTagByName = async (
|
||||||
|
name: string,
|
||||||
|
materialFilter: MaterialFilter,
|
||||||
|
): Promise<MaterialTagTree | null> => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
|
||||||
|
{ params: { material_filter: materialFilter } })
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
if (isApiError (error) && error.response?.status === 404)
|
||||||
|
return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const createMaterial = async (formData: FormData): Promise<Material> =>
|
||||||
|
await apiPost ('/materials', formData)
|
||||||
|
|
||||||
|
|
||||||
|
export const updateMaterial = async (
|
||||||
|
id: string,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<Material> =>
|
||||||
|
await apiPut (`/materials/${ id }`, formData)
|
||||||
|
|
||||||
|
|
||||||
|
export const suppressMaterialFile = async (
|
||||||
|
id: string,
|
||||||
|
payload: { reason: string; purge?: boolean },
|
||||||
|
): Promise<Material> =>
|
||||||
|
await apiPatch (`/materials/${ id }/suppress_file`, payload)
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types'
|
import type {
|
||||||
|
FetchNicoTagsParams,
|
||||||
|
FetchPostsParams,
|
||||||
|
FetchTagsParams,
|
||||||
|
MaterialFilter,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
export const postsKeys = {
|
export const postsKeys = {
|
||||||
root: ['posts'] as const,
|
root: ['posts'] as const,
|
||||||
@@ -25,6 +30,26 @@ export const tagsKeys = {
|
|||||||
['tags', 'changes', p] as const,
|
['tags', 'changes', p] as const,
|
||||||
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
|
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
|
||||||
|
|
||||||
|
export const materialsKeys = {
|
||||||
|
root: ['materials'] as const,
|
||||||
|
index: (p: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
tagId?: number | null
|
||||||
|
parentId?: number | null
|
||||||
|
unclassified?: boolean
|
||||||
|
}) => ['materials', 'index', p] as const,
|
||||||
|
byTagName: (name: string, materialFilter: MaterialFilter) =>
|
||||||
|
['materials', 'tag', name, materialFilter] as const,
|
||||||
|
show: (id: string) => ['materials', id] as const,
|
||||||
|
tree: (p: {
|
||||||
|
parentId?: number | null
|
||||||
|
materialFilter: MaterialFilter
|
||||||
|
}) => ['materials', 'tree', p] as const,
|
||||||
|
unclassified: (p: { page?: number; limit?: number } = { }) =>
|
||||||
|
['materials', 'unclassified', p] as const,
|
||||||
|
}
|
||||||
|
|
||||||
export const wikiKeys = {
|
export const wikiKeys = {
|
||||||
root: ['wiki'] as const,
|
root: ['wiki'] as const,
|
||||||
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
@@ -13,36 +14,60 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet, apiPatch, apiPut } from '@/lib/api'
|
import {
|
||||||
|
suppressMaterialFile,
|
||||||
|
fetchMaterial,
|
||||||
|
updateMaterial,
|
||||||
|
} from '@/lib/materials'
|
||||||
|
import { materialsKeys } from '@/lib/queryKeys'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { Material, Tag, User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type MaterialWithTag = Material & { tag: Tag }
|
|
||||||
|
|
||||||
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
|
type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
|
||||||
|
|
||||||
|
|
||||||
const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
||||||
const { id } = useParams ()
|
const { id } = useParams ()
|
||||||
|
const qc = useQueryClient ()
|
||||||
|
|
||||||
const [exportPath, setExportPath] = useState ('')
|
const [exportPath, setExportPath] = useState ('')
|
||||||
const [file, setFile] = useState<File | null> (null)
|
const [file, setFile] = useState<File | null> (null)
|
||||||
const [filePreview, setFilePreview] = useState ('')
|
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 [tag, setTag] = useState ('')
|
||||||
const [url, setURL] = useState ('')
|
const [url, setURL] = useState ('')
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<MaterialFormField> ()
|
useValidationErrors<MaterialFormField> ()
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const { data: material, isError, isLoading } = useQuery ({
|
||||||
clearValidationErrors ()
|
queryKey: materialsKeys.show (id ?? ''),
|
||||||
|
queryFn: () => fetchMaterial (id ?? ''),
|
||||||
|
enabled: id != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(material))
|
||||||
|
return
|
||||||
|
|
||||||
|
setTag (material.tag.name)
|
||||||
|
setURL (material.url ?? '')
|
||||||
|
setExportPath (material.exportPaths.legacyDrive ?? '')
|
||||||
|
if (material.file && material.contentType)
|
||||||
|
{
|
||||||
|
setFilePreview (material.file)
|
||||||
|
setFile (null)
|
||||||
|
}
|
||||||
|
}, [material])
|
||||||
|
|
||||||
|
const invalidateMaterialQueries = async () => {
|
||||||
|
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMutation = useMutation ({
|
||||||
|
mutationFn: async () => {
|
||||||
const formData = new FormData
|
const formData = new FormData
|
||||||
if (tag.trim ())
|
if (tag.trim ())
|
||||||
formData.append ('tag', tag)
|
formData.append ('tag', tag)
|
||||||
@@ -52,73 +77,48 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
formData.append ('url', url)
|
formData.append ('url', url)
|
||||||
formData.append ('export_paths[legacy_drive]', exportPath)
|
formData.append ('export_paths[legacy_drive]', exportPath)
|
||||||
|
|
||||||
try
|
return await updateMaterial (id ?? '', formData)
|
||||||
{
|
},
|
||||||
setSending (true)
|
onSuccess: async data => {
|
||||||
const data = await apiPut<Material> (`/materials/${ id }`, formData)
|
qc.setQueryData (materialsKeys.show (id ?? ''), data)
|
||||||
setMaterial (data)
|
await invalidateMaterialQueries ()
|
||||||
toast ({ title: '更新成功!' })
|
toast ({ title: '更新成功!' })
|
||||||
}
|
},
|
||||||
catch (e)
|
onError: error => {
|
||||||
{
|
applyValidationError (error)
|
||||||
applyValidationError (e)
|
|
||||||
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
|
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
|
||||||
}
|
},
|
||||||
finally
|
})
|
||||||
{
|
|
||||||
setSending (false)
|
const suppressMutation = useMutation ({
|
||||||
}
|
mutationFn: async (reason: string) =>
|
||||||
|
await suppressMaterialFile (id ?? '', { reason }),
|
||||||
|
onSuccess: async data => {
|
||||||
|
qc.setQueryData (materialsKeys.show (id ?? ''), data)
|
||||||
|
setFile (null)
|
||||||
|
setFilePreview ('')
|
||||||
|
await invalidateMaterialQueries ()
|
||||||
|
toast ({ title: '抑止しました' })
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast ({ title: '抑止に失敗しました' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
clearValidationErrors ()
|
||||||
|
updateMutation.mutate ()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSuppress = async () => {
|
const handleSuppress = () => {
|
||||||
const reason = window.prompt ('抑止理由を入力してください。')
|
const reason = window.prompt ('抑止理由を入力してください。')
|
||||||
if (reason == null || reason.trim () === '')
|
if (reason == null || reason.trim () === '')
|
||||||
return
|
return
|
||||||
if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。'))
|
if (!window.confirm ('素材ファイルを抑止します。表示と ZIP export から除外されます。'))
|
||||||
return
|
return
|
||||||
|
|
||||||
try
|
suppressMutation.mutate (reason)
|
||||||
{
|
|
||||||
const data = await apiPatch<Material> (
|
|
||||||
`/materials/${ id }/suppress_file`,
|
|
||||||
{ reason },
|
|
||||||
)
|
|
||||||
setMaterial (data)
|
|
||||||
setFile (null)
|
|
||||||
setFilePreview ('')
|
|
||||||
toast ({ title: '抑止しました' })
|
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
toast ({ title: '抑止に失敗しました' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (null)
|
|
||||||
}
|
|
||||||
setURL (data.url ?? '')
|
|
||||||
setExportPath (data.exportPaths.legacyDrive ?? '')
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setLoading (false)
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
@@ -127,7 +127,15 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
|
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>)}
|
</Helmet>)}
|
||||||
|
|
||||||
{loading ? 'Loading...' : (material && (
|
{isLoading ? 'Loading...' : isError ? (
|
||||||
|
<p className="text-red-600 dark:text-red-300">
|
||||||
|
素材の取得に失敗しました.
|
||||||
|
</p>
|
||||||
|
) : material == null ? (
|
||||||
|
<p className="text-stone-700 dark:text-stone-300">
|
||||||
|
素材が見つかりませんでした.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<PageTitle>
|
<PageTitle>
|
||||||
<TagLink
|
<TagLink
|
||||||
@@ -137,7 +145,9 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
</PageTitle>
|
</PageTitle>
|
||||||
|
|
||||||
{material.fileSuppressedAt && (
|
{material.fileSuppressedAt && (
|
||||||
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3 text-red-700">
|
<div className="mb-4 rounded border border-red-300 bg-red-50 p-3
|
||||||
|
text-red-900 dark:border-red-800 dark:bg-red-950
|
||||||
|
dark:text-red-100">
|
||||||
<span>素材ファイルは抑止済みです。</span>
|
<span>素材ファイルは抑止済みです。</span>
|
||||||
{material.fileSuppressionReason && (
|
{material.fileSuppressionReason && (
|
||||||
<span> 理由: {material.fileSuppressionReason}</span>)}
|
<span> 理由: {material.fileSuppressionReason}</span>)}
|
||||||
@@ -159,10 +169,9 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
<Tab name="編輯">
|
<Tab name="編輯">
|
||||||
<div className="max-w-wl pt-2 space-y-4">
|
<div className="max-w-wl space-y-4 pt-2">
|
||||||
<FieldError messages={baseErrors}/>
|
<FieldError messages={baseErrors}/>
|
||||||
|
|
||||||
{/* タグ */}
|
|
||||||
<FormField label="タグ" messages={fieldErrors.tag}>
|
<FormField label="タグ" messages={fieldErrors.tag}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<TagInput
|
<TagInput
|
||||||
@@ -172,7 +181,6 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
setValue={setTag}/>)}
|
setValue={setTag}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* ファイル */}
|
|
||||||
<FormField label="ファイル" messages={fieldErrors.file}>
|
<FormField label="ファイル" messages={fieldErrors.file}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
@@ -182,9 +190,10 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const f = e.target.files?.[0]
|
const nextFile = e.target.files?.[0]
|
||||||
setFile (f ?? null)
|
setFile (nextFile ?? null)
|
||||||
setFilePreview (f ? URL.createObjectURL (f) : '')
|
setFilePreview (
|
||||||
|
nextFile ? URL.createObjectURL (nextFile) : '')
|
||||||
}}/>
|
}}/>
|
||||||
{(file && filePreview) && (
|
{(file && filePreview) && (
|
||||||
(/image\/.*/.test (file.type) && (
|
(/image\/.*/.test (file.type) && (
|
||||||
@@ -209,7 +218,6 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
</>)}
|
</>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 参考 URL */}
|
|
||||||
<FormField label="参考 URL" messages={fieldErrors.url}>
|
<FormField label="参考 URL" messages={fieldErrors.url}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<input
|
<input
|
||||||
@@ -221,9 +229,7 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid)}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}>
|
||||||
label="ZIP 出力パス"
|
|
||||||
messages={fieldErrors.exportPaths}>
|
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -235,26 +241,27 @@ const MaterialDetailPage: FC<{ user: User | null }> = ({ user }) => {
|
|||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid)}/>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 送信 */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
className="rounded bg-blue-600 px-4 py-2 text-white
|
||||||
disabled={sending}>
|
disabled:bg-gray-400"
|
||||||
|
disabled={updateMutation.isPending}>
|
||||||
更新
|
更新
|
||||||
</Button>
|
</Button>
|
||||||
{user?.role === 'admin' && !material.fileSuppressedAt && (
|
{user?.role === 'admin' && !material.fileSuppressedAt && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleSuppress}>
|
onClick={handleSuppress}
|
||||||
|
disabled={suppressMutation.isPending}>
|
||||||
ファイルを抑止
|
ファイルを抑止
|
||||||
</Button>)}
|
</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabGroup>
|
</TabGroup>
|
||||||
</>))}
|
</>)}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,52 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import SectionTitle from '@/components/common/SectionTitle'
|
import SectionTitle from '@/components/common/SectionTitle'
|
||||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||||
|
import TagInput from '@/components/common/TagInput'
|
||||||
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 { apiGet } from '@/lib/api'
|
import {
|
||||||
|
fetchMaterials,
|
||||||
|
fetchMaterialTagByName,
|
||||||
|
parseMaterialFilter,
|
||||||
|
} from '@/lib/materials'
|
||||||
|
import { materialsKeys } from '@/lib/queryKeys'
|
||||||
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { Material, Tag } from '@/types'
|
import type { Material, MaterialFilter, MaterialTagTree } from '@/types'
|
||||||
|
|
||||||
type TagWithMaterial = Omit<Tag, 'children'> & {
|
const MATERIAL_FILTER_LABELS: Record<MaterialFilter, string> = {
|
||||||
children: TagWithMaterial[]
|
present: '素材あり',
|
||||||
material: Material | null }
|
missing: '素材なし',
|
||||||
|
any: 'すべて',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
|
const MaterialCard = ({ tag }: { tag: MaterialTagTree }) => {
|
||||||
if (!(tag.material))
|
if (!(tag.material))
|
||||||
return
|
return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/materials/${ tag.material.id }`}
|
to={`/materials/${ tag.material.id }`}
|
||||||
className="block w-40 h-40">
|
className="block h-40 w-40">
|
||||||
<div
|
<div
|
||||||
className={`w-full h-full overflow-hidden rounded-xl shadow
|
className={`h-full w-full overflow-hidden rounded-xl shadow text-center
|
||||||
text-center content-center text-4xl ${
|
content-center text-4xl ${
|
||||||
tag.material.fileSuppressedAt
|
tag.material.fileSuppressedAt
|
||||||
? 'border-2 border-red-300 bg-red-50 text-base text-red-700'
|
? 'border-2 border-red-300 bg-red-50 text-base text-red-900 ' +
|
||||||
: '' }`}
|
'dark:border-red-800 dark:bg-red-950 dark:text-red-100'
|
||||||
|
: 'bg-white text-stone-900 dark:bg-stone-900 dark:text-stone-100' }`}
|
||||||
style={{ fontFamily: 'Nikumaru' }}>
|
style={{ fontFamily: 'Nikumaru' }}>
|
||||||
{tag.material.fileSuppressedAt
|
{tag.material.fileSuppressedAt
|
||||||
? <span>抑止済み</span>
|
? <span>抑止済み</span>
|
||||||
@@ -46,53 +58,40 @@ const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const MaterialListPage: FC = () => {
|
const MaterialList = ({ materials }: { materials: Material[] }) => (
|
||||||
const [loading, setLoading] = useState (false)
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
const [tag, setTag] = useState<TagWithMaterial | null> (null)
|
{materials.map (material => (
|
||||||
|
<article
|
||||||
|
key={material.id}
|
||||||
|
className={`rounded-2xl border p-4 shadow-sm ${
|
||||||
|
material.fileSuppressedAt
|
||||||
|
? 'border-red-200 bg-red-50 text-red-900 dark:border-red-900 ' +
|
||||||
|
'dark:bg-red-950 dark:text-red-100'
|
||||||
|
: 'border-stone-200 bg-white text-stone-900 dark:border-stone-700 ' +
|
||||||
|
'dark:bg-stone-900 dark:text-stone-100'}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-medium text-stone-900 dark:text-stone-100">
|
||||||
|
{material.tag?.name ?? '未分類素材'}
|
||||||
|
</h2>
|
||||||
|
{material.fileSuppressedAt && (
|
||||||
|
<p className="mt-1 text-sm text-red-700 dark:text-red-200">抑止済み</p>)}
|
||||||
|
</div>
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/materials/${ material.id }`}
|
||||||
|
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
|
||||||
|
照会
|
||||||
|
</PrefetchLink>
|
||||||
|
</div>
|
||||||
|
{material.exportPaths.legacyDrive && (
|
||||||
|
<p className="mt-3 break-all text-sm text-stone-600 dark:text-stone-300">
|
||||||
|
{material.exportPaths.legacyDrive}
|
||||||
|
</p>)}
|
||||||
|
</article>))}
|
||||||
|
</div>)
|
||||||
|
|
||||||
const location = useLocation ()
|
|
||||||
const query = new URLSearchParams (location.search)
|
|
||||||
const tagQuery = query.get ('tag') ?? ''
|
|
||||||
|
|
||||||
useEffect (() => {
|
const MaterialTagTreeView = ({ tag }: { tag: MaterialTagTree }) => (
|
||||||
if (!(tagQuery))
|
|
||||||
{
|
|
||||||
setTag (null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
setLoading (true)
|
|
||||||
setTag (
|
|
||||||
await apiGet<TagWithMaterial> (
|
|
||||||
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setLoading (false)
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
}, [location.search, tagQuery])
|
|
||||||
|
|
||||||
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>
|
<PageTitle>
|
||||||
<TagLink
|
<TagLink
|
||||||
@@ -103,10 +102,9 @@ const MaterialListPage: FC = () => {
|
|||||||
? `/materials/${ tag.material.id }`
|
? `/materials/${ tag.material.id }`
|
||||||
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
|
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
{(!(tag.material) && tag.category !== 'meme') && (
|
{(!tag.material && tag.hasMaterial !== true && tag.category !== 'meme') && (
|
||||||
<div className="-mt-2">
|
<div className="-mt-2">
|
||||||
<PrefetchLink
|
<PrefetchLink to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
|
||||||
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
|
|
||||||
追加
|
追加
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -123,10 +121,9 @@ const MaterialListPage: FC = () => {
|
|||||||
withCount={false}
|
withCount={false}
|
||||||
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
|
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
|
||||||
</SectionTitle>
|
</SectionTitle>
|
||||||
{(!(c2.material) && c2.category !== 'meme') && (
|
{(!c2.material && c2.hasMaterial !== true && c2.category !== 'meme') && (
|
||||||
<div className="-mt-4">
|
<div className="-mt-4">
|
||||||
<PrefetchLink
|
<PrefetchLink to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
|
||||||
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
|
|
||||||
追加
|
追加
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -143,11 +140,10 @@ const MaterialListPage: FC = () => {
|
|||||||
withCount={false}
|
withCount={false}
|
||||||
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
|
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
|
||||||
</SubsectionTitle>
|
</SubsectionTitle>
|
||||||
{(!(c3.material) && c3.category !== 'meme') && (
|
{(!c3.material && c3.hasMaterial !== true && c3.category !== 'meme') && (
|
||||||
<div className="-mt-2">
|
<div className="-mt-2">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/materials/new?tag=${
|
to={`/materials/new?tag=${ encodeURIComponent (c3.name) }`}>
|
||||||
encodeURIComponent (c3.name) }`}>
|
|
||||||
追加
|
追加
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -158,19 +154,175 @@ const MaterialListPage: FC = () => {
|
|||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
</div>
|
</div>
|
||||||
</>)
|
</>)
|
||||||
: (
|
|
||||||
<>
|
|
||||||
<p>左のリストから照会したいタグを選択してください。</p>
|
const MaterialSearchTop: FC<{
|
||||||
<p>もしくは……</p>
|
materialFilter: MaterialFilter
|
||||||
<ul>
|
setMaterialFilter: (value: MaterialFilter) => void
|
||||||
<li><PrefetchLink to="/materials/new">素材を新規追加する</PrefetchLink></li>
|
tagName: string
|
||||||
<li>
|
setTagName: (value: string) => void
|
||||||
<a href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}>
|
}> = ({ materialFilter, setMaterialFilter, tagName, setTagName }) => {
|
||||||
すべての素材をダウンロードする
|
const navigate = useNavigate ()
|
||||||
|
const location = useLocation ()
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const qs = new URLSearchParams (location.search)
|
||||||
|
if (tagName.trim ())
|
||||||
|
qs.set ('tag', tagName.trim ())
|
||||||
|
else
|
||||||
|
qs.delete ('tag')
|
||||||
|
qs.delete ('unclassified')
|
||||||
|
qs.set ('material_filter', materialFilter)
|
||||||
|
navigate (`/materials?${ qs.toString () }`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<PageTitle>素材集</PageTitle>
|
||||||
|
|
||||||
|
<section className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm
|
||||||
|
text-stone-900 dark:border-stone-700 dark:bg-stone-900
|
||||||
|
dark:text-stone-100">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField label="タグ名検索">
|
||||||
|
{() => (
|
||||||
|
<TagInput
|
||||||
|
value={tagName}
|
||||||
|
setValue={setTagName}/>)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="素材状態">
|
||||||
|
{({ invalid }) => (
|
||||||
|
<select
|
||||||
|
value={materialFilter}
|
||||||
|
onChange={e => setMaterialFilter (e.target.value as MaterialFilter)}
|
||||||
|
className={inputClass (invalid)}>
|
||||||
|
{Object.entries (MATERIAL_FILTER_LABELS).map (([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>))}
|
||||||
|
</select>)}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="rounded-full bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
|
||||||
|
dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400">
|
||||||
|
この条件で探す
|
||||||
|
</button>
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/materials?unclassified=1&material_filter=${ materialFilter }`}
|
||||||
|
className="rounded-full border border-stone-300 bg-white px-4 py-2
|
||||||
|
text-stone-900 hover:bg-stone-100 dark:border-stone-700
|
||||||
|
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
|
||||||
|
未分類素材を見る
|
||||||
|
</PrefetchLink>
|
||||||
|
<PrefetchLink
|
||||||
|
to="/materials/new"
|
||||||
|
className="rounded-full border border-stone-300 bg-white px-4 py-2
|
||||||
|
text-stone-900 hover:bg-stone-100 dark:border-stone-700
|
||||||
|
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
|
||||||
|
新規素材を追加
|
||||||
|
</PrefetchLink>
|
||||||
|
<a
|
||||||
|
href={`${ API_BASE_URL }/materials/download.zip?profile=legacy_drive`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-full border border-stone-300 bg-white px-4 py-2
|
||||||
|
text-stone-900 hover:bg-stone-100 dark:border-stone-700
|
||||||
|
dark:bg-stone-900 dark:text-stone-100 dark:hover:bg-stone-800">
|
||||||
|
ZIP をダウンロード
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
</>))}
|
</section>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MaterialListPage: FC = () => {
|
||||||
|
const location = useLocation ()
|
||||||
|
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
|
||||||
|
const tagQuery = query.get ('tag') ?? ''
|
||||||
|
const unclassified = query.get ('unclassified') === '1'
|
||||||
|
const initialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
|
||||||
|
|
||||||
|
const [tagName, setTagName] = useState (tagQuery)
|
||||||
|
const [materialFilter, setMaterialFilter] = useState<MaterialFilter> (initialFilter)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: tag,
|
||||||
|
isLoading: tagLoading,
|
||||||
|
isError: tagError,
|
||||||
|
} = useQuery ({
|
||||||
|
queryKey: materialsKeys.byTagName (tagQuery, initialFilter),
|
||||||
|
queryFn: () => fetchMaterialTagByName (tagQuery, initialFilter),
|
||||||
|
enabled: tagQuery !== '' && !unclassified,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: unclassifiedData,
|
||||||
|
isLoading: unclassifiedLoading,
|
||||||
|
isError: unclassifiedError,
|
||||||
|
} = useQuery ({
|
||||||
|
queryKey: materialsKeys.unclassified ({ page: 1, limit: 50 }),
|
||||||
|
queryFn: () => fetchMaterials ({ page: 1, limit: 50, unclassified: true }),
|
||||||
|
enabled: unclassified,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setTagName (tagQuery)
|
||||||
|
setMaterialFilter (initialFilter)
|
||||||
|
}, [initialFilter, tagQuery])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainArea>
|
||||||
|
<Helmet>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@font-face
|
||||||
|
{
|
||||||
|
font-family: 'Nikumaru';
|
||||||
|
src: url(${ nikumaru }) format('opentype');
|
||||||
|
}`}
|
||||||
|
</style>
|
||||||
|
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
{unclassified ? (
|
||||||
|
<>
|
||||||
|
<PageTitle>未分類素材</PageTitle>
|
||||||
|
<div className="-mt-2 mb-4">
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/materials?material_filter=${ materialFilter }`}
|
||||||
|
className="text-sm text-sky-700 underline underline-offset-2
|
||||||
|
dark:text-sky-300">
|
||||||
|
素材検索トップへ戻る
|
||||||
|
</PrefetchLink>
|
||||||
|
</div>
|
||||||
|
{unclassifiedLoading && <p>Loading...</p>}
|
||||||
|
{unclassifiedError && (
|
||||||
|
<p className="text-red-600 dark:text-red-300">未分類素材の取得に失敗しました.</p>)}
|
||||||
|
{unclassifiedData && unclassifiedData.materials.length === 0 && (
|
||||||
|
<p>未分類素材はありません.</p>)}
|
||||||
|
{unclassifiedData && unclassifiedData.materials.length > 0 && (
|
||||||
|
<MaterialList materials={unclassifiedData.materials}/>)}
|
||||||
|
</>) : tagQuery ? (
|
||||||
|
<>
|
||||||
|
{tagLoading && <p>Loading...</p>}
|
||||||
|
{tagError && (
|
||||||
|
<p className="text-red-600">素材一覧の取得に失敗しました.</p>)}
|
||||||
|
{(!tagLoading && !tagError && !tag) && (
|
||||||
|
<p>該当する素材タグが見つかりませんでした.</p>)}
|
||||||
|
{tag && <MaterialTagTreeView tag={tag}/>}
|
||||||
|
</>) : (
|
||||||
|
<MaterialSearchTop
|
||||||
|
tagName={tagName}
|
||||||
|
setTagName={setTagName}
|
||||||
|
materialFilter={materialFilter}
|
||||||
|
setMaterialFilter={setMaterialFilter}/>)}
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
@@ -11,7 +12,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 { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiPost } from '@/lib/api'
|
import { createMaterial } from '@/lib/materials'
|
||||||
|
import { materialsKeys } from '@/lib/queryKeys'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
|
|||||||
|
|
||||||
|
|
||||||
const MaterialNewPage: FC = () => {
|
const MaterialNewPage: FC = () => {
|
||||||
|
const qc = useQueryClient ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const tagQuery = query.get ('tag') ?? ''
|
const tagQuery = query.get ('tag') ?? ''
|
||||||
@@ -29,16 +32,14 @@ const MaterialNewPage: FC = () => {
|
|||||||
|
|
||||||
const [file, setFile] = useState<File | null> (null)
|
const [file, setFile] = useState<File | null> (null)
|
||||||
const [filePreview, setFilePreview] = useState ('')
|
const [filePreview, setFilePreview] = useState ('')
|
||||||
const [sending, setSending] = useState (false)
|
|
||||||
const [tag, setTag] = useState (tagQuery)
|
const [tag, setTag] = useState (tagQuery)
|
||||||
const [url, setURL] = useState ('')
|
const [url, setURL] = useState ('')
|
||||||
const [exportPath, setExportPath] = useState ('')
|
const [exportPath, setExportPath] = useState ('')
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<MaterialFormField> ()
|
useValidationErrors<MaterialFormField> ()
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const createMutation = useMutation ({
|
||||||
clearValidationErrors ()
|
mutationFn: async () => {
|
||||||
|
|
||||||
const formData = new FormData
|
const formData = new FormData
|
||||||
if (tag)
|
if (tag)
|
||||||
formData.append ('tag', tag)
|
formData.append ('tag', tag)
|
||||||
@@ -48,22 +49,22 @@ const MaterialNewPage: FC = () => {
|
|||||||
formData.append ('url', url)
|
formData.append ('url', url)
|
||||||
formData.append ('export_paths[legacy_drive]', exportPath)
|
formData.append ('export_paths[legacy_drive]', exportPath)
|
||||||
|
|
||||||
try
|
return await createMaterial (formData)
|
||||||
{
|
},
|
||||||
setSending (true)
|
onSuccess: async () => {
|
||||||
await apiPost ('/materials', formData)
|
await qc.invalidateQueries ({ queryKey: materialsKeys.root })
|
||||||
toast ({ title: '送信成功!' })
|
toast ({ title: '送信成功!' })
|
||||||
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
|
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
|
||||||
}
|
},
|
||||||
catch (e)
|
onError: error => {
|
||||||
{
|
applyValidationError (error)
|
||||||
applyValidationError (e)
|
|
||||||
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
|
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
|
||||||
}
|
},
|
||||||
finally
|
})
|
||||||
{
|
|
||||||
setSending (false)
|
const handleSubmit = () => {
|
||||||
}
|
clearValidationErrors ()
|
||||||
|
createMutation.mutate ()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,7 +154,7 @@ const MaterialNewPage: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
||||||
disabled={sending}>
|
disabled={createMutation.isPending}>
|
||||||
追加
|
追加
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export type FetchNicoTagsOrder = `${ FetchNicoTagsOrderField }:${ 'asc' | 'desc'
|
|||||||
|
|
||||||
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
|
export type FetchNicoTagsOrderField = 'name' | 'created_at' | 'updated_at'
|
||||||
|
|
||||||
|
export type MaterialFilter = 'present' | 'missing' | 'any'
|
||||||
|
|
||||||
export type Material = {
|
export type Material = {
|
||||||
id: number
|
id: number
|
||||||
versionNo: number
|
versionNo: number
|
||||||
@@ -82,6 +84,21 @@ export type Material = {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
updatedByUser: { id: number; name: string } }
|
updatedByUser: { id: number; name: string } }
|
||||||
|
|
||||||
|
export type MaterialSidebarTag = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
category: Category
|
||||||
|
deprecated: boolean
|
||||||
|
hasChildren: boolean
|
||||||
|
hasMaterial: boolean
|
||||||
|
children: MaterialSidebarTag[] }
|
||||||
|
|
||||||
|
export type MaterialTagTree = Omit<Tag, 'children'> & {
|
||||||
|
hasChildren: boolean
|
||||||
|
hasMaterial: boolean
|
||||||
|
children: MaterialTagTree[]
|
||||||
|
material?: Material | null }
|
||||||
|
|
||||||
export type MaterialExportItem = {
|
export type MaterialExportItem = {
|
||||||
id: number
|
id: number
|
||||||
profile: string
|
profile: string
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする