import { useNavigate, usePage, useStringifySearch } from "../../lib/navigation"; import type { ArtifactCatalogItem } from "@opengeni/sdk"; import { CopyIcon, ImageIcon, LayoutGridIcon, LinkIcon, ListIcon, LockIcon, LockKeyholeIcon, MessageSquareIcon, MoreHorizontalIcon, PanelsTopLeftIcon, PinIcon, PinOffIcon, UsersIcon, } from "lucide-react"; import { lazy, Suspense, useEffect, useId, useRef, useState, type MouseEvent, type ReactNode, } from "react"; import { toast } from "../../lib/toast"; import { Button } from "../../../ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../ui/dropdown-menu"; import { EmptyState, EmptyStateLink } from "../../../ui/empty-state"; import { ListRow, ListRowSkeleton, RowList, type RowListColumn } from "../../../ui/list-row"; import { MetaChip } from "../../../ui/meta-chip"; import { Notice } from "../../../ui/notice"; import { RelativeTime } from "../../../ui/relative-time"; import { SegmentedControl } from "../../../ui/segmented-control"; import { Skeleton } from "../../../ui/skeleton"; import { StatusBadge } from "../../../ui/status-badge"; import { Toolbar, ToolbarFilterChips, ToolbarFilterMenu, ToolbarGroup, ToolbarSearch, type ToolbarFilterGroup, type ToolbarFilterValue, } from "../../../ui/toolbar"; import { useAppContext } from "../../app-context"; import { isPersonalWorkspace } from "../../lib/managed-self-context"; import { hasWorkspacePermission } from "../../lib/permissions"; import { invalidateArtifactCatalog } from "../../lib/use-artifact-catalog"; import { useDuplicateSite } from "../../lib/use-duplicate-site"; import { FILE_AUDIENCE_COPY, useArtifactAudience } from "./site-audience"; import { apiErrorAdvice, isPermissionDenied, userErrorText } from "../../../ui/api-error"; import { ARTIFACT_ACCESS_REQUIRED } from "../../lib/artifact-access"; import { artifactKey, artifactKindLabel, artifactKinds, artifactPath, artifactRoute, isEditableArtifactKind, type ArtifactCatalogFilters, type ArtifactKind, } from "../../lib/artifact-catalog"; import { artifactExtension, readArtifactView, rememberArtifactView, type ArtifactView, } from "../../lib/artifact-library-view"; import { cn } from "../../lib/utils"; import type { ArtifactReturnSearch } from "../../lib/routes"; import { ArtifactKindTile, ArtifactTypeIcon } from "./artifact-page-chrome"; const InlineChatImage = lazy(() => import("./inline-chat-image").then((module) => ({ default: module.InlineChatImage, })), ); const IMAGE_GLYPH = ; export type ArtifactPinAction = (item: ArtifactCatalogItem, pinned: boolean) => Promise; function PinnedMarker() { return ( Pinned ); } /** A Site or file only its owner (the viewer) can open, marked like an Only me schedule. */ function OnlyMeMarker({ bare = false }: { bare?: boolean | undefined }) { return ( } {...(bare ? { variant: "text" as const } : {})}> Only me ); } function onlyMe(item: ArtifactCatalogItem): boolean { return ( (item.kind === "site" || item.kind === "file" || item.kind === "image") && item.visibility === "private" ); } /** * Share or limit a file from its row, with the same confirmation as its * page. Only its owner sees the choice, and never in a Personal workspace. */ function useLibraryFileAudience(workspaceId: string, item: ArtifactCatalogItem) { const context = useAppContext(); const workspace = context.workspaces.find((candidate) => candidate.id === workspaceId) ?? null; const owned = Boolean( (item.kind === "file" || item.kind === "image") && item.ownedByViewer && item.visibility && item.status === "active" && !isPersonalWorkspace(workspace, context.managedSelfContext) && hasWorkspacePermission(context.accessContext, workspaceId, "files:upload"), ); const audience = useArtifactAudience({ client: context.client, workspaceId, workspaceName: workspace?.name ?? "this workspace", title: item.title, visibility: item.visibility === "private" ? "private" : "workspace", ownedByCurrentUser: owned, copy: FILE_AUDIENCE_COPY, canLimitToMe: true, update: async (visibility) => { await context.client.updateFileVisibility(workspaceId, item.id, { visibility }); invalidateArtifactCatalog(context.client, workspaceId); }, }); const menuItem = !owned ? null : audience.share ? ( Share with workspace… ) : audience.limit ? ( Limit to me… ) : null; return { menuItem, dialog: owned ? audience.dialog : null }; } /** Mount retained-image loaders only near the viewport, not merely their img elements. */ export function ArtifactThumbnail({ children, placeholder = IMAGE_GLYPH, }: { children: ReactNode; placeholder?: ReactNode | undefined; }) { const host = useRef(null); const [visible, setVisible] = useState(false); useEffect(() => { const element = host.current; if (!element) return; if (typeof IntersectionObserver === "undefined") { setVisible(true); return; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setVisible(true); observer.disconnect(); } }, { rootMargin: "200px 0px" }, ); observer.observe(element); return () => observer.disconnect(); }, []); return (
{visible ? children : placeholder}
); } /** An image's own pixels in the 32px row tile, loaded only near the viewport. */ function ImageTile({ workspaceId, item }: { workspaceId: string; item: ArtifactCatalogItem }) { return ( // The image's own pixels; while it loads or when it can't, the image glyph // (the words stay for screen readers, never squeezed into 32px). }> ); } const TYPE_GROUP: ToolbarFilterGroup = { id: "type", label: "Type", options: artifactKinds .filter(([kind]) => kind !== "all") .map(([kind, label]) => ({ id: kind, label })), }; const STATUS_GROUP: ToolbarFilterGroup = { id: "status", label: "Status", options: [{ id: "archived", label: "Archived" }], }; const SORT_GROUP: ToolbarFilterGroup = { id: "sort", label: "Sort", options: [ { id: "newest", label: "Newest first" }, { id: "title", label: "Title" }, ], }; /** The toolbar's filter value for the catalog filters (defaults are no filter). */ function filterValueFor(filters: ArtifactCatalogFilters, withType: boolean): ToolbarFilterValue { return { ...(withType && filters.kind !== "all" ? { type: [filters.kind] } : {}), ...(filters.status === "archived" ? { status: ["archived"] } : {}), ...(filters.sort !== "updated" ? { sort: [filters.sort] } : {}), }; } /** Every group is one value: keep the option picked last. */ function lastPicked(previous: ToolbarFilterValue, next: ToolbarFilterValue, group: string) { const ids = next[group] ?? []; if (ids.length <= 1) return ids[0]; const before = previous[group] ?? []; return ids.filter((id) => !before.includes(id)).at(-1); } function isPlainClick(event: MouseEvent) { return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; } /** Opening an artifact: its own page in the router, or the caller's handler. */ function useArtifactOpen( workspaceId: string, item: ArtifactCatalogItem, sessionId?: string, onSelect?: (item: ArtifactCatalogItem) => void, onPin?: ArtifactPinAction, loading = false, browseSearch?: ArtifactReturnSearch, ) { const page = usePage(); const navigate = useNavigate(); const stringifySearch = useStringifySearch(); const audience = useLibraryFileAudience(workspaceId, item); const { accessContext } = useAppContext(); const copies = useDuplicateSite(workspaceId); const canDuplicate = item.kind === "site" && Boolean(item.versionId) && hasWorkspacePermission(accessContext, workspaceId, "artifacts:publish"); const [pinning, setPinning] = useState(false); const pinInFlight = useRef(false); const togglePin = async () => { if (!onPin || pinInFlight.current || loading) return; pinInFlight.current = true; setPinning(true); const pinned = !item.pinned; try { await onPin(item, pinned); toast(pinned ? "Artifact pinned" : "Artifact unpinned"); } catch (error) { toast.error(pinned ? "Couldn't pin artifact" : "Couldn't unpin artifact", { description: userErrorText(error), }); } finally { pinInFlight.current = false; setPinning(false); } }; const open = () => onSelect ? onSelect(item) : void navigate({ to: artifactRoute(item.kind), params: { workspaceId, artifactId: item.id }, search: browseSearch ?? (sessionId ? { fromSession: sessionId } : {}), }); const menu = ( <> Open {onPin ? ( void togglePin()}> {item.pinned ? : } {pinning ? "Saving…" : item.pinned ? "Unpin" : "Pin"} ) : null} {audience.menuItem} {canDuplicate ? ( void copies.duplicate(item, { open: !onSelect })} > {copies.busy ? "Duplicating…" : "Duplicate"} ) : null} {item.sourceSessionId && item.sourceSessionId !== sessionId ? ( void navigate({ to: "/workspaces/$workspaceId/sessions/$sessionId", params: { workspaceId, sessionId: item.sourceSessionId! }, }) } > Open source session ) : null} { void navigator.clipboard ?.writeText(new URL(artifactPath(workspaceId, item), page.origin()).href) .then(() => toast("Copied a link to this artifact")) .catch(() => toast.error("Couldn't copy the link")); }} > Copy link ); return { open, menu, dialog: audience.dialog, // Links keep a real href (new tab, copy); a plain click stays in the router. link: onSelect ? null : { href: browseSearch ? `${artifactPath(workspaceId, item)}${stringifySearch(browseSearch)}` : artifactPath(workspaceId, item, sessionId), onClick: (event: MouseEvent) => { if (!isPlainClick(event)) return; event.preventDefault(); open(); }, }, }; } /** "updated 3 days ago", the one fact after the type in every meta line. */ function updatedMeta(item: ArtifactCatalogItem) { return ( updated ); } /** The date column every artifact list ends with, before the ⋯ menu. */ const ARTIFACT_COLUMNS: RowListColumn[] = [ { id: "updated", label: "Updated", width: 112, align: "end", hideLabel: true, }, ]; export function ArtifactRow({ workspaceId, item, sessionId, onSelect, onPin, loading, browseSearch, }: { workspaceId: string; item: ArtifactCatalogItem; sessionId?: string | undefined; onSelect?: ((item: ArtifactCatalogItem) => void) | undefined; onPin?: ArtifactPinAction | undefined; loading?: boolean | undefined; browseSearch?: ArtifactReturnSearch | undefined; }) { const { open, menu, link, dialog } = useArtifactOpen( workspaceId, item, sessionId, onSelect, onPin, loading, browseSearch, ); const archived = item.status === "archived"; return ( <> ) : ( ) } title={item.title} titleAddon={ item.pinned || onlyMe(item) ? ( {item.pinned ? : null} {onlyMe(item) ? : null} ) : undefined } meta={[ artifactKindLabel[item.kind], item.kind === "file" && item.filename && item.filename !== item.title ? item.filename : null, ].filter((part): part is string => Boolean(part))} status={ archived ? ( Archived ) : undefined } cells={{ updated: }} {...(link ? { href: link.href, onOpen: link.onClick } : { onOpen: open })} menu={menu} menuLabel={`More actions for ${item.title}`} /> {dialog} ); } // --------------------------------------------------------------------------- // Gallery // --------------------------------------------------------------------------- /* * A Site's card shows its published HTML as a still: a srcdoc frame with an * empty sandbox (no scripts, forms, popups or same-origin) and a policy that * blocks every network request, so a card never runs Site code or phones * home. The first element of the document is the policy, so it always lands * in whatever the Site's own markup. * * The frame also requires the same policy from outside (the `csp` attribute, * CSP Embedded Enforcement), so browsers that support it enforce the policy * on the srcdoc document before any of its markup is parsed or speculatively * preloaded. The in-document policy keeps other browsers covered. */ const SITE_STILL_POLICY = "default-src 'none'; img-src data: blob:; media-src data: blob:; font-src data:; style-src 'unsafe-inline'"; // `csp` is a standard iframe attribute React passes through but doesn't type. const SITE_STILL_FRAME_POLICY = { csp: SITE_STILL_POLICY }; export function siteStillDocument(html: string) { return `${html}`; } // Display HTML per published version, shared across remounts (tabs, search). const siteHtmlCache = new Map>(); const SITE_HTML_CACHE_LIMIT = 48; function useSiteHtml(workspaceId: string, item: ArtifactCatalogItem) { const { client, accessKeyVersion } = useAppContext(); const versionId = item.versionId; const [state, setState] = useState<{ key: string; html: string | null; } | null>(null); const key = `${accessKeyVersion}:${workspaceId}:${item.id}:${versionId}`; useEffect(() => { if (!versionId) return; let active = true; let request = siteHtmlCache.get(key); if (!request) { request = client.getWorkspaceArtifactHtml(workspaceId, item.id, { versionId, }); siteHtmlCache.set(key, request); if (siteHtmlCache.size > SITE_HTML_CACHE_LIMIT) siteHtmlCache.delete(siteHtmlCache.keys().next().value!); request.catch(() => siteHtmlCache.delete(key)); } request.then( (html) => active && setState({ key, html }), () => active && setState({ key, html: null }), ); return () => { active = false; }; }, [client, item.id, key, versionId, workspaceId]); return state?.key === key ? state.html : undefined; } function SiteStill({ workspaceId, item, placeholder, }: { workspaceId: string; item: ArtifactCatalogItem; placeholder: ReactNode; }) { const html = useSiteHtml(workspaceId, item); if (!html) return placeholder; return ( // Drawn at three times the card's width and scaled down, like a screenshot; // a touch dimmer in dark so a white page doesn't glare.