diff --git a/components/CollectionCard.tsx b/components/CollectionCard.tsx index da634cc..aaf177e 100644 --- a/components/CollectionCard.tsx +++ b/components/CollectionCard.tsx @@ -5,12 +5,12 @@ import ProfilePhoto from "./ProfilePhoto"; import usePermissions from "@/hooks/usePermissions"; import useLocalSettingsStore from "@/store/localSettings"; import getPublicUserData from "@/lib/client/getPublicUserData"; -import useAccountStore from "@/store/account"; import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; import { dropdownTriggerer } from "@/lib/client/utils"; import { useTranslation } from "next-i18next"; +import { useUser } from "@/hooks/store/user"; type Props = { collection: CollectionIncludingMembersAndLinkCount; @@ -20,7 +20,7 @@ type Props = { export default function CollectionCard({ collection, className }: Props) { const { t } = useTranslation(); const { settings } = useLocalSettingsStore(); - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); const formattedDate = new Date(collection.createdAt as string).toLocaleString( "en-US", @@ -45,18 +45,18 @@ export default function CollectionCard({ collection, className }: Props) { useEffect(() => { const fetchOwner = async () => { - if (collection && collection.ownerId !== account.id) { + if (collection && collection.ownerId !== user.id) { const owner = await getPublicUserData(collection.ownerId as number); setCollectionOwner(owner); - } else if (collection && collection.ownerId === account.id) { + } else if (collection && collection.ownerId === user.id) { setCollectionOwner({ - id: account.id as number, - name: account.name, - username: account.username as string, - image: account.image as string, - archiveAsScreenshot: account.archiveAsScreenshot as boolean, - archiveAsMonolith: account.archiveAsMonolith as boolean, - archiveAsPDF: account.archiveAsPDF as boolean, + id: user.id as number, + name: user.name, + username: user.username as string, + image: user.image as string, + archiveAsScreenshot: user.archiveAsScreenshot as boolean, + archiveAsMonolith: user.archiveAsMonolith as boolean, + archiveAsPDF: user.archiveAsPDF as boolean, }); } }; diff --git a/components/CollectionListing.tsx b/components/CollectionListing.tsx index fbe2f7b..e83d388 100644 --- a/components/CollectionListing.tsx +++ b/components/CollectionListing.tsx @@ -9,14 +9,14 @@ import Tree, { TreeSourcePosition, TreeDestinationPosition, } from "@atlaskit/tree"; -import useCollectionStore from "@/store/collections"; import { Collection } from "@prisma/client"; import Link from "next/link"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { useRouter } from "next/router"; -import useAccountStore from "@/store/account"; import toast from "react-hot-toast"; import { useTranslation } from "next-i18next"; +import { useCollections, useUpdateCollection } from "@/hooks/store/collections"; +import { useUpdateUser, useUser } from "@/hooks/store/user"; interface ExtendedTreeItem extends TreeItem { data: Collection; @@ -24,53 +24,57 @@ interface ExtendedTreeItem extends TreeItem { const CollectionListing = () => { const { t } = useTranslation(); - const { collections, updateCollection } = useCollectionStore(); - const { account, updateAccount } = useAccountStore(); + const updateCollection = useUpdateCollection(); + const { data: collections = [], isLoading } = useCollections(); + + const { data: user = {} } = useUser(); + const updateUser = useUpdateUser(); const router = useRouter(); const currentPath = router.asPath; + const [tree, setTree] = useState(); + const initialTree = useMemo(() => { - if (collections.length > 0) { + if ( + // !tree && + collections.length > 0 + ) { return buildTreeFromCollections( collections, router, - account.collectionOrder + user.collectionOrder ); - } - return undefined; - }, [collections, router]); - - const [tree, setTree] = useState(initialTree); + } else return undefined; + }, [collections, user, router]); useEffect(() => { + // if (!tree) setTree(initialTree); }, [initialTree]); useEffect(() => { - if (account.username) { + if (user.username) { if ( - (!account.collectionOrder || account.collectionOrder.length === 0) && + (!user.collectionOrder || user.collectionOrder.length === 0) && collections.length > 0 ) - updateAccount({ - ...account, + updateUser.mutate({ + ...user, collectionOrder: collections .filter( (e) => e.parentId === null || !collections.find((i) => i.id === e.parentId) ) // Filter out collections with non-null parentId - .map((e) => e.id as number), // Use "as number" to assert that e.id is a number + .map((e) => e.id as number), }); else { - const newCollectionOrder: number[] = [ - ...(account.collectionOrder || []), - ]; + const newCollectionOrder: number[] = [...(user.collectionOrder || [])]; // Start with collections that are in both account.collectionOrder and collections const existingCollectionIds = collections.map((c) => c.id as number); - const filteredCollectionOrder = account.collectionOrder.filter((id) => + const filteredCollectionOrder = user.collectionOrder.filter((id: any) => existingCollectionIds.includes(id) ); @@ -78,7 +82,7 @@ const CollectionListing = () => { collections.forEach((collection) => { if ( !filteredCollectionOrder.includes(collection.id as number) && - (!collection.parentId || collection.ownerId === account.id) + (!collection.parentId || collection.ownerId === user.id) ) { filteredCollectionOrder.push(collection.id as number); } @@ -87,10 +91,10 @@ const CollectionListing = () => { // check if the newCollectionOrder is the same as the old one if ( JSON.stringify(newCollectionOrder) !== - JSON.stringify(account.collectionOrder) + JSON.stringify(user.collectionOrder) ) { - updateAccount({ - ...account, + updateUser.mutateAsync({ + ...user, collectionOrder: newCollectionOrder, }); } @@ -138,9 +142,9 @@ const CollectionListing = () => { ); if ( - (movedCollection?.ownerId !== account.id && + (movedCollection?.ownerId !== user.id && destination.parentId !== source.parentId) || - (destinationCollection?.ownerId !== account.id && + (destinationCollection?.ownerId !== user.id && destination.parentId !== "root") ) { return toast.error(t("cant_change_collection_you_dont_own")); @@ -148,18 +152,25 @@ const CollectionListing = () => { setTree((currentTree) => moveItemOnTree(currentTree!, source, destination)); - const updatedCollectionOrder = [...account.collectionOrder]; + const updatedCollectionOrder = [...user.collectionOrder]; if (source.parentId !== destination.parentId) { - await updateCollection({ - ...movedCollection, - parentId: - destination.parentId && destination.parentId !== "root" - ? Number(destination.parentId) - : destination.parentId === "root" - ? "root" - : null, - } as any); + await updateCollection.mutateAsync( + { + ...movedCollection, + parentId: + destination.parentId && destination.parentId !== "root" + ? Number(destination.parentId) + : destination.parentId === "root" + ? "root" + : null, + }, + { + onError: (error) => { + toast.error(error.message); + }, + } + ); } if ( @@ -172,8 +183,8 @@ const CollectionListing = () => { updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); - await updateAccount({ - ...account, + await updateUser.mutateAsync({ + ...user, collectionOrder: updatedCollectionOrder, }); } else if ( @@ -182,8 +193,8 @@ const CollectionListing = () => { ) { updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); - await updateAccount({ - ...account, + updateUser.mutate({ + ...user, collectionOrder: updatedCollectionOrder, }); } else if ( @@ -193,14 +204,22 @@ const CollectionListing = () => { ) { updatedCollectionOrder.splice(source.index, 1); - await updateAccount({ - ...account, + await updateUser.mutateAsync({ + ...user, collectionOrder: updatedCollectionOrder, }); } }; - if (!tree) { + if (isLoading) { + return ( +
+
+
+
+
+ ); + } else if (!tree) { return (

{t("you_have_no_collections")} diff --git a/components/InputSelect/CollectionSelection.tsx b/components/InputSelect/CollectionSelection.tsx index 99b999e..81bef39 100644 --- a/components/InputSelect/CollectionSelection.tsx +++ b/components/InputSelect/CollectionSelection.tsx @@ -1,10 +1,10 @@ -import useCollectionStore from "@/store/collections"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { styles } from "./styles"; import { Options } from "./types"; import CreatableSelect from "react-select/creatable"; import Select from "react-select"; +import { useCollections } from "@/hooks/store/collections"; type Props = { onChange: any; @@ -24,7 +24,8 @@ export default function CollectionSelection({ showDefaultValue = true, creatable = true, }: Props) { - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); + const router = useRouter(); const [options, setOptions] = useState([]); diff --git a/components/InputSelect/TagSelection.tsx b/components/InputSelect/TagSelection.tsx index d901b40..efd246b 100644 --- a/components/InputSelect/TagSelection.tsx +++ b/components/InputSelect/TagSelection.tsx @@ -1,8 +1,8 @@ -import useTagStore from "@/store/tags"; import { useEffect, useState } from "react"; import CreatableSelect from "react-select/creatable"; import { styles } from "./styles"; import { Options } from "./types"; +import { useTags } from "@/hooks/store/tags"; type Props = { onChange: any; @@ -13,12 +13,12 @@ type Props = { }; export default function TagSelection({ onChange, defaultValue }: Props) { - const { tags } = useTagStore(); + const { data: tags = [] } = useTags(); const [options, setOptions] = useState([]); useEffect(() => { - const formatedCollections = tags.map((e) => { + const formatedCollections = tags.map((e: any) => { return { value: e.id, label: e.name }; }); diff --git a/components/InstallApp.tsx b/components/InstallApp.tsx index 4b70309..f191f00 100644 --- a/components/InstallApp.tsx +++ b/components/InstallApp.tsx @@ -8,7 +8,7 @@ const InstallApp = (props: Props) => { const [isOpen, setIsOpen] = useState(true); return isOpen && !isPWA() ? ( -

+
; - viewMode: string; - setViewMode: Dispatch>; + viewMode: ViewMode; + setViewMode: Dispatch>; searchFilter?: { name: boolean; url: boolean; @@ -48,8 +49,11 @@ const LinkListOptions = ({ editMode, setEditMode, }: Props) => { - const { links, selectedLinks, setSelectedLinks, deleteLinksById } = - useLinkStore(); + const { selectedLinks, setSelectedLinks } = useLinkStore(); + + const deleteLinksById = useBulkDeleteLinks(); + + const { links } = useLinks(); const router = useRouter(); @@ -73,23 +77,23 @@ const LinkListOptions = ({ }; const bulkDeleteLinks = async () => { - const load = toast.loading(t("deleting_selections")); + const load = toast.loading(t("deleting")); - const response = await deleteLinksById( - selectedLinks.map((link) => link.id as number) + await deleteLinksById.mutateAsync( + selectedLinks.map((link) => link.id as number), + { + onSettled: (data, error) => { + toast.dismiss(load); + + if (error) { + toast.error(error.message); + } else { + setSelectedLinks([]); + toast.success(t("deleted")); + } + }, + } ); - - toast.dismiss(load); - - if (response.ok) { - toast.success( - selectedLinks.length === 1 - ? t("link_deleted") - : t("links_deleted", { count: selectedLinks.length }) - ); - } else { - toast.error(response.data as string); - } }; return ( @@ -99,57 +103,64 @@ const LinkListOptions = ({
- {links.length > 0 && editMode !== undefined && setEditMode && ( -
{ - setEditMode(!editMode); - setSelectedLinks([]); - }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} - > - -
- )} + {links && + links.length > 0 && + editMode !== undefined && + setEditMode && ( +
{ + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
+ )} {searchFilter && setSearchFilter && ( )} - + { + setSortBy(value); + }} + t={t} + />
- {editMode && links.length > 0 && ( + {links && editMode && links.length > 0 && (
- {links.length > 0 && ( -
- handleSelectAll()} - checked={ - selectedLinks.length === links.length && links.length > 0 - } - /> - {selectedLinks.length > 0 ? ( - - {selectedLinks.length === 1 - ? t("link_selected") - : t("links_selected", { count: selectedLinks.length })} - - ) : ( - {t("nothing_selected")} - )} -
- )} +
+ handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length === 1 + ? t("link_selected") + : t("links_selected", { count: selectedLinks.length })} + + ) : ( + {t("nothing_selected")} + )} +
diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx index 6dc7ee2..7323373 100644 --- a/components/ModalContent/DeleteUserModal.tsx +++ b/components/ModalContent/DeleteUserModal.tsx @@ -1,8 +1,8 @@ -import toast from "react-hot-toast"; import Modal from "../Modal"; -import useUserStore from "@/store/admin/users"; import Button from "../ui/Button"; import { useTranslation } from "next-i18next"; +import { useDeleteUser } from "@/hooks/store/admin/users"; +import { useState } from "react"; type Props = { onClose: Function; @@ -11,22 +11,22 @@ type Props = { export default function DeleteUserModal({ onClose, userId }: Props) { const { t } = useTranslation(); - const { removeUser } = useUserStore(); - const deleteUser = async () => { - const load = toast.loading(t("deleting_user")); + const [submitLoader, setSubmitLoader] = useState(false); + const deleteUser = useDeleteUser(); - const response = await removeUser(userId); + const submit = async () => { + if (!submitLoader) { + setSubmitLoader(true); - toast.dismiss(load); + await deleteUser.mutateAsync(userId, { + onSuccess: () => { + onClose(); + }, + }); - if (response.ok) { - toast.success(t("user_deleted")); - } else { - toast.error(response.data as string); + setSubmitLoader(false); } - - onClose(); }; return ( @@ -45,7 +45,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
- diff --git a/components/ModalContent/EditCollectionModal.tsx b/components/ModalContent/EditCollectionModal.tsx index b2105f5..f8dfd20 100644 --- a/components/ModalContent/EditCollectionModal.tsx +++ b/components/ModalContent/EditCollectionModal.tsx @@ -1,11 +1,11 @@ import React, { useState } from "react"; import TextInput from "@/components/TextInput"; -import useCollectionStore from "@/store/collections"; -import toast from "react-hot-toast"; import { HexColorPicker } from "react-colorful"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import Modal from "../Modal"; import { useTranslation } from "next-i18next"; +import { useUpdateCollection } from "@/hooks/store/collections"; +import toast from "react-hot-toast"; type Props = { onClose: Function; @@ -21,7 +21,7 @@ export default function EditCollectionModal({ useState(activeCollection); const [submitLoader, setSubmitLoader] = useState(false); - const { updateCollection } = useCollectionStore(); + const updateCollection = useUpdateCollection(); const submit = async () => { if (!submitLoader) { @@ -32,14 +32,18 @@ export default function EditCollectionModal({ const load = toast.loading(t("updating_collection")); - let response = await updateCollection(collection as any); + await updateCollection.mutateAsync(collection, { + onSettled: (data, error) => { + toast.dismiss(load); - toast.dismiss(load); - - if (response.ok) { - toast.success(t("updated")); - onClose(); - } else toast.error(response.data as string); + if (error) { + toast.error(error.message); + } else { + onClose(); + toast.success(t("updated")); + } + }, + }); setSubmitLoader(false); } diff --git a/components/ModalContent/EditCollectionSharingModal.tsx b/components/ModalContent/EditCollectionSharingModal.tsx index 9a9f5fa..84dae82 100644 --- a/components/ModalContent/EditCollectionSharingModal.tsx +++ b/components/ModalContent/EditCollectionSharingModal.tsx @@ -1,16 +1,16 @@ import React, { useEffect, useState } from "react"; import TextInput from "@/components/TextInput"; -import useCollectionStore from "@/store/collections"; import toast from "react-hot-toast"; import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import getPublicUserData from "@/lib/client/getPublicUserData"; -import useAccountStore from "@/store/account"; import usePermissions from "@/hooks/usePermissions"; import ProfilePhoto from "../ProfilePhoto"; import addMemberToCollection from "@/lib/client/addMemberToCollection"; import Modal from "../Modal"; import { dropdownTriggerer } from "@/lib/client/utils"; import { useTranslation } from "next-i18next"; +import { useUpdateCollection } from "@/hooks/store/collections"; +import { useUser } from "@/hooks/store/user"; type Props = { onClose: Function; @@ -27,7 +27,7 @@ export default function EditCollectionSharingModal({ useState(activeCollection); const [submitLoader, setSubmitLoader] = useState(false); - const { updateCollection } = useCollectionStore(); + const updateCollection = useUpdateCollection(); const submit = async () => { if (!submitLoader) { @@ -36,24 +36,26 @@ export default function EditCollectionSharingModal({ setSubmitLoader(true); - const load = toast.loading(t("updating")); + const load = toast.loading(t("updating_collection")); - let response; + await updateCollection.mutateAsync(collection, { + onSettled: (data, error) => { + toast.dismiss(load); - response = await updateCollection(collection as any); - - toast.dismiss(load); - - if (response.ok) { - toast.success(t("updated")); - onClose(); - } else toast.error(response.data as string); + if (error) { + toast.error(error.message); + } else { + onClose(); + toast.success(t("updated")); + } + }, + }); setSubmitLoader(false); } }; - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); const permissions = usePermissions(collection.id as number); const currentURL = new URL(document.URL); @@ -165,7 +167,7 @@ export default function EditCollectionSharingModal({ onKeyDown={(e) => e.key === "Enter" && addMemberToCollection( - account.username as string, + user.username as string, memberUsername || "", collection, setMemberState, @@ -177,7 +179,7 @@ export default function EditCollectionSharingModal({
addMemberToCollection( - account.username as string, + user.username as string, memberUsername || "", collection, setMemberState, diff --git a/components/ModalContent/EditLinkModal.tsx b/components/ModalContent/EditLinkModal.tsx index 1d873c7..2d9711b 100644 --- a/components/ModalContent/EditLinkModal.tsx +++ b/components/ModalContent/EditLinkModal.tsx @@ -3,12 +3,12 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection"; import TagSelection from "@/components/InputSelect/TagSelection"; import TextInput from "@/components/TextInput"; import unescapeString from "@/lib/client/unescapeString"; -import useLinkStore from "@/store/links"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; -import toast from "react-hot-toast"; import Link from "next/link"; import Modal from "../Modal"; import { useTranslation } from "next-i18next"; +import { useUpdateLink } from "@/hooks/store/links"; +import toast from "react-hot-toast"; type Props = { onClose: Function; @@ -27,9 +27,10 @@ export default function EditLinkModal({ onClose, activeLink }: Props) { console.log(error); } - const { updateLink } = useLinkStore(); const [submitLoader, setSubmitLoader] = useState(false); + const updateLink = useUpdateLink(); + const setCollection = (e: any) => { if (e?.__isNew__) e.value = null; setLink({ @@ -50,19 +51,23 @@ export default function EditLinkModal({ onClose, activeLink }: Props) { const submit = async () => { if (!submitLoader) { setSubmitLoader(true); - const load = toast.loading(t("updating")); - let response = await updateLink(link); - toast.dismiss(load); - if (response.ok) { - toast.success(t("updated")); - onClose(); - } else { - toast.error(response.data as string); - } + const load = toast.loading(t("updating")); + + await updateLink.mutateAsync(link, { + onSettled: (data, error) => { + toast.dismiss(load); + + if (error) { + toast.error(error.message); + } else { + onClose(); + toast.success(t("updated")); + } + }, + }); setSubmitLoader(false); - return response; } }; diff --git a/components/ModalContent/NewCollectionModal.tsx b/components/ModalContent/NewCollectionModal.tsx index 980bb44..df9d9bb 100644 --- a/components/ModalContent/NewCollectionModal.tsx +++ b/components/ModalContent/NewCollectionModal.tsx @@ -1,14 +1,12 @@ import React, { useEffect, useState } from "react"; import TextInput from "@/components/TextInput"; -import useCollectionStore from "@/store/collections"; -import toast from "react-hot-toast"; import { HexColorPicker } from "react-colorful"; import { Collection } from "@prisma/client"; import Modal from "../Modal"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; -import useAccountStore from "@/store/account"; -import { useSession } from "next-auth/react"; import { useTranslation } from "next-i18next"; +import { useCreateCollection } from "@/hooks/store/collections"; +import toast from "react-hot-toast"; type Props = { onClose: Function; @@ -25,15 +23,14 @@ export default function NewCollectionModal({ onClose, parent }: Props) { } as Partial; const [collection, setCollection] = useState>(initial); - const { setAccount } = useAccountStore(); - const { data } = useSession(); useEffect(() => { setCollection(initial); }, []); const [submitLoader, setSubmitLoader] = useState(false); - const { addCollection } = useCollectionStore(); + + const createCollection = useCreateCollection(); const submit = async () => { if (submitLoader) return; @@ -43,16 +40,18 @@ export default function NewCollectionModal({ onClose, parent }: Props) { const load = toast.loading(t("creating")); - let response = await addCollection(collection as any); - toast.dismiss(load); + await createCollection.mutateAsync(collection, { + onSettled: (data, error) => { + toast.dismiss(load); - if (response.ok) { - toast.success(t("created_success")); - if (response.data) { - setAccount(data?.user.id as number); - onClose(); - } - } else toast.error(response.data as string); + if (error) { + toast.error(error.message); + } else { + onClose(); + toast.success(t("created")); + } + }, + }); setSubmitLoader(false); }; diff --git a/components/ModalContent/NewLinkModal.tsx b/components/ModalContent/NewLinkModal.tsx index 0284687..660c1e4 100644 --- a/components/ModalContent/NewLinkModal.tsx +++ b/components/ModalContent/NewLinkModal.tsx @@ -1,17 +1,16 @@ import React, { useEffect, useState } from "react"; -import { Toaster } from "react-hot-toast"; import CollectionSelection from "@/components/InputSelect/CollectionSelection"; import TagSelection from "@/components/InputSelect/TagSelection"; import TextInput from "@/components/TextInput"; import unescapeString from "@/lib/client/unescapeString"; -import useCollectionStore from "@/store/collections"; -import useLinkStore from "@/store/links"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { useSession } from "next-auth/react"; import { useRouter } from "next/router"; -import toast from "react-hot-toast"; import Modal from "../Modal"; import { useTranslation } from "next-i18next"; +import { useCollections } from "@/hooks/store/collections"; +import { useAddLink } from "@/hooks/store/links"; +import toast from "react-hot-toast"; type Props = { onClose: Function; @@ -40,11 +39,13 @@ export default function NewLinkModal({ onClose }: Props) { const [link, setLink] = useState(initial); - const { addLink } = useLinkStore(); + + const addLink = useAddLink(); + const [submitLoader, setSubmitLoader] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false); const router = useRouter(); - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); const setCollection = (e: any) => { if (e?.__isNew__) e.value = null; @@ -87,15 +88,22 @@ export default function NewLinkModal({ onClose }: Props) { const submit = async () => { if (!submitLoader) { setSubmitLoader(true); + const load = toast.loading(t("creating_link")); - const response = await addLink(link); - toast.dismiss(load); - if (response.ok) { - toast.success(t("link_created")); - onClose(); - } else { - toast.error(response.data as string); - } + + await addLink.mutateAsync(link, { + onSettled: (data, error) => { + toast.dismiss(load); + + if (error) { + toast.error(error.message); + } else { + onClose(); + toast.success(t("link_created")); + } + }, + }); + setSubmitLoader(false); } }; diff --git a/components/ModalContent/NewTokenModal.tsx b/components/ModalContent/NewTokenModal.tsx index 4d397e2..0528270 100644 --- a/components/ModalContent/NewTokenModal.tsx +++ b/components/ModalContent/NewTokenModal.tsx @@ -3,10 +3,10 @@ import TextInput from "@/components/TextInput"; import { TokenExpiry } from "@/types/global"; import toast from "react-hot-toast"; import Modal from "../Modal"; -import useTokenStore from "@/store/tokens"; import { dropdownTriggerer } from "@/lib/client/utils"; import Button from "../ui/Button"; import { useTranslation } from "next-i18next"; +import { useAddToken } from "@/hooks/store/tokens"; type Props = { onClose: Function; @@ -15,7 +15,7 @@ type Props = { export default function NewTokenModal({ onClose }: Props) { const { t } = useTranslation(); const [newToken, setNewToken] = useState(""); - const { addToken } = useTokenStore(); + const addToken = useAddToken(); const initial = { name: "", @@ -28,16 +28,20 @@ export default function NewTokenModal({ onClose }: Props) { const submit = async () => { if (!submitLoader) { setSubmitLoader(true); + const load = toast.loading(t("creating_token")); - const { ok, data } = await addToken(token); + await addToken.mutateAsync(token, { + onSettled: (data, error) => { + toast.dismiss(load); - toast.dismiss(load); - - if (ok) { - toast.success(t("token_created")); - setNewToken((data as any).secretKey); - } else toast.error(data as string); + if (error) { + toast.error(error.message); + } else { + setNewToken(data.secretKey); + } + }, + }); setSubmitLoader(false); } diff --git a/components/ModalContent/NewUserModal.tsx b/components/ModalContent/NewUserModal.tsx index d83ef40..78c36f7 100644 --- a/components/ModalContent/NewUserModal.tsx +++ b/components/ModalContent/NewUserModal.tsx @@ -1,9 +1,9 @@ import toast from "react-hot-toast"; import Modal from "../Modal"; -import useUserStore from "@/store/admin/users"; import TextInput from "../TextInput"; import { FormEvent, useState } from "react"; import { useTranslation, Trans } from "next-i18next"; +import { useAddUser } from "@/hooks/store/admin/users"; type Props = { onClose: Function; @@ -20,7 +20,9 @@ const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; export default function NewUserModal({ onClose }: Props) { const { t } = useTranslation(); - const { addUser } = useUserStore(); + + const addUser = useAddUser(); + const [form, setForm] = useState({ name: "", username: "", @@ -44,24 +46,15 @@ export default function NewUserModal({ onClose }: Props) { }; if (checkFields()) { - if (form.password.length < 8) - return toast.error(t("password_length_error")); - setSubmitLoader(true); - const load = toast.loading(t("creating_account")); + await addUser.mutateAsync(form, { + onSuccess: () => { + onClose(); + }, + }); - const response = await addUser(form); - - toast.dismiss(load); setSubmitLoader(false); - - if (response.ok) { - toast.success(t("user_created")); - onClose(); - } else { - toast.error(response.data as string); - } } else { toast.error(t("fill_all_fields_error")); } diff --git a/components/ModalContent/PreservedFormatsModal.tsx b/components/ModalContent/PreservedFormatsModal.tsx index 87e84a7..7cd1df0 100644 --- a/components/ModalContent/PreservedFormatsModal.tsx +++ b/components/ModalContent/PreservedFormatsModal.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from "react"; -import useLinkStore from "@/store/links"; import { LinkIncludingShortenedCollectionAndTags, ArchivedFormat, @@ -16,23 +15,22 @@ import { screenshotAvailable, } from "@/lib/shared/getArchiveValidity"; import PreservedFormatRow from "@/components/PreserverdFormatRow"; -import useAccountStore from "@/store/account"; import getPublicUserData from "@/lib/client/getPublicUserData"; import { useTranslation } from "next-i18next"; import { BeatLoader } from "react-spinners"; +import { useUser } from "@/hooks/store/user"; +import { useGetLink } from "@/hooks/store/links"; type Props = { onClose: Function; - activeLink: LinkIncludingShortenedCollectionAndTags; + link: LinkIncludingShortenedCollectionAndTags; }; -export default function PreservedFormatsModal({ onClose, activeLink }: Props) { +export default function PreservedFormatsModal({ onClose, link }: Props) { const { t } = useTranslation(); const session = useSession(); - const { getLink } = useLinkStore(); - const { account } = useAccountStore(); - const [link, setLink] = - useState(activeLink); + const getLink = useGetLink(); + const { data: user = {} } = useUser(); const router = useRouter(); let isPublic = router.pathname.startsWith("/public") ? true : undefined; @@ -49,20 +47,20 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { useEffect(() => { const fetchOwner = async () => { - if (link.collection.ownerId !== account.id) { + if (link.collection.ownerId !== user.id) { const owner = await getPublicUserData( link.collection.ownerId as number ); setCollectionOwner(owner); - } else if (link.collection.ownerId === account.id) { + } else if (link.collection.ownerId === user.id) { setCollectionOwner({ - id: account.id as number, - name: account.name, - username: account.username as string, - image: account.image as string, - archiveAsScreenshot: account.archiveAsScreenshot as boolean, - archiveAsMonolith: account.archiveAsScreenshot as boolean, - archiveAsPDF: account.archiveAsPDF as boolean, + id: user.id as number, + name: user.name, + username: user.username as string, + image: user.image as string, + archiveAsScreenshot: user.archiveAsScreenshot as boolean, + archiveAsMonolith: user.archiveAsScreenshot as boolean, + archiveAsPDF: user.archiveAsPDF as boolean, }); } }; @@ -98,20 +96,14 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { useEffect(() => { (async () => { - const data = await getLink(link.id as number, isPublic); - setLink( - (data as any).response as LinkIncludingShortenedCollectionAndTags - ); + await getLink.mutateAsync(link.id as number); })(); let interval: any; if (!isReady()) { interval = setInterval(async () => { - const data = await getLink(link.id as number, isPublic); - setLink( - (data as any).response as LinkIncludingShortenedCollectionAndTags - ); + await getLink.mutateAsync(link.id as number); }, 5000); } else { if (interval) { @@ -137,10 +129,8 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { toast.dismiss(load); if (response.ok) { - const newLink = await getLink(link?.id as number); - setLink( - (newLink as any).response as LinkIncludingShortenedCollectionAndTags - ); + await getLink.mutateAsync(link?.id as number); + toast.success(t("link_being_archived")); } else toast.error(data.response); }; @@ -164,7 +154,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { name={t("webpage")} icon={"bi-filetype-html"} format={ArchivedFormat.monolith} - activeLink={link} + link={link} downloadable={true} /> ) : undefined} @@ -178,7 +168,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { ? ArchivedFormat.png : ArchivedFormat.jpeg } - activeLink={link} + link={link} downloadable={true} /> ) : undefined} @@ -188,7 +178,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { name={t("pdf")} icon={"bi-file-earmark-pdf"} format={ArchivedFormat.pdf} - activeLink={link} + link={link} downloadable={true} /> ) : undefined} @@ -198,7 +188,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { name={t("readable")} icon={"bi-file-earmark-text"} format={ArchivedFormat.readability} - activeLink={link} + link={link} /> ) : undefined} diff --git a/components/ModalContent/RevokeTokenModal.tsx b/components/ModalContent/RevokeTokenModal.tsx index 6b741e6..6964d3d 100644 --- a/components/ModalContent/RevokeTokenModal.tsx +++ b/components/ModalContent/RevokeTokenModal.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from "react"; -import useTokenStore from "@/store/tokens"; -import toast from "react-hot-toast"; import Modal from "../Modal"; import Button from "../ui/Button"; import { useTranslation } from "next-i18next"; import { AccessToken } from "@prisma/client"; +import { useRevokeToken } from "@/hooks/store/tokens"; +import toast from "react-hot-toast"; type Props = { onClose: Function; @@ -15,7 +15,7 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) { const { t } = useTranslation(); const [token, setToken] = useState(activeToken); - const { revokeToken } = useTokenStore(); + const revokeToken = useRevokeToken(); useEffect(() => { setToken(activeToken); @@ -24,17 +24,18 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) { const deleteLink = async () => { const load = toast.loading(t("deleting")); - const response = await revokeToken(token.id as number); + await revokeToken.mutateAsync(token.id, { + onSettled: (data, error) => { + toast.dismiss(load); - toast.dismiss(load); - - if (response.ok) { - toast.success(t("token_revoked")); - } else { - toast.error(response.data as string); - } - - onClose(); + if (error) { + toast.error(error.message); + } else { + onClose(); + toast.success(t("token_revoked")); + } + }, + }); }; return ( diff --git a/components/ModalContent/UploadFileModal.tsx b/components/ModalContent/UploadFileModal.tsx index 2480431..bbb1577 100644 --- a/components/ModalContent/UploadFileModal.tsx +++ b/components/ModalContent/UploadFileModal.tsx @@ -3,8 +3,6 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection"; import TagSelection from "@/components/InputSelect/TagSelection"; import TextInput from "@/components/TextInput"; import unescapeString from "@/lib/client/unescapeString"; -import useCollectionStore from "@/store/collections"; -import useLinkStore from "@/store/links"; import { LinkIncludingShortenedCollectionAndTags, ArchivedFormat, @@ -14,6 +12,8 @@ import { useRouter } from "next/router"; import toast from "react-hot-toast"; import Modal from "../Modal"; import { useTranslation } from "next-i18next"; +import { useCollections } from "@/hooks/store/collections"; +import { useUploadFile } from "@/hooks/store/links"; type Props = { onClose: Function; @@ -45,11 +45,11 @@ export default function UploadFileModal({ onClose }: Props) { useState(initial); const [file, setFile] = useState(); - const { uploadFile } = useLinkStore(); + const uploadFile = useUploadFile(); const [submitLoader, setSubmitLoader] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false); const router = useRouter(); - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); const setCollection = (e: any) => { if (e?.__isNew__) e.value = null; @@ -115,20 +115,26 @@ export default function UploadFileModal({ onClose }: Props) { // } setSubmitLoader(true); + const load = toast.loading(t("creating")); - const response = await uploadFile(link, file); + await uploadFile.mutateAsync( + { link, file }, + { + onSettled: (data, error) => { + toast.dismiss(load); - toast.dismiss(load); - if (response.ok) { - toast.success(t("created_success")); - onClose(); - } else { - toast.error(response.data as string); - } + if (error) { + toast.error(error.message); + } else { + onClose(); + toast.success(t("created_success")); + } + }, + } + ); setSubmitLoader(false); - return response; } }; diff --git a/components/PreserverdFormatRow.tsx b/components/PreserverdFormatRow.tsx index ce0f21d..e8ddbd1 100644 --- a/components/PreserverdFormatRow.tsx +++ b/components/PreserverdFormatRow.tsx @@ -1,19 +1,16 @@ -import React, { useEffect, useState } from "react"; -import useLinkStore from "@/store/links"; import { ArchivedFormat, LinkIncludingShortenedCollectionAndTags, } from "@/types/global"; -import toast from "react-hot-toast"; import Link from "next/link"; import { useRouter } from "next/router"; -import { useSession } from "next-auth/react"; +import { useGetLink } from "@/hooks/store/links"; type Props = { name: string; icon: string; format: ArchivedFormat; - activeLink: LinkIncludingShortenedCollectionAndTags; + link: LinkIncludingShortenedCollectionAndTags; downloadable?: boolean; }; @@ -21,48 +18,15 @@ export default function PreservedFormatRow({ name, icon, format, - activeLink, + link, downloadable, }: Props) { - const session = useSession(); - const { getLink } = useLinkStore(); - - const [link, setLink] = - useState(activeLink); + const getLink = useGetLink(); const router = useRouter(); let isPublic = router.pathname.startsWith("/public") ? true : undefined; - useEffect(() => { - (async () => { - const data = await getLink(link.id as number, isPublic); - setLink( - (data as any).response as LinkIncludingShortenedCollectionAndTags - ); - })(); - - let interval: any; - if (link?.image === "pending" || link?.pdf === "pending") { - interval = setInterval(async () => { - const data = await getLink(link.id as number, isPublic); - setLink( - (data as any).response as LinkIncludingShortenedCollectionAndTags - ); - }, 5000); - } else { - if (interval) { - clearInterval(interval); - } - } - - return () => { - if (interval) { - clearInterval(interval); - } - }; - }, [link?.image, link?.pdf, link?.readable, link?.monolith]); - const handleDownload = () => { const path = `/api/v1/archives/${link?.id}?format=${format}`; fetch(path) diff --git a/components/ProfileDropdown.tsx b/components/ProfileDropdown.tsx index 21ce54e..d32893d 100644 --- a/components/ProfileDropdown.tsx +++ b/components/ProfileDropdown.tsx @@ -1,17 +1,17 @@ import useLocalSettingsStore from "@/store/localSettings"; import { dropdownTriggerer } from "@/lib/client/utils"; import ProfilePhoto from "./ProfilePhoto"; -import useAccountStore from "@/store/account"; import Link from "next/link"; import { signOut } from "next-auth/react"; import { useTranslation } from "next-i18next"; +import { useUser } from "@/hooks/store/user"; export default function ProfileDropdown() { const { t } = useTranslation(); const { settings, updateSettings } = useLocalSettingsStore(); - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); - const isAdmin = account.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1); + const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1); const handleToggle = () => { const newTheme = settings.theme === "dark" ? "light" : "dark"; @@ -27,7 +27,7 @@ export default function ProfileDropdown() { className="btn btn-circle btn-ghost" >
diff --git a/components/ReadableView.tsx b/components/ReadableView.tsx index b600236..801e9c1 100644 --- a/components/ReadableView.tsx +++ b/components/ReadableView.tsx @@ -1,7 +1,6 @@ import unescapeString from "@/lib/client/unescapeString"; import { readabilityAvailable } from "@/lib/shared/getArchiveValidity"; import isValidUrl from "@/lib/shared/isValidUrl"; -import useLinkStore from "@/store/links"; import { ArchivedFormat, CollectionIncludingMembersAndLinkCount, @@ -14,8 +13,9 @@ import Link from "next/link"; import { useRouter } from "next/router"; import React, { useEffect, useMemo, useState } from "react"; import LinkActions from "./LinkViews/LinkComponents/LinkActions"; -import useCollectionStore from "@/store/collections"; import { useTranslation } from "next-i18next"; +import { useCollections } from "@/hooks/store/collections"; +import { useGetLink } from "@/hooks/store/links"; type LinkContent = { title: string; @@ -45,8 +45,8 @@ export default function ReadableView({ link }: Props) { const router = useRouter(); - const { getLink } = useLinkStore(); - const { collections } = useCollectionStore(); + const getLink = useGetLink(); + const { data: collections = [] } = useCollections(); const collection = useMemo(() => { return collections.find( @@ -73,7 +73,7 @@ export default function ReadableView({ link }: Props) { }, [link]); useEffect(() => { - if (link) getLink(link?.id as number); + if (link) getLink.mutateAsync(link?.id as number); let interval: any; if ( @@ -87,7 +87,10 @@ export default function ReadableView({ link }: Props) { !link?.readable || !link?.monolith) ) { - interval = setInterval(() => getLink(link.id as number), 5000); + interval = setInterval( + () => getLink.mutateAsync(link.id as number), + 5000 + ); } else { if (interval) { clearInterval(interval); diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 11a0f62..645fda4 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -1,5 +1,3 @@ -import useCollectionStore from "@/store/collections"; -import useTagStore from "@/store/tags"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -7,6 +5,8 @@ import { Disclosure, Transition } from "@headlessui/react"; import SidebarHighlightLink from "@/components/SidebarHighlightLink"; import CollectionListing from "@/components/CollectionListing"; import { useTranslation } from "next-i18next"; +import { useCollections } from "@/hooks/store/collections"; +import { useTags } from "@/hooks/store/tags"; export default function Sidebar({ className }: { className?: string }) { const { t } = useTranslation(); @@ -22,8 +22,9 @@ export default function Sidebar({ className }: { className?: string }) { } ); - const { collections } = useCollectionStore(); - const { tags } = useTagStore(); + const { data: collections } = useCollections(); + + const { data: tags = [], isLoading } = useTags(); const [active, setActive] = useState(""); const router = useRouter(); @@ -127,10 +128,16 @@ export default function Sidebar({ className }: { className?: string }) { leaveTo="transform opacity-0 -translate-y-3" > - {tags[0] ? ( + {isLoading ? ( +
+
+
+
+
+ ) : tags[0] ? ( tags - .sort((a, b) => a.name.localeCompare(b.name)) - .map((e, i) => { + .sort((a: any, b: any) => a.name.localeCompare(b.name)) + .map((e: any, i: any) => { return (
{ + updateSettings({ sortBy }); + }, [sortBy]); + return (
>; + viewMode: ViewMode; + setViewMode: Dispatch>; }; export default function ViewDropdown({ viewMode, setViewMode }: Props) { @@ -19,7 +19,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) { }; useEffect(() => { - updateSettings({ viewMode: viewMode as ViewMode }); + updateSettings({ viewMode }); }, [viewMode]); return ( diff --git a/components/ui/Loader.tsx b/components/ui/Loader.tsx new file mode 100644 index 0000000..ac5f570 --- /dev/null +++ b/components/ui/Loader.tsx @@ -0,0 +1,272 @@ +import React from "react"; + +type Props = { + className?: string; + color: string; + size: string; +}; + +const Loader = (props: Props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Loader; diff --git a/hooks/store/admin/users.tsx b/hooks/store/admin/users.tsx new file mode 100644 index 0000000..476beb3 --- /dev/null +++ b/hooks/store/admin/users.tsx @@ -0,0 +1,93 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import { useTranslation } from "next-i18next"; +import { useSession } from "next-auth/react"; + +const useUsers = () => { + const { status } = useSession(); + + return useQuery({ + queryKey: ["users"], + queryFn: async () => { + const response = await fetch("/api/v1/users"); + if (!response.ok) { + if (response.status === 401) { + window.location.href = "/dashboard"; + } + throw new Error("Failed to fetch users."); + } + + const data = await response.json(); + return data.response; + }, + enabled: status === "authenticated", + }); +}; + +const useAddUser = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (body: any) => { + if (body.password.length < 8) throw new Error(t("password_length_error")); + + const load = toast.loading(t("creating_account")); + + const response = await fetch("/api/v1/users", { + method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.response); + + toast.dismiss(load); + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["users"], (oldData: any) => [...oldData, data]); + toast.success(t("user_created")); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; + +const useDeleteUser = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: async (userId: number) => { + const load = toast.loading(t("deleting_user")); + + const response = await fetch(`/api/v1/users/${userId}`, { + method: "DELETE", + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.response); + + toast.dismiss(load); + + return data.response; + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(["users"], (oldData: any) => + oldData.filter((user: any) => user.id !== variables) + ); + toast.success(t("user_deleted")); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +}; + +export { useUsers, useAddUser, useDeleteUser }; diff --git a/hooks/store/collections.tsx b/hooks/store/collections.tsx new file mode 100644 index 0000000..efa3809 --- /dev/null +++ b/hooks/store/collections.tsx @@ -0,0 +1,116 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; +import { useSession } from "next-auth/react"; + +const useCollections = () => { + const { status } = useSession(); + + return useQuery({ + queryKey: ["collections"], + queryFn: async (): Promise => { + const response = await fetch("/api/v1/collections"); + const data = await response.json(); + return data.response; + }, + enabled: status === "authenticated", + }); +}; + +const useCreateCollection = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body: any) => { + const response = await fetch("/api/v1/collections", { + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + return queryClient.setQueryData(["collections"], (oldData: any) => { + return [...oldData, data]; + }); + }, + }); +}; + +const useUpdateCollection = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body: any) => { + const response = await fetch(`/api/v1/collections/${body.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + { + return queryClient.setQueryData(["collections"], (oldData: any) => { + return oldData.map((collection: any) => + collection.id === data.id ? data : collection + ); + }); + } + }, + // onMutate: async (data) => { + // await queryClient.cancelQueries({ queryKey: ["collections"] }); + // queryClient.setQueryData(["collections"], (oldData: any) => { + // return oldData.map((collection: any) => + // collection.id === data.id ? data : collection + // ) + // }); + // }, + }); +}; + +const useDeleteCollection = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: number) => { + const response = await fetch(`/api/v1/collections/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + return queryClient.setQueryData(["collections"], (oldData: any) => { + return oldData.filter((collection: any) => collection.id !== data.id); + }); + }, + }); +}; + +export { + useCollections, + useCreateCollection, + useUpdateCollection, + useDeleteCollection, +}; diff --git a/hooks/store/dashboardData.tsx b/hooks/store/dashboardData.tsx new file mode 100644 index 0000000..d808ffd --- /dev/null +++ b/hooks/store/dashboardData.tsx @@ -0,0 +1,20 @@ +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; + +const useDashboardData = () => { + const { status } = useSession(); + + return useQuery({ + queryKey: ["dashboardData"], + queryFn: async (): Promise => { + const response = await fetch("/api/v1/dashboard"); + const data = await response.json(); + + return data.response; + }, + enabled: status === "authenticated", + }); +}; + +export { useDashboardData }; diff --git a/hooks/store/links.tsx b/hooks/store/links.tsx new file mode 100644 index 0000000..4702ff4 --- /dev/null +++ b/hooks/store/links.tsx @@ -0,0 +1,437 @@ +import { + InfiniteData, + useInfiniteQuery, + UseInfiniteQueryResult, + useQueryClient, + useMutation, +} from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + ArchivedFormat, + LinkIncludingShortenedCollectionAndTags, + LinkRequestQuery, +} from "@/types/global"; +import { useRouter } from "next/router"; +import { useSession } from "next-auth/react"; + +const useLinks = (params: LinkRequestQuery = {}) => { + const router = useRouter(); + + const queryParamsObject = { + sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0, + collectionId: + params.collectionId ?? router.pathname === "/collections/[id]" + ? router.query.id + : undefined, + tagId: + params.tagId ?? router.pathname === "/tags/[id]" + ? router.query.id + : undefined, + pinnedOnly: + params.pinnedOnly ?? router.pathname === "/links/pinned" + ? true + : undefined, + searchQueryString: params.searchQueryString, + searchByName: params.searchByName, + searchByUrl: params.searchByUrl, + searchByDescription: params.searchByDescription, + searchByTextContent: params.searchByTextContent, + searchByTags: params.searchByTags, + } as LinkRequestQuery; + + const queryString = buildQueryString(queryParamsObject); + + const { data, ...rest } = useFetchLinks(queryString); + + const links = useMemo(() => { + return data?.pages.reduce((acc, page) => { + return [...acc, ...page]; + }, []); + }, [data]); + + return { + links, + data: { ...data, ...rest }, + } as { + links: LinkIncludingShortenedCollectionAndTags[]; + data: UseInfiniteQueryResult, Error>; + }; +}; + +const useFetchLinks = (params: string) => { + const { status } = useSession(); + + return useInfiniteQuery({ + queryKey: ["links", { params }], + queryFn: async (params) => { + const response = await fetch( + "/api/v1/links?cursor=" + + params.pageParam + + ((params.queryKey[1] as any).params + ? "&" + (params.queryKey[1] as any).params + : "") + ); + const data = await response.json(); + + return data.response; + }, + initialPageParam: 0, + refetchOnWindowFocus: false, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) { + return undefined; + } + return lastPage.at(-1).id; + }, + enabled: status === "authenticated", + }); +}; + +const buildQueryString = (params: LinkRequestQuery) => { + return Object.keys(params) + .filter((key) => params[key as keyof LinkRequestQuery] !== undefined) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent( + params[key as keyof LinkRequestQuery] as string + )}` + ) + .join("&"); +}; + +const useAddLink = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => { + const response = await fetch("/api/v1/links", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(link), + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["dashboardData"], (oldData: any) => { + if (!oldData) return undefined; + return [data, ...oldData]; + }); + + queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { + if (!oldData) return undefined; + return { + pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)], + pageParams: oldData?.pageParams, + }; + }); + + queryClient.invalidateQueries({ queryKey: ["collections"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); + }, + }); +}; + +const useUpdateLink = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => { + const response = await fetch(`/api/v1/links/${link.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(link), + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["dashboardData"], (oldData: any) => { + if (!oldData) return undefined; + return oldData.map((e: any) => (e.id === data.id ? data : e)); + }); + + queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { + if (!oldData) return undefined; + return { + pages: oldData.pages.map((page: any) => + page.map((item: any) => (item.id === data.id ? data : item)) + ), + pageParams: oldData.pageParams, + }; + }); + + queryClient.invalidateQueries({ queryKey: ["collections"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); + }, + }); +}; + +const useDeleteLink = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: number) => { + const response = await fetch(`/api/v1/links/${id}`, { + method: "DELETE", + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["dashboardData"], (oldData: any) => { + if (!oldData) return undefined; + return oldData.filter((e: any) => e.id !== data.id); + }); + + queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { + if (!oldData) return undefined; + return { + pages: oldData.pages.map((page: any) => + page.filter((item: any) => item.id !== data.id) + ), + pageParams: oldData.pageParams, + }; + }); + + queryClient.invalidateQueries({ queryKey: ["collections"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); + }, + }); +}; + +const useGetLink = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: number) => { + const response = await fetch(`/api/v1/links/${id}`); + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["dashboardData"], (oldData: any) => { + if (!oldData) return undefined; + return oldData.map((e: any) => (e.id === data.id ? data : e)); + }); + + queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { + if (!oldData) return undefined; + return { + pages: oldData.pages.map((page: any) => + page.map((item: any) => (item.id === data.id ? data : item)) + ), + pageParams: oldData.pageParams, + }; + }); + + queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); + }, + }); +}; + +const useBulkDeleteLinks = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (linkIds: number[]) => { + const response = await fetch("/api/v1/links", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ linkIds }), + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return linkIds; + }, + onSuccess: (data) => { + queryClient.setQueryData(["dashboardData"], (oldData: any) => { + if (!oldData) return undefined; + return oldData.filter((e: any) => !data.includes(e.id)); + }); + + queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { + if (!oldData) return undefined; + return { + pages: oldData.pages.map((page: any) => + page.filter((item: any) => !data.includes(item.id)) + ), + pageParams: oldData.pageParams, + }; + }); + + queryClient.invalidateQueries({ queryKey: ["collections"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); + }, + }); +}; + +const useUploadFile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ link, file }: any) => { + let fileType: ArchivedFormat | null = null; + let linkType: "url" | "image" | "pdf" | null = null; + + if (file?.type === "image/jpg" || file.type === "image/jpeg") { + fileType = ArchivedFormat.jpeg; + linkType = "image"; + } else if (file.type === "image/png") { + fileType = ArchivedFormat.png; + linkType = "image"; + } else if (file.type === "application/pdf") { + fileType = ArchivedFormat.pdf; + linkType = "pdf"; + } else { + return { ok: false, data: "Invalid file type." }; + } + + const response = await fetch("/api/v1/links", { + body: JSON.stringify({ + ...link, + type: linkType, + name: link.name ? link.name : file.name, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + if (response.ok) { + const formBody = new FormData(); + file && formBody.append("file", file); + + await fetch( + `/api/v1/archives/${(data as any).response.id}?format=${fileType}`, + { + body: formBody, + method: "POST", + } + ); + } + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["dashboardData"], (oldData: any) => { + if (!oldData) return undefined; + return [data, ...oldData]; + }); + + queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { + if (!oldData) return undefined; + return { + pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)], + pageParams: oldData?.pageParams, + }; + }); + + queryClient.invalidateQueries({ queryKey: ["collections"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); + }, + }); +}; + +const useBulkEditLinks = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + links, + newData, + removePreviousTags, + }: { + links: LinkIncludingShortenedCollectionAndTags[]; + newData: Pick< + LinkIncludingShortenedCollectionAndTags, + "tags" | "collectionId" + >; + removePreviousTags: boolean; + }) => { + const response = await fetch("/api/v1/links", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ links, newData, removePreviousTags }), + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data, { links, newData, removePreviousTags }) => { + queryClient.setQueryData(["dashboardData"], (oldData: any) => { + if (!oldData) return undefined; + return oldData.map((e: any) => + data.find((d: any) => d.id === e.id) ? data : e + ); + }); + + // TODO: Fix this + // queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { + // if (!oldData) return undefined; + // return { + // pages: oldData.pages.map((page: any) => for (item of links) { + // page.map((item: any) => (item.id === data.id ? data : item)) + // } + // ), + // pageParams: oldData.pageParams, + // }; + // }); + queryClient.invalidateQueries({ queryKey: ["links"] }); // Temporary workaround + + queryClient.invalidateQueries({ queryKey: ["collections"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); + }, + }); +}; + +export { + useLinks, + useAddLink, + useUpdateLink, + useDeleteLink, + useBulkDeleteLinks, + useUploadFile, + useGetLink, + useBulkEditLinks, +}; diff --git a/hooks/store/publicLinks.tsx b/hooks/store/publicLinks.tsx new file mode 100644 index 0000000..86c7961 --- /dev/null +++ b/hooks/store/publicLinks.tsx @@ -0,0 +1,93 @@ +import { + InfiniteData, + useInfiniteQuery, + UseInfiniteQueryResult, +} from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + LinkIncludingShortenedCollectionAndTags, + LinkRequestQuery, +} from "@/types/global"; +import { useRouter } from "next/router"; + +const usePublicLinks = (params: LinkRequestQuery = {}) => { + const router = useRouter(); + + const queryParamsObject = { + sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0, + collectionId: params.collectionId ?? router.query.id, + tagId: + params.tagId ?? router.pathname === "/tags/[id]" + ? router.query.id + : undefined, + pinnedOnly: + params.pinnedOnly ?? router.pathname === "/links/pinned" + ? true + : undefined, + searchQueryString: params.searchQueryString, + searchByName: params.searchByName, + searchByUrl: params.searchByUrl, + searchByDescription: params.searchByDescription, + searchByTextContent: params.searchByTextContent, + searchByTags: params.searchByTags, + } as LinkRequestQuery; + + const queryString = buildQueryString(queryParamsObject); + + const { data, ...rest } = useFetchLinks(queryString); + + const links = useMemo(() => { + return data?.pages.reduce((acc, page) => { + return [...acc, ...page]; + }, []); + }, [data]); + + return { + links, + data: { ...data, ...rest }, + } as { + links: LinkIncludingShortenedCollectionAndTags[]; + data: UseInfiniteQueryResult, Error>; + }; +}; + +const useFetchLinks = (params: string) => { + return useInfiniteQuery({ + queryKey: ["links", { params }], + queryFn: async (params) => { + const response = await fetch( + "/api/v1/public/collections/links?cursor=" + + params.pageParam + + ((params.queryKey[1] as any).params + ? "&" + (params.queryKey[1] as any).params + : "") + ); + + const data = await response.json(); + + return data.response; + }, + initialPageParam: 0, + refetchOnWindowFocus: false, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) { + return undefined; + } + return lastPage.at(-1).id; + }, + }); +}; + +const buildQueryString = (params: LinkRequestQuery) => { + return Object.keys(params) + .filter((key) => params[key as keyof LinkRequestQuery] !== undefined) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent( + params[key as keyof LinkRequestQuery] as string + )}` + ) + .join("&"); +}; + +export { usePublicLinks }; diff --git a/hooks/store/tags.tsx b/hooks/store/tags.tsx new file mode 100644 index 0000000..a2c0b7d --- /dev/null +++ b/hooks/store/tags.tsx @@ -0,0 +1,71 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { TagIncludingLinkCount } from "@/types/global"; +import { useSession } from "next-auth/react"; + +const useTags = () => { + const { status } = useSession(); + + return useQuery({ + queryKey: ["tags"], + queryFn: async () => { + const response = await fetch("/api/v1/tags"); + if (!response.ok) throw new Error("Failed to fetch tags."); + + const data = await response.json(); + return data.response; + }, + enabled: status === "authenticated", + }); +}; + +const useUpdateTag = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (tag: TagIncludingLinkCount) => { + const response = await fetch(`/api/v1/tags/${tag.id}`, { + body: JSON.stringify(tag), + headers: { + "Content-Type": "application/json", + }, + method: "PUT", + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["tags"], (oldData: any) => + oldData.map((tag: TagIncludingLinkCount) => + tag.id === data.id ? data : tag + ) + ); + }, + }); +}; + +const useRemoveTag = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (tagId: number) => { + const response = await fetch(`/api/v1/tags/${tagId}`, { + method: "DELETE", + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(["tags"], (oldData: any) => + oldData.filter((tag: TagIncludingLinkCount) => tag.id !== variables) + ); + }, + }); +}; + +export { useTags, useUpdateTag, useRemoveTag }; diff --git a/hooks/store/tokens.tsx b/hooks/store/tokens.tsx new file mode 100644 index 0000000..35ac345 --- /dev/null +++ b/hooks/store/tokens.tsx @@ -0,0 +1,68 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AccessToken } from "@prisma/client"; +import { useSession } from "next-auth/react"; + +const useTokens = () => { + const { status } = useSession(); + + return useQuery({ + queryKey: ["tokens"], + queryFn: async () => { + const response = await fetch("/api/v1/tokens"); + + if (!response.ok) throw new Error("Failed to fetch tokens."); + + const data = await response.json(); + return data.response as AccessToken[]; + }, + enabled: status === "authenticated", + }); +}; + +const useAddToken = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body: Partial) => { + const response = await fetch("/api/v1/tokens", { + body: JSON.stringify(body), + method: "POST", + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data) => { + queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) => [ + ...oldData, + data.token, + ]); + }, + }); +}; + +const useRevokeToken = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (tokenId: number) => { + const response = await fetch(`/api/v1/tokens/${tokenId}`, { + method: "DELETE", + }); + + const data = await response.json(); + if (!response.ok) throw new Error(data.response); + + return data.response; + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) => + oldData.filter((token: Partial) => token.id !== variables) + ); + }, + }); +}; + +export { useTokens, useAddToken, useRevokeToken }; diff --git a/hooks/store/user.tsx b/hooks/store/user.tsx new file mode 100644 index 0000000..200fd45 --- /dev/null +++ b/hooks/store/user.tsx @@ -0,0 +1,53 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; + +const useUser = () => { + const { data, status } = useSession(); + + const userId = data?.user.id; + + return useQuery({ + queryKey: ["user"], + queryFn: async () => { + const response = await fetch(`/api/v1/users/${userId}`); + if (!response.ok) throw new Error("Failed to fetch user data."); + + const data = await response.json(); + + return data.response; + }, + enabled: !!userId && status === "authenticated", + placeholderData: {}, + }); +}; + +const useUpdateUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (user: any) => { + const response = await fetch(`/api/v1/users/${user.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(user), + }); + + const data = await response.json(); + + if (!response.ok) throw new Error(data.response); + + return data; + }, + onSuccess: (data) => { + queryClient.setQueryData(["user"], data.response); + }, + onMutate: async (user) => { + await queryClient.cancelQueries({ queryKey: ["user"] }); + queryClient.setQueryData(["user"], (oldData: any) => { + return { ...oldData, ...user }; + }); + }, + }); +}; + +export { useUser, useUpdateUser }; diff --git a/hooks/useCollectivePermissions.ts b/hooks/useCollectivePermissions.ts index b1e3b7c..0d2e5fb 100644 --- a/hooks/useCollectivePermissions.ts +++ b/hooks/useCollectivePermissions.ts @@ -1,12 +1,12 @@ -import useAccountStore from "@/store/account"; -import useCollectionStore from "@/store/collections"; import { Member } from "@/types/global"; import { useEffect, useState } from "react"; +import { useCollections } from "./store/collections"; +import { useUser } from "./store/user"; export default function useCollectivePermissions(collectionIds: number[]) { - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); const [permissions, setPermissions] = useState(); useEffect(() => { @@ -15,7 +15,7 @@ export default function useCollectivePermissions(collectionIds: number[]) { if (collection) { let getPermission: Member | undefined = collection.members.find( - (e) => e.userId === account.id + (e) => e.userId === user.id ); if ( @@ -25,10 +25,10 @@ export default function useCollectivePermissions(collectionIds: number[]) { ) getPermission = undefined; - setPermissions(account.id === collection.ownerId || getPermission); + setPermissions(user.id === collection.ownerId || getPermission); } } - }, [account, collections, collectionIds]); + }, [user, collections, collectionIds]); return permissions; } diff --git a/hooks/useInitialData.tsx b/hooks/useInitialData.tsx index 4b0dd17..21832d1 100644 --- a/hooks/useInitialData.tsx +++ b/hooks/useInitialData.tsx @@ -1,34 +1,14 @@ -import useCollectionStore from "@/store/collections"; import { useEffect } from "react"; import { useSession } from "next-auth/react"; -import useTagStore from "@/store/tags"; -import useAccountStore from "@/store/account"; import useLocalSettingsStore from "@/store/localSettings"; export default function useInitialData() { const { status, data } = useSession(); - const { setCollections } = useCollectionStore(); - const { setTags } = useTagStore(); - // const { setLinks } = useLinkStore(); - const { account, setAccount } = useAccountStore(); const { setSettings } = useLocalSettingsStore(); useEffect(() => { setSettings(); - if (status === "authenticated") { - // Get account info - setAccount(data?.user.id as number); - } }, [status, data]); - // Get the rest of the data - useEffect(() => { - if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) { - setCollections(); - setTags(); - // setLinks(); - } - }, [account]); - return status; } diff --git a/hooks/useLinks.tsx b/hooks/useLinks.tsx deleted file mode 100644 index bec2d49..0000000 --- a/hooks/useLinks.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { LinkRequestQuery } from "@/types/global"; -import { useEffect, useState } from "react"; -import useDetectPageBottom from "./useDetectPageBottom"; -import { useRouter } from "next/router"; -import useLinkStore from "@/store/links"; - -export default function useLinks( - { - sort, - collectionId, - tagId, - pinnedOnly, - searchQueryString, - searchByName, - searchByUrl, - searchByDescription, - searchByTags, - searchByTextContent, - }: LinkRequestQuery = { sort: 0 } -) { - const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } = - useLinkStore(); - const router = useRouter(); - - const [isLoading, setIsLoading] = useState(true); - - const { reachedBottom, setReachedBottom } = useDetectPageBottom(); - - const getLinks = async (isInitialCall: boolean, cursor?: number) => { - const params = { - sort, - cursor, - collectionId, - tagId, - pinnedOnly, - searchQueryString, - searchByName, - searchByUrl, - searchByDescription, - searchByTags, - searchByTextContent, - }; - - const buildQueryString = (params: LinkRequestQuery) => { - return Object.keys(params) - .filter((key) => params[key as keyof LinkRequestQuery] !== undefined) - .map( - (key) => - `${encodeURIComponent(key)}=${encodeURIComponent( - params[key as keyof LinkRequestQuery] as string - )}` - ) - .join("&"); - }; - - let queryString = buildQueryString(params); - - let basePath; - - if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard"; - else if (router.pathname.startsWith("/public/collections/[id]")) { - queryString = queryString + "&collectionId=" + router.query.id; - basePath = "/api/v1/public/collections/links"; - } else basePath = "/api/v1/links"; - - setIsLoading(true); - - const response = await fetch(`${basePath}?${queryString}`); - - const data = await response.json(); - - setIsLoading(false); - - if (response.ok) setLinks(data.response, isInitialCall); - }; - - useEffect(() => { - // Save the selected links before resetting the links - // and then restore the selected links after resetting the links - const previouslySelected = selectedLinks; - resetLinks(); - - setSelectedLinks(previouslySelected); - getLinks(true); - }, [ - router, - sort, - searchQueryString, - searchByName, - searchByUrl, - searchByDescription, - searchByTextContent, - searchByTags, - ]); - - useEffect(() => { - if (reachedBottom) getLinks(false, links?.at(-1)?.id); - - setReachedBottom(false); - }, [reachedBottom]); - - return { isLoading }; -} diff --git a/hooks/usePermissions.tsx b/hooks/usePermissions.tsx index 746897b..c672c30 100644 --- a/hooks/usePermissions.tsx +++ b/hooks/usePermissions.tsx @@ -1,12 +1,12 @@ -import useAccountStore from "@/store/account"; -import useCollectionStore from "@/store/collections"; import { Member } from "@/types/global"; import { useEffect, useState } from "react"; +import { useCollections } from "./store/collections"; +import { useUser } from "./store/user"; export default function usePermissions(collectionId: number) { - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); const [permissions, setPermissions] = useState(); useEffect(() => { @@ -14,7 +14,7 @@ export default function usePermissions(collectionId: number) { if (collection) { let getPermission: Member | undefined = collection.members.find( - (e) => e.userId === account.id + (e) => e.userId === user.id ); if ( @@ -24,9 +24,9 @@ export default function usePermissions(collectionId: number) { ) getPermission = undefined; - setPermissions(account.id === collection.ownerId || getPermission); + setPermissions(user.id === collection.ownerId || getPermission); } - }, [account, collections, collectionId]); + }, [user, collections, collectionId]); return permissions; } diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index 7f89222..c3a6a3d 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { useSession } from "next-auth/react"; import useInitialData from "@/hooks/useInitialData"; -import useAccountStore from "@/store/account"; +import { useUser } from "@/hooks/store/user"; interface Props { children: ReactNode; @@ -14,7 +14,7 @@ export default function AuthRedirect({ children }: Props) { const router = useRouter(); const { status } = useSession(); const [shouldRenderChildren, setShouldRenderChildren] = useState(false); - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); useInitialData(); @@ -23,7 +23,7 @@ export default function AuthRedirect({ children }: Props) { const isUnauthenticated = status === "unauthenticated"; const isPublicPage = router.pathname.startsWith("/public"); const hasInactiveSubscription = - account.id && !account.subscription?.active && stripeEnabled; + user.id && !user.subscription?.active && stripeEnabled; // There are better ways of doing this... but this one works for now const routes = [ @@ -63,7 +63,7 @@ export default function AuthRedirect({ children }: Props) { setShouldRenderChildren(true); } } - }, [status, account, router.pathname]); + }, [status, user, router.pathname]); function redirectTo(destination: string) { router.push(destination).then(() => setShouldRenderChildren(true)); diff --git a/lib/api/controllers/dashboard/getDashboardData.ts b/lib/api/controllers/dashboard/getDashboardData.ts index 3b0f751..bb2f9e4 100644 --- a/lib/api/controllers/dashboard/getDashboardData.ts +++ b/lib/api/controllers/dashboard/getDashboardData.ts @@ -5,7 +5,7 @@ export default async function getDashboardData( userId: number, query: LinkRequestQuery ) { - let order: any; + let order: any = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" }; @@ -42,7 +42,7 @@ export default async function getDashboardData( select: { id: true }, }, }, - orderBy: order || { id: "desc" }, + orderBy: order, }); const recentlyAddedLinks = await prisma.link.findMany({ @@ -67,10 +67,18 @@ export default async function getDashboardData( select: { id: true }, }, }, - orderBy: order || { id: "desc" }, + orderBy: order, }); - const links = [...recentlyAddedLinks, ...pinnedLinks].sort( + const combinedLinks = [...recentlyAddedLinks, ...pinnedLinks]; + + const uniqueLinks = Array.from( + combinedLinks + .reduce((map, item) => map.set(item.id, item), new Map()) + .values() + ); + + const links = uniqueLinks.sort( (a, b) => (new Date(b.id) as any) - (new Date(a.id) as any) ); diff --git a/lib/api/controllers/links/getLinks.ts b/lib/api/controllers/links/getLinks.ts index 561c919..a6b1dab 100644 --- a/lib/api/controllers/links/getLinks.ts +++ b/lib/api/controllers/links/getLinks.ts @@ -5,7 +5,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) { const POSTGRES_IS_ENABLED = process.env.DATABASE_URL?.startsWith("postgresql"); - let order: any; + let order: any = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" }; @@ -146,7 +146,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) { select: { id: true }, }, }, - orderBy: order || { id: "desc" }, + orderBy: order, }); return { response: links, status: 200 }; diff --git a/package.json b/package.json index 1c428cf..f2a357e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "@mozilla/readability": "^0.4.4", "@prisma/client": "^4.16.2", "@stripe/stripe-js": "^1.54.1", + "@tanstack/react-query": "^5.51.15", + "@tanstack/react-query-devtools": "^5.51.15", "@types/crypto-js": "^4.1.1", "@types/formidable": "^3.4.5", "@types/node": "^20.10.4", @@ -67,9 +69,10 @@ "react-hot-toast": "^2.4.1", "react-i18next": "^14.1.2", "react-image-file-resizer": "^0.4.8", + "react-intersection-observer": "^9.13.0", "react-masonry-css": "^1.0.16", "react-select": "^5.7.4", - "react-spinners": "^0.13.8", + "react-spinners": "^0.14.1", "socks-proxy-agent": "^8.0.2", "stripe": "^12.13.0", "tailwind-merge": "^2.3.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index 0cb79ff..dc6603a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -11,7 +11,16 @@ import { Session } from "next-auth"; import { isPWA } from "@/lib/client/utils"; // import useInitialData from "@/hooks/useInitialData"; import { appWithTranslation } from "next-i18next"; -import nextI18nextConfig from "../next-i18next.config"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 30, + }, + }, +}); function App({ Component, @@ -29,82 +38,76 @@ function App({ }, []); return ( - - - Linkwarden - - - - - - - - - {/* */} - - {(t) => ( - - {({ icon, message }) => ( -
- {icon} - {message} - {t.type !== "loading" && ( - - )} -
- )} -
- )} -
- - {/*
*/} -
-
+ + + + Linkwarden + + + + + + + + + {/* */} + + {(t) => ( + + {({ icon, message }) => ( +
+ {icon} + {message} + {t.type !== "loading" && ( + + )} +
+ )} +
+ )} +
+ + {/*
*/} +
+
+ +
); } export default appWithTranslation(App); - -// function GetData({ children }: { children: React.ReactNode }) { -// const status = useInitialData(); -// return typeof window !== "undefined" && status !== "loading" ? ( -// children -// ) : ( -// <> -// ); -// } diff --git a/pages/admin.tsx b/pages/admin.tsx index 7211e08..df62ffe 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -1,11 +1,11 @@ import NewUserModal from "@/components/ModalContent/NewUserModal"; -import useUserStore from "@/store/admin/users"; import { User as U } from "@prisma/client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; import UserListing from "@/components/UserListing"; +import { useUsers } from "@/hooks/store/admin/users"; interface User extends U { subscriptions: { @@ -21,7 +21,7 @@ type UserModal = { export default function Admin() { const { t } = useTranslation(); - const { users, setUsers } = useUserStore(); + const { data: users = [] } = useUsers(); const [searchQuery, setSearchQuery] = useState(""); const [filteredUsers, setFilteredUsers] = useState(); @@ -33,10 +33,6 @@ export default function Admin() { const [newUserModal, setNewUserModal] = useState(false); - useEffect(() => { - setUsers(); - }, []); - return (
@@ -71,7 +67,7 @@ export default function Admin() { if (users) { setFilteredUsers( - users.filter((user) => + users.filter((user: any) => JSON.stringify(user) .toLowerCase() .includes(e.target.value.toLowerCase()) diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index bd24a95..5ff146f 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -1,5 +1,3 @@ -import useCollectionStore from "@/store/collections"; -import useLinkStore from "@/store/links"; import { CollectionIncludingMembersAndLinkCount, Sort, @@ -9,23 +7,22 @@ import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; import MainLayout from "@/layouts/MainLayout"; import ProfilePhoto from "@/components/ProfilePhoto"; -import useLinks from "@/hooks/useLinks"; import usePermissions from "@/hooks/usePermissions"; import NoLinksFound from "@/components/NoLinksFound"; import useLocalSettingsStore from "@/store/localSettings"; -import useAccountStore from "@/store/account"; import getPublicUserData from "@/lib/client/getPublicUserData"; import EditCollectionModal from "@/components/ModalContent/EditCollectionModal"; import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal"; import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal"; -import CardView from "@/components/LinkViews/Layouts/CardView"; -import ListView from "@/components/LinkViews/Layouts/ListView"; import { dropdownTriggerer } from "@/lib/client/utils"; import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; -import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; import getServerSideProps from "@/lib/client/getServerSideProps"; import { useTranslation } from "next-i18next"; import LinkListOptions from "@/components/LinkListOptions"; +import { useCollections } from "@/hooks/store/collections"; +import { useUser } from "@/hooks/store/user"; +import { useLinks } from "@/hooks/store/links"; +import Links from "@/components/LinkViews/Links"; export default function Index() { const { t } = useTranslation(); @@ -33,25 +30,29 @@ export default function Index() { const router = useRouter(); - const { links } = useLinkStore(); - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); - const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + const [sortBy, setSortBy] = useState( + Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst + ); + + const { links, data } = useLinks({ + sort: sortBy, + collectionId: Number(router.query.id), + }); const [activeCollection, setActiveCollection] = useState(); const permissions = usePermissions(activeCollection?.id as number); - useLinks({ collectionId: Number(router.query.id), sort: sortBy }); - useEffect(() => { setActiveCollection( collections.find((e) => e.id === Number(router.query.id)) ); }, [router, collections]); - const { account } = useAccountStore(); + const { data: user = {} } = useUser(); const [collectionOwner, setCollectionOwner] = useState({ id: null as unknown as number, @@ -65,20 +66,20 @@ export default function Index() { useEffect(() => { const fetchOwner = async () => { - if (activeCollection && activeCollection.ownerId !== account.id) { + if (activeCollection && activeCollection.ownerId !== user.id) { const owner = await getPublicUserData( activeCollection.ownerId as number ); setCollectionOwner(owner); - } else if (activeCollection && activeCollection.ownerId === account.id) { + } else if (activeCollection && activeCollection.ownerId === user.id) { setCollectionOwner({ - id: account.id as number, - name: account.name, - username: account.username as string, - image: account.image as string, - archiveAsScreenshot: account.archiveAsScreenshot as boolean, - archiveAsMonolith: account.archiveAsScreenshot as boolean, - archiveAsPDF: account.archiveAsPDF as boolean, + id: user.id as number, + name: user.name, + username: user.username as string, + image: user.image as string, + archiveAsScreenshot: user.archiveAsScreenshot as boolean, + archiveAsMonolith: user.archiveAsScreenshot as boolean, + archiveAsPDF: user.archiveAsPDF as boolean, }); } }; @@ -97,19 +98,10 @@ export default function Index() { if (editMode) return setEditMode(false); }, [router]); - const [viewMode, setViewMode] = useState( - localStorage.getItem("viewMode") || ViewMode.Card + const [viewMode, setViewMode] = useState( + (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card ); - const linkView = { - [ViewMode.Card]: CardView, - [ViewMode.List]: ListView, - [ViewMode.Masonry]: MasonryView, - }; - - // @ts-ignore - const LinkComponent = linkView[viewMode]; - return (
- {links.some((e) => e.collectionId === Number(router.query.id)) ? ( - e.collection.id === activeCollection?.id - )} - /> - ) : ( - - )} + + {!data.isLoading && links && !links[0] && }
{activeCollection && ( <> diff --git a/pages/collections/index.tsx b/pages/collections/index.tsx index 642e3dd..051d02a 100644 --- a/pages/collections/index.tsx +++ b/pages/collections/index.tsx @@ -1,4 +1,3 @@ -import useCollectionStore from "@/store/collections"; import CollectionCard from "@/components/CollectionCard"; import { useState } from "react"; import MainLayout from "@/layouts/MainLayout"; @@ -10,11 +9,14 @@ import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; import PageHeader from "@/components/PageHeader"; import getServerSideProps from "@/lib/client/getServerSideProps"; import { useTranslation } from "next-i18next"; +import { useCollections } from "@/hooks/store/collections"; export default function Collections() { const { t } = useTranslation(); - const { collections } = useCollectionStore(); - const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + const { data: collections = [] } = useCollections(); + const [sortBy, setSortBy] = useState( + Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst + ); const [sortedCollections, setSortedCollections] = useState(collections); const { data } = useSession(); diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 3d1fdb6..b9ba86d 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -1,9 +1,5 @@ -import useLinkStore from "@/store/links"; -import useCollectionStore from "@/store/collections"; -import useTagStore from "@/store/tags"; import MainLayout from "@/layouts/MainLayout"; import { useEffect, useState } from "react"; -import useLinks from "@/hooks/useLinks"; import Link from "next/link"; import useWindowDimensions from "@/hooks/useWindowDimensions"; import React from "react"; @@ -12,26 +8,25 @@ import { MigrationFormat, MigrationRequest, ViewMode } from "@/types/global"; import DashboardItem from "@/components/DashboardItem"; import NewLinkModal from "@/components/ModalContent/NewLinkModal"; import PageHeader from "@/components/PageHeader"; -import CardView from "@/components/LinkViews/Layouts/CardView"; -import ListView from "@/components/LinkViews/Layouts/ListView"; import ViewDropdown from "@/components/ViewDropdown"; import { dropdownTriggerer } from "@/lib/client/utils"; -import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; import getServerSideProps from "@/lib/client/getServerSideProps"; import { useTranslation } from "next-i18next"; +import { useCollections } from "@/hooks/store/collections"; +import { useTags } from "@/hooks/store/tags"; +import { useDashboardData } from "@/hooks/store/dashboardData"; +import Links from "@/components/LinkViews/Links"; export default function Dashboard() { const { t } = useTranslation(); - const { collections } = useCollectionStore(); - const { links } = useLinkStore(); - const { tags } = useTagStore(); + const { data: collections = [] } = useCollections(); + const dashboardData = useDashboardData(); + const { data: tags = [] } = useTags(); const [numberOfLinks, setNumberOfLinks] = useState(0); const [showLinks, setShowLinks] = useState(3); - useLinks({ pinnedOnly: true, sort: 0 }); - useEffect(() => { setNumberOfLinks( collections.reduce( @@ -81,7 +76,7 @@ export default function Dashboard() { body: JSON.stringify(body), }); - const data = await response.json(); + await response.json(); toast.dismiss(load); @@ -99,20 +94,10 @@ export default function Dashboard() { const [newLinkModal, setNewLinkModal] = useState(false); - const [viewMode, setViewMode] = useState( - localStorage.getItem("viewMode") || ViewMode.Card + const [viewMode, setViewMode] = useState( + (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card ); - const linkView = { - [ViewMode.Card]: CardView, - // [ViewMode.Grid]: , - [ViewMode.List]: ListView, - [ViewMode.Masonry]: MasonryView, - }; - - // @ts-ignore - const LinkComponent = linkView[viewMode]; - return (
@@ -171,12 +156,30 @@ export default function Dashboard() {
- {links[0] ? ( + {dashboardData.isLoading ? (
- + +
+ ) : dashboardData.data && + dashboardData.data[0] && + !dashboardData.isLoading ? ( +
+
) : (
@@ -300,12 +303,21 @@ export default function Dashboard() { style={{ flex: "1 1 auto" }} className="flex flex-col 2xl:flex-row items-start 2xl:gap-2" > - {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( + {dashboardData.isLoading ? (
- +
+ ) : dashboardData.data?.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( +
+ e.pinnedBy && e.pinnedBy[0]) .slice(0, showLinks)} + layout={viewMode} />
) : ( diff --git a/pages/links/index.tsx b/pages/links/index.tsx index 35c1488..582e962 100644 --- a/pages/links/index.tsx +++ b/pages/links/index.tsx @@ -1,26 +1,28 @@ import NoLinksFound from "@/components/NoLinksFound"; -import useLinks from "@/hooks/useLinks"; +import { useLinks } from "@/hooks/store/links"; import MainLayout from "@/layouts/MainLayout"; -import useLinkStore from "@/store/links"; import React, { useEffect, useState } from "react"; import PageHeader from "@/components/PageHeader"; import { Sort, ViewMode } from "@/types/global"; -import CardView from "@/components/LinkViews/Layouts/CardView"; -import ListView from "@/components/LinkViews/Layouts/ListView"; import { useRouter } from "next/router"; -import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; import LinkListOptions from "@/components/LinkListOptions"; import getServerSideProps from "@/lib/client/getServerSideProps"; import { useTranslation } from "next-i18next"; +import Links from "@/components/LinkViews/Links"; -export default function Links() { +export default function Index() { const { t } = useTranslation(); - const { links } = useLinkStore(); - const [viewMode, setViewMode] = useState( - localStorage.getItem("viewMode") || ViewMode.Card + const [viewMode, setViewMode] = useState( + (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card ); - const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + const [sortBy, setSortBy] = useState( + Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst + ); + + const { links, data } = useLinks({ + sort: sortBy, + }); const router = useRouter(); @@ -30,17 +32,6 @@ export default function Links() { if (editMode) return setEditMode(false); }, [router]); - useLinks({ sort: sortBy }); - - const linkView = { - [ViewMode.Card]: CardView, - [ViewMode.List]: ListView, - [ViewMode.Masonry]: MasonryView, - }; - - // @ts-ignore - const LinkComponent = linkView[viewMode]; - return (
@@ -60,9 +51,14 @@ export default function Links() { /> - {links[0] ? ( - - ) : ( + + {!data.isLoading && links && !links[0] && ( )}
diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index 60d75d8..9d1fb53 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -1,45 +1,32 @@ -import useLinks from "@/hooks/useLinks"; import MainLayout from "@/layouts/MainLayout"; -import useLinkStore from "@/store/links"; import React, { useEffect, useState } from "react"; import PageHeader from "@/components/PageHeader"; import { Sort, ViewMode } from "@/types/global"; -import CardView from "@/components/LinkViews/Layouts/CardView"; -import ListView from "@/components/LinkViews/Layouts/ListView"; import { useRouter } from "next/router"; -import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; import LinkListOptions from "@/components/LinkListOptions"; +import { useLinks } from "@/hooks/store/links"; +import Links from "@/components/LinkViews/Links"; export default function PinnedLinks() { const { t } = useTranslation(); - const { links } = useLinkStore(); - - const [viewMode, setViewMode] = useState( - localStorage.getItem("viewMode") || ViewMode.Card + const [viewMode, setViewMode] = useState( + (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card + ); + const [sortBy, setSortBy] = useState( + Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst ); - const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); - useLinks({ sort: sortBy, pinnedOnly: true }); + const { links, data } = useLinks({ + sort: sortBy, + pinnedOnly: true, + }); const router = useRouter(); const [editMode, setEditMode] = useState(false); - useEffect(() => { - if (editMode) return setEditMode(false); - }, [router]); - - const linkView = { - [ViewMode.Card]: CardView, - [ViewMode.List]: ListView, - [ViewMode.Masonry]: MasonryView, - }; - - // @ts-ignore - const LinkComponent = linkView[viewMode]; - return (
@@ -59,9 +46,14 @@ export default function PinnedLinks() { /> - {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( - - ) : ( + + {!data.isLoading && links && !links[0] && (
(); @@ -18,7 +20,7 @@ export default function Index() { useEffect(() => { const fetchLink = async () => { if (router.query.id) { - await getLink(Number(router.query.id)); + await getLink.mutateAsync(Number(router.query.id)); } }; @@ -26,7 +28,8 @@ export default function Index() { }, []); useEffect(() => { - if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id))); + if (links && links[0]) + setLink(links.find((e) => e.id === Number(router.query.id))); }, [links]); return ( diff --git a/pages/public/collections/[id].tsx b/pages/public/collections/[id].tsx index 47e074f..c2229dc 100644 --- a/pages/public/collections/[id].tsx +++ b/pages/public/collections/[id].tsx @@ -8,8 +8,6 @@ import { import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; import Head from "next/head"; -import useLinks from "@/hooks/useLinks"; -import useLinkStore from "@/store/links"; import ProfilePhoto from "@/components/ProfilePhoto"; import ToggleDarkMode from "@/components/ToggleDarkMode"; import getPublicUserData from "@/lib/client/getPublicUserData"; @@ -18,21 +16,19 @@ import Link from "next/link"; import useLocalSettingsStore from "@/store/localSettings"; import SearchBar from "@/components/SearchBar"; import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal"; -import CardView from "@/components/LinkViews/Layouts/CardView"; -import ListView from "@/components/LinkViews/Layouts/ListView"; -import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; -import useCollectionStore from "@/store/collections"; import LinkListOptions from "@/components/LinkListOptions"; +import { useCollections } from "@/hooks/store/collections"; +import { usePublicLinks } from "@/hooks/store/publicLinks"; +import Links from "@/components/LinkViews/Links"; export default function PublicCollections() { const { t } = useTranslation(); - const { links } = useLinkStore(); const { settings } = useLocalSettingsStore(); - const { collections } = useCollectionStore(); + const { data: collections = [] } = useCollections(); const router = useRouter(); @@ -54,9 +50,11 @@ export default function PublicCollections() { textContent: false, }); - const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + const [sortBy, setSortBy] = useState( + Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst + ); - useLinks({ + const { links, data } = usePublicLinks({ sort: sortBy, searchQueryString: router.query.q ? decodeURIComponent(router.query.q as string) @@ -91,19 +89,10 @@ export default function PublicCollections() { const [editCollectionSharingModal, setEditCollectionSharingModal] = useState(false); - const [viewMode, setViewMode] = useState( - localStorage.getItem("viewMode") || ViewMode.Card + const [viewMode, setViewMode] = useState( + (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card ); - const linkView = { - [ViewMode.Card]: CardView, - [ViewMode.List]: ListView, - [ViewMode.Masonry]: MasonryView, - }; - - // @ts-ignore - const LinkComponent = linkView[viewMode]; - return collection ? (
- {links[0] ? ( - e.collectionId === Number(router.query.id)) - .map((e, i) => { - const linkWithCollectionData = { - ...e, - collection: collection, // Append collection data - }; - return linkWithCollectionData; - })} - /> - ) : ( + { + const linkWithCollectionData = { + ...e, + collection: collection, // Append collection data + }; + return linkWithCollectionData; + }) as any + } + layout={viewMode} + placeholderCount={1} + useData={data} + /> + {!data.isLoading && links && !links[0] && (

{t("collection_is_empty")}

)} diff --git a/pages/public/preserved/[id].tsx b/pages/public/preserved/[id].tsx index fdb332a..2f415ae 100644 --- a/pages/public/preserved/[id].tsx +++ b/pages/public/preserved/[id].tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from "react"; -import useLinkStore from "@/store/links"; import { useRouter } from "next/router"; import { ArchivedFormat, @@ -7,20 +6,20 @@ import { } from "@/types/global"; import ReadableView from "@/components/ReadableView"; import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useGetLink, useLinks } from "@/hooks/store/links"; export default function Index() { - const { links, getLink } = useLinkStore(); + const { links } = useLinks(); + const getLink = useGetLink(); const [link, setLink] = useState(); const router = useRouter(); - let isPublic = router.pathname.startsWith("/public") ? true : false; - useEffect(() => { const fetchLink = async () => { if (router.query.id) { - await getLink(Number(router.query.id), isPublic); + await getLink.mutateAsync(Number(router.query.id)); } }; @@ -28,7 +27,8 @@ export default function Index() { }, []); useEffect(() => { - if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id))); + if (links && links[0]) + setLink(links.find((e) => e.id === Number(router.query.id))); }, [links]); return ( diff --git a/pages/search.tsx b/pages/search.tsx index d075cd8..e628268 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -1,23 +1,17 @@ -import useLinks from "@/hooks/useLinks"; +import { useLinks } from "@/hooks/store/links"; import MainLayout from "@/layouts/MainLayout"; -import useLinkStore from "@/store/links"; import { Sort, ViewMode } from "@/types/global"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; -import CardView from "@/components/LinkViews/Layouts/CardView"; -import ListView from "@/components/LinkViews/Layouts/ListView"; import PageHeader from "@/components/PageHeader"; -import { GridLoader } from "react-spinners"; -import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; import LinkListOptions from "@/components/LinkListOptions"; import getServerSideProps from "@/lib/client/getServerSideProps"; import { useTranslation } from "next-i18next"; +import Links from "@/components/LinkViews/Links"; export default function Search() { const { t } = useTranslation(); - const { links } = useLinkStore(); - const router = useRouter(); const [searchFilter, setSearchFilter] = useState({ @@ -28,11 +22,13 @@ export default function Search() { textContent: false, }); - const [viewMode, setViewMode] = useState( - localStorage.getItem("viewMode") || ViewMode.Card + const [viewMode, setViewMode] = useState( + (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card ); - const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + const [sortBy, setSortBy] = useState( + Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst + ); const [editMode, setEditMode] = useState(false); @@ -40,7 +36,17 @@ export default function Search() { if (editMode) return setEditMode(false); }, [router]); - const { isLoading } = useLinks({ + // const { isLoading } = useLink({ + // sort: sortBy, + // searchQueryString: decodeURIComponent(router.query.q as string), + // searchByName: searchFilter.name, + // searchByUrl: searchFilter.url, + // searchByDescription: searchFilter.description, + // searchByTextContent: searchFilter.textContent, + // searchByTags: searchFilter.tags, + // }); + + const { links, data } = useLinks({ sort: sortBy, searchQueryString: decodeURIComponent(router.query.q as string), searchByName: searchFilter.name, @@ -50,15 +56,6 @@ export default function Search() { searchByTags: searchFilter.tags, }); - const linkView = { - [ViewMode.Card]: CardView, - [ViewMode.List]: ListView, - [ViewMode.Masonry]: MasonryView, - }; - - // @ts-ignore - const LinkComponent = linkView[viewMode]; - return (
@@ -76,7 +73,9 @@ export default function Search() { - {!isLoading && !links[0] ? ( + {/* { + !isLoading && + !links[0] ? (

{t("nothing_found")}

) : links[0] ? ( ) - )} + )} */} + +
); diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx index e526e2f..9e1cfd6 100644 --- a/pages/settings/access-tokens.tsx +++ b/pages/settings/access-tokens.tsx @@ -1,11 +1,11 @@ import SettingsLayout from "@/layouts/SettingsLayout"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import NewTokenModal from "@/components/ModalContent/NewTokenModal"; import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal"; import { AccessToken } from "@prisma/client"; -import useTokenStore from "@/store/tokens"; import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTokens } from "@/hooks/store/tokens"; export default function AccessTokens() { const [newTokenModal, setNewTokenModal] = useState(false); @@ -18,15 +18,7 @@ export default function AccessTokens() { setRevokeTokenModal(true); }; - const { setTokens, tokens } = useTokenStore(); - - useEffect(() => { - fetch("/api/v1/tokens") - .then((res) => res.json()) - .then((data) => { - if (data.response) setTokens(data.response as AccessToken[]); - }); - }, []); + const { data: tokens = [] } = useTokens(); return ( diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index cb46442..ded4683 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -1,5 +1,4 @@ import { useState, useEffect } from "react"; -import useAccountStore from "@/store/account"; import { AccountSettings } from "@/types/global"; import { toast } from "react-hot-toast"; import SettingsLayout from "@/layouts/SettingsLayout"; @@ -17,6 +16,7 @@ import Button from "@/components/ui/Button"; import { i18n } from "next-i18next.config"; import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useUpdateUser, useUser } from "@/hooks/store/user"; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; @@ -24,7 +24,8 @@ export default function Account() { const [emailChangeVerificationModal, setEmailChangeVerificationModal] = useState(false); const [submitLoader, setSubmitLoader] = useState(false); - const { account, updateAccount } = useAccountStore(); + const { data: account } = useUser(); + const updateUser = useUpdateUser(); const [user, setUser] = useState( !objectIsEmpty(account) ? account @@ -78,25 +79,38 @@ export default function Account() { const submit = async (password?: string) => { setSubmitLoader(true); + const load = toast.loading(t("applying_settings")); - const response = await updateAccount({ - ...user, - // @ts-ignore - password: password ? password : undefined, - }); + await updateUser.mutateAsync( + { + ...user, + password: password ? password : undefined, + }, + { + onSuccess: (data) => { + if (data.response.email !== user.email) { + toast.success(t("email_change_request")); + setEmailChangeVerificationModal(false); + } + }, + onSettled: (data, error) => { + toast.dismiss(load); - toast.dismiss(load); + if (error) { + toast.error(error.message); + } else { + if (data.response.email !== user.email) { + toast.success(t("email_change_request")); + setEmailChangeVerificationModal(false); + } - if (response.ok) { - const emailChanged = account.email !== user.email; - - toast.success(t("settings_applied")); - if (emailChanged) { - toast.success(t("email_change_request")); - setEmailChangeVerificationModal(false); + toast.success(t("settings_applied")); + } + }, } - } else toast.error(response.data as string); + ); + setSubmitLoader(false); }; @@ -189,17 +203,14 @@ export default function Account() {

{t("language")}