This commit is contained in:
2026-04-05 21:00:13 +09:00
parent f576373c8f
commit 64e7400ed0
14 changed files with 576 additions and 85 deletions
+40 -16
View File
@@ -22,24 +22,35 @@ class MaterialsController < ApplicationController
end
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
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
def create
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]
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(
url: params[:url].presence,
parent_id: params[:parent_id].presence,
tag_id: params[:tag_id].presence,
created_by_user: current_user)
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 = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(file)
if material.save
@@ -56,15 +67,28 @@ class MaterialsController < ApplicationController
material = Material.with_attached_file.find_by(id: params[:id])
return head :not_found unless material
material.assign_attributes(
url: params[:url].presence,
parent_id: params[:parent_id].presence,
tag_id: params[:tag_id].presence
)
material.file.attach(params[:file]) if params[:file].present?
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
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
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
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
+24 -14
View File
@@ -107,18 +107,6 @@ class TagsController < ApplicationController
}
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
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
@@ -209,6 +197,18 @@ class TagsController < ApplicationController
render json: DeerjikistRepr.many(tag.deerjikists)
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
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
@@ -231,7 +231,17 @@ class TagsController < ApplicationController
private
def build_tag_children tag
TagRepr.base(tag).merge(children: tag.children.map { build_tag_children(_1) })
def build_tag_children(tag)
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
+9 -1
View File
@@ -12,9 +12,17 @@ class Material < ApplicationRecord
has_one_attached :file, dependent: :purge
validates :tag_id, presence: true, uniqueness: true
validate :file_must_be_attached
validate :tag_must_be_material_category
def content_type
return nil unless file&.attached?
file.blob.content_type
end
private
def file_must_be_attached
@@ -24,7 +32,7 @@ class Material < ApplicationRecord
end
def tag_must_be_material_category
return if tag.blank? || tag.material?
return if tag.blank? || tag.character? || tag.material?
errors.add(:tag, '素材カテゴリのタグを指定してください.')
end
+1
View File
@@ -31,6 +31,7 @@ class Tag < ApplicationRecord
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
has_many :deerjikists, dependent: :delete_all
has_many :materials
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
+11 -6
View File
@@ -10,9 +10,11 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
// import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
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 NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage'
@@ -44,7 +46,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
return (
<LayoutGroup id="gallery-shared">
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
@@ -54,9 +56,12 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialListPage/>}/>
<Route path="/materials/search" element={<MaterialSearchPage/>}/>
{/* <Route path="/materials/:tagName" element ={<MaterialDetailPage/>}/> */}
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
+5 -4
View File
@@ -25,7 +25,9 @@ const setChildrenById = (
if (tag.children.length === 0)
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 (() => {
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 (
<SidebarComponent>
<div className="md:h-[calc(100dvh-120px)] md:overflow-y-auto">
<ul>
{renderTags (tags)}
</ul>
</div>
</SidebarComponent>)
}) satisfies FC
+3 -3
View File
@@ -79,11 +79,11 @@ export default (({ user }: Props) => {
{ name: '上位タグ', to: '/tags/implications', visible: false },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
{ name: '素材', to: '/materials', subMenu: [
{ name: '素材', to: '/materials', subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search' },
// { name: '検索', to: '/materials/search' },
{ name: '追加', to: '/materials/new' },
{ name: '履歴', to: '/materials/changes' },
// { name: '履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: 'wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
+3 -1
View File
@@ -8,6 +8,8 @@ type Props = {
export default (({ children, className }: Props) => (
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
<main className={cn ('flex-1 overflow-y-auto p-4',
'md:h-[calc(100dvh-88px)] md:overflow-y-auto',
className)}>
{children}
</main>)) satisfies FC<Props>
@@ -1,9 +1,29 @@
import React from 'react'
import { Helmet } from 'react-helmet-async'
type Props = { children: React.ReactNode }
import type { FC, ReactNode } from 'react'
type Props = { children: ReactNode }
export default ({ children }: Props) => (
<div className="p-4 w-full md:w-64 md:h-full">
export default (({ children }: Props) => (
<div
className="p-4 w-full md:w-64 md:h-full
md:h-[calc(100dvh-88px)] md:overflow-y-auto
sidebar">
<Helmet>
<style>
{`
.sidebar
{
direction: rtl;
}
.sidebar > *
{
direction: ltr;
}`}
</style>
</Helmet>
{children}
</div>)
</div>)) satisfies FC<Props>
@@ -0,0 +1,12 @@
import { Outlet } from 'react-router-dom'
import MaterialSidebar from '@/components/MaterialSidebar'
import type { FC } from 'react'
export default (() => (
<div className="md:flex md:flex-1">
<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
+116 -18
View File
@@ -1,8 +1,10 @@
import { Fragment, useEffect, useState } from 'react'
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 SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
@@ -12,15 +14,35 @@ import { apiGet } from '@/lib/api'
import type { FC } from 'react'
import type { Tag } from '@/types'
import type { Material, Tag } from '@/types'
type TagWithMaterial = Omit<Tag, 'children'> & {
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 (() => {
const [loading, setLoading] = useState (false)
const [tag, setTag] = useState<TagWithMaterial | null> (null)
const location = useLocation ()
@@ -28,41 +50,117 @@ export default (() => {
const tagQuery = query.get ('tag') ?? ''
useEffect (() => {
if (!(tagQuery))
{
setTag (null)
return
}
void (async () => {
try
{
setLoading (true)
setTag (
await apiGet<TagWithMaterial> (`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
await apiGet<TagWithMaterial> (
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
}
finally
{
setLoading (false)
}
}) ()
}, [location.search])
return (
<div className="md:flex md:flex-1">
<MainArea>
<Helmet>
<style>
{`
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
</Helmet>
<MaterialSidebar/>
<MainArea>
{tag
{loading ? 'Loading...' : (
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 => (
<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 => (
<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>))}
</div>
</Fragment>))}
</div>
</>)
: (
<>
<p></p>
<p></p>
<ul>
<li><Link to="/materials/new"></Link></li>
<li><a href="#"></a></li>
<li><PrefetchLink to="/materials/new"></PrefetchLink></li>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
</ul>
</>)}
</MainArea>
</div>)
</>))}
</MainArea>)
}) satisfies FC
@@ -0,0 +1,124 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import Form from '@/components/common/Form'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import type { FC } from 'react'
export default (() => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const navigate = useNavigate ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
const handleSubmit = async () => {
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)
try
{
setSending (true)
await apiPost ('/materials', formData)
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
}
catch
{
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}
return (
<MainArea>
<Helmet>
<title>{`素材追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<Form>
<PageTitle></PageTitle>
{/* タグ */}
<div>
<Label></Label>
<TagInput value={tag} setValue={setTag}/>
</div>
{/* ファイル */}
<div>
<Label></Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</div>
{/* 参考 URL */}
<div>
<Label> URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
</Form>
</MainArea>)
}) satisfies FC
+11
View File
@@ -49,6 +49,17 @@ export type FetchTagsParams = {
limit: number
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 MenuItem = {