| @@ -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?) | |||||
| 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) | 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( | |||||
| 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 | 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 | |||||
| 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 | ||||
| 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 | ||||
| @@ -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/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" 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> | |||||
| {renderTags (tags)} | |||||
| </ul> | |||||
| </div> | |||||
| <ul> | |||||
| {renderTags (tags)} | |||||
| </ul> | |||||
| </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) => ( | |||||
| <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> | |||||
| export default ({ children }: Props) => ( | |||||
| <div className="p-4 w-full md:w-64 md:h-full"> | |||||
| {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 () => { | ||||
| setTag ( | |||||
| await apiGet<TagWithMaterial> (`/tags/name/${ encodeURIComponent (tagQuery) }/materials`)) | |||||
| try | |||||
| { | |||||
| setLoading (true) | |||||
| setTag ( | |||||
| 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/> | |||||
| <MainArea> | |||||
| {tag | |||||
| ? ( | |||||
| <> | |||||
| <PageTitle>{tag.name}</PageTitle> | |||||
| {tag.children.map (c2 => ( | |||||
| <Fragment key={c2.id}> | |||||
| <SectionTitle>{c2.name}</SectionTitle> | |||||
| {c2.children.map (c3 => ( | |||||
| <SubsectionTitle key={c3.id}>{c3.name}</SubsectionTitle>))} | |||||
| </Fragment>))} | |||||
| </>) | |||||
| : ( | |||||
| <> | |||||
| <p>左のリストから照会したいタグを選択してください。</p> | |||||
| <p>もしくは……</p> | |||||
| <ul> | |||||
| <li><Link to="/materials/new">素材を新規追加する</Link></li> | |||||
| <li><a href="#">すべての素材をダウンロードする</a></li> | |||||
| </ul> | |||||
| </>)} | |||||
| </MainArea> | |||||
| </div>) | |||||
| {loading ? 'Loading...' : ( | |||||
| tag | |||||
| ? ( | |||||
| <> | |||||
| <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> | |||||
| <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 => ( | |||||
| <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><PrefetchLink to="/materials/new">素材を新規追加する</PrefetchLink></li> | |||||
| {/* <li><a href="#">すべての素材をダウンロードする</a></li> */} | |||||
| </ul> | |||||
| </>))} | |||||
| </MainArea>) | |||||
| }) 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 = { | ||||