Album edit endpoint

This commit is contained in:
Jorge Vargas 2025-04-06 10:30:00 -06:00
parent d3581eaeef
commit 6cef84a358
24 changed files with 936 additions and 76 deletions

View file

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

View file

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

View 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>
</>
)
}

View 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>
</>
)
}

View 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>
</>
)
}

View 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 />
))}
</>
)
}

View file

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

View 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 />
))}
</>
)
}

View file

@ -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()}

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

View file

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

View file

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

View 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)
}
}

View 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'
}
})
}

View 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'
}
})
}

View 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'
}
})
}

View 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'
}
})
}

View file

@ -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()) })
)

View file

@ -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
View 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'
}

View file

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

View file

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

View file

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

View file

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