| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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}/>}/> | |||
| @@ -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> | |||
| <ul> | |||
| {renderTags (tags)} | |||
| </ul> | |||
| </SidebarComponent>) | |||
| }) satisfies FC | |||
| @@ -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' }, | |||
| @@ -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 | |||
| 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} | |||
| </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 { 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 () => { | |||
| 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]) | |||
| 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 | |||
| ? ( | |||
| <> | |||
| <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 | |||
| @@ -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 | |||
| 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 = { | |||