Generate Transcript Picker
Compose a single-file transcript generation picker from Files and supporting components.
This picker mirrors the original create-transcription flow: single audio/video selection, search, matter filtering, view toggle, upload, provider selection in the footer, and an async confirm path that locks the dialog while work starts.
import { useMemo, useState } from "react";
import { FolderOpen } from "@tilt-legal/cubitt-icons/ui/outline";
import {
Files,
type FilesItem,
type FilesUploadEntry,
type FilesViewMode,
} from "@tilt-legal/cubitt-components/files";
import { Button } from "@tilt-legal/cubitt-components/button";
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@tilt-legal/cubitt-components/dialog";
import {
EmptyState,
EmptyStateDescription,
EmptyStateHeading,
EmptyStateMedia,
EmptyStateTitle,
} from "@tilt-legal/cubitt-components/empty-state";
import { Input } from "@tilt-legal/cubitt-components/input";
import { SearchExpand } from "@tilt-legal/cubitt-components/search-expand";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@tilt-legal/cubitt-components/select";
import { Separator } from "@tilt-legal/cubitt-components/separator";
import { toast } from "@tilt-legal/cubitt-components/toast";
import { Toolbar } from "@tilt-legal/cubitt-components/toolbar";
import {
formatPlural,
getFileLabel,
} from "@tilt-legal/cubitt-components/utilities/formatters";
import { TranscriptMatterFilter } from "@/components/transcript-matter-filter";
import { TranscriptViewToggle } from "@/components/transcript-view-toggle";
import {
providerItems,
transcriptFiles,
transcriptProviders,
type TranscriptFileSource,
type TranscriptMatter,
} from "@/lib/transcript-picker-data";
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function filterFiles({
items,
query,
selectedMatters,
}: {
items: FilesItem<TranscriptFileSource>[];
query: string;
selectedMatters: TranscriptMatter[];
}) {
const selectedMatterIds = new Set(selectedMatters.map((matter) => matter.id));
const normalizedQuery = query.trim().toLowerCase();
return items.filter((item) => {
const matchesMatter =
selectedMatterIds.size === 0 ||
item.source?.matters.some((matter) => selectedMatterIds.has(matter.id));
const matchesQuery =
!normalizedQuery ||
[item.name, item.description, item.sizeLabel, item.source?.owner]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(normalizedQuery));
return matchesMatter && matchesQuery;
});
}
function getFileType(item: FilesItem<TranscriptFileSource>) {
return item.kind === "file"
? getFileLabel(item.name, item.mediaType)
: "Folder";
}
function TranscriptListHeader() {
return (
<div
aria-hidden="true"
className="grid h-10 grid-cols-[2rem_minmax(12rem,1fr)_8rem_7rem_8rem] items-center rounded-lg border border-border-3 bg-surface-1 font-medium text-fg-2 text-xs"
>
<span />
<span className="px-1">Name</span>
<span className="px-3">Type</span>
<span className="px-3">Size</span>
<span className="px-3">Updated</span>
</div>
);
}
function renderListFiles(items: FilesItem<TranscriptFileSource>[]) {
return items.map((item) => (
<Files.Item
className="grid-cols-[2rem_minmax(12rem,1fr)_8rem_7rem_8rem]"
item={item}
key={item.id}
>
<div className="flex justify-center">
<Files.ItemIcon />
</div>
<div className="flex min-w-0 items-center gap-1.5 py-2 ps-1 pe-3">
<Files.ItemName />
</div>
<span className="truncate px-3 py-2 text-fg-2">{getFileType(item)}</span>
<span className="truncate px-3 py-2 text-fg-2">{item.sizeLabel}</span>
<span className="truncate px-3 py-2 text-fg-2">
{item.source?.updatedLabel}
</span>
</Files.Item>
));
}
function TranscriptUploadControl({ disabled }: { disabled?: boolean }) {
return (
<div className="flex items-center gap-3 pl-1">
<Separator className="h-6!" orientation="vertical" />
<Files.UploadButton
accept={["audio/*", "video/*"]}
disabled={disabled}
onFilesAccepted={(entries: FilesUploadEntry[]) => {
toast.info("Upload selected", {
description: formatPlural(entries.length, "file"),
});
}}
/>
</div>
);
}
export function GenerateTranscriptPicker() {
const [open, setOpen] = useState(false);
const [provider, setProvider] = useState("deepgram");
const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectedMatters, setSelectedMatters] = useState<TranscriptMatter[]>(
[],
);
const [view, setView] = useState<FilesViewMode>("list");
const [pending, setPending] = useState(false);
const visibleFiles = useMemo(
() => filterFiles({ items: transcriptFiles, query, selectedMatters }),
[query, selectedMatters],
);
function closePicker() {
setOpen(false);
setQuery("");
setSelectedIds([]);
setSelectedMatters([]);
setView("list");
setPending(false);
}
async function handleCreate() {
if (selectedIds.length === 0 || pending) return;
setPending(true);
await sleep(1000);
toast.success("Transcript generation started", {
description: `${formatPlural(selectedIds.length, "file")}, ${provider}`,
});
closePicker();
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) setOpen(true);
else closePicker();
}}
>
<DialogTrigger render={<Button variant="secondary" />}>
Generate Transcript
</DialogTrigger>
<DialogContent
className="w-full sm:h-137 sm:max-w-4xl"
showDismissButton={!pending}
>
<DialogHeader className="flex-row items-center justify-between py-3.5">
<DialogTitle className="shrink-0 whitespace-nowrap">
Select file
</DialogTitle>
<Toolbar
className="w-auto shrink-0 justify-end"
data-files-selection-preserve="true"
>
<SearchExpand
collapseOnBlurWhenEmpty
expandedWidth="14rem"
label="Search files"
>
<Input
aria-label="Search files"
disabled={pending}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Search files"
value={query}
/>
</SearchExpand>
<TranscriptMatterFilter
disabled={pending}
onValueChange={setSelectedMatters}
value={selectedMatters}
/>
<TranscriptViewToggle
disabled={pending}
onValueChange={setView}
value={view}
/>
<TranscriptUploadControl disabled={pending} />
</Toolbar>
</DialogHeader>
<DialogBody className="pt-.5 [&_[data-slot=table-header]]:hidden [&_tbody[aria-hidden]]:hidden">
<Files
disabled={pending}
onSelectedIdsChange={setSelectedIds}
selectedIds={selectedIds}
canSelect={(item) => item.kind !== "folder"}
selectionBehavior="toggle"
selectionMode="single"
>
<Files.Content className="min-h-0 flex-1 overflow-auto">
{visibleFiles.length === 0 ? (
<div className="flex min-h-72 items-center justify-center">
<EmptyState variant="ghost">
<EmptyStateMedia>
<FolderOpen />
</EmptyStateMedia>
<EmptyStateHeading>
<EmptyStateTitle>No audio or video found</EmptyStateTitle>
<EmptyStateDescription>
Try a different search or matter filter.
</EmptyStateDescription>
</EmptyStateHeading>
</EmptyState>
</div>
) : view === "grid" ? (
<Files.Grid>
{visibleFiles.map((item) => (
<Files.Item item={item} key={item.id} />
))}
</Files.Grid>
) : (
<Files.List header={<TranscriptListHeader />}>
{renderListFiles(visibleFiles)}
</Files.List>
)}
</Files.Content>
</Files>
</DialogBody>
<DialogFooter className="items-center sm:justify-between">
<Select
items={providerItems}
onValueChange={(nextValue) => {
if (typeof nextValue === "string") setProvider(nextValue);
}}
value={provider}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{transcriptProviders.map((transcriptProvider) => (
<SelectItem
key={transcriptProvider.id}
value={transcriptProvider.id}
>
{transcriptProvider.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<DialogClose disabled={pending}>Cancel</DialogClose>
<Button
disabled={selectedIds.length === 0 || pending}
onClick={handleCreate}
pending={pending}
>
Create
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}import { BarsFilter } from "@tilt-legal/cubitt-icons/ui/outline";
import { Button } from "@tilt-legal/cubitt-components/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
} from "@tilt-legal/cubitt-components/combobox";
import {
transcriptMatters,
type TranscriptMatter,
} from "@/lib/transcript-picker-data";
export function TranscriptMatterFilter({
disabled,
onValueChange,
value,
}: {
disabled?: boolean;
onValueChange: (matters: TranscriptMatter[]) => void;
value: TranscriptMatter[];
}) {
return (
<Combobox
items={transcriptMatters}
multiple
onValueChange={(nextValue) => {
onValueChange(Array.isArray(nextValue) ? nextValue : []);
}}
value={value}
>
<ComboboxTrigger
render={<Button disabled={disabled} mode="icon" variant="secondary" />}
>
<BarsFilter />
</ComboboxTrigger>
<ComboboxContent align="end">
<ComboboxInput placeholder="Search matters..." />
<ComboboxSeparator />
<ComboboxEmpty>No matters found.</ComboboxEmpty>
<ComboboxList>
{(matter: TranscriptMatter) => (
<ComboboxItem key={matter.id} value={matter}>
<ComboboxItemIndicator />
{matter.value}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}import { Grid, Menu as MenuIcon } from "@tilt-legal/cubitt-icons/ui/outline";
import type { FilesViewMode } from "@tilt-legal/cubitt-components/files";
import {
ToggleGroup,
ToggleGroupItem,
} from "@tilt-legal/cubitt-components/toggle-group";
export function TranscriptViewToggle({
disabled,
onValueChange,
value,
}: {
disabled?: boolean;
onValueChange: (value: FilesViewMode) => void;
value: FilesViewMode;
}) {
return (
<ToggleGroup
aria-label="View options"
disabled={disabled}
groupVariant="segmented"
multiple={false}
onValueChange={(nextValue) => {
if (nextValue === "grid" || nextValue === "list") {
onValueChange(nextValue);
}
}}
value={value}
>
<ToggleGroupItem aria-label="Grid view" mode="icon" value="grid">
<Grid />
</ToggleGroupItem>
<ToggleGroupItem aria-label="List view" mode="icon" value="list">
<MenuIcon />
</ToggleGroupItem>
</ToggleGroup>
);
}import type { FilesItem } from "@tilt-legal/cubitt-components/files";
import { formatBytes } from "@tilt-legal/cubitt-components/utilities/formatters";
export type TranscriptMatter = { id: string; value: string };
export type TranscriptFileSource = {
matters: TranscriptMatter[];
owner: string;
size: number;
updatedLabel: string;
};
export const transcriptMatters = [
{ id: "m1", value: "Smith v. Anderson" },
{ id: "m3", value: "Tech Corp Acquisition" },
] satisfies TranscriptMatter[];
export const transcriptProviders = [
{ id: "deepgram", label: "Deepgram" },
{ id: "elevenlabs", label: "ElevenLabs" },
{ id: "azure", label: "Azure Speech" },
] as const;
export const providerItems = transcriptProviders.map((provider) => ({
label: provider.label,
value: provider.id,
}));
export const transcriptFiles: FilesItem<TranscriptFileSource>[] = [
{
id: "deposition-recording",
kind: "file",
name: "Deposition Recording.mp4",
mediaType: "video/mp4",
sizeLabel: formatBytes(145_000_000),
description: "Sarah Chen",
source: {
owner: "Sarah Chen",
size: 145_000_000,
updatedLabel: "Jan 10",
matters: [transcriptMatters[0]!],
},
},
{
id: "client-call",
kind: "file",
name: "Client Call - Jan 8.mp3",
mediaType: "audio/mpeg",
sizeLabel: formatBytes(12_400_000),
description: "Michael Torres",
source: {
owner: "Michael Torres",
size: 12_400_000,
updatedLabel: "Jan 8",
matters: [transcriptMatters[0]!],
},
},
];