Side help panel (#1488)

* WIP adding a side help panel to the tasks page

* Optionally display the shortcut before the trailing icon in the button

* Updated the close icon

* WIP adding a new side help panel

* WIP adding content to the side help panel

* WIP new side help panel content

* Removed images as not needed any more

* Added content to the side help menu

* Help panel open/closed state stored as cookie

* Removed the icons from the docs and examples links
This commit is contained in:
James Ritchie
2024-11-28 11:49:52 +00:00
committed by GitHub
parent 54017cbffa
commit 1077709e15
6 changed files with 744 additions and 184 deletions
@@ -0,0 +1,15 @@
export function SideMenuRightClosedIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="12" y="4" width="1" height="12" fill="currentColor" />
<rect x="2.5" y="3.5" width="15" height="13" rx="2.5" stroke="currentColor" />
</svg>
);
}
@@ -0,0 +1,177 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
const variants = {
small: {
size: "size-[1rem]",
arrowHeadRight: "group-hover:translate-x-[3px]",
arrowLineRight: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
arrowHeadLeft: "group-hover:translate-x-[3px]",
arrowLineLeft: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
medium: {
size: "size-[1.1rem]",
arrowHeadRight: "group-hover:translate-x-[3px]",
arrowLineRight: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
arrowHeadLeft: "group-hover:translate-x-[-3px]",
arrowLineLeft: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
large: {
size: "size-6",
arrowHeadRight: "group-hover:translate-x-1",
arrowLineRight: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
arrowHeadLeft: "group-hover:translate-x-1",
arrowLineLeft: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
"extra-large": {
size: "size-8",
arrowHeadRight: "group-hover:translate-x-1",
arrowLineRight: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
arrowHeadLeft: "group-hover:translate-x-1",
arrowLineLeft: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
};
export const themes = {
dark: {
textStyle: "text-background-bright",
arrowLine: "bg-background-bright",
},
dimmed: {
textStyle: "text-text-dimmed",
arrowLine: "bg-text-dimmed",
},
bright: {
textStyle: "text-text-bright",
arrowLine: "bg-text-bright",
},
primary: {
textStyle: "text-text-dimmed group-hover:text-primary",
arrowLine: "bg-text-dimmed group-hover:bg-primary",
},
blue: {
textStyle: "text-text-dimmed group-hover:text-blue-500",
arrowLine: "bg-text-dimmed group-hover:bg-blue-500",
},
rose: {
textStyle: "text-text-dimmed group-hover:text-rose-500",
arrowLine: "bg-text-dimmed group-hover:bg-rose-500",
},
amber: {
textStyle: "text-text-dimmed group-hover:text-amber-500",
arrowLine: "bg-text-dimmed group-hover:bg-amber-500",
},
apple: {
textStyle: "text-text-dimmed group-hover:text-apple-500",
arrowLine: "bg-text-dimmed group-hover:bg-apple-500",
},
lavender: {
textStyle: "text-text-dimmed group-hover:text-lavender-500",
arrowLine: "bg-text-dimmed group-hover:bg-lavender-500",
},
};
type Variants = keyof typeof variants;
type Theme = keyof typeof themes;
type AnimatingArrowProps = {
className?: string;
variant?: Variants;
theme?: Theme;
direction?: "right" | "left" | "topRight";
};
export function AnimatingArrow({
className,
variant = "medium",
theme = "dimmed",
direction = "right",
}: AnimatingArrowProps) {
const variantStyles = variants[variant];
const themeStyles = themes[theme];
return (
<span className={cn("relative -mr-1 ml-1 flex", variantStyles.size, className)}>
{direction === "topRight" && (
<>
<svg
className={cn(
"absolute top-[5px] transition duration-200 ease-in-out",
themeStyles.textStyle
)}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<svg
className={cn(
"absolute top-[5px] transition duration-300 ease-in-out",
themeStyles.textStyle,
variantStyles.arrowHeadTopRight
)}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 1H7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M7.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M1 7.5L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</>
)}
{direction === "right" && (
<>
<span
className={cn(
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
variantStyles.arrowLineRight,
themeStyles.arrowLine
)}
/>
<ChevronRightIcon
className={cn(
"absolute -translate-x-0.5 transition duration-300 ease-in-out",
variantStyles.arrowHeadRight,
variantStyles.size,
themeStyles.textStyle
)}
/>
</>
)}
{direction === "left" && (
<>
<span
className={cn(
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
variantStyles.arrowLineLeft,
themeStyles.arrowLine
)}
/>
<ChevronLeftIcon
className={cn(
"absolute translate-x-0.5 transition duration-300 ease-in-out",
variantStyles.arrowHeadLeft,
variantStyles.size,
themeStyles.textStyle
)}
/>
</>
)}
</span>
);
}
@@ -174,6 +174,7 @@ export type ButtonContentPropsType = {
className?: string;
shortcut?: ShortcutDefinition;
variant: keyof typeof variant;
shortcutPosition?: "before-trailing-icon" | "after-trailing-icon";
};
export function ButtonContent(props: ButtonContentPropsType) {
@@ -237,6 +238,14 @@ export function ButtonContent(props: ButtonContentPropsType) {
<>{text}</>
))}
{shortcut && props.shortcutPosition === "before-trailing-icon" && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
)}
{TrailingIcon &&
(typeof TrailingIcon === "string" ? (
<NamedIcon
@@ -258,13 +267,15 @@ export function ButtonContent(props: ButtonContentPropsType) {
)}
/>
))}
{shortcut && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
)}
{shortcut &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
)}
</div>
</div>
);
@@ -4,14 +4,21 @@ import {
ChatBubbleLeftRightIcon,
ChevronDownIcon,
ChevronUpIcon,
LightBulbIcon,
UserPlusIcon,
VideoCameraIcon,
} from "@heroicons/react/20/solid";
import { useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/node";
import { Link, useRevalidator, useSubmit } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { TaskRunStatus } from "@trigger.dev/database";
import { Fragment, Suspense, useEffect, useState } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { Feedback } from "~/components/Feedback";
import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
@@ -19,15 +26,22 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { AnimatingArrow } from "~/components/primitives/AnimatingArrow";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { formatDateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog";
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { StepNumber } from "~/components/primitives/StepNumber";
import {
@@ -53,10 +67,16 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTextFilter } from "~/hooks/useTextFilter";
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
import {
getUsefulLinksPreference,
setUsefulLinksPreference,
uiPreferencesStorage,
} from "~/services/preferences/uiPreferences.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
docsPath,
inviteTeamMemberPath,
ProjectParamSchema,
v3RunsPath,
v3TasksStreamingPath,
@@ -76,12 +96,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
projectSlug: projectParam,
});
const usefulLinksPreference = await getUsefulLinksPreference(request);
return typeddefer({
tasks,
userHasTasks,
activity,
runningStats,
durations,
usefulLinksPreference,
});
} catch (error) {
console.error(error);
@@ -92,10 +115,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
};
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const showUsefulLinks = formData.get("showUsefulLinks") === "true";
const session = await setUsefulLinksPreference(showUsefulLinks, request);
return json(
{ success: true },
{
headers: {
"Set-Cookie": await uiPreferencesStorage.commitSession(session),
},
}
);
}
export default function Page() {
const organization = useOrganization();
const project = useProject();
const { tasks, userHasTasks, activity, runningStats, durations } =
const { tasks, userHasTasks, activity, runningStats, durations, usefulLinksPreference } =
useTypedLoaderData<typeof loader>();
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
items: tasks,
@@ -137,6 +176,16 @@ export default function Page() {
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
// Create a submit handler to save the preference
const submit = useSubmit();
const handleUsefulLinksToggle = (show: boolean) => {
setShowUsefulLinks(show);
submit({ showUsefulLinks: show.toString() }, { method: "post" });
};
return (
<PageContainer>
<NavBar>
@@ -168,183 +217,213 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className={cn("grid h-full grid-rows-1")}>
{hasTasks ? (
<div className="flex flex-col">
{!userHasTasks && <UserHasNoTasks />}
<div className="max-h-full overflow-hidden">
<div className="p-2">
<Input
placeholder="Search tasks"
variant="tertiary"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
autoFocus
/>
</div>
<Table containerClassName="max-h-full pb-[2.5rem]">
<TableHeader>
<TableRow>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Running</TableHeaderCell>
<TableHeaderCell>Queued</TableHeaderCell>
<TableHeaderCell>Activity (7d)</TableHeaderCell>
<TableHeaderCell>Avg. duration</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? (
filteredItems.map((task) => {
const path = v3RunsPath(organization, project, {
tasks: [task.slug],
});
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="tasks-main" className="max-h-full">
<div className={cn("grid h-full grid-rows-1")}>
{hasTasks ? (
<div className="flex min-w-0 max-w-full flex-col">
{!userHasTasks && <UserHasNoTasks />}
<div className="max-h-full overflow-hidden">
<div className="flex items-center p-2">
<Input
placeholder="Search tasks"
variant="tertiary"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
autoFocus
/>
{!showUsefulLinks && (
<Button
variant="minimal/small"
TrailingIcon={LightBulbIcon}
onClick={() => handleUsefulLinksToggle(true)}
className="px-2.5"
/>
)}
</div>
<Table containerClassName="max-h-full pb-[2.5rem]">
<TableHeader>
<TableRow>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Running</TableHeaderCell>
<TableHeaderCell>Queued</TableHeaderCell>
<TableHeaderCell>Activity (7d)</TableHeaderCell>
<TableHeaderCell>Avg. duration</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? (
filteredItems.map((task) => {
const path = v3RunsPath(organization, project, {
tasks: [task.slug],
});
const devYouEnvironment = task.environments.find(
(e) => e.type === "DEVELOPMENT" && !e.userName
);
const firstDeployedEnvironment = task.environments
.filter((e) => e.type !== "DEVELOPMENT")
.at(0);
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
const devYouEnvironment = task.environments.find(
(e) => e.type === "DEVELOPMENT" && !e.userName
);
const firstDeployedEnvironment = task.environments
.filter((e) => e.type !== "DEVELOPMENT")
.at(0);
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
const testPath = testEnvironment
? v3TestTaskPath(
organization,
project,
{ taskIdentifier: task.slug },
testEnvironment.slug
)
: v3TestPath(organization, project);
const testPath = testEnvironment
? v3TestTaskPath(
organization,
project,
{ taskIdentifier: task.slug },
testEnvironment.slug
)
: v3TestPath(organization, project);
return (
<TableRow key={task.slug} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<SimpleTooltip
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
content={taskTriggerSourceDescription(task.triggerSource)}
/>
<span>{task.slug}</span>
</div>
</TableCell>
<TableCell to={path} className="py-0" actionClassName="py-0">
<TaskFunctionName
functionName={task.exportName}
variant="extra-extra-small"
/>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense
fallback={
<>
<Spinner color="muted" />
</>
}
>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.running ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.queued ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0" actionClassName="py-0">
<Suspense fallback={<TaskActivityBlankState />}>
<TypedAwait resolve={activity}>
{(data) => {
const taskData = data[task.slug];
return (
<>
{taskData !== undefined ? (
<div className="h-6 w-[5.125rem] rounded-sm">
<TaskActivityGraph activity={taskData} />
</div>
) : (
<TaskActivityBlankState />
)}
</>
);
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={durations}>
{(data) => {
const taskData = data[task.slug];
return taskData
? formatDurationMilliseconds(taskData * 1000, {
style: "short",
})
: "";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path}>
<EnvironmentLabels environments={task.environments} />
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
icon="runs"
to={path}
title="View runs"
leadingIconClassName="text-teal-500"
return (
<TableRow key={task.slug} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<SimpleTooltip
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
content={taskTriggerSourceDescription(task.triggerSource)}
/>
<span>{task.slug}</span>
</div>
</TableCell>
<TableCell to={path} className="py-0" actionClassName="py-0">
<TaskFunctionName
functionName={task.exportName}
variant="extra-extra-small"
/>
<PopoverMenuItem icon="beaker" to={testPath} title="Test task" />
</>
}
hiddenButtons={
<LinkButton
variant="minimal/small"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-text-bright"
to={testPath}
>
Test
</LinkButton>
}
/>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={8}>
<Paragraph variant="small" className="flex items-center justify-center">
No tasks match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense
fallback={
<>
<Spinner color="muted" />
</>
}
>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.running ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.queued ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0" actionClassName="py-0">
<Suspense fallback={<TaskActivityBlankState />}>
<TypedAwait resolve={activity}>
{(data) => {
const taskData = data[task.slug];
return (
<>
{taskData !== undefined ? (
<div className="h-6 w-[5.125rem] rounded-sm">
<TaskActivityGraph activity={taskData} />
</div>
) : (
<TaskActivityBlankState />
)}
</>
);
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={durations}>
{(data) => {
const taskData = data[task.slug];
return taskData
? formatDurationMilliseconds(taskData * 1000, {
style: "short",
})
: "";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path}>
<EnvironmentLabels environments={task.environments} />
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
icon="runs"
to={path}
title="View runs"
leadingIconClassName="text-teal-500"
/>
<PopoverMenuItem
icon="beaker"
to={testPath}
title="Test task"
/>
</>
}
hiddenButtons={
<LinkButton
variant="minimal/small"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-text-bright"
to={testPath}
>
Test
</LinkButton>
}
/>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={8}>
<Paragraph variant="small" className="flex items-center justify-center">
No tasks match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</div>
) : (
<MainCenteredContainer className="max-w-prose">
<CreateTaskInstructions />
</MainCenteredContainer>
)}
</div>
) : (
<MainCenteredContainer className="max-w-prose">
<CreateTaskInstructions />
</MainCenteredContainer>
)}
</div>
</ResizablePanel>
{hasTasks && showUsefulLinks ? (
<>
<ResizableHandle id="tasks-handle" />
<ResizablePanel
id="tasks-inspector"
min="200px"
default="400px"
max="500px"
className="w-full"
>
<HelpfulInfoHasTasks onClose={() => handleUsefulLinksToggle(false)} />
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</PageBody>
</PageContainer>
);
@@ -537,3 +616,251 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
return null;
};
function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
const organization = useOrganization();
const project = useProject();
const [isVideoDialogOpen, setIsVideoDialogOpen] = useState(false);
return (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<div className="overflow-y-scroll p-3 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="mb-2 flex items-center justify-between gap-2 border-b border-grid-dimmed pb-2">
<Header2 className="flex items-center gap-2">
<LightBulbIcon className="size-4 min-w-4 text-sun-500" />
Helpful next steps
</Header2>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-[0.375rem]"
/>
</div>
<LinkWithIcon
variant="withIcon"
to={v3TestPath(organization, project)}
description="Test your tasks"
icon={<BeakerIcon className="size-5 text-lime-500" />}
/>
<LinkWithIcon
variant="withIcon"
to={inviteTeamMemberPath(organization)}
description="Invite team members"
icon={<UserPlusIcon className="size-5 text-amber-500" />}
/>
<div
role="button"
onClick={() => setIsVideoDialogOpen(true)}
className={cn(
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
variants["withIcon"].container
)}
>
<div className="flex items-center gap-2">
<div className={variants["withIcon"].iconContainer}>
<VideoCameraIcon className="size-5 text-rose-500" />
</div>
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
Watch a 14 min walkthrough video
</Paragraph>
</div>
<AnimatingArrow direction="right" theme="dimmed" />
</div>
<LinkWithIcon
variant="withIcon"
to="https://trigger.dev/discord"
description="Join our Discord for help and support"
icon={<DiscordIcon className="size-5" />}
isExternal
/>
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
<Header2 className="flex items-center gap-2">
<BookOpenIcon className="size-5 text-blue-500" />
From the docs
</Header2>
</div>
<LinkWithIcon
to={docsPath("/writing-tasks-introduction")}
description="How to write a task"
isExternal
/>
<LinkWithIcon
to={docsPath("/tasks/scheduled")}
description="Scheduled tasks (cron)"
isExternal
/>
<LinkWithIcon to={docsPath("/triggering")} description="How to trigger a task" isExternal />
<LinkWithIcon to={docsPath("/cli-dev")} description="Running the CLI" isExternal />
<LinkWithIcon
to={docsPath("/how-it-works")}
description="How Trigger.dev works"
isExternal
/>
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
<Header2 className="flex items-center gap-2">
<TaskIcon className="size-4 text-blue-500" />
Example tasks
</Header2>
</div>
<LinkWithIcon
to={docsPath("/examples/dall-e3-generate-image")}
description="DALL·E 3 image generation"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/deepgram-transcribe-audio")}
description="Deepgram audio transcription"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/fal-ai-image-to-cartoon")}
description="Fal.ai image to cartoon"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/fal-ai-realtime")}
description="Fal.ai with Realtime"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/ffmpeg-video-processing")}
description="FFmpeg video processing"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/firecrawl-url-crawl")}
description="Firecrawl URL crawl"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/libreoffice-pdf-conversion")}
description="LibreOffice PDF conversion"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/open-ai-with-retrying")}
description="OpenAI with retrying"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/pdf-to-image")}
description="PDF to image"
isExternal
/>
<LinkWithIcon to={docsPath("/examples/puppeteer")} description="Puppeteer" isExternal />
<LinkWithIcon to={docsPath("/examples/react-pdf")} description="React to PDF" isExternal />
<LinkWithIcon
to={docsPath("/examples/resend-email-sequence")}
description="Resend email sequence"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/scrape-hacker-news")}
description="Scrape Hacker News"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/sentry-error-tracking")}
description="Sentry error tracking"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/sharp-image-processing")}
description="Sharp image processing"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/supabase-database-operations")}
description="Supabase database operations"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/supabase-storage-upload")}
description="Supabase Storage upload"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/vercel-ai-sdk")}
description="Vercel AI SDK"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/vercel-sync-env-vars")}
description="Vercel sync environment variables"
isExternal
/>
</div>
<Dialog open={isVideoDialogOpen} onOpenChange={setIsVideoDialogOpen}>
<DialogContent className="sm:max-w-screen-lg">
<DialogHeader className="mb-4 pt-1">
<DialogTitle>Trigger.dev walkthrough</DialogTitle>
</DialogHeader>
<div className="aspect-video">
<iframe
width="100%"
height="100%"
src="https://www.youtube.com/embed/YH_4c0K7fGM?si=BcX6MAt_V139sRw9"
title="Trigger.dev walkthrough"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
const variants = {
withIcon: {
container: "",
iconContainer:
"grid size-9 min-w-9 place-items-center rounded border border-transparent bg-charcoal-750 shadow transition group-hover:border-charcoal-650",
},
minimal: {
container: "pl-3 py-2",
iconContainer: "",
},
} as const;
type LinkWithIconProps = {
to: string;
description: string;
icon?: React.ReactNode;
isExternal?: boolean;
variant?: keyof typeof variants;
};
function LinkWithIcon({
to,
description,
icon,
isExternal,
variant = "minimal",
}: LinkWithIconProps) {
const variation = variants[variant];
return (
<Link
to={to}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noreferrer" : undefined}
className={cn(
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
variation.container
)}
>
<div className="flex items-center gap-2">
{variant === "withIcon" && icon && <div className={variation.iconContainer}>{icon}</div>}
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
{description}
</Paragraph>
</div>
<AnimatingArrow direction={isExternal ? "topRight" : "right"} theme="dimmed" />
</Link>
);
}
@@ -216,9 +216,10 @@ export default function Page() {
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
<LinkButton
to={`${v3SchedulesPath(organization, project)}${location.search}`}
variant="minimal/medium"
LeadingIcon={ExitIcon}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
/>
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
@@ -0,0 +1,29 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { env } from "~/env.server";
export const uiPreferencesStorage = createCookieSessionStorage({
cookie: {
name: "__ui_prefs",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, // 1 year
},
});
export function getUiPreferencesSession(request: Request) {
return uiPreferencesStorage.getSession(request.headers.get("Cookie"));
}
export async function getUsefulLinksPreference(request: Request): Promise<boolean | undefined> {
const session = await getUiPreferencesSession(request);
return session.get("showUsefulLinks");
}
export async function setUsefulLinksPreference(show: boolean, request: Request) {
const session = await getUiPreferencesSession(request);
session.set("showUsefulLinks", show);
return session;
}