From 13a7f99a5dbc165b861fd67a6813bd0b6450840f Mon Sep 17 00:00:00 2001 From: Jorge Vargas Date: Thu, 3 Apr 2025 22:48:50 -0600 Subject: [PATCH 1/7] Remove unused small download album column --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 7 +++---- src/pages/api/album/create.ts | 1 - src/schemas/album.ts | 1 - 4 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20250404044756_remove_unusused_small_album_download_column/migration.sql diff --git a/prisma/migrations/20250404044756_remove_unusused_small_album_download_column/migration.sql b/prisma/migrations/20250404044756_remove_unusused_small_album_download_column/migration.sql new file mode 100644 index 0000000..6c911e5 --- /dev/null +++ b/prisma/migrations/20250404044756_remove_unusused_small_album_download_column/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `small` on the `downloads` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE `downloads` DROP COLUMN `small`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0b04567..2930090 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -219,11 +219,10 @@ model discs { } model downloads { - id Int @id @default(autoincrement()) - title String? @db.VarChar(255) - small Boolean? + id Int @id @default(autoincrement()) + title String? @db.VarChar(255) albumId Int - album albums? @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "downloads_ibfk_1") + album albums? @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "downloads_ibfk_1") links links[] } diff --git a/src/pages/api/album/create.ts b/src/pages/api/album/create.ts index c43d5fe..24c773f 100644 --- a/src/pages/api/album/create.ts +++ b/src/pages/api/album/create.ts @@ -72,7 +72,6 @@ export const POST: APIRoute = async ({ request, locals }) => { tx.downloads.create({ data: { title: d.title, - small: d.small, albumId: albumRow.id, links: { create: d.links } } diff --git a/src/schemas/album.ts b/src/schemas/album.ts index 7102683..af67f4d 100644 --- a/src/schemas/album.ts +++ b/src/schemas/album.ts @@ -11,7 +11,6 @@ const LinkInput = s.object({ export const DownloadInput = s.object({ title: s.string(), - small: s.defaulted(s.boolean(), false), links: s.defaulted(s.array(LinkInput), []) }) From d3581eaeef7e6df925fdcf3b50473b12aecf4887 Mon Sep 17 00:00:00 2001 From: Jorge Vargas Date: Fri, 4 Apr 2025 08:44:23 -0600 Subject: [PATCH 2/7] Drop custom url column --- prisma/migrations/20250404144405_/migration.sql | 8 ++++++++ prisma/schema.prisma | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20250404144405_/migration.sql diff --git a/prisma/migrations/20250404144405_/migration.sql b/prisma/migrations/20250404144405_/migration.sql new file mode 100644 index 0000000..d0d5037 --- /dev/null +++ b/prisma/migrations/20250404144405_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `custom` on the `links` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE `links` DROP COLUMN `custom`; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2930090..f909414 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -275,7 +275,6 @@ model links { url String? @db.VarChar(255) directUrl String? @db.VarChar(255) provider String? @db.VarChar(255) - custom String? @db.VarChar(255) downloadId Int url2 String? @db.VarChar(255) download downloads? @relation(fields: [downloadId], references: [id], map: "links_ibfk_1") From 6cef84a35865ae4d396940c0b5ed161d888c2e8b Mon Sep 17 00:00:00 2001 From: Jorge Vargas Date: Sun, 6 Apr 2025 10:30:00 -0600 Subject: [PATCH 3/7] Album edit endpoint --- package.json | 8 +- src/components/Button.tsx | 22 +- src/components/adminAlbum/DiscSection.tsx | 57 +++++ .../adminAlbum/DownloadSections.tsx | 128 +++++++++++ src/components/adminAlbum/StoresSection.tsx | 73 ++++++ src/components/form/AsyncMultiSelect.tsx | 71 ++++++ src/components/form/Input.tsx | 66 +++++- src/components/form/MultiSelectWrapper.tsx | 21 ++ src/components/header/RegisterButton.tsx | 21 +- src/pages/admin/album/[id].astro | 215 ++++++++++++++++++ src/pages/album/[id].astro | 6 +- src/pages/api/album/create.ts | 25 +- src/pages/api/album/edit.ts | 87 +++++++ src/pages/api/album/find.ts | 23 ++ src/pages/api/anim/find.ts | 21 ++ src/pages/api/game/find.ts | 21 ++ src/pages/api/platform/find.ts | 21 ++ src/schemas/album.ts | 31 ++- src/styles/global.css | 15 ++ src/utils/consts.ts | 22 ++ src/utils/form.ts | 15 +- src/utils/img.ts | 19 +- src/utils/prisma-client.ts | 4 +- yarn.lock | 20 ++ 24 files changed, 936 insertions(+), 76 deletions(-) create mode 100644 src/components/adminAlbum/DiscSection.tsx create mode 100644 src/components/adminAlbum/DownloadSections.tsx create mode 100644 src/components/adminAlbum/StoresSection.tsx create mode 100644 src/components/form/AsyncMultiSelect.tsx create mode 100644 src/components/form/MultiSelectWrapper.tsx create mode 100644 src/pages/admin/album/[id].astro create mode 100644 src/pages/api/album/edit.ts create mode 100644 src/pages/api/album/find.ts create mode 100644 src/pages/api/anim/find.ts create mode 100644 src/pages/api/game/find.ts create mode 100644 src/pages/api/platform/find.ts create mode 100644 src/utils/consts.ts 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 ( ) 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 ( + <> +
+ + +
+
+ {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 ( + <> +
+ +
+
+ {downloads.map((d, index) => ( +
+
+ { + setDownloads((current) => { + current[index].title = ev.target.value + }) + }} + /> +
+ {d.links.map((link, linkIndex) => ( +
+
+ + {Object.values(DownloadProvider).map((provider) => ( + + ))} + + + + +
+
+ +
+
+ ))} +
+ + +
+
+ ))} +
+ + ) +} 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 ( + <> +
+ +
+
+ {stores.map((d, index) => ( +
+ + {Object.values(StoreProviders).map((provider) => ( + + ))} + + + { + setStores((current) => { + current[index].url = ev.target.value + }) + }} + /> +
+ +
+
+ ))} +
+ + ) +} 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 ( + + ) +} + +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 ( -
- - +
+ + {label} + + +
+ ) +} + +export function InputArea(props: CustomInputProps & Omit, 'defaultValue'>) { + const { name, className, dark = false, defaultValue, label, ...attrs } = props + + return ( +
+ + {label} + +