"use client"; import useSWRInfinite from "swr/infinite"; import { useUserStore } from "#/store/auth"; import { useEffect, useState, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { ENDPOINTS } from "#/api/config"; import { Card, Button, Checkbox, TextInput, Textarea, FileInput, Label, Modal, } from "flowbite-react"; import { ReleaseLink } from "#/components/ReleaseLink/ReleaseLink"; import { CropModal } from "#/components/CropModal/CropModal"; import { b64toBlob } from "#/api/utils"; const fetcher = async (url: string) => { const res = await fetch(url); if (!res.ok) { const error = new Error( `An error occurred while fetching the data. status: ${res.status}` ); error.message = await res.json(); throw error; } return res.json(); }; export const CreateCollectionPage = () => { const userStore = useUserStore(); const searchParams = useSearchParams(); const router = useRouter(); const [edit, setEdit] = useState(false); const [imageUrl, setImageUrl] = useState<string>(null); const [tempImageUrl, setTempImageUrl] = useState<string>(null); const [isPrivate, setIsPrivate] = useState(false); const [collectionInfo, setCollectionInfo] = useState({ title: "", description: "", }); const [stringLength, setStringLength] = useState({ title: 0, description: 0, }); const [addedReleases, setAddedReleases] = useState([]); const [addedReleasesIds, setAddedReleasesIds] = useState([]); const [releasesEditModalOpen, setReleasesEditModalOpen] = useState(false); const [cropModalOpen, setCropModalOpen] = useState(false); const collection_id = searchParams.get("id") || null; const mode = searchParams.get("mode") || null; const [isSending, setIsSending] = useState(false); useEffect(() => { async function _checkMode() { if (mode === "edit" && collection_id) { setIsSending(true); const res = await fetch( `${ENDPOINTS.collection.base}/${collection_id}?token=${userStore.token}` ); const data = await res.json(); let addedReleasesIdsArray = []; let addedReleasesArray = []; for (let i = 0; i < 4; i++) { const res = await fetch( `${ENDPOINTS.collection.base}/${collection_id}/releases/${i}?token=${userStore.token}` ); const data = await res.json(); if (data.content.length > 0) { data.content.forEach((release) => { if (!addedReleasesIds.includes(release.id)) { addedReleasesIdsArray.push(release.id); addedReleasesArray.push(release); } }); } else { setAddedReleases(addedReleasesArray); setAddedReleasesIds(addedReleasesIdsArray); break; } } if ( mode === "edit" && userStore.user.id == data.collection.creator.id ) { setEdit(true); setCollectionInfo({ title: data.collection.title, description: data.collection.description, }); setStringLength({ title: data.collection.title.length, description: data.collection.description.length, }); setIsPrivate(data.collection.is_private); setImageUrl(data.collection.image); setIsSending(false); } } } if (userStore.user) { _checkMode(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [userStore.user]); const handleFileRead = (e, fileReader) => { const content = fileReader.result; setTempImageUrl(content); }; const handleFilePreview = (file) => { const fileReader = new FileReader(); fileReader.onloadend = (e) => { handleFileRead(e, fileReader); }; fileReader.readAsDataURL(file); }; function handleInput(e) { const regex = /[^a-zA-Zа-яА-Я0-9_.,:()!? \[\]]/g; setCollectionInfo({ ...collectionInfo, [e.target.name]: e.target.value.replace(regex, ""), }); setStringLength({ ...stringLength, [e.target.name]: e.target.value.replace(regex, "").length, }); } function submit(e) { e.preventDefault(); async function _createCollection() { const url = mode === "edit" ? `${ENDPOINTS.collection.edit}/${collection_id}?token=${userStore.token}` : `${ENDPOINTS.collection.create}?token=${userStore.token}`; const res = await fetch(url, { method: "POST", body: JSON.stringify({ ...collectionInfo, is_private: isPrivate, private: isPrivate, releases: addedReleasesIds, }), }); const data = await res.json(); if (data.code == 5) { alert("Вы превысили допустимый еженедельный лимит создания коллекций!"); return; } if (imageUrl && !imageUrl.startsWith("http")) { let block = imageUrl.split(";"); let contentType = block[0].split(":")[1]; let realData = block[1].split(",")[1]; const blob = b64toBlob(realData, contentType); const formData = new FormData(); formData.append("image", blob, "cropped.jpg"); formData.append("name", "image"); const uploadRes = await fetch( `${ENDPOINTS.collection.editImage}/${data.collection.id}?token=${userStore.token}`, { method: "POST", body: formData, } ); const uploadData = await uploadRes.json(); } router.push(`/collection/${data.collection.id}`); } if ( collectionInfo.title.length >= 10 && addedReleasesIds.length >= 1 && userStore.token ) { // setIsSending(true); _createCollection(); } else if (collectionInfo.title.length < 10) { alert("Необходимо ввести название коллекции не менее 10 символов"); } else if (!userStore.token) { alert("Для создания коллекции необходимо войти в аккаунт"); } else if (addedReleasesIds.length < 1) { alert("Необходимо добавить хотя бы один релиз в коллекцию"); } } function _deleteRelease(release: any) { let releasesArray = []; let idsArray = []; for (let i = 0; i < addedReleases.length; i++) { if (addedReleases[i].id != release.id) { releasesArray.push(addedReleases[i]); idsArray.push(addedReleasesIds[i]); } } setAddedReleases(releasesArray); setAddedReleasesIds(idsArray); } return ( <> <Card> <p className="text-xl font-bold"> {edit ? "Редактирование коллекции" : "Создание коллекции"} </p> <form className="flex flex-col w-full gap-2 lg:items-center lg:flex-row" onSubmit={(e) => submit(e)} > <Label htmlFor="dropzone-file" className="flex flex-col items-center w-full sm:max-w-[600px] h-[337px] border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-gray-500 dark:hover:bg-gray-600" > <div className="flex flex-col items-center justify-center max-w-[595px] h-[inherit] rounded-[inherit] pt-5 pb-6 overflow-hidden"> {!imageUrl ? ( <> <svg className="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16" > <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2" /> </svg> <p className="mb-2 text-sm text-gray-500 dark:text-gray-400"> <span className="font-semibold">Нажмите для загрузки</span>{" "} или перетащите файл </p> <p className="text-xs text-gray-500 dark:text-gray-400"> PNG или JPG (Макс. 600x337 пикселей) </p> </> ) : ( // eslint-disable-next-line @next/next/no-img-element <img src={imageUrl} className="object-cover w-[inherit] h-[inherit]" alt="" /> )} </div> <FileInput id="dropzone-file" className="hidden" accept="image/jpg, image/jpeg, image/png" onChange={(e) => { handleFilePreview(e.target.files[0]); setCropModalOpen(true); }} /> </Label> <div className="flex-1"> <div className="block mb-2"> <Label htmlFor="title" value="Название (минимум 10, максимум 60 символов)" /> </div> <TextInput id="title" name="title" type="text" sizing="md" className="w-full" required={true} onChange={(e) => handleInput(e)} value={collectionInfo.title} maxLength={60} /> <p className="text-sm text-gray-500 dark:text-gray-300"> {stringLength.title}/60 </p> <div className="block mt-2 mb-2"> <Label htmlFor="description" value="Описание (максимум 1000 символов)" /> </div> <Textarea rows={4} id="description" className="w-full" name="description" onChange={(e) => handleInput(e)} value={collectionInfo.description} maxLength={1000} /> <p className="text-sm text-gray-500 dark:text-gray-300"> {stringLength.description}/1000 </p> <div className="mt-2"> <div className="flex items-center gap-1"> <Checkbox id="private" name="private" color="blue" checked={isPrivate} onChange={(e) => setIsPrivate(e.target.checked)} /> <Label htmlFor="private" value="Приватная коллекция" /> </div> </div> <Button color={"blue"} className="mt-4" type="submit" disabled={isSending} > {edit ? "Обновить" : "Создать"} </Button> </div> </form> </Card> <div className="mt-4"> <div className="flex justify-between px-4 py-2 border-b-2 border-black dark:border-white"> <h1 className="font-bold text-md sm:text-xl md:text-lg xl:text-xl"> {"Релизов в коллекции: " + addedReleases.length}/100 </h1> <Button color={"blue"} size={"xs"} onClick={() => setReleasesEditModalOpen(!releasesEditModalOpen)} > Добавить </Button> </div> <div className="m-4"> <div className="grid justify-center sm:grid-cols-[repeat(auto-fit,minmax(400px,1fr))] grid-cols-[100%] gap-2 min-w-full"> {addedReleases.map((release) => { return ( <div key={release.id} className="relative w-full h-full aspect-video group" > <button className="absolute inset-0 z-10 text-black transition-opacity bg-white opacity-0 group-hover:opacity-75" onClick={() => _deleteRelease(release)} > Удалить </button> <ReleaseLink {...release} isLinkDisabled={true} /> </div> ); })} {addedReleases.length == 1 && <div></div>} </div> </div> </div> <ReleasesEditModal isOpen={releasesEditModalOpen} setIsOpen={setReleasesEditModalOpen} releases={addedReleases} releasesIds={addedReleasesIds} setReleases={setAddedReleases} setReleasesIds={setAddedReleasesIds} /> <CropModal src={tempImageUrl} setSrc={setImageUrl} setTempSrc={setTempImageUrl} // setImageData={setImageData} aspectRatio={600 / 337} guides={false} quality={100} isOpen={cropModalOpen} setIsOpen={setCropModalOpen} forceAspect={true} width={600} height={337} /> </> ); }; export const ReleasesEditModal = (props: { isOpen: boolean; setIsOpen: any; releases: any; setReleases: any; releasesIds: any; setReleasesIds: any; }) => { const [query, setQuery] = useState(""); const getKey = (pageIndex: number, previousPageData: any) => { if (previousPageData && !previousPageData.releases.length) return null; const url = new URL("/api/search", window.location.origin); url.searchParams.set("page", pageIndex.toString()); if (!query) return null; url.searchParams.set("q", query); return url.toString(); }; const { data, error, isLoading, size, setSize } = useSWRInfinite( getKey, fetcher, { initialSize: 2, revalidateFirstPage: false } ); const [content, setContent] = useState([]); useEffect(() => { if (data) { let allReleases = []; for (let i = 0; i < data.length; i++) { allReleases.push(...data[i].releases); } setContent(allReleases); } }, [data]); const [currentRef, setCurrentRef] = useState<any>(null); const modalRef = useCallback((ref) => { setCurrentRef(ref); }, []); const [scrollPosition, setScrollPosition] = useState(0); function handleScroll() { const height = currentRef.scrollHeight - currentRef.clientHeight; const windowScroll = currentRef.scrollTop; const scrolled = (windowScroll / height) * 100; setScrollPosition(Math.floor(scrolled)); } useEffect(() => { if (scrollPosition >= 95 && scrollPosition <= 96) { setSize(size + 1); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollPosition]); function _addRelease(release: any) { if (props.releasesIds.length == 100) { alert("Достигнуто максимальное количество релизов в коллекции - 100"); return; } if (props.releasesIds.includes(release.id)) { alert("Релиз уже добавлен в коллекцию"); return; } props.setReleases([...props.releases, release]); props.setReleasesIds([...props.releasesIds, release.id]); } return ( <Modal dismissible show={props.isOpen} onClose={() => props.setIsOpen(false)} size={"7xl"} > <Modal.Header>Изменить релизы в коллекции</Modal.Header> <div onScroll={handleScroll} ref={modalRef} className="px-4 py-4 overflow-auto" > <form className="max-w-full mx-auto" onSubmit={(e) => { e.preventDefault(); props.setReleases([]); setQuery(e.target[0].value.trim()); }} > <label htmlFor="default-search" className="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white" > Поиск </label> <div className="relative"> <div className="absolute inset-y-0 flex items-center pointer-events-none start-0 ps-3"> <svg className="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" > <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" /> </svg> </div> <input type="search" id="default-search" className="block w-full p-4 text-sm text-gray-900 border border-gray-300 rounded-lg ps-10 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Поиск аниме..." required defaultValue={query || ""} /> <button type="submit" className="text-white absolute end-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" > Поиск </button> </div> </form> <div className="flex flex-wrap gap-1 mt-2"> {content.map((release) => { return ( <button key={release.id} className="" onClick={() => _addRelease(release)} > <ReleaseLink type="poster" {...release} isLinkDisabled={true} /> </button> ); })} {content.length == 1 && <div></div>} </div> </div> </Modal> ); };