feat: add ShineBorder UI component and learning recipe templates to enhance data recipes page

This commit is contained in:
Shine1i 2026-02-23 22:38:06 +01:00
commit 813e2b5bb4
4 changed files with 231 additions and 20 deletions

View 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}
/>
)
}

View file

@ -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",
},
];

View file

@ -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" />

View file

@ -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;
}
}
}