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",
|
"@types/react-dom": "^18.3.1",
|
||||||
"astro": "^5.3.0",
|
"astro": "^5.3.0",
|
||||||
"astro-icon": "^1.1.1",
|
"astro-icon": "^1.1.1",
|
||||||
"better-auth": "^1.1.11",
|
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
|
"better-auth": "^1.1.11",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"decode-formdata": "^0.9.0",
|
||||||
|
"immer": "^10.1.1",
|
||||||
"nodemailer": "^6.10.0",
|
"nodemailer": "^6.10.0",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-multi-select-component": "^4.3.4",
|
||||||
"react-svg-spinners": "^0.3.1",
|
"react-svg-spinners": "^0.3.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"superstruct": "^2.0.2",
|
"superstruct": "^2.0.2",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2",
|
||||||
|
"use-immer": "^0.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@inlang/paraglide-js": "1.11.2",
|
"@inlang/paraglide-js": "1.11.2",
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,24 @@ import type { PropsWithChildren, JSX } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { BarsRotateFade } from 'react-svg-spinners'
|
import { BarsRotateFade } from 'react-svg-spinners'
|
||||||
|
|
||||||
export default function Button(
|
export default function Button(props: PropsWithChildren<{ loading?: boolean }> & JSX.IntrinsicElements['button']) {
|
||||||
props: PropsWithChildren<{ className?: string; loading?: boolean }> & JSX.IntrinsicElements['button']
|
const { children, className, loading = false, type = 'button', ...restProps } = props
|
||||||
) {
|
|
||||||
const { children, className, loading = false, ...restProps } = props
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type={type}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
{ 'cursor-progress': loading },
|
{ 'cursor-progress': loading, loading },
|
||||||
'py-2 px-3.5 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400',
|
'group py-2 px-3.5 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<div className='relative flex'>
|
<div className='relative flex'>
|
||||||
<span className={clsx({ invisible: loading })}>{children}</span>
|
<span className='group-[.loading]:invisible'>{children}</span>
|
||||||
{loading ? (
|
<div className='hidden group-[.loading]:flex absolute top-0 left-0 w-full justify-center'>
|
||||||
<div className='absolute top-0 left-0 w-full flex justify-center'>
|
<BarsRotateFade color='white' />
|
||||||
<BarsRotateFade color='white' />
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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 type { ComponentProps, PropsWithChildren } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export default function Input(props: PropsWithChildren<ComponentProps<'input'>>) {
|
export function InputLabel(props: PropsWithChildren<{ dark: boolean; name: string }>) {
|
||||||
const { name, className, children, ...attrs } = props
|
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 (
|
return (
|
||||||
<div className='flex flex-col'>
|
<div className={clsx('flex flex-col', className)}>
|
||||||
<label htmlFor={name} className='font-medium text-black'>
|
<InputLabel dark={dark} name={name}>
|
||||||
{children}:
|
{label}
|
||||||
</label>
|
</InputLabel>
|
||||||
<input {...attrs} name={name} className={clsx('bg-zinc-200 rounded-md p-2 mt-2 mb-3 text-black', className)} />
|
<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>
|
</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 { useState, type FormEvent } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import * as m from 'paraglide/messages.js'
|
import * as m from 'paraglide/messages.js'
|
||||||
|
|
||||||
import Button from 'components/Button'
|
import Button from 'components/Button'
|
||||||
import Modal from 'components/Modal'
|
import Modal from 'components/Modal'
|
||||||
import Input from 'components/form/Input'
|
import { Input } from 'components/form/Input'
|
||||||
|
|
||||||
import { signUp } from 'utils/auth-client'
|
import { signUp } from 'utils/auth-client'
|
||||||
|
|
||||||
export default function RegisterBtn() {
|
export default function RegisterBtn() {
|
||||||
|
|
@ -46,19 +47,11 @@ export default function RegisterBtn() {
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className='px-4 pt-5 pb-4 gap-x-4 gap-y-1 flex flex-col'>
|
<div className='px-4 pt-5 pb-4 gap-x-4 gap-y-1 flex flex-col'>
|
||||||
<div className='flex gap-x-4'>
|
<div className='flex gap-x-4'>
|
||||||
<Input name='username' required>
|
<Input name='username' required label={m.username()} />
|
||||||
{m.username()}
|
<Input name='name' required label={m.displayName()} />
|
||||||
</Input>
|
|
||||||
<Input name='name' required>
|
|
||||||
{m.displayName()}
|
|
||||||
</Input>
|
|
||||||
</div>
|
</div>
|
||||||
<Input name='email' type='email' required>
|
<Input name='email' type='email' required label={m.email()} />
|
||||||
{m.email()}
|
<Input name='password' type='password' required label={m.password()} />
|
||||||
</Input>
|
|
||||||
<Input name='password' type='password' required>
|
|
||||||
{m.password()}
|
|
||||||
</Input>
|
|
||||||
<div className='mx-auto'>
|
<div className='mx-auto'>
|
||||||
<Button type='submit' loading={loading} disabled={loading}>
|
<Button type='submit' loading={loading} disabled={loading}>
|
||||||
{m.register()}
|
{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>
|
</div>
|
||||||
{url2 && (
|
{url2 && (
|
||||||
<DownloadBtn href={url2} alt='fly' icon={flyIcon}>
|
<DownloadBtn href={url2} alt='fly inc' icon={flyIcon}>
|
||||||
{m.flyInc()}
|
Fly.inc
|
||||||
</DownloadBtn>
|
</DownloadBtn>
|
||||||
)}
|
)}
|
||||||
{url ? (
|
{url ? (
|
||||||
<DownloadBtn href={url} alt='ouo' icon={ouoIcon}>
|
<DownloadBtn href={url} alt='ouo' icon={ouoIcon}>
|
||||||
{m.ouoIO()}
|
ouo.io
|
||||||
</DownloadBtn>
|
</DownloadBtn>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,21 @@
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
import * as s from 'superstruct'
|
import * as s from 'superstruct'
|
||||||
import prismaClient from 'utils/prisma-client'
|
import prismaClient from 'utils/prisma-client'
|
||||||
|
|
||||||
import { AlbumStatus } from '@prisma/client'
|
import { AlbumStatus } from '@prisma/client'
|
||||||
|
|
||||||
import { Status, parseForm, slug } from 'utils/form'
|
import { Status, parseForm, slug } from 'utils/form'
|
||||||
import { writeImg, getImgColor } from 'utils/img'
|
import { handleCover } from 'utils/img'
|
||||||
import { handleComplete } from 'integrations/requestCat'
|
import { handleComplete } from 'integrations/requestCat'
|
||||||
import { CreateAlbum } from 'schemas/album'
|
import { AlbumBase } from 'schemas/album'
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const { session, permissions, user } = locals
|
const { permissions, user } = locals
|
||||||
|
if (!user || !permissions.includes('CREATE')) return Status(403)
|
||||||
if (!session || !user) return Status(401)
|
|
||||||
if (!permissions.includes('CREATE')) return Status(403)
|
|
||||||
|
|
||||||
let body
|
let body
|
||||||
try {
|
try {
|
||||||
const formData = await parseForm(request)
|
const formData = await parseForm(await request.formData())
|
||||||
body = s.create(formData, CreateAlbum)
|
body = s.create(formData, AlbumBase)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return Status(422, (err as Error).message)
|
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 } } } } }
|
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([
|
await Promise.all([
|
||||||
handleCover(),
|
handleCover(body.cover, 'album', albumRow.id, tx),
|
||||||
Promise.all(
|
Promise.all(
|
||||||
body.downloads.map((d) =>
|
body.downloads.map((d) =>
|
||||||
tx.downloads.create({
|
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 * as s from 'superstruct'
|
||||||
import { AlbumStatus } from '@prisma/client'
|
import { AlbumStatus } from '@prisma/client'
|
||||||
|
import { DownloadProvider } from 'utils/consts'
|
||||||
|
|
||||||
const LinkInput = s.object({
|
export const LinkInput = s.object({
|
||||||
provider: s.string(),
|
provider: s.enums(Object.values(DownloadProvider)),
|
||||||
custom: s.optional(s.string()),
|
|
||||||
url: s.optional(s.string()),
|
url: s.optional(s.string()),
|
||||||
url2: s.optional(s.string()),
|
url2: s.optional(s.string()),
|
||||||
directUrl: s.optional(s.string())
|
directUrl: s.optional(s.string())
|
||||||
|
|
@ -14,24 +14,33 @@ export const DownloadInput = s.object({
|
||||||
links: s.defaulted(s.array(LinkInput), [])
|
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),
|
cover: s.instance(File),
|
||||||
title: s.optional(s.string()),
|
title: s.optional(s.string()),
|
||||||
subTitle: s.optional(s.string()),
|
subTitle: s.optional(s.string()),
|
||||||
releaseDate: s.optional(s.string()),
|
releaseDate: s.optional(s.date()),
|
||||||
label: s.optional(s.string()),
|
label: s.optional(s.string()),
|
||||||
vgmdb: s.optional(s.string()),
|
vgmdb: s.optional(s.string()),
|
||||||
description: s.optional(s.string()),
|
description: s.optional(s.string()),
|
||||||
status: s.defaulted(s.enums(Object.values(AlbumStatus)), AlbumStatus.HIDDEN),
|
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()), []),
|
artists: s.defaulted(s.array(s.string()), []),
|
||||||
categories: s.defaulted(s.array(s.string()), []),
|
categories: s.defaulted(s.array(s.string()), []),
|
||||||
classifications: s.defaulted(s.array(s.string()), []),
|
classifications: s.defaulted(s.array(s.string()), []),
|
||||||
games: s.defaulted(s.array(s.string()), []),
|
games: s.defaulted(s.array(s.string()), []),
|
||||||
platforms: s.defaulted(s.array(s.integer()), []),
|
platforms: s.defaulted(s.array(coerceInt), []),
|
||||||
discs: s.defaulted(s.array(s.object({ number: s.integer(), body: s.string() })), []),
|
discs: s.defaulted(s.array(DiscInput), []),
|
||||||
downloads: s.defaulted(s.array(DownloadInput), []),
|
downloads: s.defaulted(s.array(DownloadInput), []),
|
||||||
related: s.defaulted(s.array(s.number()), []),
|
related: s.defaulted(s.array(coerceInt), []),
|
||||||
stores: s.defaulted(s.array(s.object({ provider: s.string(), url: s.string() })), []),
|
stores: s.defaulted(s.array(StoreInput), []),
|
||||||
request: s.optional(s.integer())
|
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);
|
color: var(--color-hover-link);
|
||||||
text-decoration-line: underline;
|
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 slugify from 'slugify'
|
||||||
|
import { decode } from 'decode-formdata'
|
||||||
|
|
||||||
export const Status = (status: number, statusText?: string) => new Response(null, { status, statusText })
|
export const Status = (status: number, statusText?: string) => new Response(null, { status, statusText })
|
||||||
export const slug = (text: string) => slugify(text, { lower: true, strict: true })
|
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> = {}
|
const object: Record<string, any> = {}
|
||||||
for (const entry of formData.entries()) {
|
for (const entry of formData.entries()) {
|
||||||
const [key, value] = entry
|
const [key, value] = entry
|
||||||
|
|
@ -13,13 +14,13 @@ function formToObject(formData: FormData) {
|
||||||
return object
|
return object
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseForm(request: Request) {
|
export async function parseForm(formData: FormData) {
|
||||||
const formData = await request.formData()
|
const formObject = decode(formData, {
|
||||||
const formObject = formToObject(formData)
|
arrays: ['animations', 'classifications', 'categories', 'platforms', 'related', 'games', 'downloads', 'discs'],
|
||||||
const { data: dataInput, ...rest } = formObject
|
dates: ['releaseDate']
|
||||||
|
})
|
||||||
|
|
||||||
const data = JSON.parse(dataInput)
|
return formObject
|
||||||
return { ...data, ...rest }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRandom<T>(array: T[]): T {
|
export function getRandom<T>(array: T[]): T {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
|
import type { PrismaClient } from '@prisma/client/extension'
|
||||||
|
|
||||||
function colorToHex(color: number) {
|
function colorToHex(color: number) {
|
||||||
const hexadecimal = color.toString(16)
|
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 fullPath = path.join(pathString, `${id}.png`)
|
||||||
|
|
||||||
const fileArray = Buffer.from(await file.arrayBuffer())
|
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
|
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) {
|
export async function getImgColor(filePath: string) {
|
||||||
const { dominant } = await sharp(filePath).stats()
|
const { dominant } = await sharp(filePath).stats()
|
||||||
const { r, g, b } = dominant
|
const { r, g, b } = dominant
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { PrismaClient } from '@prisma/client'
|
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:
|
dependencies:
|
||||||
ms "^2.1.3"
|
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:
|
decode-named-character-reference@^1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e"
|
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"
|
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
|
||||||
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
|
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:
|
import-fresh@^3.2.1:
|
||||||
version "3.3.1"
|
version "3.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf"
|
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"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
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:
|
react-refresh@^0.14.2:
|
||||||
version "0.14.2"
|
version "0.14.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
||||||
|
|
@ -6798,6 +6813,11 @@ uri-js@^4.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.0"
|
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:
|
util-deprecate@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
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