diff --git a/package.json b/package.json index 88da508..5548df7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 5e84ce0..34fcfff 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -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 ( - {children} - {loading ? ( - - - - ) : null} + {children} + + + ) diff --git a/src/components/adminAlbum/DiscSection.tsx b/src/components/adminAlbum/DiscSection.tsx new file mode 100644 index 0000000..2d4e6c1 --- /dev/null +++ b/src/components/adminAlbum/DiscSection.tsx @@ -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(defaultValue) + + return ( + <> + + { + setDiscs((current) => { + current.push({ number: 0, body: '' }) + }) + }} + > + Add empty disc + + { + setDiscs((current) => current.filter((value, index) => (value.body?.length ?? 0) > 0 || index === 0)) + }} + > + Remove empty discs + + + + {discs.map((value, index) => ( + + + { + setDiscs((current) => { + current[index].body = ev.target.value + }) + }} + /> + + ))} + + > + ) +} diff --git a/src/components/adminAlbum/DownloadSections.tsx b/src/components/adminAlbum/DownloadSections.tsx new file mode 100644 index 0000000..1e07f75 --- /dev/null +++ b/src/components/adminAlbum/DownloadSections.tsx @@ -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(defaultValue) + + return ( + <> + + { + setDownloads((current) => { + current.push(defaultSection) + }) + }} + > + Add download section + + + + {downloads.map((d, index) => ( + + + { + setDownloads((current) => { + current[index].title = ev.target.value + }) + }} + /> + + {d.links.map((link, linkIndex) => ( + + + + {Object.values(DownloadProvider).map((provider) => ( + + {provider} + + ))} + + + + + + + { + setDownloads((current) => { + current[index].links.splice(linkIndex, 1) + }) + }} + > + X + + + + ))} + + { + setDownloads((current) => { + current[index].links.push(defaultLink) + }) + }} + > + Add link + + { + setDownloads((current) => { + current.splice(index, 1) + }) + }} + > + Remove section + + + + ))} + + > + ) +} diff --git a/src/components/adminAlbum/StoresSection.tsx b/src/components/adminAlbum/StoresSection.tsx new file mode 100644 index 0000000..f3bfe17 --- /dev/null +++ b/src/components/adminAlbum/StoresSection.tsx @@ -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(defaultValue) + + return ( + <> + + { + setStores((current) => { + current.push(defaultStore) + }) + }} + > + Add store link + + + + {stores.map((d, index) => ( + + + {Object.values(StoreProviders).map((provider) => ( + + {provider} + + ))} + + + { + setStores((current) => { + current[index].url = ev.target.value + }) + }} + /> + + { + setStores((current) => { + current.splice(index, 1) + }) + }} + > + X + + + + ))} + + > + ) +} diff --git a/src/components/form/AsyncMultiSelect.tsx b/src/components/form/AsyncMultiSelect.tsx new file mode 100644 index 0000000..61c3641 --- /dev/null +++ b/src/components/form/AsyncMultiSelect.tsx @@ -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(defaultSelected) + const [options, setOptions] = useState(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 ( + <> + + {selected.map((s, i) => ( + + ))} + > + ) +} diff --git a/src/components/form/Input.tsx b/src/components/form/Input.tsx index 9c0bbf5..9ee79a7 100644 --- a/src/components/form/Input.tsx +++ b/src/components/form/Input.tsx @@ -1,15 +1,67 @@ import type { ComponentProps, PropsWithChildren } from 'react' import clsx from 'clsx' -export default function Input(props: PropsWithChildren>) { - const { name, className, children, ...attrs } = props +export function InputLabel(props: PropsWithChildren<{ dark: boolean; name: string }>) { + const { dark, name, children } = props + return ( + + {children}: + + ) +} + +interface CustomInputProps { + name: string + label: string + dark?: boolean + defaultValue?: string | number | null +} + +export function Input(props: CustomInputProps & Omit, 'defaultValue'>) { + const { name, className, dark = false, defaultValue, label, ...attrs } = props return ( - - - {children}: - - + + + {label} + + + + ) +} + +export function InputArea(props: CustomInputProps & Omit, 'defaultValue'>) { + const { name, className, dark = false, defaultValue, label, ...attrs } = props + + return ( + + + {label} + + + + ) +} + +export function InputSelect(props: CustomInputProps & ComponentProps<'select'>) { + const { name, className, dark = false, label, ...attrs } = props + + return ( + + + {label} + + ) } diff --git a/src/components/form/MultiSelectWrapper.tsx b/src/components/form/MultiSelectWrapper.tsx new file mode 100644 index 0000000..8eef762 --- /dev/null +++ b/src/components/form/MultiSelectWrapper.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import { type SelectProps, type Option, MultiSelect } from 'react-multi-select-component' + +interface Props extends Omit { + name: string + defaultSelected?: Option[] +} + +export default function MultiSelectWrapper(props: Props) { + const { defaultSelected = [], name, ...rest } = props + const [selected, setSelected] = useState(defaultSelected) + + return ( + <> + + {selected.map((s, i) => ( + + ))} + > + ) +} diff --git a/src/components/header/RegisterButton.tsx b/src/components/header/RegisterButton.tsx index 325766e..a43b573 100644 --- a/src/components/header/RegisterButton.tsx +++ b/src/components/header/RegisterButton.tsx @@ -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() { - - {m.username()} - - - {m.displayName()} - + + - - {m.email()} - - - {m.password()} - + + {m.register()} diff --git a/src/pages/admin/album/[id].astro b/src/pages/admin/album/[id].astro new file mode 100644 index 0000000..4a3da69 --- /dev/null +++ b/src/pages/admin/album/[id].astro @@ -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') +} +--- + + + + + + + + + + + + + + + + + + + Hidden + Show + + + + + + a.artist.name).join(', ')} + name='artists' + label='Artists' + className='col-span-3' + /> + + Classifications + ({ + value: classificationName, + label: classificationName + })) || []} + labelledBy='classifications' + className='rounded-md py-2 h-full' + /> + + + Categories + ({ + value: categoryName, + label: categoryName + })) || []} + labelledBy='categories' + className='rounded-md py-2 h-full' + /> + + + Platforms + ({ + value: p.platform.id, + label: p.platform.name as string + })) || []} + className='rounded-md py-2 h-full' + /> + + + + Games + ({ + value: g.game.slug, + label: g.game.name as string + })) || []} + className='rounded-md py-2 h-full' + /> + + + Animations + ({ + value: g.animation.id, + label: g.animation.title as string + })) || []} + className='rounded-md py-2 h-full' + /> + + + Related albums + ({ + value: g.relatedAlbum.id, + label: g.relatedAlbum.title as string + })) || []} + className='rounded-md py-2 h-full' + /> + + + + + + + + + + + + + + + Save changes + + + + + diff --git a/src/pages/album/[id].astro b/src/pages/album/[id].astro index 94f48bf..e9f9887 100644 --- a/src/pages/album/[id].astro +++ b/src/pages/album/[id].astro @@ -286,13 +286,13 @@ const { currentLocale } = Astro /> {url2 && ( - - {m.flyInc()} + + Fly.inc )} {url ? ( - {m.ouoIO()} + ouo.io ) : null} diff --git a/src/pages/api/album/create.ts b/src/pages/api/album/create.ts index 24c773f..5e8f357 100644 --- a/src/pages/api/album/create.ts +++ b/src/pages/api/album/create.ts @@ -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({ diff --git a/src/pages/api/album/edit.ts b/src/pages/api/album/edit.ts new file mode 100644 index 0000000..b168d82 --- /dev/null +++ b/src/pages/api/album/edit.ts @@ -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) + } +} diff --git a/src/pages/api/album/find.ts b/src/pages/api/album/find.ts new file mode 100644 index 0000000..ee18d22 --- /dev/null +++ b/src/pages/api/album/find.ts @@ -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' + } + }) +} diff --git a/src/pages/api/anim/find.ts b/src/pages/api/anim/find.ts new file mode 100644 index 0000000..9ae1e30 --- /dev/null +++ b/src/pages/api/anim/find.ts @@ -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' + } + }) +} diff --git a/src/pages/api/game/find.ts b/src/pages/api/game/find.ts new file mode 100644 index 0000000..5b0c380 --- /dev/null +++ b/src/pages/api/game/find.ts @@ -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' + } + }) +} diff --git a/src/pages/api/platform/find.ts b/src/pages/api/platform/find.ts new file mode 100644 index 0000000..4b69bbd --- /dev/null +++ b/src/pages/api/platform/find.ts @@ -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' + } + }) +} diff --git a/src/schemas/album.ts b/src/schemas/album.ts index af67f4d..1f38445 100644 --- a/src/schemas/album.ts +++ b/src/schemas/album.ts @@ -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()) }) +) diff --git a/src/styles/global.css b/src/styles/global.css index cae017e..f625126 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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); + } } diff --git a/src/utils/consts.ts b/src/utils/consts.ts new file mode 100644 index 0000000..3261004 --- /dev/null +++ b/src/utils/consts.ts @@ -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' +} diff --git a/src/utils/form.ts b/src/utils/form.ts index 2ae18b4..8bf2543 100644 --- a/src/utils/form.ts +++ b/src/utils/form.ts @@ -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 = {} 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(array: T[]): T { diff --git a/src/utils/img.ts b/src/utils/img.ts index d5b5627..bf05362 100644 --- a/src/utils/img.ts +++ b/src/utils/img.ts @@ -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 diff --git a/src/utils/prisma-client.ts b/src/utils/prisma-client.ts index 6f337ee..6547495 100644 --- a/src/utils/prisma-client.ts +++ b/src/utils/prisma-client.ts @@ -1,5 +1,5 @@ import { PrismaClient } from '@prisma/client' -const prismaClient = new PrismaClient() +const prismaClient = new PrismaClient({ log: ['error'] }) -export default prismaClient \ No newline at end of file +export default prismaClient diff --git a/yarn.lock b/yarn.lock index 00abb65..97189d5 100644 --- a/yarn.lock +++ b/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"