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:
daniel31x13
2025-12-14 11:57:27 -05:00
parent 639f777b8a
commit cb9cdc92c8
13 changed files with 402 additions and 342 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -494,7 +494,7 @@ const useBulkEditLinks = () => {
newData,
removePreviousTags,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
links: Pick<LinkIncludingShortenedCollectionAndTags, "id">[];
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
"tags" | "collectionId"