Files
zeekayandHanzo Dev 10282c06f6 Rename the persisted keys, and carry returning visitors across
Four localStorage keys still spelled upstream's brand — papermark.email,
papermark.name, last_papermark_login, papermark-all-links-collapsed. The first
two hold a visitor's email and name and are what makes an access form
prefill on a return visit, so renaming them naively signs every returning
visitor out of their own convenience.

lib/webstorage.ts already owned web storage, so the rename lives there:

  - StorageKey names each key once. Eight call sites used to spell the string
    themselves, one of them via a local const, which is why the set had
    drifted into three different naming shapes.
  - LEGACY_KEYS maps each new key to the name it used to have. getItem falls
    back to the old key, copies the value forward and drops the old entry.
    Migrating on read means no boot hook to register and no ordering to get
    wrong; a key migrates the first time something asks for it, and only
    then. setItem and removeItem clear the legacy entry too, so a value
    cannot come back from the dead.

Six of those call sites were reaching for window.localStorage directly and
so skipped the wrapper's try/catch. They now go through it, which is what it
is for — a viewer opening a link in a third-party context with storage
blocked was throwing rather than degrading.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 18:58:44 -07:00

1182 lines
44 KiB
TypeScript

import { useRouter } from "next/router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { DocumentVersion, LinkAudienceType } from "@prisma/client";
import { isWithinInterval, subMinutes } from "date-fns";
import {
BoxesIcon,
ChevronRightIcon,
ClockFadingIcon,
Code2Icon,
CopyCheckIcon,
CopyIcon,
CopyPlusIcon,
EyeIcon,
FileSlidersIcon,
LinkIcon,
Settings2Icon,
SquareArrowOutUpRightIcon,
SquareDashedIcon,
TimerOffIcon,
Trash2Icon,
} from "lucide-react";
import { useQueryState } from "nuqs";
import { toast } from "sonner";
import { mutate } from "swr";
import z from "zod";
import { LinkWithViews, WatermarkConfig } from "@/lib/types";
import { cn, copyToClipboard, nFormatter, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/lib/utils/use-media-query";
import { StorageKey, localStorage } from "@/lib/webstorage";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import FileProcessStatusBar from "../documents/file-process-status-bar";
import BarChart from "../shared/icons/bar-chart";
import ChevronDown from "../shared/icons/chevron-down";
import MoreHorizontal from "../shared/icons/more-horizontal";
import { Badge } from "../ui/badge";
import { Label } from "../ui/label";
import { ButtonTooltip } from "../ui/tooltip";
import { useDeleteLinkModal } from "./delete-link-modal";
import EmbedCodeModal from "./embed-code-modal";
import LinkActiveControls, {
countActiveSettings,
} from "./link-active-controls";
import LinkSheet, {
DEFAULT_LINK_PROPS,
type DEFAULT_LINK_TYPE,
} from "./link-sheet";
import { PermissionsSheet } from "./link-sheet/permissions-sheet";
import { TagColumn } from "./link-sheet/tags/tag-details";
import LinksVisitors from "./links-visitors";
const isDocumentProcessing = (version?: DocumentVersion) => {
if (!version) return false;
return (
!version.hasPages &&
["pdf", "slides", "docs", "cad"].includes(version.type!)
);
};
// Full URL helper
const getFullUrl = (link: LinkWithViews) => {
if (link.domainId) {
return `https://${link.domainSlug}/${link.slug}`;
}
return `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;
};
// Display URL helper - shows the path portion that fits the cell
const getDisplayUrl = (link: LinkWithViews) => {
if (link.domainId) {
return `${link.domainSlug}/${link.slug}`;
}
return `dataroom.hanzo.ai/view/${link.id}`;
};
// Link URL cell component - displays URL with click-to-copy hover overlay
const LinkUrlCell = ({
link,
onCopy,
isProcessing,
primaryVersion,
mutateDocument,
isPopoverOpen,
}: {
link: LinkWithViews;
onCopy: (url: string) => void;
isProcessing: boolean;
primaryVersion?: DocumentVersion;
mutateDocument?: () => void;
isPopoverOpen?: boolean;
}) => {
const fullUrl = getFullUrl(link);
const displayUrl = getDisplayUrl(link);
return (
<div
className={cn(
"group/url relative min-w-0 flex-1 cursor-pointer overflow-hidden rounded-md bg-secondary px-3 py-1.5 text-sm text-secondary-foreground transition-all hover:bg-emerald-700 group-hover/row:ring-1 group-hover/row:ring-gray-400 hover:dark:bg-emerald-200 group-hover/row:dark:ring-gray-100",
isPopoverOpen && "ring-1 ring-gray-400 dark:ring-gray-100",
)}
onClick={() => onCopy(fullUrl)}
>
{/* Progress bar for document processing */}
{isProcessing && primaryVersion && (
<FileProcessStatusBar
documentVersionId={primaryVersion.id}
className="absolute bottom-0 left-0 right-0 top-0 z-20 flex h-full items-center gap-x-8"
// @ts-ignore: mutateDocument is not present on datarooms but on document pages
mutateDocument={mutateDocument}
/>
)}
{/* URL text - hidden on hover */}
<span
className="block overflow-hidden text-ellipsis whitespace-nowrap group-hover/url:opacity-0"
style={{ direction: "rtl", textAlign: "left" }}
>
{displayUrl}
</span>
{/* Copy & Share overlay */}
<span className="absolute inset-0 z-10 hidden items-center justify-center whitespace-nowrap text-center text-sm text-primary-foreground group-hover/url:flex">
Copy & Share
</span>
</div>
);
};
// Link actions cell component - copy, preview, edit buttons
const LinkActionsCell = ({
link,
onCopy,
onPreview,
isProcessing,
}: {
link: LinkWithViews;
onCopy: (url: string) => void;
onPreview: (link: LinkWithViews) => void;
isProcessing: boolean;
}) => {
const [copied, setCopied] = useState(false);
const fullUrl = getFullUrl(link);
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timeout);
}
}, [copied]);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onCopy(fullUrl);
setCopied(true);
};
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onPreview(link);
};
return (
<div className="flex items-center gap-1">
<ButtonTooltip content={copied ? "Copied!" : "Copy link"}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 transition-colors hover:text-foreground group-hover/link:bg-emerald-500/10 group-hover/link:hover:bg-emerald-500/20"
onClick={handleCopy}
>
{copied ? (
<CopyCheckIcon className="h-4 w-4 text-emerald-500" />
) : (
<CopyIcon className="h-4 w-4 text-muted-foreground transition-colors hover:text-foreground group-hover/link:text-emerald-500" />
)}
</Button>
</ButtonTooltip>
<ButtonTooltip
content={isProcessing ? "Preparing preview" : "Preview link"}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:text-foreground"
onClick={handlePreview}
disabled={isProcessing}
>
{isProcessing ? (
<SquareDashedIcon className="h-4 w-4 text-muted-foreground" />
) : (
<SquareArrowOutUpRightIcon className="h-4 w-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</ButtonTooltip>
</div>
);
};
export default function LinksTable({
targetType,
links,
primaryVersion,
mutateDocument,
}: {
targetType: "DOCUMENT" | "DATAROOM";
links?: LinkWithViews[];
primaryVersion?: DocumentVersion;
mutateDocument?: () => void;
}) {
const [tags, _] = useQueryState<string[]>("tags", {
parse: (value: string) => value.split(",").filter(Boolean),
serialize: (value: string[]) => value.join(","),
});
const selectedTagNames = useMemo(() => tags ?? [], [tags]);
const now = Date.now();
const router = useRouter();
const { currentTeamId } = useTeam();
const { id: targetId, groupId } = router.query as {
id: string;
groupId?: string;
};
const { isMobile } = useMediaQuery();
let processedLinks = useMemo(() => {
if (!links?.length) return [];
const oneMinuteAgo = subMinutes(now, 1);
const sortedLinks = links.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return sortedLinks.map((link) => {
const createdDate = new Date(link.createdAt);
const updatedDate = new Date(link.updatedAt);
return {
...link,
isNew: isWithinInterval(createdDate, {
start: oneMinuteAgo,
end: now,
}),
isUpdated:
isWithinInterval(updatedDate, {
start: oneMinuteAgo,
end: now,
}) && updatedDate.getTime() !== createdDate.getTime(),
};
});
}, [links, now]);
processedLinks = useMemo(() => {
if (!links?.length) return [];
return processedLinks.filter((link) => {
if (selectedTagNames.length === 0) return true;
return link.tags.some((tag) => selectedTagNames.includes(tag.name));
});
}, [links, processedLinks, selectedTagNames]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [loadingLinks, setLoadingLinks] = useState<Set<string>>(new Set());
const [isLinkSheetVisible, setIsLinkSheetVisible] = useState<boolean>(false);
const [selectedLink, setSelectedLink] = useState<DEFAULT_LINK_TYPE>(
DEFAULT_LINK_PROPS(`${targetType}_LINK`, groupId),
);
const [embedModalOpen, setEmbedModalOpen] = useState(false);
const [selectedEmbedLink, setSelectedEmbedLink] = useState<{
id: string;
name: string;
} | null>(null);
const [popoverOpen, setPopoverOpen] = useState<string | null>(null);
const hoverTimeout = useRef<NodeJS.Timeout | null>(null);
const [showPermissionsSheet, setShowPermissionsSheet] =
useState<boolean>(false);
const [editPermissionLink, setEditPermissionLink] =
useState<LinkWithViews | null>(null);
const [linkToDelete, setLinkToDelete] = useState<LinkWithViews | null>(null);
const { setShowDeleteLinkModal, DeleteLinkModal } = useDeleteLinkModal({
link: linkToDelete,
targetType,
});
const handleCopyToClipboard = (linkString: string) => {
copyToClipboard(`${linkString}`, "Link copied to clipboard.");
};
const handleEditLink = (link: LinkWithViews) => {
setSelectedLink({
id: link.id,
name: link.name || `Link #${link.id.slice(-5)}`,
domain: link.domainSlug,
slug: link.slug,
expiresAt: link.expiresAt,
password: link.password,
emailProtected: link.emailProtected,
emailAuthenticated: link.emailAuthenticated,
allowDownload: link.allowDownload ? link.allowDownload : false,
allowList: link.allowList,
denyList: link.denyList,
visitorGroupIds:
link.visitorGroups?.map(
(vg: { visitorGroupId: string }) => vg.visitorGroupId,
) || [],
enableNotification: link.enableNotification
? link.enableNotification
: false,
enableFeedback: link.enableFeedback ? link.enableFeedback : false,
enableScreenshotProtection: link.enableScreenshotProtection
? link.enableScreenshotProtection
: false,
enableCustomMetatag: link.enableCustomMetatag
? link.enableCustomMetatag
: false,
enableQuestion: link.enableQuestion ? link.enableQuestion : false,
questionText: link.feedback ? link.feedback.data?.question : "",
questionType: link.feedback ? link.feedback.data?.type : "",
metaTitle: link.metaTitle,
metaDescription: link.metaDescription,
metaImage: link.metaImage,
metaFavicon: link.metaFavicon,
enableAgreement: link.enableAgreement ? link.enableAgreement : false,
agreementId: link.agreementId,
showBanner: link.showBanner ?? false,
enableWatermark: link.enableWatermark ?? false,
watermarkConfig: link.watermarkConfig as WatermarkConfig | null,
audienceType: link.audienceType,
groupId: link.groupId,
customFields: link.customFields || [],
tags: link.tags.map((tag) => tag.id) || [],
enableConversation: link.enableConversation ?? false,
enableUpload: link.enableUpload ?? false,
isFileRequestOnly: link.isFileRequestOnly ?? false,
uploadFolderId: link.uploadFolderId ?? null,
uploadFolderName: link.uploadFolderName ?? "Home",
enableIndexFile: link.enableIndexFile ?? false,
permissionGroupId: link.permissionGroupId ?? null,
welcomeMessage: link.welcomeMessage ?? null,
enableAIAgents: link.enableAIAgents ?? false,
});
//wait for dropdown to close before opening the link sheet
setTimeout(() => {
setIsLinkSheetVisible(true);
}, 0);
};
const handlePreviewLink = async (link: LinkWithViews) => {
if (isDocumentProcessing(primaryVersion)) {
toast.error(
"Document is still processing. Please wait a moment and try again.",
);
return;
}
const response = await fetch(`/api/links/${link.id}/preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
toast.error("Failed to generate preview link");
return;
}
const { previewToken } = await response.json();
const previewLink = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}?previewToken=${previewToken}`;
window.open(previewLink, "_blank");
};
const handleDuplicateLink = async (link: LinkWithViews) => {
setIsLoading(true);
const response = await fetch(`/api/links/${link.id}/duplicate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
teamId: currentTeamId,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const duplicatedLink = await response.json();
const endpointTargetType = `${targetType.toLowerCase()}s`; // "documents" or "datarooms"
// Update the duplicated link in the list of links
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
link.documentId ?? link.dataroomId ?? "",
)}/links`,
(links || []).concat(duplicatedLink),
false,
);
// Update the group-specific links cache if this is a group link
if (!!groupId) {
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
duplicatedLink.documentId ?? duplicatedLink.dataroomId ?? "",
)}/groups/${duplicatedLink.groupId}/links`,
groupLinks.concat(duplicatedLink),
false,
);
}
toast.success("Link duplicated successfully");
setIsLoading(false);
};
const handleEditPermissions = (link: LinkWithViews) => {
setEditPermissionLink(link);
setShowPermissionsSheet(true);
};
const handlePermissionsSave = async (permissions: any) => {
if (!editPermissionLink) return;
// Handle the case where user wants to share entire dataroom (permissions === null)
if (permissions === null && editPermissionLink.permissionGroupId) {
// Delete the permission group - database will set permissionGroupId to null automatically
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const deleteResponse = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
{
method: "DELETE",
},
);
if (!deleteResponse.ok) {
const { error } = await deleteResponse.json();
throw new Error(error ?? "Failed to delete permission group");
}
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${teamIdParsed}/${endpointTargetType}/${encodeURIComponent(
targetIdParsed,
)}/links`,
(currentLinks: LinkWithViews[] | undefined) =>
(currentLinks || []).map((link: LinkWithViews) =>
link.id === editPermissionLink.id
? { ...link, permissionGroupId: null }
: link,
),
false,
);
// Invalidate the permission group cache
mutate(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
);
setShowPermissionsSheet(false);
setEditPermissionLink(null);
toast.success("File permissions updated successfully");
} catch (error) {
console.error("Error updating file permissions:", error);
toast.error("Failed to update file permissions");
}
return;
}
if (!editPermissionLink.permissionGroupId) {
setIsLoading(true);
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const response = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
permissions: permissions,
linkId: editPermissionLink.id,
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create permission group");
}
const { permissionGroup: newPermissionGroup, _ } =
await response.json();
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
// Cache the new permission group data
if (newPermissionGroup?.id) {
mutate(
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
newPermissionGroup,
false,
);
}
setShowPermissionsSheet(false);
setEditPermissionLink(null);
toast.success("File permissions updated successfully");
} catch (error) {
console.error("Error creating permission group:", error);
toast.error("Failed to create permission group");
} finally {
setIsLoading(false);
}
} else {
try {
// Update the permissions for the existing link
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const res = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
permissions: permissions,
linkId: editPermissionLink.id,
}),
},
);
if (!res.ok) {
const { error } = await res.json();
throw new Error(error ?? "Failed to update permissions");
}
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
// Invalidate the permission group cache
if (editPermissionLink.permissionGroupId) {
mutate(
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
);
}
setShowPermissionsSheet(false);
setEditPermissionLink(null);
toast.success("File permissions updated successfully");
} catch (error) {
console.error("Error updating file permissions:", error);
toast.error("Failed to update file permissions");
}
}
};
const AddLinkButton = () => (
<Button onClick={() => setIsLinkSheetVisible(true)}>
Create link to share
</Button>
);
const handleArchiveLink = async (
linkId: string,
targetId: string,
isArchived: boolean,
) => {
setLoadingLinks((prev) => new Set(prev).add(linkId));
try {
const response = await fetch(`/api/links/${linkId}/archive`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isArchived: !isArchived,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const archivedLink = await response.json();
const endpointTargetType = `${targetType.toLowerCase()}s`; // "documents" or "datarooms"
// Update the archived link in the list of links
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
(links || []).map((link) => (link.id === linkId ? archivedLink : link)),
false,
);
// Update the group-specific links cache if this is a group link
if (!!groupId) {
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
archivedLink.documentId ?? archivedLink.dataroomId ?? "",
)}/groups/${groupId}/links`,
groupLinks.map((link) => (link.id === linkId ? archivedLink : link)),
false,
);
}
toast.success(
!isArchived
? "Link successfully archived"
: "Link successfully reactivated",
);
} catch (error) {
console.error("Error archiving link:", error);
toast.error("Failed to update link status");
} finally {
setLoadingLinks((prev) => {
const newSet = new Set(prev);
newSet.delete(linkId);
return newSet;
});
}
};
const hasAnyTags = useMemo(
() =>
processedLinks.reduce(
(acc, link) => acc || (link?.tags && link.tags.length > 0),
false,
),
[processedLinks],
);
// Collapsible state for "All Links" section (document pages only)
const [isAllLinksOpen, setIsAllLinksOpen] = useState<boolean>(true);
// Load collapse state from localStorage on mount
useEffect(() => {
if (targetType !== "DOCUMENT") return;
try {
const stored = localStorage.getItem(StorageKey.linksCollapsed);
if (stored !== null) {
// stored value is "true" if collapsed, so we invert for isOpen
setIsAllLinksOpen(stored !== "true");
}
} catch (e) {
// localStorage might be unavailable
console.warn("Could not read from localStorage:", e);
}
}, [targetType]);
// Handle toggle and persist to localStorage
const handleAllLinksToggle = useCallback((open: boolean) => {
setIsAllLinksOpen(open);
try {
// Store "true" when collapsed, "false" when expanded
localStorage.setItem(StorageKey.linksCollapsed, String(!open));
} catch (e) {
console.warn("Could not write to localStorage:", e);
}
}, []);
const isDataroom = targetType === "DATAROOM";
const linksTableContent = (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Link</TableHead>
{hasAnyTags ? (
<TableHead className="w-[250px] 2xl:w-auto">Tags</TableHead>
) : null}
<TableHead className="w-[250px] sm:w-auto">Views</TableHead>
<TableHead>Last Viewed</TableHead>
<TableHead className="w-[80px]">Active</TableHead>
<TableHead className="text-center sm:text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processedLinks && processedLinks.length > 0 ? (
processedLinks.map((link) => (
<Collapsible key={link.id} asChild>
<>
<TableRow
key={link.id}
className={cn(
"group/row",
popoverOpen === link.id && "bg-gray-100",
link.isArchived &&
"bg-gray-50 opacity-50 dark:bg-gray-700",
)}
>
<TableCell className="w-[200px] truncate font-medium md:w-[220px] lg:w-[250px] xl:w-[280px] 2xl:w-[350px]">
<div className="flex items-center gap-x-2">
{link.groupId ? (
<ButtonTooltip content="Group Link">
<BoxesIcon className="size-4 shrink-0" />
</ButtonTooltip>
) : null}
<ButtonTooltip
content={link.name || `Link #${link.id.slice(-5)}`}
>
<span className="max-w-[150px] truncate md:max-w-[170px] lg:max-w-[200px] xl:max-w-[230px] 2xl:max-w-[300px]">
{link.name || `Link #${link.id.slice(-5)}`}
</span>
</ButtonTooltip>
{link.isNew && !link.isUpdated && (
<Badge
variant="outline"
className="border-emerald-600/80 text-emerald-600/80"
>
New
</Badge>
)}
{link.isUpdated && (
<Badge
variant="outline"
className="border-blue-500/80 text-blue-500/80"
>
Updated
</Badge>
)}
{link.expiresAt &&
(new Date(link.expiresAt) < new Date() ? (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expired at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-destructive/10 p-1 text-destructive ring-1 ring-destructive/20">
<TimerOffIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
) : (
<TimestampTooltip
timestamp={link.expiresAt}
side="right"
rows={["local", "utc"]}
title="Expires at"
fullLabels
>
<span className="flex cursor-default items-center rounded-full bg-orange-100 p-1 text-orange-700 ring-1 ring-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:ring-orange-800">
<ClockFadingIcon className="h-3 w-3" />
</span>
</TimestampTooltip>
))}
</div>
</TableCell>
{/* Link URL Cell */}
<TableCell className="group/link max-w-[250px] pr-1 md:max-w-[300px] lg:max-w-[350px] xl:max-w-[400px] 2xl:max-w-[450px]">
<div className="flex flex-row gap-x-1">
<LinkUrlCell
link={link}
onCopy={handleCopyToClipboard}
isProcessing={isDocumentProcessing(primaryVersion)}
primaryVersion={primaryVersion}
mutateDocument={mutateDocument}
isPopoverOpen={popoverOpen === link.id}
/>
<div className="flex shrink-0 items-center">
<LinkActionsCell
link={link}
onCopy={handleCopyToClipboard}
onPreview={handlePreviewLink}
isProcessing={isDocumentProcessing(primaryVersion)}
/>
{isMobile ? (
<ButtonTooltip content="Edit link">
<Button
variant="link"
size="icon"
className="group h-8 w-8"
onClick={() => handleEditLink(link)}
>
<span className="sr-only">Edit link</span>
<Settings2Icon className="text-gray-400 group-hover:text-gray-500" />
</Button>
</ButtonTooltip>
) : (
<Popover
open={popoverOpen === link.id}
onOpenChange={() => {}}
>
<PopoverTrigger asChild>
<Button
variant="link"
className={cn(
"h-8 w-8 font-normal hover:no-underline focus-visible:ring-0 focus-visible:ring-offset-0",
popoverOpen === link.id
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
size="sm"
onClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => {
hoverTimeout.current = setTimeout(
() => setPopoverOpen(link.id),
250,
);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<Settings2Icon />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-56 p-0"
onMouseEnter={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
setPopoverOpen(link.id);
}}
onMouseLeave={() => {
if (hoverTimeout.current)
clearTimeout(hoverTimeout.current);
hoverTimeout.current = setTimeout(
() => setPopoverOpen(null),
100,
);
}}
>
<LinkActiveControls
link={link}
onEditClick={(e) => {
e.preventDefault();
handleEditLink(link);
}}
/>
</PopoverContent>
</Popover>
)}
{/* File permissions icon (dataroom only) */}
{isDataroom && (
<div className="flex w-8 items-center justify-center">
{link.permissionGroupId && (
<ButtonTooltip content="Limited File Access">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-default"
>
<FileSlidersIcon className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</Button>
</ButtonTooltip>
)}
</div>
)}
</div>
</div>
</TableCell>
{hasAnyTags ? (
<TableCell className="w-[250px] 2xl:w-auto">
<TagColumn link={link} />
</TableCell>
) : null}
<TableCell>
<CollapsibleTrigger
disabled={isDataroom || link._count.views === 0}
>
<div className="flex items-center space-x-1 [&[data-state=open]>svg.chevron]:rotate-180">
<BarChart className="h-4 w-4 text-muted-foreground" />
<p className="whitespace-nowrap text-sm text-muted-foreground">
{nFormatter(link._count.views)}
<span className="ml-1 hidden sm:inline-block">
views
</span>
</p>
{!isDataroom && link._count.views > 0 ? (
<ChevronDown className="chevron h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
) : null}
</div>
</CollapsibleTrigger>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{link.views[0] ? (
<TimestampTooltip
timestamp={link.views[0].viewedAt}
side="right"
rows={["local", "utc", "unix"]}
>
<time
className="select-none"
dateTime={new Date(
link.views[0].viewedAt,
).toISOString()}
>
{timeAgo(link.views[0].viewedAt)}
</time>
</TimestampTooltip>
) : (
"-"
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-x-1">
<Switch
className="data-[state=checked]:bg-primary/80 data-[state=checked]:hover:bg-primary data-[state=unchecked]:hover:bg-muted-foreground/80"
id={`${link.id}-active-switch`}
checked={!link.isArchived}
onCheckedChange={(checked) =>
handleArchiveLink(
link.id,
link.documentId ?? link.dataroomId ?? "",
checked,
)
}
disabled={loadingLinks.has(link.id)}
/>
<Label
className="font-normal"
htmlFor={`${link.id}-active-switch`}
>
{link.isArchived ? "No" : "Yes"}
</Label>
</div>
</TableCell>
<TableCell className="text-center sm:text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleEditLink(link)}
>
<Settings2Icon className="mr-2 h-4 w-4" />
Edit Link
</DropdownMenuItem>
{/* Dataroom-only: Edit File Permissions */}
{isDataroom &&
link.audienceType !== LinkAudienceType.GROUP && (
<DropdownMenuItem
onClick={() => handleEditPermissions(link)}
disabled={isLoading}
>
<FileSlidersIcon className="mr-2 h-4 w-4" />
Edit File Permissions
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handlePreviewLink(link)}
>
<EyeIcon className="mr-2 h-4 w-4" />
Preview Link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDuplicateLink(link)}
>
<CopyPlusIcon className="mr-2 h-4 w-4" />
Duplicate Link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedEmbedLink({
id: link.id,
name: link.name || `Link #${link.id.slice(-5)}`,
});
setEmbedModalOpen(true);
}}
>
<Code2Icon className="mr-2 h-4 w-4" />
Get Embed Code
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setLinkToDelete(link);
setShowDeleteLinkModal(true);
}}
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
>
<Trash2Icon className="mr-2 h-4 w-4" />
Delete Link
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<LinksVisitors
linkName={link.name || "No link name"}
linkId={link.id}
/>
</CollapsibleContent>
</>
</Collapsible>
))
) : (
<TableRow>
<TableCell colSpan={hasAnyTags ? 7 : 6}>
<div className="flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4">
<div className="hidden rounded-full sm:block">
<div
className={cn(
"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3",
)}
>
<LinkIcon className="size-6" />
</div>
</div>
<p>No links found for this {targetType.toLowerCase()}</p>
<AddLinkButton />
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
return (
<>
<div className="w-full">
{/* Collapsible wrapper for DOCUMENT type, plain div for DATAROOM */}
{targetType === "DOCUMENT" ? (
<Collapsible
open={isAllLinksOpen}
onOpenChange={handleAllLinksToggle}
className="w-full"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="mb-2 flex w-full cursor-pointer items-center gap-2 text-left md:mb-4"
>
<ChevronRightIcon
className={cn(
"h-5 w-5 text-muted-foreground transition-transform duration-200",
isAllLinksOpen && "rotate-90",
)}
/>
<h2 className="m-0">All links</h2>
{processedLinks && processedLinks.length > 0 && (
<Badge variant="outline" className="text-muted-foreground">
{processedLinks.length}
</Badge>
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
{linksTableContent}
</CollapsibleContent>
</Collapsible>
) : (
linksTableContent
)}
<LinkSheet
isOpen={isLinkSheetVisible}
setIsOpen={setIsLinkSheetVisible}
linkType={`${targetType}_LINK`}
currentLink={selectedLink.id ? selectedLink : undefined}
existingLinks={links}
/>
{isDataroom ? (
<PermissionsSheet
isOpen={showPermissionsSheet}
setIsOpen={(open: boolean) => {
setShowPermissionsSheet(open);
if (!open) {
setEditPermissionLink(null);
}
}}
dataroomId={targetId}
linkId={editPermissionLink?.id}
permissionGroupId={editPermissionLink?.permissionGroupId}
onSave={handlePermissionsSave}
/>
) : null}
{selectedEmbedLink && (
<EmbedCodeModal
isOpen={embedModalOpen}
setIsOpen={setEmbedModalOpen}
linkId={selectedEmbedLink.id}
linkName={selectedEmbedLink.name}
/>
)}
<DeleteLinkModal />
</div>
</>
);
}