This commit is contained in:
@@ -22,24 +22,35 @@ class MaterialsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
material = Material.includes(:tag, :created_by_user).with_attached_file.find_by(id: params[:id])
|
material =
|
||||||
|
Material
|
||||||
|
.includes(:tag)
|
||||||
|
.with_attached_file
|
||||||
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless material
|
return head :not_found unless material
|
||||||
|
|
||||||
render json: material_json(material)
|
render json: material.as_json(methods: [:content_type]).merge(
|
||||||
|
file: if material.file.attached?
|
||||||
|
rails_storage_proxy_url(material.file, only_path: false)
|
||||||
|
end,
|
||||||
|
tag: TagRepr.base(material.tag))
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
|
||||||
|
|
||||||
|
tag_name_raw = params[:tag].to_s.strip
|
||||||
file = params[:file]
|
file = params[:file]
|
||||||
return head :bad_request if file.blank?
|
url = params[:url].to_s.strip.presence
|
||||||
|
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
|
||||||
|
|
||||||
material = Material.new(
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
url: params[:url].presence,
|
tag = tag_name.tag
|
||||||
parent_id: params[:parent_id].presence,
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
tag_id: params[:tag_id].presence,
|
|
||||||
created_by_user: current_user)
|
material = Material.new(tag:, url:,
|
||||||
|
created_by_user: current_user,
|
||||||
|
updated_by_user: current_user)
|
||||||
material.file.attach(file)
|
material.file.attach(file)
|
||||||
|
|
||||||
if material.save
|
if material.save
|
||||||
@@ -56,15 +67,28 @@ class MaterialsController < ApplicationController
|
|||||||
material = Material.with_attached_file.find_by(id: params[:id])
|
material = Material.with_attached_file.find_by(id: params[:id])
|
||||||
return head :not_found unless material
|
return head :not_found unless material
|
||||||
|
|
||||||
material.assign_attributes(
|
tag_name_raw = params[:tag].to_s.strip
|
||||||
url: params[:url].presence,
|
file = params[:file]
|
||||||
parent_id: params[:parent_id].presence,
|
url = params[:url].to_s.strip.presence
|
||||||
tag_id: params[:tag_id].presence
|
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
|
||||||
)
|
|
||||||
material.file.attach(params[:file]) if params[:file].present?
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
|
tag = tag_name.tag
|
||||||
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
|
|
||||||
|
material.update!(tag:, url:, updated_by_user: current_user)
|
||||||
|
if file
|
||||||
|
material.file.attach(file)
|
||||||
|
else
|
||||||
|
material.file.purge(file)
|
||||||
|
end
|
||||||
|
|
||||||
if material.save
|
if material.save
|
||||||
render json: material_json(material)
|
render json: material.as_json(methods: [:content_type]).merge(
|
||||||
|
file: if material.file.attached?
|
||||||
|
rails_storage_proxy_url(material.file, only_path: false)
|
||||||
|
end,
|
||||||
|
tag: TagRepr.base(material.tag))
|
||||||
else
|
else
|
||||||
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
|
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -107,18 +107,6 @@ class TagsController < ApplicationController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def materials_by_name
|
|
||||||
name = params[:name].to_s.strip
|
|
||||||
return head :bad_request if name.blank?
|
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
|
||||||
.includes(:tag_name, tag_name: :wiki_page)
|
|
||||||
.find_by(tag_names: { name: })
|
|
||||||
return :not_found unless tag
|
|
||||||
|
|
||||||
render json: build_tag_children(tag)
|
|
||||||
end
|
|
||||||
|
|
||||||
def autocomplete
|
def autocomplete
|
||||||
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
||||||
|
|
||||||
@@ -209,6 +197,18 @@ class TagsController < ApplicationController
|
|||||||
render json: DeerjikistRepr.many(tag.deerjikists)
|
render json: DeerjikistRepr.many(tag.deerjikists)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def materials_by_name
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
return head :bad_request if name.blank?
|
||||||
|
|
||||||
|
tag = Tag.joins(:tag_name)
|
||||||
|
.includes(:tag_name, tag_name: :wiki_page)
|
||||||
|
.find_by(tag_names: { name: })
|
||||||
|
return :not_found unless tag
|
||||||
|
|
||||||
|
render json: build_tag_children(tag)
|
||||||
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
@@ -231,7 +231,17 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_tag_children tag
|
def build_tag_children(tag)
|
||||||
TagRepr.base(tag).merge(children: tag.children.map { build_tag_children(_1) })
|
material = tag.materials.first
|
||||||
|
file = nil
|
||||||
|
content_type = nil
|
||||||
|
if material&.file&.attached?
|
||||||
|
file = rails_storage_proxy_url(material.file, only_path: false)
|
||||||
|
content_type = material.file.blob.content_type
|
||||||
|
end
|
||||||
|
|
||||||
|
TagRepr.base(tag).merge(
|
||||||
|
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
|
||||||
|
material: material.as_json&.merge(file:, content_type:))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ class Material < ApplicationRecord
|
|||||||
|
|
||||||
has_one_attached :file, dependent: :purge
|
has_one_attached :file, dependent: :purge
|
||||||
|
|
||||||
|
validates :tag_id, presence: true, uniqueness: true
|
||||||
|
|
||||||
validate :file_must_be_attached
|
validate :file_must_be_attached
|
||||||
validate :tag_must_be_material_category
|
validate :tag_must_be_material_category
|
||||||
|
|
||||||
|
def content_type
|
||||||
|
return nil unless file&.attached?
|
||||||
|
|
||||||
|
file.blob.content_type
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def file_must_be_attached
|
def file_must_be_attached
|
||||||
@@ -24,7 +32,7 @@ class Material < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def tag_must_be_material_category
|
def tag_must_be_material_category
|
||||||
return if tag.blank? || tag.material?
|
return if tag.blank? || tag.character? || tag.material?
|
||||||
|
|
||||||
errors.add(:tag, '素材カテゴリのタグを指定してください.')
|
errors.add(:tag, '素材カテゴリのタグを指定してください.')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Tag < ApplicationRecord
|
|||||||
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
|
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
|
||||||
|
|
||||||
has_many :deerjikists, dependent: :delete_all
|
has_many :deerjikists, dependent: :delete_all
|
||||||
|
has_many :materials
|
||||||
|
|
||||||
belongs_to :tag_name
|
belongs_to :tag_name
|
||||||
delegate :wiki_page, to: :tag_name
|
delegate :wiki_page, to: :tag_name
|
||||||
|
|||||||
+11
-6
@@ -10,9 +10,11 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
|||||||
import TopNav from '@/components/TopNav'
|
import TopNav from '@/components/TopNav'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { apiPost, isApiError } from '@/lib/api'
|
import { apiPost, isApiError } from '@/lib/api'
|
||||||
// import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
||||||
|
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
||||||
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
||||||
import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
|
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||||
@@ -44,7 +46,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
return (
|
return (
|
||||||
<LayoutGroup id="gallery-shared">
|
<LayoutGroup id="gallery-shared">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<Routes location={location} key={location.pathname}>
|
<Routes location={location}>
|
||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
@@ -54,9 +56,12 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/tags" element={<TagListPage/>}/>
|
<Route path="/tags" element={<TagListPage/>}/>
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
||||||
<Route path="/materials" element={<MaterialListPage/>}/>
|
<Route path="/materials" element={<MaterialBasePage/>}>
|
||||||
<Route path="/materials/search" element={<MaterialSearchPage/>}/>
|
<Route index element={<MaterialListPage/>}/>
|
||||||
{/* <Route path="/materials/:tagName" element ={<MaterialDetailPage/>}/> */}
|
<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" element={<WikiSearchPage/>}/>
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ const setChildrenById = (
|
|||||||
if (tag.children.length === 0)
|
if (tag.children.length === 0)
|
||||||
return tag
|
return tag
|
||||||
|
|
||||||
return { ...tag, children: setChildrenById (tag.children, targetId, children) }
|
return { ...tag,
|
||||||
|
children: (setChildrenById (tag.children, targetId, children)
|
||||||
|
.filter (t => t.category !== 'meme' || t.hasChildren)) }
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +38,8 @@ export default (() => {
|
|||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setTags (await apiGet<TagWithDepth[]> ('/tags/with-depth'))
|
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
|
||||||
|
.filter (t => t.category !== 'meme' || t.hasChildren))
|
||||||
}) ()
|
}) ()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -87,10 +90,8 @@ export default (() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarComponent>
|
<SidebarComponent>
|
||||||
<div className="md:h-[calc(100dvh-120px)] md:overflow-y-auto">
|
|
||||||
<ul>
|
<ul>
|
||||||
{renderTags (tags)}
|
{renderTags (tags)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
</SidebarComponent>)
|
</SidebarComponent>)
|
||||||
}) satisfies FC
|
}) satisfies FC
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ export default (({ user }: Props) => {
|
|||||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||||||
{ name: '素材集', to: '/materials', subMenu: [
|
{ name: '素材', to: '/materials', subMenu: [
|
||||||
{ name: '一覧', to: '/materials' },
|
{ name: '一覧', to: '/materials' },
|
||||||
{ name: '検索', to: '/materials/search' },
|
// { name: '検索', to: '/materials/search' },
|
||||||
{ name: '追加', to: '/materials/new' },
|
{ name: '追加', to: '/materials/new' },
|
||||||
{ name: '履歴', to: '/materials/changes' },
|
// { name: '履歴', to: '/materials/changes' },
|
||||||
{ name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] },
|
{ name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] },
|
||||||
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
||||||
{ name: <>第 1 会場</>, to: '/theatres/1' },
|
{ name: <>第 1 会場</>, to: '/theatres/1' },
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ type Props = {
|
|||||||
|
|
||||||
|
|
||||||
export default (({ children, className }: 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}
|
{children}
|
||||||
</main>)) satisfies FC<Props>
|
</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) => (
|
export default (({ children }: Props) => (
|
||||||
<div className="p-4 w-full md:w-64 md:h-full">
|
<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}
|
{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">
|
||||||
|
<MaterialSidebar/>
|
||||||
|
<Outlet/>
|
||||||
|
</div>)) satisfies FC
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import TagLink from '@/components/TagLink'
|
||||||
|
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="編輯">
|
||||||
|
<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
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react'
|
import { Fragment, useEffect, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import MaterialSidebar from '@/components/MaterialSidebar'
|
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import TagLink from '@/components/TagLink'
|
||||||
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'
|
||||||
@@ -12,15 +14,35 @@ import { apiGet } from '@/lib/api'
|
|||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Material, Tag } from '@/types'
|
||||||
|
|
||||||
type TagWithMaterial = Omit<Tag, 'children'> & {
|
type TagWithMaterial = Omit<Tag, 'children'> & {
|
||||||
children: TagWithMaterial[]
|
children: TagWithMaterial[]
|
||||||
material: any | null }
|
material: Material | null }
|
||||||
|
|
||||||
|
|
||||||
|
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
|
||||||
|
if (!(tag.material))
|
||||||
|
return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/materials/${ tag.material.id }/edit`}
|
||||||
|
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 (() => {
|
export default (() => {
|
||||||
|
const [loading, setLoading] = useState (false)
|
||||||
const [tag, setTag] = useState<TagWithMaterial | null> (null)
|
const [tag, setTag] = useState<TagWithMaterial | null> (null)
|
||||||
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
@@ -28,41 +50,117 @@ export default (() => {
|
|||||||
const tagQuery = query.get ('tag') ?? ''
|
const tagQuery = query.get ('tag') ?? ''
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
if (!(tagQuery))
|
||||||
|
{
|
||||||
|
setTag (null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
setLoading (true)
|
||||||
setTag (
|
setTag (
|
||||||
await apiGet<TagWithMaterial> (`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
|
await apiGet<TagWithMaterial> (
|
||||||
|
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setLoading (false)
|
||||||
|
}
|
||||||
}) ()
|
}) ()
|
||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1">
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@font-face
|
||||||
|
{
|
||||||
|
font-family: 'Nikumaru';
|
||||||
|
src: url(${ nikumaru }) format('opentype');
|
||||||
|
}`}
|
||||||
|
</style>
|
||||||
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
|
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<MaterialSidebar/>
|
{loading ? 'Loading...' : (
|
||||||
|
tag
|
||||||
<MainArea>
|
|
||||||
{tag
|
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<PageTitle>{tag.name}</PageTitle>
|
<PageTitle>
|
||||||
|
<TagLink
|
||||||
|
tag={tag}
|
||||||
|
withWiki={false}
|
||||||
|
withCount={false}
|
||||||
|
to={tag.material
|
||||||
|
? `/materials/${ tag.material.id }/edit`
|
||||||
|
: `/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 => (
|
{tag.children.map (c2 => (
|
||||||
<Fragment key={c2.id}>
|
<Fragment key={c2.id}>
|
||||||
<SectionTitle>{c2.name}</SectionTitle>
|
<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 => (
|
{c2.children.map (c3 => (
|
||||||
<SubsectionTitle key={c3.id}>{c3.name}</SubsectionTitle>))}
|
<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>))}
|
</Fragment>))}
|
||||||
|
</div>
|
||||||
|
</Fragment>))}
|
||||||
|
</div>
|
||||||
</>)
|
</>)
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<p>左のリストから照会したいタグを選択してください。</p>
|
<p>左のリストから照会したいタグを選択してください。</p>
|
||||||
<p>もしくは……</p>
|
<p>もしくは……</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><Link to="/materials/new">素材を新規追加する</Link></li>
|
<li><PrefetchLink to="/materials/new">素材を新規追加する</PrefetchLink></li>
|
||||||
<li><a href="#">すべての素材をダウンロードする</a></li>
|
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
|
||||||
</ul>
|
</ul>
|
||||||
</>)}
|
</>))}
|
||||||
</MainArea>
|
</MainArea>)
|
||||||
</div>)
|
|
||||||
}) satisfies FC
|
}) 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
|
||||||
@@ -49,6 +49,17 @@ export type FetchTagsParams = {
|
|||||||
limit: number
|
limit: number
|
||||||
order: FetchTagsOrder }
|
order: FetchTagsOrder }
|
||||||
|
|
||||||
|
export type Material = {
|
||||||
|
id: number
|
||||||
|
tag: Tag
|
||||||
|
file: string | null
|
||||||
|
url: 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 Menu = MenuItem[]
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
|
|||||||
Reference in New Issue
Block a user