mirror of
https://github.com/hanzo-apps/dataroom.git
synced 2026-10-30 14:47:36 +00:00
Papermark dual-licenses: AGPL-3.0 for the tree, a separate commercial
licence for ee/. We hold the first and not the second, so none of the
second may live here.
ee/ was already absent from main, but it was never deleted. 98f0da7fa
("ci: amd64-only build") renamed 150 files out of ee/ byte-identical
into features/, lib/billing/legacy/ (ee/stripe), lib/billing/limits/
(ee/limits), and flattened app/(ee)/ into app/ — putting commercially
licensed code under our AGPL root LICENSE. A rename is not a deletion.
Every path that commit moved is now gone, edited-since or not.
Deleted, with their routes and UI entry points: features/ai and
app/api/ai, features/workflows and app/api/{workflows,workflow-entry},
features/conversations and the Q&A/FAQ surface, features/security/sso
with app/api/auth/saml + app/api/scim + lib/jackson.ts (we use Hanzo
IAM OIDC natively), features/dataroom-invitations, features/templates,
features/access-notifications, features/conversions, features/
permissions. Five of these were gated by lib/featureFlags, which reads
@vercel/edge-config and returns false for every flag when EDGE_CONFIG
is unset — every non-Vercel deployment we run. Those flags are gone too.
Deleted the commercial gating outright rather than reimplementing it:
lib/billing/legacy (ee/stripe) and lib/billing/limits (ee/limits), the
upgrade modals and banners, UpgradeButton, FeaturePreview, PlanBadge,
the trial and blocking modals, /settings/upgrade, /settings/billing and
the billing checkout/seat/pause/cancel API. usePlan() no longer returns
capability booleans — the plan tag describes provisioning, it is not a
permission — and useLimits() is gone with every quota check it fed.
Subscription pause went the same way. Also dropped the npm dependencies
that existed only for the removed features (@boxyhq/saml-jackson, the
@ai-sdk packages, openai, streamdown, use-stick-to-bottom,
@trigger.dev/python) and the Jackson-specific next.config webpack shims.
Rewritten as original Hanzo code where a capability was load-bearing:
- lib/utils/get-file-size-limits.ts — one UPLOAD_LIMITS set plus
getFileSizeLimit(contentType), env-overridable. Operational limits,
not commercial ones, so the tus and browser-upload paths still refuse
oversized files without consulting a plan matrix.
- components/links/link-sheet/permissions-sheet.tsx — a flat, depth
annotated item list with view/download switches over the same
/permission-groups API. Two states, not three. ItemPermission now has
a single definition in lib/types.ts.
- app/api/links/[id]/upload/route.ts — visitor uploads, built around one
authorize() helper shared by GET and POST, and a notification debounce
keyed per (link, viewer) instead of listing and cancelling runs.
There is now exactly one link sheet: the ee DataroomLinkSheet duplicated
the AGPL LinkSheet, which already handled DATAROOM_LINK. Replaced
upstream's Omit<LinkType, "WORKFLOW_LINK"> — Omit on a string union
yields an object type and never narrowed anything — with a real union.
Verified: the S3/tus upload+download harness passes 13/13 against MinIO
after the deletions, and tsc goes from 257 pre-existing errors on
origin/main to 192 with zero files worse than baseline.
Reasoning recorded in LLM.md. LICENSE untouched.
Co-authored-by: Hanzo Dev <[email protected]>
863 lines
30 KiB
TypeScript
863 lines
30 KiB
TypeScript
import Link from "next/link";
|
|
import { useRouter } from "next/router";
|
|
|
|
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
|
|
|
|
import { useTeam } from "@/context/team-context";
|
|
import { LinkAudienceType, LinkPreset } from "@prisma/client";
|
|
import { RefreshCwIcon } from "lucide-react";
|
|
import { useHotkeys } from "react-hotkeys-hook";
|
|
import { toast } from "sonner";
|
|
import { mutate } from "swr";
|
|
import useSWR from "swr";
|
|
|
|
import { useAnalytics } from "@/lib/analytics";
|
|
import useDataroomGroups from "@/lib/swr/use-dataroom-groups";
|
|
import { useDomains } from "@/lib/swr/use-domains";
|
|
import { ItemPermission, LinkWithViews, WatermarkConfig, ShareableLinkType } from "@/lib/types";
|
|
import { convertDataUrlToFile, fetcher, uploadImage } from "@/lib/utils";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { ButtonTooltip } from "@/components/ui/tooltip";
|
|
|
|
import { CustomFieldData } from "./custom-fields-panel";
|
|
import DomainSection from "./domain-section";
|
|
import { LinkOptions } from "./link-options";
|
|
import TagSection from "./tags/tag-section";
|
|
|
|
export const DEFAULT_LINK_PROPS = (
|
|
linkType: ShareableLinkType,
|
|
groupId: string | null = null,
|
|
) => ({
|
|
id: null,
|
|
name: null,
|
|
domain: null,
|
|
slug: null,
|
|
expiresAt: null,
|
|
password: null,
|
|
emailProtected: true,
|
|
emailAuthenticated: false,
|
|
allowDownload: false,
|
|
allowList: [],
|
|
denyList: [],
|
|
visitorGroupIds: [],
|
|
enableNotification: true,
|
|
enableFeedback: false,
|
|
enableScreenshotProtection: false,
|
|
enableCustomMetatag: false,
|
|
metaTitle: null,
|
|
metaDescription: null,
|
|
metaImage: null,
|
|
metaFavicon: null,
|
|
welcomeMessage: null,
|
|
enableQuestion: false,
|
|
questionText: null,
|
|
questionType: null,
|
|
enableAgreement: false,
|
|
agreementId: null,
|
|
showBanner: linkType === "DOCUMENT_LINK",
|
|
enableWatermark: false,
|
|
watermarkConfig: null,
|
|
audienceType: groupId ? LinkAudienceType.GROUP : LinkAudienceType.GENERAL,
|
|
groupId: groupId,
|
|
customFields: [],
|
|
tags: [],
|
|
enableConversation: false,
|
|
enableAIAgents: false,
|
|
enableUpload: false,
|
|
isFileRequestOnly: false,
|
|
uploadFolderId: null,
|
|
uploadFolderName: "Home",
|
|
enableIndexFile: false,
|
|
permissions: {},
|
|
permissionGroupId: null,
|
|
});
|
|
|
|
export type DEFAULT_LINK_TYPE = {
|
|
id: string | null;
|
|
name: string | null;
|
|
domain: string | null;
|
|
slug: string | null;
|
|
expiresAt: Date | null;
|
|
password: string | null;
|
|
emailProtected: boolean;
|
|
emailAuthenticated: boolean;
|
|
allowDownload: boolean;
|
|
allowList: string[];
|
|
denyList: string[];
|
|
visitorGroupIds: string[];
|
|
enableNotification: boolean;
|
|
enableFeedback: boolean;
|
|
enableScreenshotProtection: boolean;
|
|
enableCustomMetatag: boolean; // metatags
|
|
metaTitle: string | null; // metatags
|
|
metaDescription: string | null; // metatags
|
|
metaImage: string | null; // metatags
|
|
metaFavicon: string | null; // metaFavicon
|
|
welcomeMessage: string | null; // custom welcome message
|
|
enableQuestion?: boolean; // feedback question
|
|
questionText: string | null;
|
|
questionType: string | null;
|
|
enableAgreement: boolean; // agreement
|
|
agreementId: string | null;
|
|
showBanner: boolean;
|
|
enableWatermark: boolean;
|
|
watermarkConfig: WatermarkConfig | null;
|
|
audienceType: LinkAudienceType;
|
|
groupId: string | null;
|
|
customFields: CustomFieldData[];
|
|
tags: string[];
|
|
enableConversation: boolean;
|
|
enableAIAgents: boolean;
|
|
enableUpload: boolean;
|
|
isFileRequestOnly: boolean;
|
|
uploadFolderId: string | null;
|
|
uploadFolderName: string;
|
|
enableIndexFile: boolean;
|
|
permissions?: ItemPermission | null; // For dataroom links file permissions
|
|
permissionGroupId?: string | null;
|
|
};
|
|
|
|
export default function LinkSheet({
|
|
isOpen,
|
|
setIsOpen,
|
|
linkType,
|
|
currentLink,
|
|
existingLinks,
|
|
}: {
|
|
isOpen: boolean;
|
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
|
linkType: ShareableLinkType;
|
|
currentLink?: DEFAULT_LINK_TYPE;
|
|
existingLinks?: LinkWithViews[];
|
|
}) {
|
|
const router = useRouter();
|
|
const { id: targetId, groupId } = router.query as {
|
|
id: string;
|
|
groupId?: string;
|
|
};
|
|
|
|
const { domains } = useDomains({ enabled: isOpen });
|
|
|
|
const {
|
|
viewerGroups,
|
|
loading: isLoadingGroups,
|
|
mutate: mutateGroups,
|
|
} = useDataroomGroups();
|
|
const teamInfo = useTeam();
|
|
const analytics = useAnalytics();
|
|
const [data, setData] = useState<DEFAULT_LINK_TYPE>(
|
|
DEFAULT_LINK_PROPS(linkType, groupId),
|
|
);
|
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
|
const [currentPreset, setCurrentPreset] = useState<LinkPreset | null>(null);
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
|
|
// Presets
|
|
const { data: presets } = useSWR<LinkPreset[]>(
|
|
teamInfo?.currentTeam?.id
|
|
? `/api/teams/${teamInfo.currentTeam.id}/presets`
|
|
: null,
|
|
fetcher,
|
|
{
|
|
dedupingInterval: 10000,
|
|
},
|
|
);
|
|
|
|
useEffect(() => {
|
|
setData(currentLink || DEFAULT_LINK_PROPS(linkType, groupId));
|
|
}, [currentLink]);
|
|
|
|
// Handle Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) to submit the form
|
|
useHotkeys(
|
|
"mod+enter",
|
|
(e) => {
|
|
e.preventDefault();
|
|
if (!isSaving && formRef.current) {
|
|
formRef.current.requestSubmit();
|
|
}
|
|
},
|
|
{ enabled: isOpen, enableOnFormTags: true },
|
|
[isSaving],
|
|
);
|
|
|
|
const handlePreviewLink = async (link: LinkWithViews) => {
|
|
setIsLoading(true);
|
|
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");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
const { previewToken } = await response.json();
|
|
const previewLink = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}?previewToken=${previewToken}`;
|
|
setIsLoading(false);
|
|
const linkElement = document.createElement("a");
|
|
linkElement.href = previewLink;
|
|
linkElement.target = "_blank";
|
|
document.body.appendChild(linkElement);
|
|
linkElement.click();
|
|
|
|
setTimeout(() => {
|
|
document.body.removeChild(linkElement);
|
|
}, 100);
|
|
};
|
|
|
|
const applyPreset = (presetId: string) => {
|
|
const preset = presets?.find((p) => p.id === presetId);
|
|
if (!preset) return;
|
|
|
|
setData((prev) => {
|
|
const isGroupLink = prev.audienceType === LinkAudienceType.GROUP;
|
|
|
|
return {
|
|
...prev,
|
|
name: prev.name, // Keep existing name
|
|
domain: prev.domain, // Keep existing domain
|
|
slug: prev.slug, // Keep existing slug
|
|
emailProtected: preset.emailProtected ?? prev.emailProtected,
|
|
emailAuthenticated:
|
|
preset.emailAuthenticated ?? prev.emailAuthenticated,
|
|
// For group links, ignore allow/deny lists from presets as access is controlled by group membership
|
|
allowList: isGroupLink
|
|
? prev.allowList
|
|
: preset.allowList || prev.allowList,
|
|
denyList: isGroupLink
|
|
? prev.denyList
|
|
: preset.denyList || prev.denyList,
|
|
password: preset.password || prev.password,
|
|
enableCustomMetatag:
|
|
preset.enableCustomMetaTag ?? prev.enableCustomMetatag,
|
|
metaTitle: preset.metaTitle || prev.metaTitle,
|
|
metaDescription: preset.metaDescription || prev.metaDescription,
|
|
metaImage: preset.metaImage || prev.metaImage,
|
|
metaFavicon: preset.metaFavicon || prev.metaFavicon,
|
|
welcomeMessage: preset.welcomeMessage || prev.welcomeMessage,
|
|
allowDownload: preset.allowDownload || prev.allowDownload,
|
|
enableAgreement: preset.enableAgreement || prev.enableAgreement,
|
|
agreementId: preset.agreementId || prev.agreementId,
|
|
enableScreenshotProtection:
|
|
preset.enableScreenshotProtection || prev.enableScreenshotProtection,
|
|
enableNotification: !!preset.enableNotification,
|
|
showBanner: preset.showBanner ?? prev.showBanner,
|
|
};
|
|
});
|
|
|
|
setCurrentPreset(preset);
|
|
};
|
|
|
|
const handleSubmit = async (event: any, shouldPreview: boolean = false) => {
|
|
event.preventDefault();
|
|
|
|
setIsSaving(true);
|
|
|
|
// Upload the image if it's a data URL
|
|
let blobUrl: string | null =
|
|
data.metaImage && data.metaImage.startsWith("data:")
|
|
? null
|
|
: data.metaImage;
|
|
if (data.metaImage && data.metaImage.startsWith("data:")) {
|
|
// Convert the data URL to a blob
|
|
const blob = convertDataUrlToFile({ dataUrl: data.metaImage });
|
|
// Upload the blob to vercel storage
|
|
blobUrl = await uploadImage(blob);
|
|
setData({ ...data, metaImage: blobUrl });
|
|
}
|
|
|
|
// Upload meta favicon if it's a data URL
|
|
let blobUrlFavicon: string | null =
|
|
data.metaFavicon && data.metaFavicon.startsWith("data:")
|
|
? null
|
|
: data.metaFavicon;
|
|
if (data.metaFavicon && data.metaFavicon.startsWith("data:")) {
|
|
const blobFavicon = convertDataUrlToFile({ dataUrl: data.metaFavicon });
|
|
blobUrlFavicon = await uploadImage(blobFavicon);
|
|
setData({
|
|
...data,
|
|
metaFavicon: blobUrlFavicon,
|
|
});
|
|
}
|
|
|
|
let endpoint = "/api/links";
|
|
let method = "POST";
|
|
|
|
if (currentLink) {
|
|
// Assuming that your endpoint to update links appends the link's ID to the URL
|
|
endpoint = `/api/links/${currentLink.id}`;
|
|
method = "PUT";
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
...data,
|
|
metaImage: blobUrl,
|
|
metaFavicon: blobUrlFavicon,
|
|
targetId: targetId,
|
|
linkType: linkType,
|
|
teamId: teamInfo?.currentTeam?.id,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// handle error with toast message
|
|
const { error } = await response.json();
|
|
toast.error(error);
|
|
setIsSaving(false);
|
|
return;
|
|
}
|
|
|
|
const returnedLink = await response.json();
|
|
const endpointTargetType = `${linkType.replace("_LINK", "").toLowerCase()}s`; // "documents" or "datarooms"
|
|
|
|
if (currentLink) {
|
|
setIsOpen(false);
|
|
// Update the link in the list of links
|
|
mutate(
|
|
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
|
targetId,
|
|
)}/links`,
|
|
(existingLinks || []).map((link) =>
|
|
link.id === currentLink.id ? returnedLink : link,
|
|
),
|
|
false,
|
|
);
|
|
|
|
// Handle group changes
|
|
if (!!groupId && returnedLink.audienceType === LinkAudienceType.GROUP) {
|
|
// If we're viewing a group page
|
|
if (currentLink.groupId !== returnedLink.groupId) {
|
|
// If the link's group has changed
|
|
if (currentLink.groupId === groupId) {
|
|
// If the link was in the current group but is now in a different group
|
|
// Remove it from the current group's view
|
|
const groupLinks =
|
|
existingLinks?.filter(
|
|
(link) =>
|
|
link.id !== currentLink.id && link.groupId === groupId,
|
|
) || [];
|
|
|
|
mutate(
|
|
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
|
targetId,
|
|
)}/groups/${groupId}/links`,
|
|
groupLinks,
|
|
false,
|
|
);
|
|
} else if (returnedLink.groupId === groupId) {
|
|
// If the link was in a different group but is now in the current group
|
|
// Add it to the current group's view
|
|
const groupLinks =
|
|
existingLinks?.filter((link) => link.groupId === groupId) || [];
|
|
|
|
mutate(
|
|
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
|
targetId,
|
|
)}/groups/${groupId}/links`,
|
|
[returnedLink, ...groupLinks],
|
|
false,
|
|
);
|
|
}
|
|
} else if (returnedLink.groupId === groupId) {
|
|
// If the link's group hasn't changed and it's in the current group
|
|
// Update it in the current group's view
|
|
const groupLinks =
|
|
existingLinks?.filter((link) => link.groupId === groupId) || [];
|
|
|
|
mutate(
|
|
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
|
targetId,
|
|
)}/groups/${groupId}/links`,
|
|
groupLinks.map((link) =>
|
|
link.id === currentLink.id ? returnedLink : link,
|
|
),
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Track what changed for analytics
|
|
const changedFields: Record<string, { from: unknown; to: unknown }> = {};
|
|
const trackableFields: (keyof DEFAULT_LINK_TYPE)[] = [
|
|
"name",
|
|
"domain",
|
|
"slug",
|
|
"expiresAt",
|
|
"emailProtected",
|
|
"emailAuthenticated",
|
|
"allowDownload",
|
|
"allowList",
|
|
"denyList",
|
|
"enableNotification",
|
|
"enableFeedback",
|
|
"enableScreenshotProtection",
|
|
"enableCustomMetatag",
|
|
"metaTitle",
|
|
"metaDescription",
|
|
"welcomeMessage",
|
|
"enableQuestion",
|
|
"questionText",
|
|
"questionType",
|
|
"enableAgreement",
|
|
"agreementId",
|
|
"showBanner",
|
|
"enableWatermark",
|
|
"audienceType",
|
|
"groupId",
|
|
"enableConversation",
|
|
"enableAIAgents",
|
|
"enableUpload",
|
|
"isFileRequestOnly",
|
|
"uploadFolderId",
|
|
"enableIndexFile",
|
|
"permissionGroupId",
|
|
"tags",
|
|
];
|
|
|
|
for (const field of trackableFields) {
|
|
if (
|
|
JSON.stringify(currentLink[field]) !== JSON.stringify(data[field])
|
|
) {
|
|
changedFields[field] = {
|
|
from: currentLink[field],
|
|
to: data[field],
|
|
};
|
|
}
|
|
}
|
|
|
|
// Password: log set/unset/changed status only, not actual values
|
|
if (!!currentLink.password !== !!data.password) {
|
|
changedFields.password = {
|
|
from: currentLink.password ? "set" : "unset",
|
|
to: data.password ? "set" : "unset",
|
|
};
|
|
} else if (
|
|
currentLink.password &&
|
|
data.password &&
|
|
currentLink.password !== data.password
|
|
) {
|
|
changedFields.password = { from: "set", to: "changed" };
|
|
}
|
|
|
|
// Image fields: log set/unset status only, not URLs
|
|
if (currentLink.metaImage !== data.metaImage) {
|
|
changedFields.metaImage = {
|
|
from: currentLink.metaImage ? "set" : "unset",
|
|
to: data.metaImage ? "set" : "unset",
|
|
};
|
|
}
|
|
if (currentLink.metaFavicon !== data.metaFavicon) {
|
|
changedFields.metaFavicon = {
|
|
from: currentLink.metaFavicon ? "set" : "unset",
|
|
to: data.metaFavicon ? "set" : "unset",
|
|
};
|
|
}
|
|
|
|
// Watermark config: log configured/unset status
|
|
if (
|
|
JSON.stringify(currentLink.watermarkConfig) !==
|
|
JSON.stringify(data.watermarkConfig)
|
|
) {
|
|
changedFields.watermarkConfig = {
|
|
from: currentLink.watermarkConfig ? "configured" : "unset",
|
|
to: data.watermarkConfig ? "configured" : "unset",
|
|
};
|
|
}
|
|
|
|
// Custom fields: log count change
|
|
if (
|
|
JSON.stringify(currentLink.customFields) !==
|
|
JSON.stringify(data.customFields)
|
|
) {
|
|
changedFields.customFields = {
|
|
from: currentLink.customFields?.length ?? 0,
|
|
to: data.customFields?.length ?? 0,
|
|
};
|
|
}
|
|
|
|
analytics.capture("Link Updated", {
|
|
linkId: currentLink.id,
|
|
targetId,
|
|
linkType,
|
|
teamId: teamInfo?.currentTeam?.id,
|
|
customDomain: returnedLink.domainSlug ?? null,
|
|
changes: changedFields,
|
|
changedProperties: Object.keys(changedFields),
|
|
});
|
|
|
|
toast.success("Link updated successfully");
|
|
} else {
|
|
setIsOpen(false);
|
|
|
|
// Add the new link to the list of links
|
|
mutate(
|
|
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
|
targetId,
|
|
)}/links`,
|
|
[returnedLink, ...(existingLinks || [])],
|
|
false,
|
|
);
|
|
|
|
// Also update the group-specific links cache if this is a group link
|
|
if (
|
|
!!groupId &&
|
|
returnedLink.audienceType === LinkAudienceType.GROUP &&
|
|
returnedLink.groupId === groupId
|
|
) {
|
|
const groupLinks =
|
|
existingLinks?.filter((link) => link.groupId === groupId) || [];
|
|
mutate(
|
|
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
|
|
targetId,
|
|
)}/groups/${groupId}/links`,
|
|
[returnedLink, ...groupLinks],
|
|
false,
|
|
);
|
|
}
|
|
|
|
analytics.capture("Link Added", {
|
|
linkId: returnedLink.id,
|
|
targetId,
|
|
linkType,
|
|
customDomain: returnedLink.domainSlug,
|
|
});
|
|
|
|
toast.success("Link created successfully");
|
|
}
|
|
|
|
setData(DEFAULT_LINK_PROPS(linkType, groupId));
|
|
setIsSaving(false);
|
|
|
|
if (shouldPreview) {
|
|
await handlePreviewLink(returnedLink);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Sheet open={isOpen} onOpenChange={(open: boolean) => setIsOpen(open)}>
|
|
<SheetContent className="flex w-[90%] flex-col justify-between border-l border-gray-200 bg-background px-4 text-foreground dark:border-gray-800 dark:bg-gray-900 sm:w-[800px] sm:max-w-4xl md:px-5">
|
|
<SheetHeader className="text-start">
|
|
<SheetTitle>
|
|
{currentLink
|
|
? `Edit ${currentLink.audienceType === LinkAudienceType.GROUP ? "group" : ""} link`
|
|
: "Create a new link"}
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<form
|
|
ref={formRef}
|
|
className="flex grow flex-col"
|
|
onSubmit={(e) => handleSubmit(e, false)}
|
|
>
|
|
<ScrollArea className="flex-grow">
|
|
<div className="h-0 flex-1">
|
|
<div className="flex flex-1 flex-col justify-between pb-6">
|
|
<div className="divide-y divide-gray-200">
|
|
<Tabs
|
|
value={data.audienceType}
|
|
onValueChange={(value) =>
|
|
setData({
|
|
...data,
|
|
audienceType: value as LinkAudienceType,
|
|
})
|
|
}
|
|
>
|
|
{linkType === "DATAROOM_LINK" && !!!currentLink ? (
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value={LinkAudienceType.GENERAL}>
|
|
General
|
|
</TabsTrigger>
|
|
<TabsTrigger value={LinkAudienceType.GROUP}>
|
|
Group
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
) : null}
|
|
|
|
<TabsContent value={LinkAudienceType.GENERAL}>
|
|
{/* GENERAL LINK */}
|
|
<div className="space-y-6 pt-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="link-name">Link Name</Label>
|
|
<Input
|
|
type="text"
|
|
name="link-name"
|
|
id="link-name"
|
|
placeholder="Recipient's Organization"
|
|
value={data.name || ""}
|
|
className="focus:ring-inset"
|
|
onChange={(e) =>
|
|
setData({ ...data, name: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<DomainSection
|
|
{...{ data, setData, domains }}
|
|
linkType={linkType}
|
|
editLink={!!currentLink}
|
|
/>
|
|
</div>
|
|
|
|
{/* Preset Selector - only show when creating a new link */}
|
|
{!currentLink && presets && presets.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="preset">Link Preset</Label>
|
|
<Link
|
|
href="/settings/presets"
|
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
Manage
|
|
</Link>
|
|
</div>
|
|
<Select onValueChange={applyPreset}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select a preset" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{presets.map((preset) => (
|
|
<SelectItem key={preset.id} value={preset.id}>
|
|
{preset.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Apply a preset to quickly configure link settings
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative flex items-center">
|
|
<Separator className="absolute bg-muted-foreground" />
|
|
<div className="relative mx-auto">
|
|
<span className="bg-background px-2 text-sm text-muted-foreground dark:bg-gray-900">
|
|
Link Options
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<LinkOptions
|
|
data={data}
|
|
setData={setData}
|
|
targetId={targetId}
|
|
linkType={linkType}
|
|
editLink={!!currentLink}
|
|
currentPreset={currentPreset}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value={LinkAudienceType.GROUP}>
|
|
{/* GROUP LINK */}
|
|
<div className="space-y-6 pt-2">
|
|
<div className="space-y-2">
|
|
<div className="flex w-full items-center justify-between">
|
|
<Label htmlFor="group-id">Group </Label>
|
|
<ButtonTooltip content="Refresh groups">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-6"
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
await mutateGroups();
|
|
}}
|
|
>
|
|
<RefreshCwIcon className="h-4 w-4" />
|
|
</Button>
|
|
</ButtonTooltip>
|
|
</div>
|
|
<Select
|
|
onValueChange={(value) => {
|
|
if (value === "add_group") {
|
|
// Open the group sheet
|
|
console.log("add_group redirect");
|
|
return;
|
|
}
|
|
|
|
setData({ ...data, groupId: value });
|
|
}}
|
|
defaultValue={data.groupId ?? undefined}
|
|
>
|
|
<SelectTrigger className="focus:ring-offset-3 flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-gray-400 sm:text-sm sm:leading-6">
|
|
<SelectValue placeholder="Select an group" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{isLoadingGroups ? (
|
|
<SelectItem value="loading" disabled>
|
|
Loading groups...
|
|
</SelectItem>
|
|
) : viewerGroups && viewerGroups.length > 0 ? (
|
|
viewerGroups.map(({ id, name, _count }) => (
|
|
<SelectItem key={id} value={id}>
|
|
{name}{" "}
|
|
<span className="text-muted-foreground">
|
|
({_count.members} members)
|
|
</span>
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value="no-groups" disabled>
|
|
No groups available
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="link-name">Link Name</Label>
|
|
|
|
<Input
|
|
type="text"
|
|
name="link-name"
|
|
id="link-name"
|
|
placeholder={
|
|
viewerGroups?.find(
|
|
(group) => group.id === data.groupId,
|
|
)?.name
|
|
? `${
|
|
viewerGroups?.find(
|
|
(group) => group.id === data.groupId,
|
|
)?.name
|
|
} Link`
|
|
: "Group Link"
|
|
}
|
|
value={data.name || ""}
|
|
className="focus:ring-inset"
|
|
onChange={(e) =>
|
|
setData({ ...data, name: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<DomainSection
|
|
{...{ data, setData, domains }}
|
|
linkType={linkType}
|
|
editLink={!!currentLink}
|
|
/>
|
|
</div>
|
|
|
|
{/* Preset Selector for Group links - only show when creating a new link */}
|
|
{!currentLink && presets && presets.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="preset">Link Preset</Label>
|
|
<Link
|
|
href="/settings/presets"
|
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
Manage
|
|
</Link>
|
|
</div>
|
|
<Select onValueChange={applyPreset}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Select a preset" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{presets.map((preset) => (
|
|
<SelectItem key={preset.id} value={preset.id}>
|
|
{preset.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Apply a preset to quickly configure link settings
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative flex items-center">
|
|
<Separator className="absolute bg-muted-foreground" />
|
|
<div className="relative mx-auto">
|
|
<span className="bg-background px-2 text-sm text-muted-foreground dark:bg-gray-900">
|
|
Link Options
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<LinkOptions
|
|
data={data}
|
|
setData={setData}
|
|
targetId={targetId}
|
|
linkType={linkType}
|
|
editLink={!!currentLink}
|
|
currentPreset={currentPreset}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<Separator className="mb-6 mt-2" />
|
|
|
|
<div className="space-y-2">
|
|
<TagSection
|
|
{...{ data, setData }}
|
|
teamId={teamInfo?.currentTeam?.id as string}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<SheetFooter>
|
|
<div className="flex flex-row-reverse items-center gap-2 pt-2">
|
|
<Button
|
|
type="submit"
|
|
loading={isSaving}
|
|
onClick={(e) => handleSubmit(e, false)}
|
|
>
|
|
{currentLink ? "Update Link" : "Save Link"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
loading={isLoading}
|
|
onClick={(e) => handleSubmit(e, true)}
|
|
>
|
|
{currentLink ? "Update & Preview" : "Save & Preview"}
|
|
</Button>
|
|
</div>
|
|
</SheetFooter>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|