mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 02:27:00 +00:00
Refactor link selection management and bulk actions
- Replaced the use of selectedLinks with selectedIds in the link store for better performance and clarity. - Updated LinkListOptions, BulkDeleteLinksModal, and BulkEditLinksModal components to utilize the new selection management. - Modified LinkCard, LinkMasonry, and LinkList components to handle selection state through props. - Enhanced updateLinks API to support bulk updates with improved tag management. - Cleaned up unused imports and code related to previous selection methods.
This commit is contained in:
@@ -27,6 +27,7 @@ import LinkPin from "./LinkViews/LinkComponents/LinkPin";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
export function DashboardLinks({
|
||||
links,
|
||||
@@ -63,6 +64,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export function Card({ link, editMode, dashboardType }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `${link.id}-${dashboardType}`,
|
||||
data: {
|
||||
@@ -221,7 +224,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
t={t}
|
||||
linkModal={linkModal}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
|
||||
@@ -4,7 +4,6 @@ import ViewDropdown from "./ViewDropdown";
|
||||
import { TFunction } from "i18next";
|
||||
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
import { useRouter } from "next/router";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
@@ -46,7 +45,13 @@ const LinkListOptions = ({
|
||||
setEditMode,
|
||||
links,
|
||||
}: Props) => {
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const {
|
||||
selectedIds,
|
||||
setSelected,
|
||||
clearSelected,
|
||||
isSelected,
|
||||
selectionCount,
|
||||
} = useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
const refreshPreservations = useArchiveAction();
|
||||
@@ -62,45 +67,42 @@ const LinkListOptions = ({
|
||||
if (editMode && setEditMode) return setEditMode(false);
|
||||
}, [router]);
|
||||
|
||||
const collectivePermissions = useCollectivePermissions(
|
||||
selectedLinks.map((link) => link.collectionId as number)
|
||||
);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
if (selectionCount === links.length) {
|
||||
clearSelected();
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
setSelected(links.map((link) => link.id as number));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
setEditMode?.(false);
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const bulkRefreshPreservations = async () => {
|
||||
const load = toast.loading(t("sending_request"));
|
||||
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await refreshPreservations.mutateAsync(
|
||||
{
|
||||
linkIds: selectedLinks.map((link) => link.id as number),
|
||||
linkIds: ids,
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
@@ -108,7 +110,7 @@ const LinkListOptions = ({
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("links_being_archived"));
|
||||
}
|
||||
@@ -133,7 +135,7 @@ const LinkListOptions = ({
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
}}
|
||||
className={
|
||||
editMode ? "bg-primary/20 hover:bg-primary/20" : ""
|
||||
@@ -161,15 +163,15 @@ const LinkListOptions = ({
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
checked={selectionCount === links.length && links.length > 0}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
{selectionCount > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_selected")
|
||||
: t("links_selected", { count: selectedLinks.length })}
|
||||
: t("links_selected", {
|
||||
count: selectionCount,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
@@ -183,7 +185,7 @@ const LinkListOptions = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setBulkRefreshPreservationsModal(true)}
|
||||
disabled={selectedLinks.length === 0}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-arrow-clockwise" />
|
||||
</Button>
|
||||
@@ -201,13 +203,7 @@ const LinkListOptions = ({
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canUpdate
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-pencil-square" />
|
||||
</Button>
|
||||
@@ -229,13 +225,7 @@ const LinkListOptions = ({
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canDelete
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-trash text-error" />
|
||||
</Button>
|
||||
@@ -278,10 +268,10 @@ const LinkListOptions = ({
|
||||
title={t("refresh_preserved_formats")}
|
||||
>
|
||||
<p className="mb-5">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("refresh_preserved_formats_confirmation_desc")
|
||||
: t("refresh_multiple_preserved_formats_confirmation_desc", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkModal from "@/components/ModalContent/LinkModal";
|
||||
@@ -21,24 +17,25 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmationModal from "@/components/ConfirmationModal";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
linkModal: boolean;
|
||||
className?: string;
|
||||
setLinkModal: (value: boolean) => void;
|
||||
t: TFunction<"translation", undefined>;
|
||||
className?: string;
|
||||
ghost?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkActions({
|
||||
link,
|
||||
linkModal,
|
||||
className,
|
||||
t,
|
||||
setLinkModal,
|
||||
className,
|
||||
ghost,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -18,11 +18,8 @@ import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink } from "@linkwarden/router/links";
|
||||
import { useRouter } from "next/router";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
@@ -31,17 +28,29 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
className?: string;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
imageHeightClass: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export default function LinkCard({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
@@ -52,57 +61,14 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
disabled: isSmallScreen,
|
||||
});
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const router = useRouter();
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections.find((c) => c.id === link.collection.id);
|
||||
}, [collections, link.collection.id]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
@@ -131,10 +97,6 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const isLinkSelected = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
@@ -144,13 +106,13 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||
isLinkSelected && "border-primary bg-base-300",
|
||||
isSelected && "border-primary bg-base-300",
|
||||
isDragging ? "opacity-30" : "opacity-100",
|
||||
"relative group touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -244,6 +206,7 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
<LinkActions
|
||||
link={link}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -10,28 +12,37 @@ import { cn, isPWA } from "@/lib/utils";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
count: number;
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export default function LinkList({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
@@ -41,57 +52,18 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
disabled: isSmallScreen,
|
||||
});
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
const linkIndex = selectedLinks.findIndex(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
if (linkIndex !== -1) {
|
||||
const updatedLinks = [...selectedLinks];
|
||||
updatedLinks.splice(linkIndex, 1);
|
||||
setSelectedLinks(updatedLinks);
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections.find((c) => c.id === link.collection.id);
|
||||
}, [collections, link.collection.id]);
|
||||
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border border-primary bg-base-300"
|
||||
: "border-transparent";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -100,14 +72,16 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"rounded-md border relative group items-center flex",
|
||||
selectedStyle,
|
||||
isSelected
|
||||
? "border border-primary bg-base-300"
|
||||
: "border-transparent",
|
||||
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1",
|
||||
isDragging ? "opacity-30" : "opacity-100",
|
||||
"duration-200, touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -154,10 +128,11 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isPublic && <LinkPin link={link} />}
|
||||
{!isPublicRoute && <LinkPin link={link} />}
|
||||
<LinkActions
|
||||
link={link}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -19,14 +19,11 @@ import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink } from "@linkwarden/router/links";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import clsx from "clsx";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,16 +31,29 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
imageHeightClass: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
export default function LinkMasonry({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
@@ -54,57 +64,14 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
disabled: isSmallScreen,
|
||||
});
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections.find((c) => c.id === link.collection.id);
|
||||
}, [collections, link.collection.id]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
@@ -131,10 +98,6 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const isLinkSelected = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
@@ -146,11 +109,11 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||
isLinkSelected && "border-primary bg-base-300"
|
||||
isSelected && "border-primary bg-base-300"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -261,6 +224,7 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
<LinkActions
|
||||
link={link}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
@@ -10,19 +11,34 @@ import Masonry from "react-masonry-css";
|
||||
import { useMemo } from "react";
|
||||
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import useLinkStore from "@/store/links";
|
||||
|
||||
function CardView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
const settings = useLocalSettingsStore((state) => state.settings);
|
||||
|
||||
@@ -55,6 +71,23 @@ function CardView({
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() =>
|
||||
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -79,12 +112,20 @@ function CardView({
|
||||
return (
|
||||
<div className={`${gridColClass} grid gap-5 pb-5`}>
|
||||
{links?.map((e) => {
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkCard
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
imageHeightClass={imageHeightClass}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -104,16 +145,26 @@ function CardView({
|
||||
|
||||
function MasonryView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
const settings = useLocalSettingsStore((state) => state.settings);
|
||||
|
||||
@@ -146,6 +197,23 @@ function MasonryView({
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() =>
|
||||
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -178,12 +246,20 @@ function MasonryView({
|
||||
className={`${gridColClass} grid gap-5 pb-5`}
|
||||
>
|
||||
{links?.map((e) => {
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkMasonry
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
imageHeightClass={imageHeightClass}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -203,22 +279,46 @@ function MasonryView({
|
||||
|
||||
function ListView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{links?.map((e, i) => {
|
||||
return <LinkList key={e.id} link={e} count={i} editMode={editMode} />;
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkList
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
count={i}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) && (
|
||||
@@ -248,17 +348,43 @@ export default function Links({
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
|
||||
useData.fetchNextPage();
|
||||
}
|
||||
}, [useData?.fetchNextPage, useData?.hasNextPage, inView]);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const collectionsById = useMemo(() => {
|
||||
const m = new Map<number, (typeof collections)[number]>();
|
||||
for (const c of collections) m.set(c.id as any, c);
|
||||
return m;
|
||||
}, [collections]);
|
||||
|
||||
const { clearSelected, isSelected, toggleSelected } = useLinkStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
clearSelected();
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
if (layout === ViewMode.List) {
|
||||
return (
|
||||
<ListView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
@@ -267,8 +393,13 @@ export default function Links({
|
||||
} else if (layout === ViewMode.Masonry) {
|
||||
return (
|
||||
<MasonryView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
@@ -278,8 +409,13 @@ export default function Links({
|
||||
// Default to card view
|
||||
return (
|
||||
<CardView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
|
||||
@@ -13,47 +13,45 @@ type Props = {
|
||||
|
||||
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
|
||||
const deleteLink = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin text-red-500">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("delete_link")
|
||||
: t("delete_links", { count: selectedLinks.length })}
|
||||
: t("delete_links", { count: selectionCount })}
|
||||
</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_deletion_confirmation_message")
|
||||
: t("links_deletion_confirmation_message", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
|
||||
export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||
const [updatedValues, setUpdatedValues] = useState<
|
||||
@@ -40,9 +40,13 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
const links = Object.keys(selectedIds).map((k) => ({
|
||||
id: Number(k),
|
||||
}));
|
||||
|
||||
await updateLinks.mutateAsync(
|
||||
{
|
||||
links: selectedLinks,
|
||||
links,
|
||||
newData: updatedValues,
|
||||
removePreviousTags,
|
||||
},
|
||||
@@ -54,7 +58,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
@@ -67,9 +71,9 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("edit_link")
|
||||
: t("edit_links", { count: selectedLinks.length })}
|
||||
: t("edit_links", { count: selectionCount })}
|
||||
</p>
|
||||
<Separator className="my-3" />
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import updateLinkById from "../linkId/updateLinkById";
|
||||
import { UpdateLinkSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
|
||||
export default async function updateLinks(
|
||||
userId: number,
|
||||
links: UpdateLinkSchemaType[],
|
||||
links: { id: number }[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
@@ -13,19 +14,35 @@ export default async function updateLinks(
|
||||
) {
|
||||
let allUpdatesSuccessful = true;
|
||||
|
||||
// Have to use a loop here rather than updateMany, see the following:
|
||||
// https://github.com/prisma/prisma/issues/3143
|
||||
for (const link of links) {
|
||||
let updatedTags = [...link.tags, ...(newData.tags ?? [])];
|
||||
const ids = links.map((l) => l.id);
|
||||
|
||||
if (removePreviousTags) {
|
||||
// If removePreviousTags is true, replace the existing tags with new tags
|
||||
updatedTags = [...(newData.tags ?? [])];
|
||||
}
|
||||
const dbLinks = await prisma.link.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
iconWeight: true,
|
||||
color: true,
|
||||
collectionId: true,
|
||||
collection: { select: { id: true, ownerId: true } },
|
||||
tags: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Map id -> link for quick lookup
|
||||
const byId = new Map(dbLinks.map((l) => [l.id, l]));
|
||||
|
||||
for (const l of links) {
|
||||
const link = byId.get(l.id);
|
||||
|
||||
if (!link) continue;
|
||||
|
||||
const updatedData: UpdateLinkSchemaType = {
|
||||
...link,
|
||||
tags: updatedTags,
|
||||
tags: [...(newData.tags ?? [])],
|
||||
collection: {
|
||||
...link.collection,
|
||||
id: newData.collectionId ?? link.collection.id,
|
||||
@@ -35,7 +52,8 @@ export default async function updateLinks(
|
||||
const updatedLink = await updateLinkById(
|
||||
userId,
|
||||
link.id as number,
|
||||
updatedData
|
||||
updatedData,
|
||||
removePreviousTags
|
||||
);
|
||||
|
||||
if (updatedLink.status !== 200) {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
export default async function updateLinkById(
|
||||
userId: number,
|
||||
linkId: number,
|
||||
body: UpdateLinkSchemaType
|
||||
body: UpdateLinkSchemaType,
|
||||
removePreviousTags?: boolean
|
||||
) {
|
||||
const dataValidation = UpdateLinkSchema.safeParse(body);
|
||||
|
||||
@@ -105,6 +106,30 @@ export default async function updateLinkById(
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueTags = (() => {
|
||||
const seen = new Set<string>();
|
||||
return (data.tags ?? []).filter((t) => {
|
||||
const key = t.name;
|
||||
if (!key) return false;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
})();
|
||||
|
||||
const tagConnectOrCreate = uniqueTags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name,
|
||||
ownerId: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.name,
|
||||
owner: { connect: { id: data.collection.ownerId } },
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
data.url &&
|
||||
oldLink &&
|
||||
@@ -140,25 +165,14 @@ export default async function updateLinkById(
|
||||
id: data.collection.id,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
set: [],
|
||||
connectOrCreate: data.tags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name,
|
||||
ownerId: data.collection.ownerId,
|
||||
},
|
||||
tags: removePreviousTags
|
||||
? {
|
||||
set: [],
|
||||
connectOrCreate: tagConnectOrCreate,
|
||||
}
|
||||
: {
|
||||
connectOrCreate: tagConnectOrCreate,
|
||||
},
|
||||
create: {
|
||||
name: tag.name,
|
||||
owner: {
|
||||
connect: {
|
||||
id: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
pinnedBy: data?.pinnedBy
|
||||
? data.pinnedBy[0]?.id === userId
|
||||
? { connect: { id: userId } }
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
type ResponseObject = {
|
||||
ok: boolean;
|
||||
data: object | string;
|
||||
};
|
||||
|
||||
type LinkStore = {
|
||||
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
|
||||
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
|
||||
updateLinks: (
|
||||
links: LinkIncludingShortenedCollectionAndTags[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
>
|
||||
) => Promise<ResponseObject>;
|
||||
selectedIds: Record<number, true>;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
clearSelected: () => void;
|
||||
setSelected: (ids: number[]) => void;
|
||||
selectionCount: number;
|
||||
};
|
||||
|
||||
const useLinkStore = create<LinkStore>()((set) => ({
|
||||
selectedLinks: [],
|
||||
setSelectedLinks: (links) => set({ selectedLinks: links }),
|
||||
updateLinks: async (links, removePreviousTags, newData) => {
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify({ links, removePreviousTags, newData }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
const useLinkStore = create<LinkStore>()((set, get) => ({
|
||||
selectedIds: {},
|
||||
|
||||
const data = await response.json();
|
||||
isSelected: (id) => !!get().selectedIds[id],
|
||||
|
||||
if (response.ok) {
|
||||
// Update the selected links with the new data
|
||||
}
|
||||
toggleSelected: (id) =>
|
||||
set((state) => {
|
||||
const next = { ...state.selectedIds };
|
||||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
if (next[id]) {
|
||||
delete next[id];
|
||||
return { selectedIds: next, selectionCount: state.selectionCount - 1 };
|
||||
} else {
|
||||
next[id] = true;
|
||||
return { selectedIds: next, selectionCount: state.selectionCount + 1 };
|
||||
}
|
||||
}),
|
||||
|
||||
clearSelected: () => set({ selectedIds: {}, selectionCount: 0 }),
|
||||
|
||||
setSelected: (ids) =>
|
||||
set(() => {
|
||||
const next: Record<number, true> = {};
|
||||
for (let i = 0; i < ids.length; i++) next[ids[i]] = true;
|
||||
return { selectedIds: next, selectionCount: Object.keys(next).length };
|
||||
}),
|
||||
|
||||
selectionCount: 0,
|
||||
}));
|
||||
|
||||
export default useLinkStore;
|
||||
|
||||
@@ -494,7 +494,7 @@ const useBulkEditLinks = () => {
|
||||
newData,
|
||||
removePreviousTags,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
links: Pick<LinkIncludingShortenedCollectionAndTags, "id">[];
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
|
||||
Reference in New Issue
Block a user