mirror of
https://github.com/jorgev259/soc_site-astro.git
synced 2025-06-29 07:57:41 +00:00
Album edit endpoint
This commit is contained in:
parent
d3581eaeef
commit
6cef84a358
24 changed files with 936 additions and 76 deletions
|
|
@ -20,20 +20,24 @@
|
|||
"@types/react-dom": "^18.3.1",
|
||||
"astro": "^5.3.0",
|
||||
"astro-icon": "^1.1.1",
|
||||
"better-auth": "^1.1.11",
|
||||
"axios": "^1.8.1",
|
||||
"better-auth": "^1.1.11",
|
||||
"clsx": "^2.1.1",
|
||||
"decode-formdata": "^0.9.0",
|
||||
"immer": "^10.1.1",
|
||||
"nodemailer": "^6.10.0",
|
||||
"prisma": "^6.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-multi-select-component": "^4.3.4",
|
||||
"react-svg-spinners": "^0.3.1",
|
||||
"sharp": "^0.33.5",
|
||||
"slugify": "^1.6.6",
|
||||
"superstruct": "^2.0.2",
|
||||
"tailwindcss": "^4.0.7",
|
||||
"typescript": "^5.6.2"
|
||||
"typescript": "^5.6.2",
|
||||
"use-immer": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "1.11.2",
|
||||
|
|
|
|||
|
|
@ -2,26 +2,24 @@ import type { PropsWithChildren, JSX } from 'react'
|
|||
import clsx from 'clsx'
|
||||
import { BarsRotateFade } from 'react-svg-spinners'
|
||||
|
||||
export default function Button(
|
||||
props: PropsWithChildren<{ className?: string; loading?: boolean }> & JSX.IntrinsicElements['button']
|
||||
) {
|
||||
const { children, className, loading = false, ...restProps } = props
|
||||
export default function Button(props: PropsWithChildren<{ loading?: boolean }> & JSX.IntrinsicElements['button']) {
|
||||
const { children, className, loading = false, type = 'button', ...restProps } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={clsx(
|
||||
{ 'cursor-progress': loading },
|
||||
'py-2 px-3.5 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400',
|
||||
{ 'cursor-progress': loading, loading },
|
||||
'group py-2 px-3.5 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<div className='relative flex'>
|
||||
<span className={clsx({ invisible: loading })}>{children}</span>
|
||||
{loading ? (
|
||||
<div className='absolute top-0 left-0 w-full flex justify-center'>
|
||||
<BarsRotateFade color='white' />
|
||||
</div>
|
||||
) : null}
|
||||
<span className='group-[.loading]:invisible'>{children}</span>
|
||||
<div className='hidden group-[.loading]:flex absolute top-0 left-0 w-full justify-center'>
|
||||
<BarsRotateFade color='white' />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
57
src/components/adminAlbum/DiscSection.tsx
Normal file
57
src/components/adminAlbum/DiscSection.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useImmer } from 'use-immer'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
import Button from 'components/Button'
|
||||
import { InputArea } from 'components/form/Input'
|
||||
|
||||
type Disc = Prisma.discsGetPayload<{ select: { number: true; body: true } }>
|
||||
|
||||
interface Props {
|
||||
defaultValue?: Disc[]
|
||||
}
|
||||
|
||||
export default function DiscSection(props: Props) {
|
||||
const { defaultValue = [{ number: 0, body: '' }] } = props
|
||||
const [discs, setDiscs] = useImmer<Disc[]>(defaultValue)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex gap-x-2'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDiscs((current) => {
|
||||
current.push({ number: 0, body: '' })
|
||||
})
|
||||
}}
|
||||
>
|
||||
Add empty disc
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDiscs((current) => current.filter((value, index) => (value.body?.length ?? 0) > 0 || index === 0))
|
||||
}}
|
||||
>
|
||||
Remove empty discs
|
||||
</Button>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
{discs.map((value, index) => (
|
||||
<div key={index}>
|
||||
<input hidden name={`discs.${index}.number`} value={index} readOnly type='number' />
|
||||
<InputArea
|
||||
dark
|
||||
label={`Disc ${index + 1}`}
|
||||
name={`discs.${index}.body`}
|
||||
defaultValue={value.body}
|
||||
onChange={(ev) => {
|
||||
setDiscs((current) => {
|
||||
current[index].body = ev.target.value
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
128
src/components/adminAlbum/DownloadSections.tsx
Normal file
128
src/components/adminAlbum/DownloadSections.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useImmer } from 'use-immer'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
import Button from 'components/Button'
|
||||
import { Input, InputSelect } from 'components/form/Input'
|
||||
|
||||
import { DownloadProvider } from 'utils/consts'
|
||||
|
||||
type Download = Prisma.downloadsGetPayload<{
|
||||
select: { title: true }
|
||||
include: { links: { select: { provider: true; directUrl: true; url: true; url2: true } } }
|
||||
}>
|
||||
|
||||
const defaultLink = { provider: DownloadProvider.MEDIAFIRE, url: null, url2: null, directUrl: null }
|
||||
//@ts-ignore
|
||||
const defaultSection: Download = { title: '', links: [defaultLink] }
|
||||
|
||||
interface Props {
|
||||
defaultValue?: Download[]
|
||||
}
|
||||
|
||||
export default function DownloadSection(props: Props) {
|
||||
const { defaultValue = [defaultSection] } = props
|
||||
const [downloads, setDownloads] = useImmer<Download[]>(defaultValue)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex gap-x-2'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDownloads((current) => {
|
||||
current.push(defaultSection)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Add download section
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1.5'>
|
||||
{downloads.map((d, index) => (
|
||||
<div key={index} className='border-2 border-white/60 rounded-md p-2'>
|
||||
<div>
|
||||
<Input
|
||||
dark
|
||||
label='Title'
|
||||
name={`downloads.${index}.title`}
|
||||
value={d.title ?? ''}
|
||||
onChange={(ev) => {
|
||||
setDownloads((current) => {
|
||||
current[index].title = ev.target.value
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{d.links.map((link, linkIndex) => (
|
||||
<div key={linkIndex} className='flex'>
|
||||
<div className='grid grid-cols-4 gap-4'>
|
||||
<InputSelect
|
||||
dark
|
||||
name={`downloads.${index}.links.${linkIndex}.provider`}
|
||||
label='Provider'
|
||||
defaultValue={link.provider ?? ''}
|
||||
>
|
||||
{Object.values(DownloadProvider).map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider}
|
||||
</option>
|
||||
))}
|
||||
</InputSelect>
|
||||
<Input
|
||||
dark
|
||||
label='Ouo.io (Url)'
|
||||
name={`downloads.${index}.links.${linkIndex}.url`}
|
||||
defaultValue={link.url ?? ''}
|
||||
/>
|
||||
<Input
|
||||
dark
|
||||
label='Fly.inc (Url 2)'
|
||||
name={`downloads.${index}.links.${linkIndex}.url2`}
|
||||
defaultValue={link.url2 ?? ''}
|
||||
/>
|
||||
<Input
|
||||
dark
|
||||
label='Direct'
|
||||
name={`downloads.${index}.links.${linkIndex}.directUrl`}
|
||||
defaultValue={link.directUrl ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex p-3'>
|
||||
<Button
|
||||
className='mt-auto bg-red-500 hover:bg-red-600'
|
||||
onClick={() => {
|
||||
setDownloads((current) => {
|
||||
current[index].links.splice(linkIndex, 1)
|
||||
})
|
||||
}}
|
||||
>
|
||||
X
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className='flex gap-x-2'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDownloads((current) => {
|
||||
current[index].links.push(defaultLink)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDownloads((current) => {
|
||||
current.splice(index, 1)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
73
src/components/adminAlbum/StoresSection.tsx
Normal file
73
src/components/adminAlbum/StoresSection.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useImmer } from 'use-immer'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
import Button from 'components/Button'
|
||||
import { Input, InputSelect } from 'components/form/Input'
|
||||
|
||||
import { StoreProviders } from 'utils/consts'
|
||||
|
||||
type Store = Prisma.storesGetPayload<{ select: { url: true; provider: true } }>
|
||||
|
||||
interface Props {
|
||||
defaultValue?: Store[]
|
||||
}
|
||||
|
||||
const defaultStore: Store = { provider: StoreProviders.AMAZON, url: '' }
|
||||
|
||||
export default function StoresSection(props: Props) {
|
||||
const { defaultValue = [defaultStore] } = props
|
||||
const [stores, setStores] = useImmer<typeof defaultValue>(defaultValue)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex gap-x-2'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setStores((current) => {
|
||||
current.push(defaultStore)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Add store link
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-2.5'>
|
||||
{stores.map((d, index) => (
|
||||
<div key={index} className='flex gap-x-2 '>
|
||||
<InputSelect dark name={`stores.${index}.provider`} label='Provider' defaultValue={d.provider ?? ''}>
|
||||
{Object.values(StoreProviders).map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{provider}
|
||||
</option>
|
||||
))}
|
||||
</InputSelect>
|
||||
|
||||
<Input
|
||||
dark
|
||||
label='Url'
|
||||
name={`stores.${index}.url`}
|
||||
value={d.url ?? ''}
|
||||
onChange={(ev) => {
|
||||
setStores((current) => {
|
||||
current[index].url = ev.target.value
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className='flex py-3'>
|
||||
<Button
|
||||
className='mt-auto bg-red-500 hover:bg-red-600'
|
||||
onClick={() => {
|
||||
setStores((current) => {
|
||||
current.splice(index, 1)
|
||||
})
|
||||
}}
|
||||
>
|
||||
X
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
src/components/form/AsyncMultiSelect.tsx
Normal file
71
src/components/form/AsyncMultiSelect.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { MultiSelect, type Option } from 'react-multi-select-component'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
nameColumn: string
|
||||
className?: string
|
||||
valueColumn?: string
|
||||
defaultSelected?: Option[]
|
||||
name: string
|
||||
}
|
||||
|
||||
function toMapValue(data: any[], nameColumn: string, valueColumn: string) {
|
||||
return data.map((item: { [nameColumn]: string; [valueColumn]: string }) => ({
|
||||
label: item[nameColumn],
|
||||
value: item[valueColumn]
|
||||
}))
|
||||
}
|
||||
|
||||
export default function AsyncMultiSelect(props: Props) {
|
||||
const { url: defaultUrl, nameColumn, valueColumn = 'id', className, defaultSelected = [], name } = props
|
||||
const [url, setUrl] = useState(defaultUrl)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Option[]>(defaultSelected)
|
||||
const [options, setOptions] = useState<Option[]>(defaultSelected)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json()
|
||||
const dataOptions = toMapValue(data, nameColumn, valueColumn)
|
||||
|
||||
setOptions([...defaultSelected, ...dataOptions])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [url])
|
||||
|
||||
function filterOptions(options: Option[], search: string) {
|
||||
if (search.length === 0) setUrl(defaultUrl)
|
||||
else setUrl(`${defaultUrl}?q=${search}`)
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
labelledBy={name}
|
||||
isLoading={loading}
|
||||
options={options}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
filterOptions={filterOptions}
|
||||
className={className}
|
||||
/>
|
||||
{selected.map((s, i) => (
|
||||
<input key={i} hidden name={`${name}.${i}`} value={s.value} readOnly />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +1,67 @@
|
|||
import type { ComponentProps, PropsWithChildren } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function Input(props: PropsWithChildren<ComponentProps<'input'>>) {
|
||||
const { name, className, children, ...attrs } = props
|
||||
export function InputLabel(props: PropsWithChildren<{ dark: boolean; name: string }>) {
|
||||
const { dark, name, children } = props
|
||||
return (
|
||||
<label htmlFor={name} className={clsx('font-medium', dark ? 'text-white' : 'text-black')}>
|
||||
{children}:
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
interface CustomInputProps {
|
||||
name: string
|
||||
label: string
|
||||
dark?: boolean
|
||||
defaultValue?: string | number | null
|
||||
}
|
||||
|
||||
export function Input(props: CustomInputProps & Omit<ComponentProps<'input'>, 'defaultValue'>) {
|
||||
const { name, className, dark = false, defaultValue, label, ...attrs } = props
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<label htmlFor={name} className='font-medium text-black'>
|
||||
{children}:
|
||||
</label>
|
||||
<input {...attrs} name={name} className={clsx('bg-zinc-200 rounded-md p-2 mt-2 mb-3 text-black', className)} />
|
||||
<div className={clsx('flex flex-col', className)}>
|
||||
<InputLabel dark={dark} name={name}>
|
||||
{label}
|
||||
</InputLabel>
|
||||
<input
|
||||
{...attrs}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
name={name}
|
||||
className='bg-zinc-200 rounded-md p-2 mt-2 mb-3 text-black'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InputArea(props: CustomInputProps & Omit<ComponentProps<'textarea'>, 'defaultValue'>) {
|
||||
const { name, className, dark = false, defaultValue, label, ...attrs } = props
|
||||
|
||||
return (
|
||||
<div className={clsx('flex flex-col', className)}>
|
||||
<InputLabel dark={dark} name={name}>
|
||||
{label}
|
||||
</InputLabel>
|
||||
<textarea
|
||||
{...attrs}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
name={name}
|
||||
className='bg-zinc-200 rounded-md p-2 mt-2 mb-3 text-black'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InputSelect(props: CustomInputProps & ComponentProps<'select'>) {
|
||||
const { name, className, dark = false, label, ...attrs } = props
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<InputLabel dark={dark} name={name}>
|
||||
{label}
|
||||
</InputLabel>
|
||||
<select name={name} className='bg-zinc-200 rounded-md p-2 mt-2 h-full mb-3 text-black' {...attrs} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
21
src/components/form/MultiSelectWrapper.tsx
Normal file
21
src/components/form/MultiSelectWrapper.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useState } from 'react'
|
||||
import { type SelectProps, type Option, MultiSelect } from 'react-multi-select-component'
|
||||
|
||||
interface Props extends Omit<SelectProps, 'value' | 'onChange'> {
|
||||
name: string
|
||||
defaultSelected?: Option[]
|
||||
}
|
||||
|
||||
export default function MultiSelectWrapper(props: Props) {
|
||||
const { defaultSelected = [], name, ...rest } = props
|
||||
const [selected, setSelected] = useState<Option[]>(defaultSelected)
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect value={selected} onChange={setSelected} {...rest} />
|
||||
{selected.map((s, i) => (
|
||||
<input key={i} name={`${name}.${i}`} value={s.value} hidden readOnly />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as m from 'paraglide/messages.js'
|
||||
|
||||
import Button from 'components/Button'
|
||||
import Modal from 'components/Modal'
|
||||
import Input from 'components/form/Input'
|
||||
import { Input } from 'components/form/Input'
|
||||
|
||||
import { signUp } from 'utils/auth-client'
|
||||
|
||||
export default function RegisterBtn() {
|
||||
|
|
@ -46,19 +47,11 @@ export default function RegisterBtn() {
|
|||
<form onSubmit={handleSubmit}>
|
||||
<div className='px-4 pt-5 pb-4 gap-x-4 gap-y-1 flex flex-col'>
|
||||
<div className='flex gap-x-4'>
|
||||
<Input name='username' required>
|
||||
{m.username()}
|
||||
</Input>
|
||||
<Input name='name' required>
|
||||
{m.displayName()}
|
||||
</Input>
|
||||
<Input name='username' required label={m.username()} />
|
||||
<Input name='name' required label={m.displayName()} />
|
||||
</div>
|
||||
<Input name='email' type='email' required>
|
||||
{m.email()}
|
||||
</Input>
|
||||
<Input name='password' type='password' required>
|
||||
{m.password()}
|
||||
</Input>
|
||||
<Input name='email' type='email' required label={m.email()} />
|
||||
<Input name='password' type='password' required label={m.password()} />
|
||||
<div className='mx-auto'>
|
||||
<Button type='submit' loading={loading} disabled={loading}>
|
||||
{m.register()}
|
||||
|
|
|
|||
215
src/pages/admin/album/[id].astro
Normal file
215
src/pages/admin/album/[id].astro
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
---
|
||||
import { AlbumStatus } from '@prisma/client'
|
||||
import prismaClient from 'utils/prisma-client'
|
||||
|
||||
import Base from 'layouts/base.astro'
|
||||
import DiscSection from 'components/adminAlbum/DiscSection'
|
||||
import DownloadSections from 'components/adminAlbum/DownloadSections'
|
||||
import AsyncMultiSelect from 'components/form/AsyncMultiSelect'
|
||||
import { Input, InputArea, InputLabel, InputSelect } from 'components/form/Input'
|
||||
import Button from 'components/Button'
|
||||
import MultiSelectWrapper from 'components/form/MultiSelectWrapper'
|
||||
import StoresSection from 'components/adminAlbum/StoresSection'
|
||||
|
||||
const { user, permissions } = Astro.locals
|
||||
if (!user || !permissions.includes('UPDATE')) return Astro.redirect('/404')
|
||||
|
||||
const albumId = parseInt(Astro.params.id as string)
|
||||
const album = await prismaClient.albums.findUnique({
|
||||
where: { id: albumId },
|
||||
include: {
|
||||
categories: true,
|
||||
classifications: true,
|
||||
platforms: { select: { platform: { select: { id: true, name: true } } } },
|
||||
games: { select: { game: { select: { slug: true, name: true } } } },
|
||||
animations: { select: { animation: { select: { id: true, title: true } } } },
|
||||
artists: { select: { artist: { select: { name: true } } } },
|
||||
relatedAlbums: {
|
||||
select: {
|
||||
relatedAlbum: { select: { id: true, title: true } }
|
||||
}
|
||||
},
|
||||
stores: { select: { provider: true, url: true } },
|
||||
downloads: { select: { title: true }, include: { links: true } },
|
||||
discs: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!album) {
|
||||
return Astro.redirect('/404')
|
||||
}
|
||||
---
|
||||
|
||||
<style>
|
||||
hr {
|
||||
margin-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
document.getElementById('editAlbum')?.addEventListener('submit', async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.target as HTMLFormElement)
|
||||
|
||||
document.getElementById('editAlbumSubmit')?.classList.add('loading')
|
||||
const response = await fetch('/api/album/edit', { method: 'POST', body: formData })
|
||||
document.getElementById('editAlbumSubmit')?.classList.remove('loading')
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Album updated successfully')
|
||||
} else {
|
||||
toast.error(response.statusText)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Base>
|
||||
<div class='flex flex-col w-full'>
|
||||
<div class='w-full min-h-100vh mx-auto max-w-[1140px]'>
|
||||
<form id='editAlbum' class='my-4 p-4 bg-dark rounded-md mx-2 flex gap-y-2 flex-col'>
|
||||
<input hidden readonly name='albumId' value={album.id} type='number' />
|
||||
<div class='grid grid-cols-4 gap-x-4 gap-y-1'>
|
||||
<Input dark defaultValue={album.title} name='title' label='Title' className='col-span-2' />
|
||||
<Input dark defaultValue={album.subTitle} name='subTitle' label='Subtitle' className='col-span-2' />
|
||||
|
||||
<Input dark defaultValue={album.description} name='description' label='Description' className='col-span-3' />
|
||||
<Input dark name='cover' label='Cover image' type='file' />
|
||||
|
||||
<InputSelect dark name='status' label='Status' value={album.status}>
|
||||
<option value={AlbumStatus.HIDDEN}>Hidden</option>
|
||||
<option value={AlbumStatus.SHOW}>Show</option>
|
||||
</InputSelect>
|
||||
<Input
|
||||
dark
|
||||
defaultValue={album.releaseDate?.toISOString().slice(0, 10)}
|
||||
name='releaseDate'
|
||||
type='date'
|
||||
label='Release Date'
|
||||
/>
|
||||
<Input dark defaultValue={album.label} name='label' label='Label' />
|
||||
<Input dark defaultValue={album.vgmdb} name='vgmdb' label='VGMDB' />
|
||||
|
||||
<InputArea
|
||||
dark
|
||||
defaultValue={album.artists.map((a) => a.artist.name).join(', ')}
|
||||
name='artists'
|
||||
label='Artists'
|
||||
className='col-span-3'
|
||||
/>
|
||||
<div class='flex flex-col'>
|
||||
<InputLabel dark name='classifications'>Classifications</InputLabel>
|
||||
<MultiSelectWrapper
|
||||
client:only='react'
|
||||
name='classifications'
|
||||
options={[
|
||||
{ value: 'Arrangement', label: 'Arrangement' },
|
||||
{ value: 'Drama', label: 'Drama' },
|
||||
{ value: 'GameRip', label: 'GameRip' },
|
||||
{ value: 'Live Event', label: 'Live Event' },
|
||||
{ value: 'Original Soundtrack', label: 'Original Soundtrack' },
|
||||
{ value: 'Vocal', label: 'Vocal' }
|
||||
]}
|
||||
defaultSelected={album.classifications?.map(({ classificationName }) => ({
|
||||
value: classificationName,
|
||||
label: classificationName
|
||||
})) || []}
|
||||
labelledBy='classifications'
|
||||
className='rounded-md py-2 h-full'
|
||||
/>
|
||||
</div>
|
||||
<div class='flex flex-col'>
|
||||
<InputLabel dark name='categories'>Categories</InputLabel>
|
||||
<MultiSelectWrapper
|
||||
client:only='react'
|
||||
options={[
|
||||
{ value: 'Game', label: 'Game' },
|
||||
{ value: 'Animation', label: 'Animation' }
|
||||
]}
|
||||
name='categories'
|
||||
defaultSelected={album.categories?.map(({ categoryName }) => ({
|
||||
value: categoryName,
|
||||
label: categoryName
|
||||
})) || []}
|
||||
labelledBy='categories'
|
||||
className='rounded-md py-2 h-full'
|
||||
/>
|
||||
</div>
|
||||
<div class='flex flex-col'>
|
||||
<InputLabel dark name='platforms'>Platforms</InputLabel>
|
||||
<AsyncMultiSelect
|
||||
client:only='react'
|
||||
name='platforms'
|
||||
url='/api/platform/find'
|
||||
nameColumn='name'
|
||||
defaultSelected={album.platforms?.map((p) => ({
|
||||
value: p.platform.id,
|
||||
label: p.platform.name as string
|
||||
})) || []}
|
||||
className='rounded-md py-2 h-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class='flex flex-col'>
|
||||
<InputLabel dark name='games'>Games</InputLabel>
|
||||
<AsyncMultiSelect
|
||||
client:only='react'
|
||||
name='games'
|
||||
url='/api/game/find'
|
||||
valueColumn='slug'
|
||||
nameColumn='name'
|
||||
defaultSelected={album.games?.map((g) => ({
|
||||
value: g.game.slug,
|
||||
label: g.game.name as string
|
||||
})) || []}
|
||||
className='rounded-md py-2 h-full'
|
||||
/>
|
||||
</div>
|
||||
<div class='flex flex-col'>
|
||||
<InputLabel dark name='animations'>Animations</InputLabel>
|
||||
<AsyncMultiSelect
|
||||
client:only='react'
|
||||
name='animations'
|
||||
url='/api/anim/find'
|
||||
nameColumn='title'
|
||||
defaultSelected={album.animations?.map((g) => ({
|
||||
value: g.animation.id,
|
||||
label: g.animation.title as string
|
||||
})) || []}
|
||||
className='rounded-md py-2 h-full'
|
||||
/>
|
||||
</div>
|
||||
<div class='flex flex-col'>
|
||||
<InputLabel dark name='related'>Related albums</InputLabel>
|
||||
<AsyncMultiSelect
|
||||
client:only='react'
|
||||
name='related'
|
||||
url='/api/album/find'
|
||||
nameColumn='title'
|
||||
defaultSelected={album.relatedAlbums?.map((g) => ({
|
||||
value: g.relatedAlbum.id,
|
||||
label: g.relatedAlbum.title as string
|
||||
})) || []}
|
||||
className='rounded-md py-2 h-full'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<StoresSection client:only='react' defaultValue={album.stores} />
|
||||
<hr />
|
||||
<div class='flex flex-col gap-y-4'>
|
||||
<DiscSection client:only='react' defaultValue={album.discs} />
|
||||
</div>
|
||||
<hr />
|
||||
<div class='flex flex-col gap-y-4'>
|
||||
<DownloadSections client:only='react' defaultValue={album.downloads} />
|
||||
</div>
|
||||
<hr />
|
||||
<div class=''>
|
||||
<Button type='submit' id='editAlbumSubmit'>Save changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
|
@ -286,13 +286,13 @@ const { currentLocale } = Astro
|
|||
/>
|
||||
</div>
|
||||
{url2 && (
|
||||
<DownloadBtn href={url2} alt='fly' icon={flyIcon}>
|
||||
{m.flyInc()}
|
||||
<DownloadBtn href={url2} alt='fly inc' icon={flyIcon}>
|
||||
Fly.inc
|
||||
</DownloadBtn>
|
||||
)}
|
||||
{url ? (
|
||||
<DownloadBtn href={url} alt='ouo' icon={ouoIcon}>
|
||||
{m.ouoIO()}
|
||||
ouo.io
|
||||
</DownloadBtn>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,21 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import * as s from 'superstruct'
|
||||
import prismaClient from 'utils/prisma-client'
|
||||
|
||||
import { AlbumStatus } from '@prisma/client'
|
||||
|
||||
import { Status, parseForm, slug } from 'utils/form'
|
||||
import { writeImg, getImgColor } from 'utils/img'
|
||||
import { handleCover } from 'utils/img'
|
||||
import { handleComplete } from 'integrations/requestCat'
|
||||
import { CreateAlbum } from 'schemas/album'
|
||||
import { AlbumBase } from 'schemas/album'
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const { session, permissions, user } = locals
|
||||
|
||||
if (!session || !user) return Status(401)
|
||||
if (!permissions.includes('CREATE')) return Status(403)
|
||||
const { permissions, user } = locals
|
||||
if (!user || !permissions.includes('CREATE')) return Status(403)
|
||||
|
||||
let body
|
||||
try {
|
||||
const formData = await parseForm(request)
|
||||
body = s.create(formData, CreateAlbum)
|
||||
const formData = await parseForm(await request.formData())
|
||||
body = s.create(formData, AlbumBase)
|
||||
} catch (err) {
|
||||
return Status(422, (err as Error).message)
|
||||
}
|
||||
|
|
@ -58,15 +56,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||
include: { artists: { include: { artist: { select: { name: true } } } } }
|
||||
})
|
||||
|
||||
const handleCover = async () => {
|
||||
const coverPath = await writeImg(body.cover, 'album', albumRow.id)
|
||||
const headerColor = await getImgColor(coverPath)
|
||||
await tx.albums.update({ where: { id: albumRow.id }, data: { headerColor } })
|
||||
albumRow.headerColor = headerColor
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
handleCover(),
|
||||
handleCover(body.cover, 'album', albumRow.id, tx),
|
||||
Promise.all(
|
||||
body.downloads.map((d) =>
|
||||
tx.downloads.create({
|
||||
|
|
|
|||
87
src/pages/api/album/edit.ts
Normal file
87
src/pages/api/album/edit.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import * as s from 'superstruct'
|
||||
import prismaClient from 'utils/prisma-client'
|
||||
|
||||
import { Status, parseForm, slug } from 'utils/form'
|
||||
import { handleCover } from 'utils/img'
|
||||
import { EditAlbum } from 'schemas/album'
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const { permissions, user } = locals
|
||||
if (!user || !permissions.includes('UPDATE')) return Status(403)
|
||||
|
||||
let body
|
||||
try {
|
||||
const formData = await parseForm(await request.formData())
|
||||
body = s.create(formData, EditAlbum)
|
||||
} catch (err) {
|
||||
return Status(422, (err as Error).message)
|
||||
}
|
||||
|
||||
try {
|
||||
await prismaClient.$transaction(async (tx) => {
|
||||
const {
|
||||
artists,
|
||||
animations,
|
||||
categories,
|
||||
classifications,
|
||||
games,
|
||||
platforms,
|
||||
discs,
|
||||
downloads,
|
||||
stores,
|
||||
cover,
|
||||
related,
|
||||
albumId,
|
||||
...rest
|
||||
} = body
|
||||
|
||||
await tx.albums.update({
|
||||
where: { id: body.albumId },
|
||||
data: {
|
||||
...rest,
|
||||
artists: {
|
||||
deleteMany: {},
|
||||
create: artists
|
||||
?.split(',')
|
||||
.map((name: string) => ({ slug: slug(name.trim()), name: name.trim() }))
|
||||
.map((a) => ({
|
||||
artist: {
|
||||
connectOrCreate: {
|
||||
create: a,
|
||||
where: { slug: a.slug }
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
animations: { deleteMany: {}, create: animations?.map((id) => ({ animation: { connect: { id } } })) },
|
||||
categories: { deleteMany: {}, create: categories?.map((c) => ({ category: { connect: { name: c } } })) },
|
||||
games: { deleteMany: {}, create: games?.map((slug) => ({ game: { connect: { slug } } })) },
|
||||
platforms: { deleteMany: {}, create: platforms?.map((id) => ({ platform: { connect: { id } } })) },
|
||||
discs: { deleteMany: {}, createMany: { data: body.discs ?? [] } },
|
||||
relatedAlbums: { deleteMany: {}, create: related?.map((id) => ({ relatedAlbum: { connect: { id } } })) }
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
cover ? handleCover(cover, 'album', albumId, tx) : undefined,
|
||||
downloads
|
||||
? tx.downloads.createMany({
|
||||
data: downloads.map((d) => ({
|
||||
title: d.title,
|
||||
albumId: albumId,
|
||||
links: { create: d.links }
|
||||
}))
|
||||
})
|
||||
: undefined
|
||||
])
|
||||
})
|
||||
|
||||
// if (albumRow.status === AlbumStatus.SHOW) await handleComplete(albumRow, body.request)
|
||||
|
||||
return Status(200, body.albumId.toString())
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return Status(500, (err as Error).message)
|
||||
}
|
||||
}
|
||||
23
src/pages/api/album/find.ts
Normal file
23
src/pages/api/album/find.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import prismaClient from 'utils/prisma-client'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const { url } = context
|
||||
const titleParam = url.searchParams.get('q')
|
||||
|
||||
const anims = await prismaClient.albums.findMany({
|
||||
where: titleParam
|
||||
? { OR: [{ title: { contains: titleParam } }, { subTitle: { contains: titleParam } }] }
|
||||
: undefined,
|
||||
select: { id: true, title: true },
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify(anims), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
21
src/pages/api/anim/find.ts
Normal file
21
src/pages/api/anim/find.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import prismaClient from 'utils/prisma-client'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const { url } = context
|
||||
const titleParam = url.searchParams.get('q')
|
||||
|
||||
const anims = await prismaClient.animation.findMany({
|
||||
where: titleParam ? { title: { contains: titleParam } } : undefined,
|
||||
select: { id: true, title: true },
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify(anims), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
21
src/pages/api/game/find.ts
Normal file
21
src/pages/api/game/find.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import prismaClient from 'utils/prisma-client'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const { url } = context
|
||||
const titleParam = url.searchParams.get('q')
|
||||
|
||||
const anims = await prismaClient.game.findMany({
|
||||
where: titleParam ? { name: { contains: titleParam } } : undefined,
|
||||
select: { slug: true, name: true },
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify(anims), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
21
src/pages/api/platform/find.ts
Normal file
21
src/pages/api/platform/find.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import prismaClient from 'utils/prisma-client'
|
||||
|
||||
export const GET: APIRoute = async (context) => {
|
||||
const { url } = context
|
||||
const titleParam = url.searchParams.get('q')
|
||||
|
||||
const anims = await prismaClient.platform.findMany({
|
||||
where: titleParam ? { name: { contains: titleParam } } : undefined,
|
||||
select: { id: true, name: true },
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify(anims), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import * as s from 'superstruct'
|
||||
import { AlbumStatus } from '@prisma/client'
|
||||
import { DownloadProvider } from 'utils/consts'
|
||||
|
||||
const LinkInput = s.object({
|
||||
provider: s.string(),
|
||||
custom: s.optional(s.string()),
|
||||
export const LinkInput = s.object({
|
||||
provider: s.enums(Object.values(DownloadProvider)),
|
||||
url: s.optional(s.string()),
|
||||
url2: s.optional(s.string()),
|
||||
directUrl: s.optional(s.string())
|
||||
|
|
@ -14,24 +14,33 @@ export const DownloadInput = s.object({
|
|||
links: s.defaulted(s.array(LinkInput), [])
|
||||
})
|
||||
|
||||
export const CreateAlbum = s.object({
|
||||
const coerceInt = s.coerce(s.integer(), s.string(), (value) => parseInt(value))
|
||||
export const StoreInput = s.object({ provider: s.string(), url: s.string() })
|
||||
export const DiscInput = s.object({ number: coerceInt, body: s.string() })
|
||||
|
||||
export const AlbumBase = s.object({
|
||||
cover: s.instance(File),
|
||||
title: s.optional(s.string()),
|
||||
subTitle: s.optional(s.string()),
|
||||
releaseDate: s.optional(s.string()),
|
||||
releaseDate: s.optional(s.date()),
|
||||
label: s.optional(s.string()),
|
||||
vgmdb: s.optional(s.string()),
|
||||
description: s.optional(s.string()),
|
||||
status: s.defaulted(s.enums(Object.values(AlbumStatus)), AlbumStatus.HIDDEN),
|
||||
animations: s.defaulted(s.array(s.integer()), []),
|
||||
animations: s.defaulted(s.array(coerceInt), []),
|
||||
artists: s.defaulted(s.array(s.string()), []),
|
||||
categories: s.defaulted(s.array(s.string()), []),
|
||||
classifications: s.defaulted(s.array(s.string()), []),
|
||||
games: s.defaulted(s.array(s.string()), []),
|
||||
platforms: s.defaulted(s.array(s.integer()), []),
|
||||
discs: s.defaulted(s.array(s.object({ number: s.integer(), body: s.string() })), []),
|
||||
platforms: s.defaulted(s.array(coerceInt), []),
|
||||
discs: s.defaulted(s.array(DiscInput), []),
|
||||
downloads: s.defaulted(s.array(DownloadInput), []),
|
||||
related: s.defaulted(s.array(s.number()), []),
|
||||
stores: s.defaulted(s.array(s.object({ provider: s.string(), url: s.string() })), []),
|
||||
request: s.optional(s.integer())
|
||||
related: s.defaulted(s.array(coerceInt), []),
|
||||
stores: s.defaulted(s.array(StoreInput), []),
|
||||
request: s.optional(coerceInt)
|
||||
})
|
||||
|
||||
export const EditAlbum = s.assign(
|
||||
s.partial(AlbumBase),
|
||||
s.object({ albumId: coerceInt, artists: s.optional(s.string()) })
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,4 +42,19 @@
|
|||
color: var(--color-hover-link);
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
option {
|
||||
color: var(--color-dark);
|
||||
}
|
||||
|
||||
.rmsc .select-item .item-renderer span,
|
||||
.rmsc .dropdown-heading-value span {
|
||||
color: var(--color-dark);
|
||||
}
|
||||
|
||||
.rmsc svg.gray,
|
||||
.rmsc svg.gray line {
|
||||
fill: var(--color-dark);
|
||||
color: var(--color-dark);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
src/utils/consts.ts
Normal file
22
src/utils/consts.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export enum DownloadProvider {
|
||||
MEGA = 'MEGA',
|
||||
MEDIAFIRE = 'MEDIAFIRE',
|
||||
RANOZ = 'RANOZ',
|
||||
TERABOX = 'TERABOX',
|
||||
MIRROR = 'MIRROR'
|
||||
}
|
||||
|
||||
export enum StoreProviders {
|
||||
AMAZON = 'amazon',
|
||||
AMAZON_JP = 'amazon_jp',
|
||||
PLAY_ASIA = 'play_asia',
|
||||
CD_JAPAN = 'cd_japan',
|
||||
SPOTIFY = 'spotify',
|
||||
GOOGLE_PLAY = 'google_play',
|
||||
STEAM = 'steam',
|
||||
MORA = 'mora',
|
||||
APPLE_MUSIC = 'apple_music',
|
||||
OTOTOY = 'ototoy',
|
||||
BANDCAMP = 'bandcamp',
|
||||
DEEZER = 'deezer'
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import slugify from 'slugify'
|
||||
import { decode } from 'decode-formdata'
|
||||
|
||||
export const Status = (status: number, statusText?: string) => new Response(null, { status, statusText })
|
||||
export const slug = (text: string) => slugify(text, { lower: true, strict: true })
|
||||
|
||||
function formToObject(formData: FormData) {
|
||||
export function formToObject(formData: FormData) {
|
||||
const object: Record<string, any> = {}
|
||||
for (const entry of formData.entries()) {
|
||||
const [key, value] = entry
|
||||
|
|
@ -13,13 +14,13 @@ function formToObject(formData: FormData) {
|
|||
return object
|
||||
}
|
||||
|
||||
export async function parseForm(request: Request) {
|
||||
const formData = await request.formData()
|
||||
const formObject = formToObject(formData)
|
||||
const { data: dataInput, ...rest } = formObject
|
||||
export async function parseForm(formData: FormData) {
|
||||
const formObject = decode(formData, {
|
||||
arrays: ['animations', 'classifications', 'categories', 'platforms', 'related', 'games', 'downloads', 'discs'],
|
||||
dates: ['releaseDate']
|
||||
})
|
||||
|
||||
const data = JSON.parse(dataInput)
|
||||
return { ...data, ...rest }
|
||||
return formObject
|
||||
}
|
||||
|
||||
export function getRandom<T>(array: T[]): T {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import sharp from 'sharp'
|
||||
import type { PrismaClient } from '@prisma/client/extension'
|
||||
|
||||
function colorToHex(color: number) {
|
||||
const hexadecimal = color.toString(16)
|
||||
|
|
@ -16,11 +17,27 @@ export async function writeImg(file: File, folder: string, id: number | string)
|
|||
const fullPath = path.join(pathString, `${id}.png`)
|
||||
|
||||
const fileArray = Buffer.from(await file.arrayBuffer())
|
||||
await fs.writeFile(fullPath, fileArray)
|
||||
await fs.mkdir(pathString, { recursive: true })
|
||||
|
||||
if (await fs.stat(fullPath).catch(() => false)) {
|
||||
await fs.rm(fullPath)
|
||||
}
|
||||
|
||||
await fs.writeFile(fullPath, fileArray)
|
||||
return fullPath
|
||||
}
|
||||
|
||||
export async function handleImg(file: File, folder: string, id: number | string) {
|
||||
const coverPath = await writeImg(file, folder, id)
|
||||
const headerColor = await getImgColor(coverPath)
|
||||
return headerColor
|
||||
}
|
||||
|
||||
export async function handleCover(file: File, folder: string, id: number | string, tx: PrismaClient) {
|
||||
const headerColor = await handleImg(file, folder, id)
|
||||
await tx.albums.update({ where: { id: id }, data: { headerColor } })
|
||||
}
|
||||
|
||||
export async function getImgColor(filePath: string) {
|
||||
const { dominant } = await sharp(filePath).stats()
|
||||
const { r, g, b } = dominant
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prismaClient = new PrismaClient()
|
||||
const prismaClient = new PrismaClient({ log: ['error'] })
|
||||
|
||||
export default prismaClient
|
||||
export default prismaClient
|
||||
|
|
|
|||
20
yarn.lock
20
yarn.lock
|
|
@ -2807,6 +2807,11 @@ debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3
|
|||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
decode-formdata@^0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/decode-formdata/-/decode-formdata-0.9.0.tgz#fa9c0c0ea0a279d6d1ea825c156534d2d5fa6721"
|
||||
integrity sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==
|
||||
|
||||
decode-named-character-reference@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e"
|
||||
|
|
@ -4032,6 +4037,11 @@ ignore@^5.2.0, ignore@^5.3.1, ignore@^5.3.2:
|
|||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
||||
|
||||
immer@^10.1.1:
|
||||
version "10.1.1"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc"
|
||||
integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==
|
||||
|
||||
import-fresh@^3.2.1:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf"
|
||||
|
|
@ -5750,6 +5760,11 @@ react-is@^16.13.1:
|
|||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-multi-select-component@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/react-multi-select-component/-/react-multi-select-component-4.3.4.tgz#4f4b354bfa1f0353fa9c3bccf8178c87c9780450"
|
||||
integrity sha512-Ui/bzCbROF4WfKq3OKWyQJHmy/bd1mW7CQM+L83TfiltuVvHElhKEyPM3JzO9urIcWplBUKv+kyxqmEnd9jPcA==
|
||||
|
||||
react-refresh@^0.14.2:
|
||||
version "0.14.2"
|
||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
||||
|
|
@ -6798,6 +6813,11 @@ uri-js@^4.2.2:
|
|||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
use-immer@^0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/use-immer/-/use-immer-0.11.0.tgz#8dd46cbc913cbc3298e60b8f2d9f58d82fc22e08"
|
||||
integrity sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==
|
||||
|
||||
util-deprecate@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue