Compare commits

...

9 Commits
i18n ... dev

27 changed files with 760 additions and 282 deletions

View File

@@ -120,8 +120,8 @@ const RootComponent = ({
auth: MobileAuth;
}) => {
const { colorScheme } = useColorScheme();
const updateLink = useUpdateLink(auth);
const deleteLink = useDeleteLink(auth);
const updateLink = useUpdateLink({ auth, Alert });
const deleteLink = useDeleteLink({ auth, Alert });
const { tmp } = useTmpStore();
@@ -229,12 +229,12 @@ const RootComponent = ({
{tmp.link && tmp.user && (
<DropdownMenu.Item
key="pin-link"
onSelect={async () => {
onSelect={() => {
const isAlreadyPinned =
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
? true
: false;
await updateLink.mutateAsync({
updateLink.mutateAsync({
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
@@ -282,18 +282,15 @@ const RootComponent = ({
{
text: "Delete",
style: "destructive",
onPress: () => {
onPress: async () => {
deleteLink.mutate(
tmp.link?.id as number,
{
onSuccess: async () => {
await deleteLinkCache(
tmp.link?.id as number
);
},
}
tmp.link?.id as number
);
// go back
await deleteLinkCache(
tmp.link?.id as number
);
router.back();
},
},

View File

@@ -21,7 +21,7 @@ export default function IncomingScreen() {
const { auth } = useAuthStore();
const router = useRouter();
const { data, updateData } = useDataStore();
const addLink = useAddLink(auth);
const addLink = useAddLink({ auth });
const { colorScheme } = useColorScheme();
const [showSuccess, setShowSuccess] = useState(false);
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();

View File

@@ -12,7 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AddLinkSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const addLink = useAddLink({ auth, Alert });
const [link, setLink] = useState("");
const { colorScheme } = useColorScheme();
@@ -43,21 +43,12 @@ export default function AddLinkSheet() {
/>
<Button
onPress={() =>
addLink.mutate(
{ url: link },
{
onSuccess: () => {
actionSheetRef.current?.hide();
setLink("");
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
)
}
onPress={() => {
addLink.mutate({ url: link });
actionSheetRef.current?.hide();
setLink("");
}}
isLoading={addLink.isPending}
variant="accent"
className="mb-2"

View File

@@ -33,7 +33,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
const [link, setLink] = useState<
LinkIncludingShortenedCollectionAndTags | undefined
>(props.payload?.link);
const editLink = useUpdateLink(auth);
const updateLink = useUpdateLink({ auth, Alert });
const router = useSheetRouter("edit-link-sheet");
const { colorScheme } = useColorScheme();
@@ -124,23 +124,15 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
/>
<Button
onPress={() =>
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
onSuccess: () => {
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
},
onError: (error) => {
Alert.alert("Error", "There was an error editing the link.");
console.error("Error editing link:", error);
},
})
}
isLoading={editLink.isPending}
onPress={() => {
updateLink.mutate(link as LinkIncludingShortenedCollectionAndTags);
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
}}
isLoading={updateLink.isPending}
variant="accent"
className="mb-2"
>
@@ -162,7 +154,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
const Collections = () => {
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const addLink = useAddLink({ auth });
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const { link: currentLink } = useSheetRouteParams<
@@ -270,7 +262,7 @@ const Collections = () => {
const Tags = () => {
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
const addLink = useAddLink({ auth });
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const params = useSheetRouteParams("edit-link-sheet", "tags");

View File

@@ -19,7 +19,7 @@ const CollectionListing = ({ collection }: Props) => {
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useDeleteCollection(auth);
const deleteCollection = useDeleteCollection({ auth, Alert });
return (
<ContextMenu.Root>

View File

@@ -40,12 +40,12 @@ type Props = {
const LinkListing = ({ link, dashboard }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const updateLink = useUpdateLink(auth);
const updateLink = useUpdateLink({ auth, Alert });
const { data: user } = useUser(auth);
const { colorScheme } = useColorScheme();
const { data } = useDataStore();
const deleteLink = useDeleteLink(auth);
const deleteLink = useDeleteLink({ auth, Alert });
const [url, setUrl] = useState("");
@@ -57,7 +57,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
} catch (error) {
console.log(error);
}
}, [link]);
}, [link.url]);
return (
<ContextMenu.Root>
@@ -122,8 +122,8 @@ const LinkListing = ({ link, dashboard }: Props) => {
<View className="flex flex-row gap-1 items-center mt-1.5 pr-1.5 self-start rounded-md">
<Folder
size={16}
fill={link.collection.color || ""}
color={link.collection.color || ""}
fill={link.collection.color || "#0ea5e9"}
color={link.collection.color || "#0ea5e9"}
/>
<Text
numberOfLines={1}
@@ -215,11 +215,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Item
key="pin-link"
onSelect={async () => {
onSelect={() => {
const isAlreadyPinned =
link?.pinnedBy && link.pinnedBy[0] ? true : false;
await updateLink.mutateAsync({
updateLink.mutateAsync({
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
@@ -319,12 +319,10 @@ const LinkListing = ({ link, dashboard }: Props) => {
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteLink.mutate(link.id as number, {
onSuccess: async () => {
await deleteLinkCache(link.id as number);
},
});
onPress: async () => {
deleteLink.mutate(link.id as number);
await deleteLinkCache(link.id as number);
},
},
]

View File

@@ -17,7 +17,7 @@ import {
import useOnScreen from "@/hooks/useOnScreen";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import { useGetLink } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import openLink from "@/lib/client/openLink";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
@@ -82,8 +82,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
@@ -102,7 +100,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, links]);
}, [collections, link]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);

View File

@@ -53,7 +53,7 @@ export default function DragNDrop({
onDragEnd: onDragEndProp,
}: DragNDropProps) {
const { t } = useTranslation();
const updateLink = useUpdateLink();
const updateLink = useUpdateLink({ toast, t });
const pinLink = usePinLink();
const { data: user } = useUser();
const queryClient = useQueryClient();
@@ -104,25 +104,7 @@ export default function DragNDrop({
updatedLink: LinkIncludingShortenedCollectionAndTags,
opts?: { invalidateDashboardOnError?: boolean }
) => {
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(updatedLink, {
onSettled: async (_, error) => {
toast.dismiss(load);
if (error) {
if (
opts?.invalidateDashboardOnError &&
typeof queryClient !== "undefined"
) {
await queryClient.invalidateQueries({
queryKey: ["dashboardData"],
});
}
toast.error(error.message);
} else {
toast.success(t("updated"));
}
},
});
updateLink.mutateAsync(updatedLink);
};
// DROP ON TAG

View File

@@ -113,7 +113,7 @@ export default function LinkDetails({
);
};
const updateLink = useUpdateLink();
const updateLink = useUpdateLink({ toast, t });
const updateFile = useUpdateFile();
const submit = async (e?: any) => {
@@ -126,21 +126,9 @@ export default function LinkDetails({
return;
}
const load = toast.loading(t("updating"));
updateLink.mutateAsync(link);
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setMode && setMode("view");
setLink(data);
}
},
});
setMode && setMode("view");
};
const setCollection = (e: any) => {

View File

@@ -54,7 +54,7 @@ export default function LinkActions({
const [refreshPreservationsModal, setRefreshPreservationsModal] =
useState(false);
const deleteLink = useDeleteLink();
const deleteLink = useDeleteLink({ toast, t });
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
@@ -131,13 +131,7 @@ export default function LinkActions({
onClick={async (e) => {
if (e.shiftKey) {
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"));
},
});
await deleteLink.mutateAsync(link.id as number);
} else {
setDeleteLinkModal(true);
}

View File

@@ -1,7 +1,7 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import Icon from "@/components/Icon";
import { IconWeight } from "@phosphor-icons/react";
import clsx from "clsx";
@@ -30,6 +30,10 @@ function LinkIcon({
const [faviconLoaded, setFaviconLoaded] = useState(false);
useEffect(() => {
setFaviconLoaded(false);
}, [link.url]);
return (
<div onClick={() => onClick && onClick()}>
{link.icon ? (

View File

@@ -66,7 +66,13 @@ export default function MobileNavigation({}: Props) {
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
@@ -22,7 +21,6 @@ export default function DeleteCollectionModal({
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const permissions = usePermissions(collection.id as number);
@@ -30,32 +28,15 @@ export default function DeleteCollectionModal({
setCollection(activeCollection);
}, []);
const deleteCollection = useDeleteCollection();
const deleteCollection = useDeleteCollection({ toast, t });
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
if (!collection) return null;
setSubmitLoader(true);
deleteCollection.mutateAsync(collection.id as number);
const load = toast.loading(t("deleting_collection"));
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
}
onClose();
router.push("/collections");
};
return (

View File

@@ -18,7 +18,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const deleteLink = useDeleteLink();
const deleteLink = useDeleteLink({ toast, t });
const router = useRouter();
useEffect(() => {
@@ -26,26 +26,15 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
}, []);
const submit = async () => {
const load = toast.loading(t("deleting"));
deleteLink.mutateAsync(link.id as number);
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
if (
router.pathname.startsWith("/links/[id]") ||
router.pathname.startsWith("/preserved/[id]")
) {
router.push("/dashboard");
}
toast.success(t("deleted"));
onClose();
}
},
});
if (
router.pathname.startsWith("/links/[id]") ||
router.pathname.startsWith("/preserved/[id]")
) {
router.push("/dashboard");
}
onClose();
};
return (

View File

@@ -43,7 +43,7 @@ export default function LinkModal({
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const deleteLink = useDeleteLink();
const deleteLink = useDeleteLink({ toast, t });
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
@@ -51,13 +51,8 @@ export default function LinkModal({
setTimeout(() => (document.body.style.pointerEvents = ""), 0);
if (e.shiftKey && link.id) {
const loading = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id, {
onSettled: (data, error) => {
toast.dismiss(loading);
error ? toast.error(error.message) : toast.success(t("deleted"));
},
});
deleteLink.mutateAsync(link.id);
onClose();
} else {
onDelete();

View File

@@ -7,14 +7,17 @@ import { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useAddLink } from "@linkwarden/router/links";
import toast from "react-hot-toast";
import { PostLinkSchemaType } from "@linkwarden/lib/schemaValidation";
import {
PostLinkSchema,
PostLinkSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { Button } from "@/components/ui/button";
import { Separator } from "../ui/separator";
import { useAddLink } from "@linkwarden/router/links";
type Props = {
onClose: Function;
onClose: () => void;
};
export default function NewLinkModal({ onClose }: Props) {
@@ -31,10 +34,13 @@ export default function NewLinkModal({ onClose }: Props) {
},
} as PostLinkSchemaType;
const addLink = useAddLink({
toast,
t,
});
const inputRef = useRef<HTMLInputElement>(null);
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const addLink = useAddLink();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { data: collections = [] } = useCollections();
@@ -80,22 +86,17 @@ export default function NewLinkModal({ onClose }: Props) {
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_link"));
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(t(error.message));
} else {
onClose();
toast.success(t("link_created"));
}
},
});
}
const dataValidation = PostLinkSchema.safeParse(link);
if (!dataValidation.success)
return toast.error(
`Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`
);
addLink.mutateAsync(link);
onClose();
};
return (

View File

@@ -182,7 +182,11 @@ export default function Navbar({
</div>
)}
{newLinkModal && (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />

View File

@@ -40,7 +40,13 @@ export default function NoLinksFound({ text }: Props) {
</span>
</Button>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
</div>
);
}

View File

@@ -186,7 +186,7 @@ export default function SearchBar({ placeholder }: Props) {
</Button>
</div>
{user?.hasUnIndexedLinks && !dismissSearchNote ? (
{/* {user?.hasUnIndexedLinks && !dismissSearchNote ? (
<div
role="alert"
className="border border-neutral p-2 my-1 rounded flex flex-col gap-2"
@@ -204,7 +204,7 @@ export default function SearchBar({ placeholder }: Props) {
Dismiss
</Button>
</div>
) : undefined}
) : undefined} */}
</div>
</div>
)}

View File

@@ -6,39 +6,21 @@ import { useUser } from "@linkwarden/router/user";
const usePinLink = () => {
const { t } = useTranslation();
const updateLink = useUpdateLink();
const updateLink = useUpdateLink({ toast, t });
const { data: user } = useUser();
// Return a function that can be used to pin/unpin the link
const pinLink = async (link: LinkIncludingShortenedCollectionAndTags) => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const load = toast.loading(t("updating"));
try {
await updateLink.mutateAsync(
{
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: user?.id }]) as any,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
);
}
},
}
);
updateLink.mutateAsync({
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: user?.id }]) as any,
});
} catch (e) {
toast.dismiss(load);
console.error(e);
}
};

View File

@@ -158,7 +158,7 @@ const Page: NextPageWithLayout = () => {
<ul className="px-5 list-disc">
<Trans
i18nKey="regenerate_broken_preserved_content_desc"
components={[<li />]}
components={[<li key={0} />]}
values={{
count: workerStats.data?.link.failed,
}}
@@ -181,7 +181,7 @@ const Page: NextPageWithLayout = () => {
<ul className="px-5 list-disc">
<Trans
i18nKey="delete_all_preserved_content_and_regenerate_desc"
components={[<li />]}
components={[<li key={0} />]}
/>
</ul>
<div role="alert" className="alert alert-warning mt-3">

View File

@@ -26,7 +26,6 @@ import ViewDropdown from "@/components/ViewDropdown";
import clsx from "clsx";
import Icon from "@/components/Icon";
import Droppable from "@/components/Droppable";
import { useUpdateLink } from "@linkwarden/router/links";
import { NextPageWithLayout } from "./_app";
const Page: NextPageWithLayout = () => {
@@ -95,7 +94,6 @@ const Page: NextPageWithLayout = () => {
const [showSurveyModal, setShowsSurveyModal] = useState(false);
const updateUser = useUpdateUser();
const updateLink = useUpdateLink();
const [submitLoader, setSubmitLoader] = useState(false);
@@ -192,7 +190,13 @@ const Page: NextPageWithLayout = () => {
}}
/>
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
</>
);
};

View File

@@ -50,14 +50,17 @@ export default async function archiveHandler(
process.env.PERPLEXITY_API_KEY)
? true
: undefined,
indexVersion: null,
},
});
return;
}
const abortController = new AbortController();
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
timeoutId = setTimeout(() => {
abortController.abort();
reject(
new Error(
@@ -208,6 +211,10 @@ export default async function archiveHandler(
console.log("Reason:", err);
throw err;
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
const finalLink = await prisma.link.findUnique({
where: { id: link.id },
});
@@ -227,6 +234,7 @@ export default async function archiveHandler(
!finalLink.aiTagged
? true
: undefined,
indexVersion: null,
},
});
} else {

View File

@@ -25,17 +25,10 @@ export default async function handleMonolith(
const child = spawn("monolith", args, {
stdio: ["pipe", "pipe", "inherit"],
detached: true,
signal,
killSignal: "SIGKILL",
});
const abortListener = () => {
try {
if (child.pid) killProcess(child.pid);
} catch {}
reject(new Error("Monolith aborted"));
};
signal?.addEventListener("abort", abortListener);
child.stdin.write(htmlFromPage);
child.stdin.end();
@@ -43,14 +36,11 @@ export default async function handleMonolith(
child.stdout.on("data", (c) => chunks.push(c));
child.on("error", (err) => {
cleanup();
reject(err);
});
child.on("close", async (code) => {
cleanup();
if (code !== 0) {
if (code !== 0 && code !== null) {
return reject(new Error(`Monolith exited with code ${code}`));
}
@@ -64,29 +54,21 @@ export default async function handleMonolith(
return reject(new Error("Monolith output exceeded buffer limit"));
}
await createFile({
data: html,
filePath: `archives/${link.collectionId}/${link.id}.html`,
});
try {
await createFile({
data: html,
filePath: `archives/${link.collectionId}/${link.id}.html`,
});
await prisma.link.update({
where: { id: link.id },
data: { monolith: `archives/${link.collectionId}/${link.id}.html` },
});
await prisma.link.update({
where: { id: link.id },
data: { monolith: `archives/${link.collectionId}/${link.id}.html` },
});
resolve();
resolve();
} catch (err) {
reject(err);
}
});
function cleanup() {
signal?.removeEventListener("abort", abortListener);
}
});
}
const killProcess = async (PID: number) => {
if (process.platform === "win32") {
process.kill(PID, "SIGKILL");
} else {
process.kill(-PID, "SIGKILL");
}
};

View File

@@ -80,14 +80,6 @@ export async function startIndexing(interval = 10) {
{ indexVersion: null },
],
},
{
OR: [
{ lastPreserved: { not: null } },
{
url: null,
},
],
},
{
...(process.env.STRIPE_SECRET_KEY
? {
@@ -172,7 +164,6 @@ export async function startIndexing(interval = 10) {
{ indexVersion: { not: MEILI_INDEX_VERSION } },
{ indexVersion: null },
],
lastPreserved: { not: null },
},
});

View File

@@ -4,6 +4,9 @@ import {
MobileAuth,
} from "@linkwarden/types/global";
import { useSession } from "next-auth/react";
import type toaster from "react-hot-toast";
import { TFunction } from "next-i18next";
import type { Alert as Alert_ } from "react-native";
const useCollections = (auth?: MobileAuth) => {
let status: "loading" | "authenticated" | "unauthenticated";
@@ -109,7 +112,17 @@ const useUpdateCollection = () => {
});
};
const useDeleteCollection = (auth?: MobileAuth) => {
const useDeleteCollection = ({
auth,
Alert,
toast,
t,
}: {
auth?: MobileAuth;
Alert?: typeof Alert_;
toast?: typeof toaster;
t?: TFunction;
}) => {
const queryClient = useQueryClient();
return useMutation({
@@ -133,6 +146,44 @@ const useDeleteCollection = (auth?: MobileAuth) => {
return data.response;
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["collections"] });
await queryClient.cancelQueries({ queryKey: ["dashboardData"] });
const previousCollections = queryClient.getQueryData(["collections"]);
const previousDashboard = queryClient.getQueryData(["dashboardData"]);
queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData?.filter((collection: any) => collection.id !== id);
});
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return oldData;
return {
...oldData,
links:
oldData.links?.filter((link: any) => link.collectionId !== id) ||
[],
numberOfPinnedLinks:
oldData.links?.filter(
(link: any) => link.collectionId !== id && link.isPinned
).length || 0,
};
});
return { previousCollections, previousDashboard };
},
onError: (error, _variables, context) => {
if (toast && t) toast.error(t(error.message));
else if (Alert)
Alert.alert("Error", "There was an error deleting the collection.");
if (!context) return;
queryClient.setQueryData(["collections"], context.previousCollections);
queryClient.setQueryData(["dashboardData"], context.previousDashboard);
},
onSuccess: (data, id) => {
queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData.filter((collection: any) => collection.id !== id);
@@ -153,6 +204,9 @@ const useDeleteCollection = (auth?: MobileAuth) => {
).length || 0,
};
});
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["links"] });
},
});
};

View File

@@ -3,6 +3,7 @@ import {
useQueryClient,
useMutation,
useQuery,
QueryKey,
} from "@tanstack/react-query";
import { useMemo } from "react";
import {
@@ -18,6 +19,9 @@ import {
} from "@linkwarden/lib/schemaValidation";
import getFormatFromContentType from "@linkwarden/lib/getFormatFromContentType";
import getLinkTypeFromFormat from "@linkwarden/lib/getLinkTypeFromFormat";
import type toaster from "react-hot-toast";
import { TFunction } from "next-i18next";
import type { Alert as Alert_ } from "react-native";
const useLinks = (params: LinkRequestQuery = {}, auth?: MobileAuth) => {
const sort =
@@ -111,7 +115,254 @@ const buildQueryString = (params: LinkRequestQuery) => {
.join("&");
};
const useAddLink = (auth?: MobileAuth) => {
const upsertLinkInList = (
links: LinkIncludingShortenedCollectionAndTags[] = [],
link: LinkIncludingShortenedCollectionAndTags,
optimisticId?: number
) => {
const existingIndex = links.findIndex(
(item) => item.id === optimisticId || item.id === link.id
);
if (existingIndex === -1) return [link, ...links];
const nextLinks = [...links];
nextLinks[existingIndex] = link;
return nextLinks;
};
const upsertLinkInInfiniteData = (
oldData: any,
link: LinkIncludingShortenedCollectionAndTags,
optimisticId?: number
) => {
if (!oldData?.pages?.length) return oldData;
let replaced = false;
const pages = oldData.pages.map((page: any) => {
const links = page?.links?.map((item: any) => {
if (item.id === optimisticId || item.id === link.id) {
replaced = true;
return link;
}
return item;
});
return { ...page, links };
});
if (!replaced) {
const firstPage = pages[0];
pages[0] = {
...firstPage,
links: upsertLinkInList(firstPage?.links ?? [], link, optimisticId),
};
}
return { ...oldData, pages };
};
const upsertLinkInDashboardData = (
oldData: any,
link: LinkIncludingShortenedCollectionAndTags,
optimisticId?: number
) => {
if (!oldData) return oldData;
const updatedLinks = upsertLinkInList(
oldData.links ?? [],
link,
optimisticId
).slice(0, 16);
const collectionLinks = { ...(oldData.collectionLinks ?? {}) };
const collectionId = link.collection?.id;
if (collectionId != null && collectionLinks[collectionId]) {
collectionLinks[collectionId] = upsertLinkInList(
collectionLinks[collectionId],
link,
optimisticId
).slice(0, 16);
}
return {
...oldData,
links: updatedLinks,
collectionLinks,
};
};
const removeLinkFromInfiniteData = (oldData: any, linkId: number) => {
if (!oldData?.pages?.length) return oldData;
return {
...oldData,
pages: oldData.pages.map((page: any) => ({
...page,
links: (page.links ?? []).filter((item: any) => item.id !== linkId),
})),
};
};
const removeLinkFromDashboardData = (oldData: any, linkId: number) => {
if (!oldData) return oldData;
const removedLink = (oldData.links ?? []).find(
(item: any) => item.id === linkId
);
const numberOfPinnedLinks = removedLink?.pinnedBy?.length
? Math.max(0, (oldData.numberOfPinnedLinks ?? 0) - 1)
: oldData.numberOfPinnedLinks;
const hasCollectionLinks = oldData.collectionLinks != null;
const collectionLinks = hasCollectionLinks
? { ...oldData.collectionLinks }
: oldData.collectionLinks;
if (hasCollectionLinks) {
for (const [collectionId, links] of Object.entries(collectionLinks)) {
collectionLinks[Number(collectionId)] = (links as any[]).filter(
(item: any) => item.id !== linkId
);
}
}
return {
...oldData,
links: (oldData.links ?? []).filter((item: any) => item.id !== linkId),
collectionLinks,
numberOfPinnedLinks,
};
};
const isLinkPinned = (link?: LinkIncludingShortenedCollectionAndTags) => {
return Boolean(link?.pinnedBy && link.pinnedBy.length > 0);
};
const findLinkInInfiniteData = (data: any, linkId: number) => {
if (!data?.pages?.length) return undefined;
for (const page of data.pages) {
const match = page?.links?.find((item: any) => item.id === linkId);
if (match) return match;
}
return undefined;
};
const findLinkInQueriesData = (
queries: [QueryKey, unknown][],
linkId: number
) => {
for (const [, data] of queries ?? []) {
const match = findLinkInInfiniteData(data as any, linkId);
if (match) return match;
}
return undefined;
};
const findLinkInDashboardData = (data: any, linkId: number) => {
const match = data?.links?.find((item: any) => item.id === linkId);
if (match) return match;
const collectionLinks = data?.collectionLinks;
if (!collectionLinks) return undefined;
for (const links of Object.values(collectionLinks)) {
const collectionMatch = (links as any[])?.find(
(item: any) => item.id === linkId
);
if (collectionMatch) return collectionMatch;
}
return undefined;
};
const replaceLinkInInfiniteData = (
oldData: any,
link: LinkIncludingShortenedCollectionAndTags
) => {
if (!oldData?.pages?.length) return oldData;
let updated = false;
const pages = oldData.pages.map((page: any) => {
const links = (page.links ?? []).map((item: any) => {
if (item.id === link.id) {
updated = true;
return link;
}
return item;
});
return { ...page, links };
});
if (!updated) return oldData;
return { ...oldData, pages };
};
const replaceLinkInDashboardData = (
oldData: any,
link: LinkIncludingShortenedCollectionAndTags
) => {
if (!oldData) return oldData;
let updated = false;
const links = (oldData.links ?? []).map((item: any) => {
if (item.id === link.id) {
updated = true;
return link;
}
return item;
});
let collectionLinks = oldData.collectionLinks;
if (oldData.collectionLinks != null) {
const nextCollectionLinks = { ...oldData.collectionLinks };
for (const [collectionId, linksForCollection] of Object.entries(
nextCollectionLinks
)) {
const linkList = linksForCollection as any[];
if (!Array.isArray(linkList)) continue;
if (!linkList.some((item) => item.id === link.id)) continue;
updated = true;
nextCollectionLinks[Number(collectionId)] = linkList.map((item) =>
item.id === link.id ? link : item
);
}
collectionLinks = nextCollectionLinks;
}
if (!updated) return oldData;
return {
...oldData,
links,
collectionLinks,
};
};
const applyPinnedDelta = (oldData: any, delta: number) => {
if (!oldData || !delta) return oldData;
return {
...oldData,
numberOfPinnedLinks: Math.max(
0,
(oldData.numberOfPinnedLinks ?? 0) + delta
),
};
};
const useAddLink = ({
auth,
Alert,
toast,
t,
}: {
auth?: MobileAuth;
Alert?: typeof Alert_;
toast?: typeof toaster;
t?: TFunction;
}) => {
const queryClient = useQueryClient();
return useMutation({
@@ -144,20 +395,118 @@ const useAddLink = (auth?: MobileAuth) => {
return data.response;
},
onSuccess: (data: LinkIncludingShortenedCollectionAndTags[]) => {
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [
{
links: [data, ...oldData?.pages?.[0]?.links],
nextCursor: oldData?.pages?.[0]?.nextCursor,
},
...oldData?.pages?.slice(1),
],
pageParams: oldData?.pageParams,
};
onMutate: async (link) => {
await queryClient.cancelQueries({ queryKey: ["links"] });
await queryClient.cancelQueries({ queryKey: ["dashboardData"] });
const previousLinks = queryClient.getQueriesData({
queryKey: ["links"],
});
const previousDashboard = queryClient.getQueryData(["dashboardData"]);
const collections =
(queryClient.getQueryData(["collections"]) as any[]) ?? [];
const tags = (queryClient.getQueryData(["tags"]) as any[]) ?? [];
const user = queryClient.getQueryData(["user"]) as any;
const collectionFromId =
link.collection?.id != null
? collections.find(
(collection) => collection.id === link.collection?.id
)
: undefined;
const collectionFromName =
!collectionFromId && link.collection?.name
? collections.find(
(collection) => collection.name === link.collection?.name
)
: undefined;
const resolvedCollection = collectionFromId ?? collectionFromName;
const tempId = -Date.now();
const tempCollectionId = tempId - 1;
const collectionId =
resolvedCollection?.id ?? link.collection?.id ?? tempCollectionId;
const collectionName =
resolvedCollection?.name ?? link.collection?.name ?? "Unorganized";
const resolvedTags =
link.tags?.map((tag, index) => {
if (tag.id != null) {
return (
tags.find((existing) => existing.id === tag.id) ?? {
id: tag.id,
name: tag.name,
}
);
}
const existingTag = tags.find(
(existing) => existing.name === tag.name
);
return (
existingTag ?? {
id: tempId - 2 - index,
name: tag.name,
}
);
}) ?? [];
const optimisticLink = {
id: tempId,
name: link.name?.trim() || link.url || "",
url: link.url || "",
description: link.description || "",
type: link.type || "url",
preview: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
collectionId,
collection:
resolvedCollection ??
({
id: collectionId,
name: collectionName,
ownerId: user?.id ?? 0,
} as any),
tags: resolvedTags,
} as LinkIncludingShortenedCollectionAndTags;
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) =>
upsertLinkInInfiniteData(oldData, optimisticLink, tempId)
);
queryClient.setQueryData(["dashboardData"], (oldData: any) =>
upsertLinkInDashboardData(oldData, optimisticLink, tempId)
);
return {
previousLinks,
previousDashboard,
optimisticId: tempId,
};
},
onError: (error, _variables, context) => {
if (toast && t) toast.error(t(error.message));
else if (Alert)
Alert.alert("Error", "There was an error adding the link.");
if (!context) return;
context.previousLinks?.forEach(([queryKey, data]: [unknown, unknown]) => {
queryClient.setQueryData(queryKey as QueryKey, data);
});
queryClient.setQueryData(["dashboardData"], context.previousDashboard);
},
onSuccess: (
data: LinkIncludingShortenedCollectionAndTags,
_link,
context
) => {
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) =>
upsertLinkInInfiniteData(oldData, data, context?.optimisticId)
);
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
queryClient.invalidateQueries({ queryKey: ["collections"] });
@@ -167,7 +516,17 @@ const useAddLink = (auth?: MobileAuth) => {
});
};
const useUpdateLink = (auth?: MobileAuth) => {
const useUpdateLink = ({
auth,
Alert,
toast,
t,
}: {
auth?: MobileAuth;
Alert?: typeof Alert_;
toast?: typeof toaster;
t?: TFunction;
}) => {
const queryClient = useQueryClient();
return useMutation({
@@ -192,6 +551,117 @@ const useUpdateLink = (auth?: MobileAuth) => {
return data.response;
},
onMutate: async (link) => {
const linkId = link.id;
if (linkId == null) {
return {};
}
await queryClient.cancelQueries({ queryKey: ["links"] });
await queryClient.cancelQueries({ queryKey: ["publicLinks"] });
await queryClient.cancelQueries({ queryKey: ["dashboardData"] });
const previousLinks = queryClient.getQueriesData({
queryKey: ["links"],
});
const previousPublicLinks = queryClient.getQueriesData({
queryKey: ["publicLinks"],
});
const previousDashboard = queryClient.getQueryData(["dashboardData"]);
const previousLinkQueries = queryClient.getQueriesData({
queryKey: ["link", linkId],
});
const cachedLink =
findLinkInQueriesData(previousLinks, linkId) ??
findLinkInDashboardData(previousDashboard, linkId);
const collections =
(queryClient.getQueryData(["collections"]) as any[]) ?? [];
const nextCollectionId =
link.collection?.id ?? cachedLink?.collection?.id;
const resolvedCollection =
nextCollectionId != null
? collections.find((collection) => collection.id === nextCollectionId)
: undefined;
const optimisticCollection =
resolvedCollection ??
(link.collection?.id &&
cachedLink?.collection?.id === link.collection.id
? { ...cachedLink.collection, ...link.collection }
: link.collection ?? cachedLink?.collection);
const previousPinned = isLinkPinned(cachedLink);
const nextPinned =
typeof link.pinnedBy === "undefined"
? previousPinned
: isLinkPinned(link);
const pinnedDelta =
nextPinned === previousPinned ? 0 : nextPinned ? 1 : -1;
const optimisticLink = {
...(cachedLink ?? {}),
...link,
collection: optimisticCollection,
collectionId:
optimisticCollection?.id ??
link.collection?.id ??
cachedLink?.collectionId,
updatedAt: new Date().toISOString(),
} as LinkIncludingShortenedCollectionAndTags;
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) =>
replaceLinkInInfiniteData(oldData, optimisticLink)
);
queryClient.setQueriesData(
{ queryKey: ["publicLinks"] },
(oldData: any) => replaceLinkInInfiniteData(oldData, optimisticLink)
);
queryClient.setQueriesData(
{ queryKey: ["link", linkId] },
() => optimisticLink
);
queryClient.setQueryData(["dashboardData"], (oldData: any) =>
applyPinnedDelta(
replaceLinkInDashboardData(oldData, optimisticLink),
pinnedDelta
)
);
return {
previousLinks,
previousPublicLinks,
previousDashboard,
previousLinkQueries,
};
},
onError: (error, _variables, context) => {
if (toast && t) toast.error(t(error.message));
else if (Alert)
Alert.alert("Error", "There was an error updating the link.");
if (!context) return;
context.previousLinks?.forEach(([queryKey, data]: [unknown, unknown]) => {
queryClient.setQueryData(queryKey as QueryKey, data);
});
context.previousPublicLinks?.forEach(
([queryKey, data]: [unknown, unknown]) => {
queryClient.setQueryData(queryKey as QueryKey, data);
}
);
context.previousLinkQueries?.forEach(
([queryKey, data]: [unknown, unknown]) => {
queryClient.setQueryData(queryKey as QueryKey, data);
}
);
queryClient.setQueryData(["dashboardData"], context.previousDashboard);
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["links"] });
queryClient.invalidateQueries({ queryKey: ["link", data.id] });
@@ -203,7 +673,17 @@ const useUpdateLink = (auth?: MobileAuth) => {
});
};
const useDeleteLink = (auth?: MobileAuth) => {
const useDeleteLink = ({
auth,
Alert,
toast,
t,
}: {
auth?: MobileAuth;
Alert?: typeof Alert_;
toast?: typeof toaster;
t?: TFunction;
}) => {
const queryClient = useQueryClient();
return useMutation({
@@ -226,6 +706,57 @@ const useDeleteLink = (auth?: MobileAuth) => {
return data.response;
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["links"] });
await queryClient.cancelQueries({ queryKey: ["dashboardData"] });
await queryClient.cancelQueries({ queryKey: ["publicLinks"] });
const previousLinks = queryClient.getQueriesData({
queryKey: ["links"],
});
const previousPublicLinks = queryClient.getQueriesData({
queryKey: ["publicLinks"],
});
const previousDashboard = queryClient.getQueryData(["dashboardData"]);
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) =>
removeLinkFromInfiniteData(oldData, id)
);
queryClient.setQueriesData(
{ queryKey: ["publicLinks"] },
(oldData: any) => removeLinkFromInfiniteData(oldData, id)
);
queryClient.setQueryData(["dashboardData"], (oldData: any) =>
removeLinkFromDashboardData(oldData, id)
);
return {
previousLinks,
previousPublicLinks,
previousDashboard,
};
},
onError: (error, _variables, context) => {
if (toast && t) toast.error(t(error.message));
else if (Alert)
Alert.alert("Error", "There was an error deleting the link.");
if (!context) return;
context.previousLinks?.forEach(([queryKey, data]: [unknown, unknown]) => {
queryClient.setQueryData(queryKey as QueryKey, data);
});
context.previousPublicLinks?.forEach(
([queryKey, data]: [unknown, unknown]) => {
queryClient.setQueryData(queryKey as QueryKey, data);
}
);
queryClient.setQueryData(["dashboardData"], context.previousDashboard);
},
onSuccess: (data) => {
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData?.pages?.[0]) return undefined;