feat: add ShineBorder UI component and learning recipe templates to enhance data recipes page
This commit is contained in:
parent
ad097e359f
commit
813e2b5bb4
4 changed files with 231 additions and 20 deletions
61
studio/frontend/src/components/ui/shine-border.tsx
Normal file
61
studio/frontend/src/components/ui/shine-border.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Width of the border in pixels
|
||||
* @default 1
|
||||
*/
|
||||
borderWidth?: number
|
||||
/**
|
||||
* Duration of the animation in seconds
|
||||
* @default 14
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* Color of the border, can be a single color or an array of colors
|
||||
* @default "#000000"
|
||||
*/
|
||||
shineColor?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shine Border
|
||||
*
|
||||
* An animated background border effect component with configurable properties.
|
||||
*/
|
||||
export function ShineBorder({
|
||||
borderWidth = 1,
|
||||
duration = 14,
|
||||
shineColor = "#000000",
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ShineBorderProps) {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--border-width": `${borderWidth}px`,
|
||||
"--duration": `${duration}s`,
|
||||
backgroundImage: `radial-gradient(transparent,transparent, ${
|
||||
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
|
||||
},transparent,transparent)`,
|
||||
backgroundSize: "300% 300%",
|
||||
mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
padding: "var(--border-width)",
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export type LearningRecipeDef = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
export const LEARNING_RECIPES: LearningRecipeDef[] = [
|
||||
{
|
||||
id: "structured-outputs-jinja",
|
||||
title: "Structured Outputs + Jinja Expressions",
|
||||
description: "Minimal schema + Jinja refs + if/else patterns.",
|
||||
filePath:
|
||||
"/src/features/data-recipes/learning-recipes/structured-outputs-jinja.json",
|
||||
},
|
||||
];
|
||||
|
|
@ -7,12 +7,92 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { CookBookIcon, Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
import { ShineBorder } from "@/components/ui/shine-border";
|
||||
import {
|
||||
AiChat02Icon,
|
||||
CodeIcon,
|
||||
CookBookIcon,
|
||||
Database02Icon,
|
||||
Delete02Icon,
|
||||
FunctionIcon,
|
||||
Plant01Icon,
|
||||
PlusSignIcon,
|
||||
Shield02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
import { createRecipeDraft, deleteRecipe, useRecipes } from "../data/recipes-db";
|
||||
import {
|
||||
createRecipeDraft,
|
||||
deleteRecipe,
|
||||
useRecipes,
|
||||
} from "../data/recipes-db";
|
||||
|
||||
type TemplateCard = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof CookBookIcon;
|
||||
surfaceClassName: string;
|
||||
shineColor: string[];
|
||||
};
|
||||
|
||||
const TEMPLATE_CARDS: TemplateCard[] = [
|
||||
{
|
||||
title: "Structured Outputs + Jinja Expressions",
|
||||
description:
|
||||
"Support ticket triage dataset with structured JSON outputs and Jinja if/else refs.",
|
||||
icon: FunctionIcon,
|
||||
surfaceClassName:
|
||||
"from-cyan-500/15 via-sky-500/5 to-transparent border-cyan-500/30",
|
||||
shineColor: ["#06b6d4", "#38bdf8", "#22d3ee"],
|
||||
},
|
||||
{
|
||||
title: "Basic MCP Tool Use",
|
||||
description:
|
||||
"Agent workflow starter showing tool-call patterns and grounded tool result usage.",
|
||||
icon: Shield02Icon,
|
||||
surfaceClassName:
|
||||
"from-violet-500/15 via-fuchsia-500/5 to-transparent border-violet-500/30",
|
||||
shineColor: ["#8b5cf6", "#d946ef", "#a855f7"],
|
||||
},
|
||||
{
|
||||
title: "Seed Dataset",
|
||||
description:
|
||||
"Start from real rows, then expand with synthetic fields while preserving source context.",
|
||||
icon: Plant01Icon,
|
||||
surfaceClassName:
|
||||
"from-emerald-500/15 via-green-500/5 to-transparent border-emerald-500/30",
|
||||
shineColor: ["#10b981", "#22c55e", "#34d399"],
|
||||
},
|
||||
{
|
||||
title: "Text to Python",
|
||||
description:
|
||||
"Instruction-to-code pairs for training models that generate clean Python implementations.",
|
||||
icon: CodeIcon,
|
||||
surfaceClassName:
|
||||
"from-amber-500/15 via-orange-500/5 to-transparent border-amber-500/30",
|
||||
shineColor: ["#f59e0b", "#f97316", "#fb923c"],
|
||||
},
|
||||
{
|
||||
title: "Text to SQL",
|
||||
description:
|
||||
"Natural language to SQL pairs, including schema-aware query construction patterns.",
|
||||
icon: Database02Icon,
|
||||
surfaceClassName:
|
||||
"from-blue-500/15 via-indigo-500/5 to-transparent border-blue-500/30",
|
||||
shineColor: ["#3b82f6", "#6366f1", "#60a5fa"],
|
||||
},
|
||||
{
|
||||
title: "Multi-Turn Chat",
|
||||
description:
|
||||
"Role-based multi-turn conversations for assistant behavior, memory, and response quality.",
|
||||
icon: AiChat02Icon,
|
||||
surfaceClassName:
|
||||
"from-rose-500/15 via-pink-500/5 to-transparent border-rose-500/30",
|
||||
shineColor: ["#f43f5e", "#ec4899", "#fb7185"],
|
||||
},
|
||||
];
|
||||
|
||||
function formatRelativeTime(value: number): string {
|
||||
const now = Date.now();
|
||||
|
|
@ -63,10 +143,10 @@ export function DataRecipesPage(): ReactElement {
|
|||
}
|
||||
|
||||
function openRecipe(recipeId: string): void {
|
||||
void navigate({
|
||||
navigate({
|
||||
to: "/data-recipes/$recipeId",
|
||||
params: { recipeId },
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function handleDeleteRecipe(recipeId: string): Promise<void> {
|
||||
|
|
@ -78,7 +158,9 @@ export function DataRecipesPage(): ReactElement {
|
|||
<main className="mx-auto w-full max-w-7xl px-6 py-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Data Recipes</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Data Recipes
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create and manage local recipe workflows.
|
||||
</p>
|
||||
|
|
@ -86,7 +168,7 @@ export function DataRecipesPage(): ReactElement {
|
|||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openNewRecipe();
|
||||
openNewRecipe().catch(() => undefined);
|
||||
}}
|
||||
disabled={creatingRecipe}
|
||||
>
|
||||
|
|
@ -103,20 +185,53 @@ export function DataRecipesPage(): ReactElement {
|
|||
</EmptyMedia>
|
||||
<EmptyTitle>No recipes yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Create your first recipe to start building workflows.
|
||||
Browse Learning Recipes below to understand how recipe workflows
|
||||
work.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<EmptyContent className="max-w-6xl items-stretch">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openNewRecipe();
|
||||
}}
|
||||
disabled={creatingRecipe}
|
||||
variant="secondary"
|
||||
className="mx-auto"
|
||||
disabled={true}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
|
||||
Create Recipe
|
||||
Start Tutorial
|
||||
</Button>
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{TEMPLATE_CARDS.map((template) => (
|
||||
<div
|
||||
key={template.title}
|
||||
className={`group relative overflow-hidden rounded-2xl border bg-gradient-to-br ${template.surfaceClassName}`}
|
||||
>
|
||||
<ShineBorder
|
||||
borderWidth={1.2}
|
||||
duration={11}
|
||||
shineColor={template.shineColor}
|
||||
/>
|
||||
<div className="relative flex h-full min-h-40 flex-col justify-between gap-3 p-4 text-left">
|
||||
<div className="inline-flex size-10 items-center justify-center rounded-xl border border-foreground/10 bg-background/80">
|
||||
<HugeiconsIcon
|
||||
icon={template.icon}
|
||||
className="size-5 text-foreground/90"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="line-clamp-2 text-sm font-semibold leading-tight text-foreground">
|
||||
{template.title}
|
||||
</p>
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80">
|
||||
Template
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
|
|
@ -132,13 +247,18 @@ export function DataRecipesPage(): ReactElement {
|
|||
onClick={() => openRecipe(recipe.id)}
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20">
|
||||
<HugeiconsIcon icon={CookBookIcon} className="size-4 text-muted-foreground" />
|
||||
<HugeiconsIcon
|
||||
icon={CookBookIcon}
|
||||
className="size-4 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{recipe.name}</p>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{recipe.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last updated {formatRelativeTime(recipe.updatedAt)} | Created{" "}
|
||||
{formatRelativeTime(recipe.createdAt)}
|
||||
Last updated {formatRelativeTime(recipe.updatedAt)} |
|
||||
Created {formatRelativeTime(recipe.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -147,7 +267,9 @@ export function DataRecipesPage(): ReactElement {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => void handleDeleteRecipe(recipe.id)}
|
||||
onClick={() => {
|
||||
handleDeleteRecipe(recipe.id).catch(() => undefined);
|
||||
}}
|
||||
aria-label={`Delete ${recipe.name}`}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
|
|
|
|||
|
|
@ -242,7 +242,19 @@
|
|||
background-position: calc(100% + var(--shiny-width)) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
--animate-shine: shine var(--duration) infinite linear
|
||||
;
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
to {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
|
|
@ -328,4 +340,4 @@
|
|||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue