Merge pull request #703 from linkwarden/chore/react-query-implementation

Chore/react query implementation
This commit is contained in:
Daniel 2024-08-14 16:45:40 -04:00 committed by GitHub
commit 299498ffa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 2501 additions and 1804 deletions

View File

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

View File

@ -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<TreeData | undefined>();
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,10 +152,11 @@ const CollectionListing = () => {
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...account.collectionOrder];
const updatedCollectionOrder = [...user.collectionOrder];
if (source.parentId !== destination.parentId) {
await updateCollection({
await updateCollection.mutateAsync(
{
...movedCollection,
parentId:
destination.parentId && destination.parentId !== "root"
@ -159,7 +164,13 @@ const CollectionListing = () => {
: destination.parentId === "root"
? "root"
: null,
} as any);
},
{
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 (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
);
} else if (!tree) {
return (
<p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8">
{t("you_have_no_collections")}

View File

@ -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<Options[]>([]);

View File

@ -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<Options[]>([]);
useEffect(() => {
const formatedCollections = tags.map((e) => {
const formatedCollections = tags.map((e: any) => {
return { value: e.id, label: e.name };
});

View File

@ -8,7 +8,7 @@ const InstallApp = (props: Props) => {
const [isOpen, setIsOpen] = useState(true);
return isOpen && !isPWA() ? (
<div className="absolute left-0 right-0 bottom-10 w-full p-5">
<div className="fixed left-0 right-0 bottom-10 w-full p-5">
<div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@ -5,17 +5,18 @@ import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next";
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
import toast from "react-hot-toast";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
import { Sort } from "@/types/global";
import { Sort, ViewMode } from "@/types/global";
import { useBulkDeleteLinks, useLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
children: React.ReactNode;
t: TFunction<"translation", undefined>;
viewMode: string;
setViewMode: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
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 response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
const load = toast.loading(t("deleting"));
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(
selectedLinks.length === 1
? t("link_deleted")
: t("links_deleted", { count: selectedLinks.length })
);
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
setSelectedLinks([]);
toast.success(t("deleted"));
}
},
}
);
};
return (
@ -99,7 +103,10 @@ const LinkListOptions = ({
<div className="flex gap-3 items-center justify-end">
<div className="flex gap-2 items-center mt-2">
{links.length > 0 && editMode !== undefined && setEditMode && (
{links &&
links.length > 0 &&
editMode !== undefined &&
setEditMode && (
<div
role="button"
onClick={() => {
@ -121,15 +128,20 @@ const LinkListOptions = ({
setSearchFilter={setSearchFilter}
/>
)}
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
<SortDropdown
sortBy={sortBy}
setSort={(value) => {
setSortBy(value);
}}
t={t}
/>
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div>
</div>
</div>
{editMode && links.length > 0 && (
{links && editMode && links.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]">
{links.length > 0 && (
<div className="flex gap-3 ml-3">
<input
type="checkbox"
@ -149,7 +161,6 @@ const LinkListOptions = ({
<span>{t("nothing_selected")}</span>
)}
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}

View File

@ -1,39 +0,0 @@
import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { link } from "fs";
import { GridLoader } from "react-spinners";
export default function CardView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
return (
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
{links.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div>
);
}

View File

@ -1,38 +0,0 @@
import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
export default function ListView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
return (
<div className="flex gap-1 flex-col">
{links.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div>
);
}

View File

@ -1,58 +0,0 @@
import LinkMasonry from "@/components/LinkViews/LinkMasonry";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../../tailwind.config.js";
import { useMemo } from "react";
export default function MasonryView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={breakpointColumnsObj}
columnClassName="flex flex-col gap-5 !w-full"
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
>
{links.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</Masonry>
);
}

View File

@ -7,11 +7,11 @@ import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -39,41 +39,35 @@ export default function LinkActions({
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const { account } = useAccountStore();
const { data: user = {} } = useUser();
const { removeLink, updateLink } = useLinkStore();
const updateLink = useUpdateLink();
const deleteLink = useDeleteLink();
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const load = toast.loading(t("applying"));
const load = toast.loading(t("updating"));
const response = await updateLink({
await updateLink.mutateAsync(
{
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
});
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(isAlreadyPinned ? t("link_unpinned") : t("link_unpinned"));
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
toast.success(
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
);
}
};
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number);
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
} else {
toast.error(response.data as string);
},
}
);
};
return (
@ -157,9 +151,25 @@ export default function LinkActions({
<div
role="button"
tabIndex={0}
onClick={(e) => {
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
e.shiftKey
? async () => {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
}
: setDeleteLinkModal(true);
}}
>
{t("delete")}
@ -184,7 +194,7 @@ export default function LinkActions({
{preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
activeLink={link}
link={link}
/>
) : undefined}
{/* {expandedLink ? (

View File

@ -5,7 +5,6 @@ import {
} from "@/types/global";
import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@ -13,14 +12,16 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon";
import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -33,11 +34,16 @@ type Props = {
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const viewMode = localStorage.getItem("viewMode") || "card";
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { data: collections = [] } = useCollections();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
data: { data: links = [] },
} = useLinks();
const getLink = useGetLink();
useEffect(() => {
if (!editMode) {
@ -93,7 +99,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink(link.id as number);
getLink.mutateAsync(link.id as number);
}, 5000);
}
@ -131,7 +137,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
<div
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div>

View File

@ -3,7 +3,6 @@ import {
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
export default function LinkCollection({
@ -13,9 +12,8 @@ export default function LinkCollection({
link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount;
}) {
const router = useRouter();
return (
<>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
@ -30,5 +28,6 @@ export default function LinkCollection({
></i>
<p className="truncate capitalize">{collection?.name}</p>
</Link>
</>
);
}

View File

@ -4,7 +4,6 @@ import {
} from "@/types/global";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@ -12,11 +11,13 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import { isPWA } from "@/lib/client/utils";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -33,9 +34,12 @@ export default function LinkCardCompact({
}: Props) {
const { t } = useTranslation();
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const { links } = useLinks();
useEffect(() => {
if (!editMode) {
@ -119,7 +123,7 @@ export default function LinkCardCompact({
<div
className="flex items-center cursor-pointer w-full"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div className="shrink-0">

View File

@ -5,7 +5,6 @@ import {
} from "@/types/global";
import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@ -13,14 +12,16 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon";
import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -33,10 +34,13 @@ type Props = {
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const { links } = useLinks();
const getLink = useGetLink();
useEffect(() => {
if (!editMode) {
@ -92,7 +96,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink(link.id as number);
getLink.mutateAsync(link.id as number);
}, 5000);
}
@ -130,7 +134,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div className="relative rounded-t-2xl overflow-hidden">

View File

@ -0,0 +1,238 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
export function CardView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
return (
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</div>
);
}
export function MasonryView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={breakpointColumnsObj}
columnClassName="flex flex-col gap-5 !w-full"
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</Masonry>
);
}
export function ListView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
return (
<div className="flex gap-1 flex-col">
{links?.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-4 p-4"
>
<div className="skeleton h-16 w-16"></div>
<div className="flex flex-col gap-4 w-full">
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
</div>
);
})}
</div>
);
}
export default function Links({
layout,
links,
editMode,
placeholderCount,
useData,
}: {
layout: ViewMode;
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
placeholderCount?: number;
useData?: any;
}) {
const { ref, inView } = useInView();
useEffect(() => {
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
useData.fetchNextPage();
}
}, [useData, inView]);
if (layout === ViewMode.List) {
return (
<ListView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else if (layout === ViewMode.Masonry) {
return (
<MasonryView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else {
// Default to card view
return (
<CardView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
}
}
const placeholderCountToArray = (num?: number) =>
num ? Array.from({ length: num }, (_, i) => i + 1) : [];

View File

@ -1,9 +1,10 @@
import React from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useBulkDeleteLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@ -11,22 +12,29 @@ type Props = {
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const deleteLink = async () => {
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 (response.ok) {
toast.success(t("deleted"));
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
toast.success(t("deleted"));
}
},
}
);
};
return (

View File

@ -6,6 +6,7 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useBulkEditLinks } from "@/hooks/store/links";
type Props = {
onClose: Function;
@ -13,13 +14,14 @@ type Props = {
export default function BulkEditLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [removePreviousTags, setRemovePreviousTags] = useState(false);
const [updatedValues, setUpdatedValues] = useState<
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
>({ tags: [] });
const updateLinks = useBulkEditLinks();
const setCollection = (e: any) => {
const collectionId = e?.value || null;
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
@ -36,22 +38,28 @@ export default function BulkEditLinksModal({ onClose }: Props) {
const load = toast.loading(t("updating"));
const response = await updateLinks(
selectedLinks,
await updateLinks.mutateAsync(
{
links: selectedLinks,
newData: updatedValues,
removePreviousTags,
updatedValues
);
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
toast.success(t("updated"));
}
},
}
);
setSubmitLoader(false);
return response;
}
};

View File

@ -1,13 +1,13 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@ -22,7 +22,6 @@ export default function DeleteCollectionModal({
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { removeCollection } = useCollectionStore();
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
@ -31,6 +30,8 @@ export default function DeleteCollectionModal({
setCollection(activeCollection);
}, []);
const deleteCollection = useDeleteCollection();
const submit = async () => {
if (permissions === true && collection.name !== inputField) return;
if (!submitLoader) {
@ -41,17 +42,19 @@ export default function DeleteCollectionModal({
const load = toast.loading(t("deleting_collection"));
let response = await removeCollection(collection.id as number);
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
onClose();
router.push("/collections");
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
setSubmitLoader(false);
}

View File

@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
@ -16,31 +16,32 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore();
const deleteLink = useDeleteLink();
const router = useRouter();
useEffect(() => {
setLink(activeLink);
}, []);
const deleteLink = async () => {
const submit = async () => {
const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number);
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
}
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
toast.success(t("deleted"));
onClose();
}
},
});
};
return (
@ -61,7 +62,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>

View File

@ -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);
toast.dismiss(load);
if (response.ok) {
toast.success(t("user_deleted"));
} else {
toast.error(response.data as string);
}
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
await deleteUser.mutateAsync(userId, {
onSuccess: () => {
onClose();
},
});
setSubmitLoader(false);
}
};
return (
@ -45,7 +45,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
</span>
</div>
<Button className="ml-auto" intent="destructive" onClick={deleteUser}>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete_confirmation")}
</Button>

View File

@ -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<CollectionIncludingMembersAndLinkCount>(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);
if (response.ok) {
toast.success(t("updated"));
if (error) {
toast.error(error.message);
} else {
onClose();
} else toast.error(response.data as string);
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}

View File

@ -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<CollectionIncludingMembersAndLinkCount>(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"));
let response;
response = await updateCollection(collection as any);
const load = toast.loading(t("updating_collection"));
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
if (error) {
toast.error(error.message);
} else {
onClose();
} else toast.error(response.data as string);
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({
<div
onClick={() =>
addMemberToCollection(
account.username as string,
user.username as string,
memberUsername || "",
collection,
setMemberState,

View File

@ -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);
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
onClose();
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
return response;
}
};

View File

@ -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<Collection>;
const [collection, setCollection] = useState<Partial<Collection>>(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);
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);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("created"));
}
} else toast.error(response.data as string);
},
});
setSubmitLoader(false);
};

View File

@ -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<LinkIncludingShortenedCollectionAndTags>(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);
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("link_created"));
onClose();
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
onClose();
toast.success(t("link_created"));
}
},
});
setSubmitLoader(false);
}
};

View File

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

View File

@ -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<FormData>({
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"));
const response = await addUser(form);
toast.dismiss(load);
setSubmitLoader(false);
if (response.ok) {
toast.success(t("user_created"));
await addUser.mutateAsync(form, {
onSuccess: () => {
onClose();
} else {
toast.error(response.data as string);
}
},
});
setSubmitLoader(false);
} else {
toast.error(t("fill_all_fields_error"));
}

View File

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

View File

@ -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<AccessToken>(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);
if (response.ok) {
toast.success(t("token_revoked"));
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
}
onClose();
toast.success(t("token_revoked"));
}
},
});
};
return (

View File

@ -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<LinkIncludingShortenedCollectionAndTags>(initial);
const [file, setFile] = useState<File>();
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);
if (response.ok) {
toast.success(t("created_success"));
onClose();
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
onClose();
toast.success(t("created_success"));
}
},
}
);
setSubmitLoader(false);
return response;
}
};

View File

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

View File

@ -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"
>
<ProfilePhoto
src={account.image ? account.image : undefined}
src={user.image ? user.image : undefined}
priority={true}
/>
</div>

View File

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

View File

@ -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"
>
<Disclosure.Panel className="flex flex-col gap-1">
{tags[0] ? (
{isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : 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 (
<Link key={i} href={`/tags/${e.id}`}>
<div

View File

@ -1,7 +1,8 @@
import React, { Dispatch, SetStateAction } from "react";
import React, { Dispatch, SetStateAction, useEffect } from "react";
import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import { TFunction } from "i18next";
import useLocalSettingsStore from "@/store/localSettings";
type Props = {
sortBy: Sort;
@ -10,6 +11,12 @@ type Props = {
};
export default function SortDropdown({ sortBy, setSort, t }: Props) {
const { updateSettings } = useLocalSettingsStore();
useEffect(() => {
updateSettings({ sortBy });
}, [sortBy]);
return (
<div className="dropdown dropdown-bottom dropdown-end">
<div

View File

@ -4,8 +4,8 @@ import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global";
type Props = {
viewMode: string;
setViewMode: Dispatch<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
};
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 (

272
components/ui/Loader.tsx Normal file
View File

@ -0,0 +1,272 @@
import React from "react";
type Props = {
className?: string;
color: string;
size: string;
};
const Loader = (props: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
width={props.size}
height={props.size}
className={props.className}
style={{
shapeRendering: "auto",
display: "block",
background: "rgba(255, 255, 255, 0)",
}}
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g>
<g transform="rotate(0 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.9166666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(30 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.8333333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(60 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.75s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(90 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.6666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(120 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5833333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(150 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(180 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.4166666666666667s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(210 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.3333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(240 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.25s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(270 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.16666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(300 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.08333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(330 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="0s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g></g>
</g>
</svg>
);
};
export default Loader;

View File

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

116
hooks/store/collections.tsx Normal file
View File

@ -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<CollectionIncludingMembersAndLinkCount[]> => {
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,
};

View File

@ -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<LinkIncludingShortenedCollectionAndTags[]> => {
const response = await fetch("/api/v1/dashboard");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
export { useDashboardData };

437
hooks/store/links.tsx Normal file
View File

@ -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<InfiniteData<any, unknown>, 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,
};

View File

@ -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<InfiniteData<any, unknown>, 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 };

71
hooks/store/tags.tsx Normal file
View File

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

68
hooks/store/tokens.tsx Normal file
View File

@ -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<AccessToken>) => {
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<AccessToken>) => token.id !== variables)
);
},
});
};
export { useTokens, useAddToken, useRevokeToken };

53
hooks/store/user.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,6 +38,7 @@ function App({
}, []);
return (
<QueryClientProvider client={queryClient}>
<SessionProvider
session={pageProps.session}
refetchOnWindowFocus={false}
@ -95,16 +105,9 @@ function App({
{/* </GetData> */}
</AuthRedirect>
</SessionProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default appWithTranslation(App);
// function GetData({ children }: { children: React.ReactNode }) {
// const status = useInitialData();
// return typeof window !== "undefined" && status !== "loading" ? (
// children
// ) : (
// <></>
// );
// }

View File

@ -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<User[]>();
@ -33,10 +33,6 @@ export default function Admin() {
const [newUserModal, setNewUserModal] = useState(false);
useEffect(() => {
setUsers();
}, []);
return (
<div className="max-w-6xl mx-auto p-5">
<div className="flex sm:flex-row flex-col justify-between gap-2">
@ -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())

View File

@ -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>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const { links, data } = useLinks({
sort: sortBy,
collectionId: Number(router.query.id),
});
const [activeCollection, setActiveCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
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<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(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 (
<MainLayout>
<div
@ -323,16 +315,14 @@ export default function Index() {
</p>
</LinkListOptions>
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
<LinkComponent
<Links
editMode={editMode}
links={links.filter(
(e) => e.collection.id === activeCollection?.id
)}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
) : (
<NoLinksFound />
)}
{!data.isLoading && links && !links[0] && <NoLinksFound />}
</div>
{activeCollection && (
<>

View File

@ -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>(Sort.DateNewestFirst);
const { data: collections = [] } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [sortedCollections, setSortedCollections] = useState(collections);
const { data } = useSession();

View File

@ -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<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(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 (
<MainLayout>
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
@ -171,12 +156,30 @@ export default function Dashboard() {
</div>
<div
style={{ flex: links[0] ? "0 1 auto" : "1 1 auto" }}
style={{
flex:
dashboardData.data || dashboardData.isLoading
? "0 1 auto"
: "1 1 auto",
}}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
>
{links[0] ? (
{dashboardData.isLoading ? (
<div className="w-full">
<LinkComponent links={links.slice(0, showLinks)} />
<Links
layout={viewMode}
placeholderCount={showLinks / 2}
useData={dashboardData}
/>
</div>
) : dashboardData.data &&
dashboardData.data[0] &&
!dashboardData.isLoading ? (
<div className="w-full">
<Links
links={dashboardData.data.slice(0, showLinks)}
layout={viewMode}
/>
</div>
) : (
<div className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200">
@ -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 ? (
<div className="w-full">
<LinkComponent
links={links
<Links
layout={viewMode}
placeholderCount={showLinks / 2}
useData={dashboardData}
/>
</div>
) : dashboardData.data?.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full">
<Links
links={dashboardData.data
.filter((e) => e.pinnedBy && e.pinnedBy[0])
.slice(0, showLinks)}
layout={viewMode}
/>
</div>
) : (

View File

@ -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<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
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 (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
@ -60,9 +51,14 @@ export default function Links() {
/>
</LinkListOptions>
{links[0] ? (
<LinkComponent editMode={editMode} links={links} />
) : (
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && (
<NoLinksFound text={t("you_have_not_added_any_links")} />
)}
</div>

View File

@ -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<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [sortBy, setSortBy] = useState<Sort>(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 (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
@ -59,9 +46,14 @@ export default function PinnedLinks() {
/>
</LinkListOptions>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<LinkComponent editMode={editMode} links={links} />
) : (
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && (
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { useRouter } from "next/router";
import {
ArchivedFormat,
@ -7,9 +6,12 @@ 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<LinkIncludingShortenedCollectionAndTags>();
@ -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 (

View File

@ -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>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
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<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(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 ? (
<div
className="h-96"
@ -227,19 +216,21 @@ export default function PublicCollections() {
/>
</LinkListOptions>
{links[0] ? (
<LinkComponent
links={links
.filter((e) => e.collectionId === Number(router.query.id))
.map((e, i) => {
<Links
links={
links?.map((e, i) => {
const linkWithCollectionData = {
...e,
collection: collection, // Append collection data
};
return linkWithCollectionData;
})}
}) as any
}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
) : (
{!data.isLoading && links && !links[0] && (
<p>{t("collection_is_empty")}</p>
)}

View File

@ -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<LinkIncludingShortenedCollectionAndTags>();
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 (

View File

@ -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<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
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 (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
@ -76,7 +73,9 @@ export default function Search() {
<PageHeader icon={"bi-search"} title={"Search Results"} />
</LinkListOptions>
{!isLoading && !links[0] ? (
{/* {
!isLoading &&
!links[0] ? (
<p>{t("nothing_found")}</p>
) : links[0] ? (
<LinkComponent
@ -93,7 +92,15 @@ export default function Search() {
className="m-auto py-10"
/>
)
)}
)} */}
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
</div>
</MainLayout>
);

View File

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

View File

@ -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<AccountSettings>(
!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({
await updateUser.mutateAsync(
{
...user,
// @ts-ignore
password: password ? password : undefined,
});
toast.dismiss(load);
if (response.ok) {
const emailChanged = account.email !== user.email;
toast.success(t("settings_applied"));
if (emailChanged) {
},
{
onSuccess: (data) => {
if (data.response.email !== user.email) {
toast.success(t("email_change_request"));
setEmailChangeVerificationModal(false);
}
} else toast.error(response.data as string);
},
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
if (data.response.email !== user.email) {
toast.success(t("email_change_request"));
setEmailChangeVerificationModal(false);
}
toast.success(t("settings_applied"));
}
},
}
);
setSubmitLoader(false);
};
@ -189,17 +203,14 @@ export default function Account() {
<div>
<p className="mb-2">{t("language")}</p>
<select
value={user.locale || ""}
onChange={(e) => {
setUser({ ...user, locale: e.target.value });
}}
className="select border border-neutral-content focus:outline-none focus:border-primary duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2"
>
{i18n.locales.map((locale) => (
<option
key={locale}
value={locale}
selected={user.locale === locale}
>
<option key={locale} value={locale}>
{new Intl.DisplayNames(locale, { type: "language" }).of(
locale
) || ""}

View File

@ -1,11 +1,11 @@
import SettingsLayout from "@/layouts/SettingsLayout";
import { useState } from "react";
import useAccountStore from "@/store/account";
import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useUpdateUser, useUser } from "@/hooks/store/user";
export default function Password() {
const { t } = useTranslation();
@ -13,7 +13,8 @@ export default function Password() {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const { data: account } = useUser();
const updateUser = useUpdateUser();
const submit = async () => {
if (newPassword === "" || oldPassword === "") {
@ -23,23 +24,29 @@ export default function Password() {
setSubmitLoader(true);
const load = toast.loading(t("applying_changes"));
const load = toast.loading(t("applying_settings"));
const response = await updateAccount({
await updateUser.mutateAsync(
{
...account,
newPassword,
oldPassword,
});
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("settings_applied"));
if (error) {
toast.error(error.message);
} else {
setNewPassword("");
setOldPassword("");
} else {
toast.error(response.data as string);
toast.success(t("settings_applied"));
}
},
}
);
setSubmitLoader(false);
};

View File

@ -1,6 +1,5 @@
import SettingsLayout from "@/layouts/SettingsLayout";
import { useState, useEffect } from "react";
import useAccountStore from "@/store/account";
import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast";
import Checkbox from "@/components/Checkbox";
@ -8,12 +7,14 @@ import useLocalSettingsStore from "@/store/localSettings";
import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; // Import getServerSideProps for server-side data fetching
import { LinksRouteTo } from "@prisma/client";
import { useUpdateUser, useUser } from "@/hooks/store/user";
export default function Appearance() {
const { t } = useTranslation();
const { updateSettings } = useLocalSettingsStore();
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const { data: account } = useUser();
const updateUser = useUpdateUser();
const [user, setUser] = useState(account);
const [preventDuplicateLinks, setPreventDuplicateLinks] = useState<boolean>(
@ -73,17 +74,23 @@ export default function Appearance() {
const submit = async () => {
setSubmitLoader(true);
const load = toast.loading(t("applying_changes"));
const response = await updateAccount({ ...user });
const load = toast.loading(t("applying_settings"));
await updateUser.mutateAsync(
{ ...user },
{
onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) {
toast.success(t("settings_applied"));
if (error) {
toast.error(error.message);
} else {
toast.error(response.data as string);
toast.success(t("settings_applied"));
}
},
}
);
setSubmitLoader(false);
};

View File

@ -7,7 +7,7 @@ import { Plan } from "@/types/global";
import Button from "@/components/ui/Button";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { Trans, useTranslation } from "next-i18next";
import useAccountStore from "@/store/account";
import { useUser } from "@/hooks/store/user";
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
@ -20,11 +20,11 @@ export default function Subscribe() {
const router = useRouter();
const { account } = useAccountStore();
const { data: user = {} } = useUser();
useEffect(() => {
const hasInactiveSubscription =
account.id && !account.subscription?.active && stripeEnabled;
user.id && !user.subscription?.active && stripeEnabled;
if (session.status === "authenticated" && !hasInactiveSubscription) {
router.push("/dashboard");

View File

@ -1,29 +1,29 @@
import useLinkStore from "@/store/links";
import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import useTagStore from "@/store/tags";
import { Sort, TagIncludingLinkCount, ViewMode } from "@/types/global";
import useLinks from "@/hooks/useLinks";
import { toast } from "react-hot-toast";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { useLinks } from "@/hooks/store/links";
import { dropdownTriggerer } from "@/lib/client/utils";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps";
import LinkListOptions from "@/components/LinkListOptions";
import { useRemoveTag, useTags, useUpdateTag } from "@/hooks/store/tags";
import Links from "@/components/LinkViews/Links";
import toast from "react-hot-toast";
export default function Index() {
const { t } = useTranslation();
const router = useRouter();
const { links } = useLinkStore();
const { tags, updateTag, removeTag } = useTagStore();
const { data: tags = [] } = useTags();
const updateTag = useUpdateTag();
const removeTag = useRemoveTag();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [renameTag, setRenameTag] = useState(false);
const [newTagName, setNewTagName] = useState<string>();
@ -38,10 +38,13 @@ export default function Index() {
if (editMode) return setEditMode(false);
}, [router]);
useLinks({ tagId: Number(router.query.id), sort: sortBy });
const { links, data } = useLinks({
sort: sortBy,
tagId: Number(router.query.id),
});
useEffect(() => {
const tag = tags.find((e) => e.id === Number(router.query.id));
const tag = tags.find((e: any) => e.id === Number(router.query.id));
if (tags.length > 0 && !tag?.id) {
router.push("/dashboard");
@ -72,21 +75,28 @@ export default function Index() {
setSubmitLoader(true);
if (activeTag && newTagName) {
const load = toast.loading(t("applying_changes"));
let response;
if (activeTag && newTagName)
response = await updateTag({
await updateTag.mutateAsync(
{
...activeTag,
name: newTagName,
});
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (response?.ok) {
if (error) {
toast.error(error.message);
} else {
toast.success(t("tag_renamed"));
} else toast.error(response?.data as string);
}
},
}
);
}
setSubmitLoader(false);
setRenameTag(false);
};
@ -94,35 +104,31 @@ export default function Index() {
const remove = async () => {
setSubmitLoader(true);
if (activeTag?.id) {
const load = toast.loading(t("applying_changes"));
let response;
if (activeTag?.id) response = await removeTag(activeTag?.id);
await removeTag.mutateAsync(activeTag?.id, {
onSettled: (data, error) => {
toast.dismiss(load);
if (response?.ok) {
if (error) {
toast.error(error.message);
} else {
toast.success(t("tag_deleted"));
router.push("/links");
} else toast.error(response?.data as string);
}
},
});
}
setSubmitLoader(false);
setRenameTag(false);
};
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
const [viewMode, setViewMode] = useState<ViewMode>(
(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 (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full">
@ -222,11 +228,12 @@ export default function Index() {
</div>
</LinkListOptions>
<LinkComponent
<Links
editMode={editMode}
links={links.filter((e) =>
e.tags.some((e) => e.id === Number(router.query.id))
)}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
</div>
{bulkDeleteLinksModal && (

View File

@ -297,6 +297,7 @@
"create_new_collection": "Create a New Collection",
"color": "Color",
"reset": "Reset",
"updating_collection": "Updating Collection...",
"collection_name_placeholder": "e.g. Example Collection",
"collection_description_placeholder": "The purpose of this Collection...",
"create_collection_button": "Create Collection",

View File

@ -1,41 +0,0 @@
import { create } from "zustand";
import { AccountSettings } from "@/types/global";
type ResponseObject = {
ok: boolean;
data: Omit<AccountSettings, "password"> | object | string;
};
type AccountStore = {
account: AccountSettings;
setAccount: (id: number) => void;
updateAccount: (user: AccountSettings) => Promise<ResponseObject>;
};
const useAccountStore = create<AccountStore>()((set) => ({
account: {} as AccountSettings,
setAccount: async (id) => {
const response = await fetch(`/api/v1/users/${id}`);
const data = await response.json();
if (response.ok) set({ account: { ...data.response } });
},
updateAccount: async (user) => {
const response = await fetch(`/api/v1/users/${user.id}`, {
method: "PUT",
body: JSON.stringify(user),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (response.ok) set({ account: { ...data.response } });
return { ok: response.ok, data: data.response };
},
}));
export default useAccountStore;

View File

@ -1,66 +0,0 @@
import { User as U } from "@prisma/client";
import { create } from "zustand";
interface User extends U {
subscriptions: {
active: boolean;
};
}
type ResponseObject = {
ok: boolean;
data: object | string;
};
type UserStore = {
users: User[];
setUsers: () => void;
addUser: (body: Partial<U>) => Promise<ResponseObject>;
removeUser: (userId: number) => Promise<ResponseObject>;
};
const useUserStore = create<UserStore>((set) => ({
users: [],
setUsers: async () => {
const response = await fetch("/api/v1/users");
const data = await response.json();
if (response.ok) set({ users: data.response });
else if (response.status === 401) window.location.href = "/dashboard";
},
addUser: async (body) => {
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)
set((state) => ({
users: [...state.users, data.response],
}));
return { ok: response.ok, data: data.response };
},
removeUser: async (userId) => {
const response = await fetch(`/api/v1/users/${userId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
users: state.users.filter((user) => user.id !== userId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useUserStore;

View File

@ -1,94 +0,0 @@
import { create } from "zustand";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useTagStore from "./tags";
type ResponseObject = {
ok: boolean;
data: object | string;
};
type CollectionStore = {
collections: CollectionIncludingMembersAndLinkCount[];
setCollections: () => void;
addCollection: (
body: CollectionIncludingMembersAndLinkCount
) => Promise<ResponseObject>;
updateCollection: (
collection: CollectionIncludingMembersAndLinkCount
) => Promise<ResponseObject>;
removeCollection: (collectionId: number) => Promise<ResponseObject>;
};
const useCollectionStore = create<CollectionStore>()((set) => ({
collections: [],
setCollections: async () => {
const response = await fetch("/api/v1/collections");
const data = await response.json();
if (response.ok) set({ collections: data.response });
},
addCollection: async (body) => {
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)
set((state) => ({
collections: [...state.collections, data.response],
}));
return { ok: response.ok, data: data.response };
},
updateCollection: async (collection) => {
const response = await fetch(`/api/v1/collections/${collection.id}`, {
body: JSON.stringify(collection),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (response.ok)
set((state) => ({
collections: state.collections.map((e) =>
e.id === data.response.id ? data.response : e
),
}));
return { ok: response.ok, data: data.response };
},
removeCollection: async (collectionId) => {
const response = await fetch(`/api/v1/collections/${collectionId}`, {
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
collections: state.collections.filter(
(collection) =>
collection.id !== collectionId &&
collection.parentId !== collectionId
),
}));
useTagStore.getState().setTags();
}
return { ok: response.ok, data: data.response };
},
}));
export default useCollectionStore;

View File

@ -1,10 +1,5 @@
import { create } from "zustand";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import useTagStore from "./tags";
import useCollectionStore from "./collections";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
type ResponseObject = {
ok: boolean;
@ -12,24 +7,8 @@ type ResponseObject = {
};
type LinkStore = {
links: LinkIncludingShortenedCollectionAndTags[];
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
setLinks: (
data: LinkIncludingShortenedCollectionAndTags[],
isInitialCall: boolean
) => void;
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
addLink: (
body: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
uploadFile: (
link: LinkIncludingShortenedCollectionAndTags,
file: File
) => Promise<ResponseObject>;
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
updateLink: (
link: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
updateLinks: (
links: LinkIncludingShortenedCollectionAndTags[],
removePreviousTags: boolean,
@ -38,186 +17,11 @@ type LinkStore = {
"tags" | "collectionId"
>
) => Promise<ResponseObject>;
removeLink: (linkId: number) => Promise<ResponseObject>;
deleteLinksById: (linkIds: number[]) => Promise<ResponseObject>;
resetLinks: () => void;
};
const useLinkStore = create<LinkStore>()((set) => ({
links: [],
selectedLinks: [],
setLinks: async (data, isInitialCall) => {
isInitialCall &&
set(() => ({
links: [],
}));
set((state) => ({
// Filter duplicate links by id
links: [...state.links, ...data].reduce(
(links: LinkIncludingShortenedCollectionAndTags[], item) => {
if (!links.some((link) => link.id === item.id)) {
links.push(item);
}
return links;
},
[]
),
}));
},
setSelectedLinks: (links) => set({ selectedLinks: links }),
addLink: async (body) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: [data.response, ...state.links],
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
uploadFile: async (link, file) => {
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();
const createdLink: LinkIncludingShortenedCollectionAndTags = data.response;
console.log(data);
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",
}
);
// get file extension
const extension = file.name.split(".").pop() || "";
set((state) => ({
links: [
{
...createdLink,
image:
linkType === "image"
? `archives/${createdLink.collectionId}/${
createdLink.id + extension
}`
: null,
pdf:
linkType === "pdf"
? `archives/${createdLink.collectionId}/${
createdLink.id + ".pdf"
}`
: null,
},
...state.links,
],
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
getLink: async (linkId, publicRoute) => {
const path = publicRoute
? `/api/v1/public/links/${linkId}`
: `/api/v1/links/${linkId}`;
const response = await fetch(path);
const data = await response.json();
if (response.ok) {
set((state) => {
const linkExists = state.links.some(
(link) => link.id === data.response.id
);
if (linkExists) {
return {
links: state.links.map((e) =>
e.id === data.response.id ? data.response : e
),
};
} else {
return {
links: [...state.links, data.response],
};
}
});
return data;
}
return { ok: response.ok, data: data.response };
},
updateLink: async (link) => {
const response = await fetch(`/api/v1/links/${link.id}`, {
body: JSON.stringify(link),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.map((e) =>
e.id === data.response.id ? data.response : e
),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
updateLinks: async (links, removePreviousTags, newData) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify({ links, removePreviousTags, newData }),
@ -230,71 +34,11 @@ const useLinkStore = create<LinkStore>()((set) => ({
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.map((e) =>
links.some((link) => link.id === e.id)
? {
...e,
collectionId: newData.collectionId ?? e.collectionId,
collection: {
...e.collection,
id: newData.collectionId ?? e.collection.id,
},
tags: removePreviousTags
? [...(newData.tags ?? [])]
: [...e.tags, ...(newData.tags ?? [])],
}
: e
),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
// Update the selected links with the new data
}
return { ok: response.ok, data: data.response };
},
removeLink: async (linkId) => {
const response = await fetch(`/api/v1/links/${linkId}`, {
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.filter((e) => e.id !== linkId),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
deleteLinksById: async (linkIds: number[]) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify({ linkIds }),
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.filter((e) => !linkIds.includes(e.id as number)),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
resetLinks: () => set({ links: [] }),
}));
export default useLinkStore;

View File

@ -1,8 +1,10 @@
import { Sort } from "@/types/global";
import { create } from "zustand";
type LocalSettings = {
theme?: string;
viewMode?: string;
sortBy?: Sort;
};
type LocalSettingsStore = {
@ -15,10 +17,11 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
settings: {
theme: "",
viewMode: "",
sortBy: Sort.DateNewestFirst,
},
updateSettings: async (newSettings) => {
if (
newSettings.theme &&
newSettings.theme !== undefined &&
newSettings.theme !== localStorage.getItem("theme")
) {
localStorage.setItem("theme", newSettings.theme);
@ -29,7 +32,7 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
}
if (
newSettings.viewMode &&
newSettings.viewMode !== undefined &&
newSettings.viewMode !== localStorage.getItem("viewMode")
) {
localStorage.setItem("viewMode", newSettings.viewMode);
@ -37,6 +40,13 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
// const localTheme = localStorage.getItem("viewMode") || "";
}
if (
newSettings.sortBy !== undefined &&
newSettings.sortBy !== Number(localStorage.getItem("sortBy"))
) {
localStorage.setItem("sortBy", newSettings.sortBy.toString());
}
set((state) => ({ settings: { ...state.settings, ...newSettings } }));
},
setSettings: async () => {

View File

@ -1,64 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { create } from "zustand";
type Modal =
| {
modal: "LINK";
state: boolean;
method: "CREATE";
active?: LinkIncludingShortenedCollectionAndTags;
}
| {
modal: "LINK";
state: boolean;
method: "UPDATE";
active: LinkIncludingShortenedCollectionAndTags;
}
| {
modal: "LINK";
state: boolean;
method: "FORMATS";
active: LinkIncludingShortenedCollectionAndTags;
}
| {
modal: "COLLECTION";
state: boolean;
method: "UPDATE";
isOwner: boolean;
active: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| {
modal: "COLLECTION";
state: boolean;
method: "CREATE";
isOwner?: boolean;
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| {
modal: "COLLECTION";
state: boolean;
method: "VIEW_TEAM";
isOwner?: boolean;
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| null;
type ModalsStore = {
modal: Modal;
setModal: (modal: Modal) => void;
};
const useModalStore = create<ModalsStore>((set) => ({
modal: null,
setModal: (modal: Modal) => {
set({ modal });
},
}));
export default useModalStore;

View File

@ -1,62 +0,0 @@
import { create } from "zustand";
import { TagIncludingLinkCount } from "@/types/global";
type ResponseObject = {
ok: boolean;
data: object | string;
};
type TagStore = {
tags: TagIncludingLinkCount[];
setTags: () => void;
updateTag: (tag: TagIncludingLinkCount) => Promise<ResponseObject>;
removeTag: (tagId: number) => Promise<ResponseObject>;
};
const useTagStore = create<TagStore>()((set) => ({
tags: [],
setTags: async () => {
const response = await fetch("/api/v1/tags");
const data = await response.json();
if (response.ok) set({ tags: data.response });
},
updateTag: async (tag) => {
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) {
set((state) => ({
tags: state.tags.map((e) =>
e.id === data.response.id ? data.response : e
),
}));
}
return { ok: response.ok, data: data.response };
},
removeTag: async (tagId) => {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: "DELETE",
});
if (response.ok) {
set((state) => ({
tags: state.tags.filter((e) => e.id !== tagId),
}));
}
const data = await response.json();
return { ok: response.ok, data: data.response };
},
}));
export default useTagStore;

View File

@ -1,56 +0,0 @@
import { AccessToken } from "@prisma/client";
import { create } from "zustand";
// Token store
type ResponseObject = {
ok: boolean;
data: object | string;
};
type TokenStore = {
tokens: Partial<AccessToken>[];
setTokens: (data: Partial<AccessToken>[]) => void;
addToken: (body: Partial<AccessToken>[]) => Promise<ResponseObject>;
revokeToken: (tokenId: number) => Promise<ResponseObject>;
};
const useTokenStore = create<TokenStore>((set) => ({
tokens: [],
setTokens: async (data) => {
set(() => ({
tokens: data,
}));
},
addToken: async (body) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: [...state.tokens, data.response.token],
}));
return { ok: response.ok, data: data.response };
},
revokeToken: async (tokenId) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: state.tokens.filter((token) => token.id !== tokenId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useTokenStore;

View File

@ -67,7 +67,6 @@ export interface PublicCollectionIncludingLinks extends Collection {
export enum ViewMode {
Card = "card",
Grid = "grid",
List = "list",
Masonry = "masonry",
}
@ -82,7 +81,7 @@ export enum Sort {
}
export type LinkRequestQuery = {
sort: Sort;
sort?: Sort;
cursor?: number;
collectionId?: number;
tagId?: number;

View File

@ -1903,6 +1903,30 @@
dependencies:
tslib "^2.4.0"
"@tanstack/query-core@5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.51.15.tgz#7aee6a2d5d3f64de3e54096607233b1132dc6afd"
integrity sha512-xyobHDJ0yhPE3+UkSQ2/4X1fLSg7ICJI5J1JyU9yf7F3deQfEwSImCDrB1WSRrauJkMtXW7YIEcC0oA6ZZWt5A==
"@tanstack/query-devtools@5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.51.15.tgz#81c5c28231adc4b95fe4a5e1004020fdca5ea447"
integrity sha512-1oSCl+PsCa/aBCGVM2ZdcQLuQ0QYmKXJJB264twEMVM1M0n5CI40trtywORPF+wLGuZNIZzkKL7j/98mOLAIag==
"@tanstack/react-query-devtools@^5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.51.15.tgz#5c4d21305fd25c35dc88bd280304f77a45554fc2"
integrity sha512-bvGvJoncjZ3irEofoFevptj5BPkDpQrp2+dZhtFqPUZXRT6MAKPmOqtSmZPfacLR5jQLpqw/7d3Zxr173z7WDA==
dependencies:
"@tanstack/query-devtools" "5.51.15"
"@tanstack/react-query@^5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.51.15.tgz#059bb2966f828263adb355de81410d107e22b5bc"
integrity sha512-UgFg23SrdIYrmfTSxAUn9g+J64VQy11pb9/EefoY/u2+zWuNMeqEOnvpJhf52XQy0yztQoyM9p6x8PFyTNaxXg==
dependencies:
"@tanstack/query-core" "5.51.15"
"@tokenizer/token@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
@ -5250,6 +5274,11 @@ react-image-file-resizer@^0.4.8:
resolved "https://registry.yarnpkg.com/react-image-file-resizer/-/react-image-file-resizer-0.4.8.tgz#85f4ae4469fd2867d961568af660ef403d7a79af"
integrity sha512-Ue7CfKnSlsfJ//SKzxNMz8avDgDSpWQDOnTKOp/GNRFJv4dO9L5YGHNEnj40peWkXXAK2OK0eRIoXhOYpUzUTQ==
react-intersection-observer@^9.13.0:
version "9.13.0"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.13.0.tgz#ee10827954cf6ccc204d027f8400a6ddb8df163a"
integrity sha512-y0UvBfjDiXqC8h0EWccyaj4dVBWMxgEx0t5RGNzQsvkfvZwugnKwxpu70StY4ivzYuMajavwUDjH4LJyIki9Lw==
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -5311,10 +5340,10 @@ react-select@^5.7.4:
react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2"
react-spinners@^0.13.8:
version "0.13.8"
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc"
integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==
react-spinners@^0.14.1:
version "0.14.1"
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.14.1.tgz#de7d7d6b3e6d4f29d9620c65495b502c7dd90812"
integrity sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==
react-style-singleton@^2.2.1:
version "2.2.1"