Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/web/src/components/apps/document-viewer-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ChevronDown, Download, FileText, Loader2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { Button, Menu, Typography } from "@vellum/design-library";
import { exportDocumentPDF } from "@/lib/documents-api";
import { documentsByIdPdfGet } from "@/generated/daemon/sdk.gen";

export interface DocumentViewerContainerProps {
documentName: string;
Expand Down Expand Up @@ -182,8 +182,13 @@ export function DocumentViewerContainer({
}
setIsExportingPDF(true);
try {
const pdfBlob = await exportDocumentPDF(assistantId, surfaceId);
if (pdfBlob) {
const { response: pdfResponse } = await documentsByIdPdfGet({
path: { assistant_id: assistantId, id: surfaceId },
throwOnError: false,
parseAs: "stream",
});
if (pdfResponse && pdfResponse.ok) {
const pdfBlob = await pdfResponse.blob();
downloadBlob(pdfBlob, sanitizeFilename(documentName) + ".pdf");
}
} finally {
Expand Down
285 changes: 285 additions & 0 deletions apps/web/src/components/apps/library-app-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import {
ArrowUp,
Ellipsis,
Globe,
Pin,
PinOff,
Trash2,
} from "lucide-react";
import { type MouseEvent, useCallback, useState } from "react";

import type { AppSummary } from "@/types/app-types";
import { getCachedAppHtml } from "@/utils/app-html-cache";
import { shareApp } from "@/utils/share-app";
import { AppPreviewThumbnail } from "@/components/app-card";
import {
BottomSheet,
Button,
Menu,
PanelItem,
toast,
} from "@vellum/design-library";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { cn } from "@/utils/misc";
import { formatLibraryDate } from "@/components/apps/library-date";

interface LibraryAppCardProps {
app: AppSummary;
assistantId: string;
isPinned: boolean;
onOpen: (appId: string) => void;
onPin: (app: AppSummary) => void;
onDelete?: (app: AppSummary) => void;
onDeploy?: () => void;
isOpening?: boolean;
justImported?: boolean;
onAnimationEnd?: () => void;
}

export function LibraryAppCard({
app,
assistantId,
isPinned,
onOpen,
onPin,
onDelete,
onDeploy,
isOpening,
justImported,
onAnimationEnd,
}: LibraryAppCardProps) {
const [isSharing, setIsSharing] = useState(false);
const loadHtml = useCallback(
() => getCachedAppHtml(assistantId, app.id),
[assistantId, app.id],
);
const handleShare = useCallback(async () => {
if (isSharing) return;
setIsSharing(true);
try {
await shareApp(assistantId, app.id, app.name);
toast.success("App exported", { description: `${app.name}.vellum` });
} catch (err) {
toast.error("Failed to share app", {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setIsSharing(false);
}
}, [assistantId, app.id, app.name, isSharing]);

const [menuOpen, setMenuOpen] = useState(false);
const isMobile = useIsMobile();

return (
<div
className={cn(
"group relative flex flex-col gap-2",
justImported && "animate-[card-entrance_400ms_ease-out]",
)}
onAnimationEnd={justImported ? onAnimationEnd : undefined}
>
<button
type="button"
onClick={() => onOpen(app.id)}
className={cn(
"relative w-full cursor-pointer overflow-hidden rounded-xl",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]",
)}
>
<AppPreviewThumbnail
name={app.name}
icon={app.icon}
loadHtml={loadHtml}
isLoading={isOpening}
/>
</button>

<div
className={cn(
"absolute right-2 top-2 z-20 transition-opacity",
"max-md:opacity-100",
"md:group-hover:opacity-100 md:group-focus-within:opacity-100",
menuOpen ? "opacity-100" : "md:opacity-0",
)}
>
<LibraryAppCardActionsMenu
appName={app.name}
isPinned={isPinned}
open={menuOpen}
onOpenChange={setMenuOpen}
onPin={() => onPin(app)}
onDelete={onDelete ? () => onDelete(app) : undefined}
onShare={handleShare}
onDeploy={onDeploy}
isMobile={isMobile}
/>
</div>

<button
type="button"
onClick={() => onOpen(app.id)}
className="flex cursor-pointer flex-col gap-0.5 px-0.5 text-left outline-none"
>
<span className="truncate text-body-large-default text-[color:var(--content-emphasised)]">
{app.name}
</span>
<span className="text-body-small-default text-[color:var(--content-tertiary)]">
{formatLibraryDate(app.createdAt)}
</span>
</button>
</div>
);
}

// ---------------------------------------------------------------------------
// Actions menu (desktop dropdown + mobile bottom sheet)
// ---------------------------------------------------------------------------

export interface LibraryAppCardActionsMenuProps {
appName: string;
isPinned: boolean;
open: boolean;
onOpenChange: (next: boolean) => void;
onPin: () => void;
onDelete?: () => void;
onShare?: () => void;
onDeploy?: () => void;
isMobile: boolean;
}

export function LibraryAppCardActionsMenu({
appName,
isPinned,
open,
onOpenChange,
onPin,
onDelete,
onShare,
onDeploy,
isMobile,
}: LibraryAppCardActionsMenuProps) {
if (isMobile) {
return (
<BottomSheet.Root open={open} onOpenChange={onOpenChange}>
<BottomSheet.Trigger asChild>
<Button
variant="primary"
size="compact"
iconOnly={<Ellipsis />}
aria-label="App actions"
onClick={(e: MouseEvent) => e.stopPropagation()}
/>
</BottomSheet.Trigger>
<BottomSheet.Content>
<BottomSheet.Header className="sr-only">
<BottomSheet.Title>{appName}</BottomSheet.Title>
</BottomSheet.Header>
<BottomSheet.Body className="pt-0">
<PanelItem
icon={isPinned ? PinOff : Pin}
label={isPinned ? "Unpin" : "Pin"}
onSelect={() => {
onOpenChange(false);
onPin();
}}
/>
{onShare ? (
<PanelItem
icon={ArrowUp}
label={
<span className="flex flex-col gap-0.5 overflow-visible whitespace-normal">
<span>Share</span>
<span className="text-body-small-default text-[var(--content-tertiary)]">
Export as .vellum file
</span>
</span>
}
onSelect={() => {
onOpenChange(false);
onShare();
}}
/>
) : null}
{onDeploy ? (
<PanelItem
icon={Globe}
label={
<span className="flex flex-col gap-0.5 overflow-visible whitespace-normal">
<span>Deploy to Vercel</span>
<span className="text-body-small-default text-[var(--content-tertiary)]">
Publish as a static page
</span>
</span>
}
onSelect={() => {
onOpenChange(false);
onDeploy();
}}
/>
) : null}
{onDelete ? (
<PanelItem
icon={Trash2}
label="Delete"
onSelect={() => {
onOpenChange(false);
onDelete();
}}
/>
) : null}
</BottomSheet.Body>
</BottomSheet.Content>
</BottomSheet.Root>
);
}
return (
<Menu.Root open={open} onOpenChange={onOpenChange}>
<Menu.Trigger asChild>
<Button
variant="primary"
size="compact"
iconOnly={<Ellipsis />}
aria-label="App actions"
onClick={(e: MouseEvent) => e.stopPropagation()}
/>
</Menu.Trigger>
<Menu.Content align="end" sideOffset={4}>
<Menu.Item
leftIcon={isPinned ? <PinOff size={14} /> : <Pin size={14} />}
onSelect={() => onPin()}
className="whitespace-nowrap"
>
{isPinned ? "Unpin" : "Pin"}
</Menu.Item>
{onShare ? (
<Menu.Item
leftIcon={<ArrowUp size={14} />}
onSelect={() => onShare()}
className="whitespace-nowrap"
>
Share
</Menu.Item>
) : null}
{onDeploy ? (
<Menu.Item
leftIcon={<Globe size={14} />}
onSelect={() => onDeploy()}
className="whitespace-nowrap"
>
Deploy to Vercel
</Menu.Item>
) : null}
{onDelete ? (
<Menu.Item
leftIcon={<Trash2 size={14} className="text-red-600" />}
onSelect={() => onDelete()}
className="whitespace-nowrap text-red-600 data-[highlighted]:text-red-700"
>
Delete
</Menu.Item>
) : null}
</Menu.Content>
</Menu.Root>
);
}
8 changes: 8 additions & 0 deletions apps/web/src/components/apps/library-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function formatLibraryDate(epochMs: number): string {
const date = new Date(epochMs);
return date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined,
});
}
48 changes: 48 additions & 0 deletions apps/web/src/components/apps/library-document-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { FileText } from "lucide-react";

import type { DocumentSummary } from "@/types/document-types";
import { cn } from "@/utils/misc";
import { formatLibraryDate } from "@/components/apps/library-date";

function formatWordCount(count: number): string {
return count === 1 ? "1 word" : `${count} words`;
}

interface LibraryDocumentCardProps {
document: DocumentSummary;
onOpen: (documentSurfaceId: string) => void;
}

export function LibraryDocumentCard({ document, onOpen }: LibraryDocumentCardProps) {
return (
<div className="group relative flex flex-col gap-2">
<button
type="button"
onClick={() => onOpen(document.surfaceId)}
className={cn(
"relative flex w-full cursor-pointer flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-[var(--border-base)] bg-[var(--surface-base)]",
"aspect-[16/10]",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]",
)}
>
<FileText size={34} className="text-[var(--content-tertiary)]" />
<span className="text-body-small-default text-[var(--content-tertiary)]">
{formatWordCount(document.wordCount)}
</span>
</button>

<button
type="button"
onClick={() => onOpen(document.surfaceId)}
className="flex cursor-pointer flex-col gap-0.5 px-0.5 text-left outline-none"
>
<span className="truncate text-body-large-default text-[color:var(--content-emphasised)]">
{document.title}
</span>
<span className="text-body-small-default text-[color:var(--content-tertiary)]">
{formatLibraryDate(document.updatedAt)}
</span>
</button>
</div>
);
}
Loading