refactored link state management + a lot of other changes...

This commit is contained in:
daniel31x13
2024-08-13 00:08:57 -04:00
parent a73e5fa6c6
commit 80f366cd7b
58 changed files with 1302 additions and 819 deletions
-3
View File
@@ -11,7 +11,6 @@ const useCollections = () => {
const data = await response.json();
return data.response;
},
initialData: [],
});
};
@@ -42,8 +41,6 @@ const useCreateCollection = () => {
onSuccess: (data) => {
toast.success(t("created"));
return queryClient.setQueryData(["collections"], (oldData: any) => {
console.log([...oldData, data]);
return [...oldData, data];
});
},
+16
View File
@@ -0,0 +1,16 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useQuery } from "@tanstack/react-query";
const useDashboardData = () => {
return useQuery({
queryKey: ["dashboardData"],
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => {
const response = await fetch("/api/v1/dashboard");
const data = await response.json();
return data.response;
},
});
};
export { useDashboardData };
+499
View File
@@ -0,0 +1,499 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQueryClient,
useMutation,
} from "@tanstack/react-query";
import { useEffect, useMemo } from "react";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
LinkRequestQuery,
} from "@/types/global";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
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) => {
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;
},
});
};
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 { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const load = toast.loading(t("creating_link"));
const response = await fetch("/api/v1/links", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
toast.dismiss(load);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
toast.success(t("link_created"));
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueryData(["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"] });
},
onError: (error) => {
toast.error(error.message);
},
});
};
const useUpdateLink = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const load = toast.loading(t("updating"));
const response = await fetch(`/api/v1/links/${link.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
toast.dismiss(load);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
toast.success(t("updated"));
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
});
queryClient.setQueryData(["links"], (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [
oldData.pages[0].map((e: any) => (e.id === data.id ? data : e)),
...oldData.pages.slice(1),
],
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
onError: (error) => {
toast.error(error.message);
},
});
};
const useDeleteLink = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const load = toast.loading(t("deleting"));
const response = await fetch(`/api/v1/links/${id}`, {
method: "DELETE",
});
toast.dismiss(load);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
toast.success(t("deleted"));
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => e.id !== data.id);
});
queryClient.setQueryData(["links"], (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [
oldData.pages[0].filter((e: any) => e.id !== data.id),
...oldData.pages.slice(1),
],
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
onError: (error) => {
toast.error(error.message);
},
});
};
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.setQueryData(["links"], (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [
oldData.pages[0].map((e: any) => (e.id === data.id ? data : e)),
...oldData.pages.slice(1),
],
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkDeleteLinks = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (linkIds: number[]) => {
const load = toast.loading(t("deleting"));
const response = await fetch("/api/v1/links", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ linkIds }),
});
toast.dismiss(load);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return linkIds;
},
onSuccess: (data) => {
toast.success(t("deleted"));
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => !data.includes(e.id));
});
queryClient.setQueryData(["links"], (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [
oldData.pages[0].filter((e: any) => !data.includes(e.id)),
...oldData.pages.slice(1),
],
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
onError: (error) => {
toast.error(error.message);
},
});
};
const useUploadFile = () => {
const { t } = useTranslation();
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 load = toast.loading(t("creating"));
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",
}
);
}
toast.dismiss(load);
return data.response;
},
onSuccess: (data) => {
toast.success(t("created_success"));
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueryData(["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"] });
},
onError: (error) => {
toast.error(error.message);
},
});
};
const useBulkEditLinks = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
links,
newData,
removePreviousTags,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
"tags" | "collectionId"
>;
removePreviousTags: boolean;
}) => {
const load = toast.loading(t("updating"));
const response = await fetch("/api/v1/links", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ links, newData, removePreviousTags }),
});
toast.dismiss(load);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
toast.success(t("updated"));
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) =>
data.find((d: any) => d.id === e.id) ? data : e
);
});
queryClient.setQueryData(["links"], (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [
oldData.pages[0].map((e: any) =>
data.find((d: any) => d.id === e.id) ? data : e
),
...oldData.pages.slice(1),
],
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
onError: (error) => {
toast.error(error.message);
},
});
};
export {
useLinks,
useAddLink,
useUpdateLink,
useDeleteLink,
useBulkDeleteLinks,
useUploadFile,
useGetLink,
useBulkEditLinks,
};
+93
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 };
-1
View File
@@ -13,7 +13,6 @@ const useTags = () => {
const data = await response.json();
return data.response;
},
initialData: [],
});
};
-1
View File
@@ -14,7 +14,6 @@ const useTokens = () => {
const data = await response.json();
return data.response as AccessToken[];
},
initialData: [],
});
};
+1 -1
View File
@@ -19,7 +19,7 @@ const useUser = () => {
return data.response;
},
enabled: !!userId,
initialData: {},
placeholderData: {},
});
};
+2 -2
View File
@@ -4,9 +4,9 @@ import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function useCollectivePermissions(collectionIds: number[]) {
const { data: collections } = useCollections();
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const { data: user = {} } = useUser();
const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { useUser } from "./store/user";
export default function useInitialData() {
const { status, data } = useSession();
// const { setLinks } = useLinkStore();
const { data: user } = useUser();
const { data: user = {} } = useUser();
const { setSettings } = useLocalSettingsStore();
useEffect(() => {
-103
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 };
}
+2 -2
View File
@@ -4,9 +4,9 @@ import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function usePermissions(collectionId: number) {
const { data: collections } = useCollections();
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const { data: user = {} } = useUser();
const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => {