From 64e7400ed077a79bf58d3e1231a0f7d39096e13d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Apr 2026 21:00:13 +0900 Subject: [PATCH] #99 --- .../app/controllers/materials_controller.rb | 56 ++++-- backend/app/controllers/tags_controller.rb | 38 ++-- backend/app/models/material.rb | 10 +- backend/app/models/tag.rb | 1 + frontend/src/App.tsx | 17 +- frontend/src/components/MaterialSidebar.tsx | 15 +- frontend/src/components/TopNav.tsx | 6 +- frontend/src/components/layout/MainArea.tsx | 4 +- .../components/layout/SidebarComponent.tsx | 30 ++- .../src/pages/materials/MaterialBasePage.tsx | 12 ++ .../pages/materials/MaterialDetailPage.tsx | 175 ++++++++++++++++++ .../src/pages/materials/MaterialListPage.tsx | 164 ++++++++++++---- .../src/pages/materials/MaterialNewPage.tsx | 124 +++++++++++++ frontend/src/types.ts | 11 ++ 14 files changed, 577 insertions(+), 86 deletions(-) create mode 100644 frontend/src/pages/materials/MaterialBasePage.tsx create mode 100644 frontend/src/pages/materials/MaterialDetailPage.tsx create mode 100644 frontend/src/pages/materials/MaterialNewPage.tsx diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index fd5e9b8..e467747 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -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?) + + 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( - url: params[:url].presence, - parent_id: params[:parent_id].presence, - 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) 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 diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index c48e447..337d31c 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -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 diff --git a/backend/app/models/material.rb b/backend/app/models/material.rb index 37af665..417b292 100644 --- a/backend/app/models/material.rb +++ b/backend/app/models/material.rb @@ -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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index a8926c2..684d97e 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f306f47..07f004b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - + }/> }/> }/> @@ -54,9 +56,12 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> - }/> - }/> - {/* }/> */} + }> + }/> + }/> + }/> + + {/* }/> */} }/> }/> }/> diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index ee65d7c..08bf2b2 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -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 ('/tags/with-depth')) + setTags ((await apiGet ('/tags/with-depth')) + .filter (t => t.category !== 'meme' || t.hasChildren)) }) () }, []) @@ -87,10 +90,8 @@ export default (() => { return ( -
-
    - {renderTags (tags)} -
-
+
    + {renderTags (tags)} +
) }) satisfies FC diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 4686e5a..b173dac 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -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: <>第 1 会場, to: '/theatres/1' }, diff --git a/frontend/src/components/layout/MainArea.tsx b/frontend/src/components/layout/MainArea.tsx index 1067101..8839ebc 100644 --- a/frontend/src/components/layout/MainArea.tsx +++ b/frontend/src/components/layout/MainArea.tsx @@ -8,6 +8,8 @@ type Props = { export default (({ children, className }: Props) => ( -
+
{children}
)) satisfies FC diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index cfe2c08..d6e8803 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -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) => ( -
{children} -
) +
)) satisfies FC diff --git a/frontend/src/pages/materials/MaterialBasePage.tsx b/frontend/src/pages/materials/MaterialBasePage.tsx new file mode 100644 index 0000000..4c948f1 --- /dev/null +++ b/frontend/src/pages/materials/MaterialBasePage.tsx @@ -0,0 +1,12 @@ +import { Outlet } from 'react-router-dom' + +import MaterialSidebar from '@/components/MaterialSidebar' + +import type { FC } from 'react' + + +export default (() => ( +
+ + +
)) satisfies FC diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx new file mode 100644 index 0000000..04b66d8 --- /dev/null +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -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 (null) + const [filePreview, setFilePreview] = useState ('') + const [loading, setLoading] = useState (false) + const [material, setMaterial] = useState (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 (`/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 (`/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 ( + + {material && ( + + {`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`} + )} + + {loading ? 'Loading...' : (material && ( + <> + + + + + {(material.file && material.contentType) && ( + (/image\/.*/.test (material.contentType) && ( + {material.tag.name)) + || (/video\/.*/.test (material.contentType) && ( + ) +}) satisfies FC diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx index 0a0b635..4ca7272 100644 --- a/frontend/src/pages/materials/MaterialListPage.tsx +++ b/frontend/src/pages/materials/MaterialListPage.tsx @@ -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 & { children: TagWithMaterial[] - material: any | null } - + material: Material | null } + + +const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => { + if (!(tag.material)) + return + + return ( + +
+ {(tag.material.contentType && /image\/.*/.test (tag.material.contentType)) + ? + : 照会} +
+
) +} export default (() => { + const [loading, setLoading] = useState (false) const [tag, setTag] = useState (null) const location = useLocation () @@ -28,41 +50,117 @@ export default (() => { const tagQuery = query.get ('tag') ?? '' useEffect (() => { + if (!(tagQuery)) + { + setTag (null) + return + } + void (async () => { - setTag ( - await apiGet (`/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) + try + { + setLoading (true) + setTag ( + await apiGet ( + `/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) + } + finally + { + setLoading (false) + } }) () }, [location.search]) return ( -
+ + {`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`} - - - - {tag - ? ( - <> - {tag.name} - {tag.children.map (c2 => ( - - {c2.name} - {c2.children.map (c3 => ( - {c3.name}))} - ))} - ) - : ( - <> -

左のリストから照会したいタグを選択してください。

-

もしくは……

- - )} -
-
) + {loading ? 'Loading...' : ( + tag + ? ( + <> + + + + {(!(tag.material) && tag.category !== 'meme') && ( +
+ + 追加 + +
)} + + + +
+ {tag.children.map (c2 => ( + + + + + {(!(c2.material) && c2.category !== 'meme') && ( +
+ + 追加 + +
)} + + + +
+ {c2.children.map (c3 => ( + + + + + {(!(c3.material) && c3.category !== 'meme') && ( +
+ + 追加 + +
)} + + +
))} +
+
))} +
+ ) + : ( + <> +

左のリストから照会したいタグを選択してください。

+

もしくは……

+ + ))} + ) }) satisfies FC diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx new file mode 100644 index 0000000..9260639 --- /dev/null +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -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 (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 ( + + + {`素材追加 | ${ SITE_TITLE }`} + + +
+ 素材追加 + + {/* タグ */} +
+ + +
+ + {/* ファイル */} +
+ + { + const f = e.target.files?.[0] + setFile (f ?? null) + setFilePreview (f ? URL.createObjectURL (f) : '') + }}/> + {(file && filePreview) && ( + (/image\/.*/.test (file.type) && ( + preview)) + || (/video\/.*/.test (file.type) && ( +
+ + {/* 参考 URL */} +
+ + setURL (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* 送信 */} + +
+
) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d788ebd..a9b2581 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 = {