Browse Source

#99

feature/099
みてるぞ 1 week ago
parent
commit
64e7400ed0
14 changed files with 577 additions and 86 deletions
  1. +40
    -16
      backend/app/controllers/materials_controller.rb
  2. +24
    -14
      backend/app/controllers/tags_controller.rb
  3. +9
    -1
      backend/app/models/material.rb
  4. +1
    -0
      backend/app/models/tag.rb
  5. +11
    -6
      frontend/src/App.tsx
  6. +8
    -7
      frontend/src/components/MaterialSidebar.tsx
  7. +3
    -3
      frontend/src/components/TopNav.tsx
  8. +3
    -1
      frontend/src/components/layout/MainArea.tsx
  9. +25
    -5
      frontend/src/components/layout/SidebarComponent.tsx
  10. +12
    -0
      frontend/src/pages/materials/MaterialBasePage.tsx
  11. +175
    -0
      frontend/src/pages/materials/MaterialDetailPage.tsx
  12. +131
    -33
      frontend/src/pages/materials/MaterialListPage.tsx
  13. +124
    -0
      frontend/src/pages/materials/MaterialNewPage.tsx
  14. +11
    -0
      frontend/src/types.ts

+ 40
- 16
backend/app/controllers/materials_controller.rb 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?)

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


+ 24
- 14
backend/app/controllers/tags_controller.rb 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
backend/app/models/material.rb 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
- 0
backend/app/models/tag.rb 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
frontend/src/App.tsx 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}/>}/>


+ 8
- 7
frontend/src/components/MaterialSidebar.tsx 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>
<ul>
{renderTags (tags)}
</ul>
</SidebarComponent>)
}) satisfies FC

+ 3
- 3
frontend/src/components/TopNav.tsx 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
frontend/src/components/layout/MainArea.tsx 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>

+ 25
- 5
frontend/src/components/layout/SidebarComponent.tsx View File

@@ -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>

+ 12
- 0
frontend/src/pages/materials/MaterialBasePage.tsx View File

@@ -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

+ 175
- 0
frontend/src/pages/materials/MaterialDetailPage.tsx View File

@@ -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

+ 131
- 33
frontend/src/pages/materials/MaterialListPage.tsx 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 () => {
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

+ 124
- 0
frontend/src/pages/materials/MaterialNewPage.tsx View File

@@ -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
- 0
frontend/src/types.ts 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 = {


Loading…
Cancel
Save