From 9c9d07db9caf33b60329449d9326c1c1fb1e1bf0 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Wed, 4 Dec 2024 12:58:07 -0600 Subject: [PATCH 01/30] initial cookbook commit --- app/(docs)/layout.client.tsx | 2 +- app/(docs)/layout.tsx | 2 +- app/api/run/route.ts | 24 ++ app/cookbook/[id]/page.tsx | 101 ++++++++ app/cookbook/components/code-result.tsx | 19 ++ app/cookbook/components/cookbook-ui.tsx | 235 ++++++++++++++++++ app/cookbook/components/snippet-result.tsx | 85 +++++++ app/cookbook/layout.tsx | 19 ++ app/cookbook/page.tsx | 90 +++++++ app/global.css | 9 + components/code/clarinet-sdk.tsx | 131 +++------- .../docskit/annotations/hover-line.client.tsx | 24 ++ .../docskit/annotations/hover.client.tsx | 22 ++ components/docskit/annotations/hover.tsx | 14 ++ components/docskit/code.tsx | 2 + components/table.tsx | 24 +- components/ui/badge.tsx | 2 +- components/ui/icon.tsx | 13 +- content/_recipes/create-random-number.mdx | 19 ++ .../get-tenure-height-for-a-block.mdx | 32 +++ content/docs/stacks/api/architecture.mdx | 2 +- content/docs/stacks/api/txs.mdx | 2 +- content/docs/stacks/clarinet/index.mdx | 90 ++----- context/hover.tsx | 30 +++ data/recipes.ts | 76 ++++++ mdx-components.tsx | 20 +- next.config.mjs | 1 + public/contracts/hello-world.clar | 9 - types/recipes.ts | 18 ++ 29 files changed, 917 insertions(+), 200 deletions(-) create mode 100644 app/api/run/route.ts create mode 100644 app/cookbook/[id]/page.tsx create mode 100644 app/cookbook/components/code-result.tsx create mode 100644 app/cookbook/components/cookbook-ui.tsx create mode 100644 app/cookbook/components/snippet-result.tsx create mode 100644 app/cookbook/layout.tsx create mode 100644 app/cookbook/page.tsx create mode 100644 components/docskit/annotations/hover-line.client.tsx create mode 100644 components/docskit/annotations/hover.client.tsx create mode 100644 components/docskit/annotations/hover.tsx create mode 100644 content/_recipes/create-random-number.mdx create mode 100644 content/_recipes/get-tenure-height-for-a-block.mdx create mode 100644 context/hover.tsx create mode 100644 data/recipes.ts delete mode 100644 public/contracts/hello-world.clar create mode 100644 types/recipes.ts diff --git a/app/(docs)/layout.client.tsx b/app/(docs)/layout.client.tsx index e6d2bc70c..bef178ed4 100644 --- a/app/(docs)/layout.client.tsx +++ b/app/(docs)/layout.client.tsx @@ -99,7 +99,7 @@ export function SidebarBanner(): JSX.Element { return ( <Link key={currentMode.param} href={`/${currentMode.param}`}> - <div className="group flex flex-row items-center gap-2 rounded-lg px-2 mb-3 transition-colors"> + <div className="group flex flex-row items-center gap-2 rounded-lg px-2 mb-4 transition-colors"> <ChevronLeft className="text-muted-foreground size-4 shrink-0 rounded-md group-hover:text-primary" /> <div> <p className="text-muted-foreground group-hover:text-primary">Back</p> diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx index 5f6581278..7e772f2af 100644 --- a/app/(docs)/layout.tsx +++ b/app/(docs)/layout.tsx @@ -7,7 +7,7 @@ import { Body, NavChildren, SidebarBanner } from "./layout.client"; import { Statuspage } from "statuspage.io"; const statuspage = new Statuspage("3111l89394q4"); -console.log({ status: await statuspage.api.getStatus() }); +// console.log({ status: await statuspage.api.getStatus() }); export const layoutOptions: Omit<DocsLayoutProps, "children"> = { tree: utils.pageTree, diff --git a/app/api/run/route.ts b/app/api/run/route.ts new file mode 100644 index 000000000..08739c9db --- /dev/null +++ b/app/api/run/route.ts @@ -0,0 +1,24 @@ +import { initSimnet } from "@hirosystems/clarinet-sdk-browser"; +import { Cl } from "@stacks/transactions"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + const { code } = await request.json(); + + const simnet = await initSimnet(); + await simnet.initEmtpySession(); + simnet.setEpoch("3.0"); + + const result = simnet.runSnippet(code); + const deserializedResult = Cl.deserialize(result); + const prettyResult = Cl.prettyPrint(deserializedResult, 2); + + return NextResponse.json({ result: prettyResult }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/cookbook/[id]/page.tsx b/app/cookbook/[id]/page.tsx new file mode 100644 index 000000000..53ab06ffd --- /dev/null +++ b/app/cookbook/[id]/page.tsx @@ -0,0 +1,101 @@ +import { Code } from "@/components/docskit/code"; +import { recipes } from "@/data/recipes"; +import { ArrowUpRight, Play, TestTube } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { HoverProvider } from "@/context/hover"; +import { HoverLink } from "@/components/docskit/annotations/hover"; +import Link from "next/link"; +import { Terminal } from "@/components/docskit/terminal"; +import { InlineCode } from "@/components/docskit/inline-code"; +import { Github } from "@/components/ui/icon"; +import { WithNotes } from "@/components/docskit/notes"; +import { SnippetResult } from "../components/snippet-result"; + +interface Param { + id: string; +} + +export const dynamicParams = false; + +export default async function Page({ + params, +}: { + params: Param; +}): Promise<JSX.Element> { + const { id } = params; + const recipe = recipes.find((r) => r.id === id); + + if (!recipe) { + return <div>Recipe not found</div>; + } + + // Dynamically import MDX content based on recipe id + const Content = await import(`@/content/_recipes/${id}.mdx`).catch(() => { + console.error(`Failed to load MDX content for recipe: ${id}`); + return { default: () => <div>Content not found</div> }; + }); + + const snippetCodeResult = (result: string) => { + <Code + codeblocks={[ + { + lang: "bash", + value: result, + meta: `-nw`, + }, + ]} + />; + }; + + return ( + <HoverProvider> + <div className="min-h-screen"> + <div className="px-4 py-8"> + <div className="grid grid-cols-12 gap-12"> + <div className="col-span-6"> + <div className="space-y-3"> + <div className="flex flex-wrap gap-2"> + {recipe.tags.map((tag) => ( + <Badge key={tag} variant="secondary"> + {tag.toUpperCase()} + </Badge> + ))} + </div> + <div className="prose max-w-none"> + <Content.default + components={{ + HoverLink, + Terminal, + Code, + InlineCode, + WithNotes, + }} + /> + </div> + </div> + </div> + + {/* Sticky sidebar */} + <div className="col-span-6"> + <div className="sticky top-20 space-y-4"> + <div className="recipe group relative w-full bg-card overflow-hidden"> + <Code + codeblocks={[ + { + lang: recipe.type, + value: recipe.files[0].content, + meta: `${recipe.files[0].name} -cnw`, // filename + flags + }, + ]} + /> + </div> + <SnippetResult code={recipe.files[0].content as string} /> + </div> + </div> + </div> + </div> + </div> + </HoverProvider> + ); +} diff --git a/app/cookbook/components/code-result.tsx b/app/cookbook/components/code-result.tsx new file mode 100644 index 000000000..38805699d --- /dev/null +++ b/app/cookbook/components/code-result.tsx @@ -0,0 +1,19 @@ +import { Code } from "@/components/docskit/code"; + +interface CodeResultProps { + result: string; +} + +export function CodeResult({ result }: CodeResultProps) { + return ( + <Code + codeblocks={[ + { + lang: "bash", + value: result, + meta: `-nw`, + }, + ]} + /> + ); +} diff --git a/app/cookbook/components/cookbook-ui.tsx b/app/cookbook/components/cookbook-ui.tsx new file mode 100644 index 000000000..1dd062f3b --- /dev/null +++ b/app/cookbook/components/cookbook-ui.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useState, useMemo, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Recipe, RecipeTag } from "@/types/recipes"; +import { cn } from "@/lib/utils"; +import { CustomTable } from "@/components/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { LayoutGrid, List } from "lucide-react"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; + +// Internal components +function ViewToggle({ + view, + onChange, +}: { + view: "grid" | "list"; + onChange: (view: "grid" | "list") => void; +}) { + return ( + <div className="flex items-center gap-1 rounded-lg p-1 border border-code bg-code"> + <Button + size="sm" + onClick={() => onChange("grid")} + className={cn( + "px-2", + view === "grid" + ? "bg-card hover:bg-card text-primary" + : "bg-code text-muted-foreground hover:bg-card" + )} + > + <LayoutGrid className="h-4 w-4" /> + </Button> + <Button + size="sm" + onClick={() => onChange("list")} + className={cn( + "px-2", + view === "list" + ? "bg-card hover:bg-card text-primary" + : "bg-code text-muted-foreground hover:bg-card" + )} + > + <List className="h-4 w-4" /> + </Button> + </div> + ); +} + +const ALL_TAGS: RecipeTag[] = ["api", "stacks.js", "clarity", "clarinet"]; + +function RecipeFilters({ + selectedTags, + onTagToggle, +}: { + search: string; + onSearchChange: (value: string) => void; + selectedTags: RecipeTag[]; + onTagToggle: (tag: RecipeTag) => void; +}) { + return ( + <div className="space-y-4"> + <div className="flex flex-wrap gap-2"> + {ALL_TAGS.map((tag) => ( + <Badge + key={tag} + variant={selectedTags.includes(tag) ? "default" : "outline"} + className="cursor-pointer" + onClick={() => onTagToggle(tag)} + > + {tag.toUpperCase()} + </Badge> + ))} + </div> + </div> + ); +} + +interface CookbookProps { + initialRecipes: Recipe[]; + recipeCards: React.ReactNode[]; +} + +function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + // Initialize state from URL params + const [view, setView] = useState<"grid" | "list">(() => { + return (searchParams.get("view") as "grid" | "list") || "grid"; + }); + const [search, setSearch] = useState(""); + const [selectedTags, setSelectedTags] = useState<RecipeTag[]>(() => { + const tagParam = searchParams.get("tags"); + return tagParam ? (tagParam.split(",") as RecipeTag[]) : []; + }); + + // Update URL when filters change + const updateURL = (newView?: "grid" | "list", newTags?: RecipeTag[]) => { + const params = new URLSearchParams(); + + // Only add view param if it's list (grid is default) + if (newView === "list") { + params.set("view", newView); + } + + // Only add tags if there are any selected + if (newTags && newTags.length > 0) { + params.set("tags", newTags.join(",")); + } + + // Create the new URL + const newURL = params.toString() + ? `?${params.toString()}` + : window.location.pathname; + + router.push(newURL, { scroll: false }); + }; + + // Handle view changes + const handleViewChange = (newView: "grid" | "list") => { + setView(newView); + updateURL(newView, selectedTags); + }; + + // Handle tag changes + const handleTagToggle = (tag: RecipeTag) => { + const newTags = selectedTags.includes(tag) + ? selectedTags.filter((t) => t !== tag) + : [...selectedTags, tag]; + + setSelectedTags(newTags); + updateURL(view, newTags); + }; + + // Create a map of recipe IDs to their corresponding rendered cards + const recipeCardMap = useMemo(() => { + return initialRecipes.reduce( + (map, recipe, index) => { + map[recipe.id] = recipeCards[index]; + return map; + }, + {} as Record<string, React.ReactNode> + ); + }, [initialRecipes, recipeCards]); + + // Filter recipes and get their corresponding cards + const filteredRecipeCards = useMemo(() => { + const filteredRecipes = initialRecipes.filter((recipe) => { + const matchesSearch = + search === "" || + recipe.title.toLowerCase().includes(search.toLowerCase()) || + recipe.description.toLowerCase().includes(search.toLowerCase()); + + const matchesTags = + selectedTags.length === 0 || + selectedTags.some((tag) => recipe.tags.includes(tag)); + + return matchesSearch && matchesTags; + }); + + // Return the cards for the filtered recipes + return filteredRecipes.map((recipe) => recipeCardMap[recipe.id]); + }, [search, selectedTags, initialRecipes, recipeCardMap]); + + return ( + <div className="max-w-5xl mx-auto"> + <div className="flex flex-col gap-6 space-y-6"> + <div className="flex items-start justify-between gap-4"> + <div className="space-y-1"> + <h1 className="text-3xl font-semibold">Cookbook</h1> + <p className="text-lg text-muted-foreground w-2/3"> + An open-source collection of copy & paste code recipes for + building on Stacks and Bitcoin. + </p> + </div> + <ViewToggle view={view} onChange={handleViewChange} /> + </div> + + <div className="space-y-6"> + <RecipeFilters + search={search} + onSearchChange={setSearch} + selectedTags={selectedTags} + onTagToggle={handleTagToggle} + /> + + {view === "grid" ? ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + {filteredRecipeCards} + </div> + ) : ( + <Table> + <TableBody> + {initialRecipes.map((recipe) => ( + <TableRow + key={recipe.id} + className="cursor-pointer group" + onClick={() => router.push(`/cookbook/${recipe.id}`)} + > + <TableCell className="py-4 text-primary whitespace-normal break-words text-base"> + <span className="group-hover:underline decoration-primary/50"> + {recipe.title} + </span> + </TableCell> + <TableCell> + <div className="flex flex-wrap gap-2"> + {recipe.tags.map((tag) => ( + <Badge key={tag} variant="outline"> + {tag.toUpperCase()} + </Badge> + ))} + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </div> + </div> + </div> + ); +} + +export function CookbookUI({ initialRecipes, recipeCards }: CookbookProps) { + return ( + <Suspense fallback={<div>Loading...</div>}> + <CookbookContent + initialRecipes={initialRecipes} + recipeCards={recipeCards} + /> + </Suspense> + ); +} diff --git a/app/cookbook/components/snippet-result.tsx b/app/cookbook/components/snippet-result.tsx new file mode 100644 index 000000000..458a985a3 --- /dev/null +++ b/app/cookbook/components/snippet-result.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { Play } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ArrowUpRight } from "lucide-react"; +import { Code } from "@/components/docskit/code"; +import { initSimnet } from "@hirosystems/clarinet-sdk-browser"; +import { Cl } from "@stacks/transactions"; + +interface SnippetResultProps { + code: string; +} + +export function SnippetResult({ code }: SnippetResultProps) { + const [result, setResult] = React.useState<string | null>(null); + const [isLoading, setIsLoading] = React.useState(false); + + console.log({ result }); + + async function runCode() { + setIsLoading(true); + setResult(null); + + try { + const simnet = await initSimnet(); + await simnet.initEmtpySession(); + simnet.setEpoch("3.0"); + + const result = simnet.runSnippet(code) as string; + const deserializedResult = Cl.deserialize(result); + const prettyResult = Cl.prettyPrint(deserializedResult, 2); + + // Add a 2-second delay before updating the result + await new Promise((resolve) => setTimeout(resolve, 1000)); + + setResult(prettyResult); + } catch (error) { + console.error("Error running code snippet:", error); + setResult("An error occurred while running the code snippet."); + } finally { + setIsLoading(false); + } + } + + return ( + <div className="space-y-4"> + <div className="flex items-center gap-2"> + <Button + variant="outline" + className="gap-2" + size="sm" + onClick={runCode} + disabled={isLoading} + > + <Play className="w-4 h-4" /> + {isLoading ? "Running..." : "Run code snippet"} + </Button> + <Button className="gap-2" size="sm" asChild> + <Link + href={`https://play.hiro.so/?epoch=3.0&snippet=KGRlZmluZS1yZWFkLW9ubHkgKGdldC10ZW51cmUtaGVpZ2h0IChibG9jayB1aW50KSkKICAob2sKICAgIChhdC1ibG9jawogICAgICAodW53cmFwIQogICAgICAgIChnZXQtc3RhY2tzLWJsb2NrLWluZm8_IGlkLWhlYWRlci1oYXNoIGJsb2NrKQogICAgICAgIChlcnIgdTQwNCkKICAgICAgKQogICAgICB0ZW51cmUtaGVpZ2h0CiAgICApCiAgKQop`} + target="_blank" + > + Open in Clarity Playground <ArrowUpRight className="w-4 h-4" /> + </Link> + </Button> + </div> + {result && ( + <div + className="flex bg-ch-code p-2 rounded text-sm leading-6 font-mono whitespace-pre-wrap" + data-active="false" + > + <span className="ch-terminal-content break-all"> + <div> + <div className="indent-0 ml-0"> + <span className="indent-0 text-[var(--ch-4)]">{result}</span> + </div> + </div> + </span> + </div> + )} + </div> + ); +} diff --git a/app/cookbook/layout.tsx b/app/cookbook/layout.tsx new file mode 100644 index 000000000..58df7d647 --- /dev/null +++ b/app/cookbook/layout.tsx @@ -0,0 +1,19 @@ +import { Layout } from "fumadocs-ui/layout"; +import type { ReactNode } from "react"; +import { homeLayoutOptions } from "../(docs)/layout"; + +export default function CookbookLayout({ + children, +}: { + children: ReactNode; +}): JSX.Element { + return ( + <div className="px-10 *:border-none"> + <Layout {...homeLayoutOptions}> + <div className="min-h-screen py-8"> + <div className="space-y-6">{children}</div> + </div> + </Layout> + </div> + ); +} diff --git a/app/cookbook/page.tsx b/app/cookbook/page.tsx new file mode 100644 index 000000000..95ab6a9a3 --- /dev/null +++ b/app/cookbook/page.tsx @@ -0,0 +1,90 @@ +import { recipes } from "@/data/recipes"; +import { CookbookUI } from "./components/cookbook-ui"; +import { Code } from "@/components/docskit/code"; +import { Recipe } from "@/types/recipes"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Copy } from "lucide-react"; + +// Server Components for Recipe Display +function RecipeCard({ + recipe, + codeElement, +}: { + recipe: Recipe; + codeElement: React.ReactNode; +}) { + return ( + <div className="group relative w-full rounded-lg border border-border bg-card overflow-hidden"> + <div className="p-4 space-y-2"> + <div className="flex items-start justify-between gap-2"> + <div className="space-y-2"> + <h3 className="text-lg font-medium text-card-foreground"> + {recipe.title} + </h3> + <p className="text-sm text-muted-foreground mt-1"> + {recipe.description} + </p> + </div> + <div className="flex gap-2"> + <Button + size="icon" + className="bg-code dark:bg-background hover:bg-accent h-8 w-8" + aria-label="Copy to clipboard" + > + <Copy className="h-3.5 w-3.5 text-primary" /> + </Button> + </div> + </div> + <div className="flex flex-wrap gap-2 pt-2"> + {recipe.tags.map((tag) => ( + <Badge key={tag} variant="secondary"> + {tag.toUpperCase()} + </Badge> + ))} + </div> + </div> + + <div className="relative"> + <div className="recipe-preview max-h-[200px] overflow-hidden border-t border-border"> + {codeElement} + <div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-code via-code/100 to-transparent" /> + </div> + <Link + href={`/cookbook/${recipe.id}`} + className="absolute inset-0 flex h-[185px] items-center top-12 justify-center opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-background/25" + > + <Button + variant="outline" + className="bg-transparent hover:bg-transparent" + > + View details + </Button> + </Link> + </div> + </div> + ); +} + +export default async function Page() { + // Pre-render the recipe cards with Code components on the server + const recipeCards = await Promise.all( + recipes.map(async (recipe) => { + const codeElement = await Code({ + codeblocks: [ + { + lang: recipe.type, + value: recipe.files[0].content, + meta: "", + }, + ], + }); + + return ( + <RecipeCard key={recipe.id} recipe={recipe} codeElement={codeElement} /> + ); + }) + ); + return <CookbookUI initialRecipes={recipes} recipeCards={recipeCards} />; +} diff --git a/app/global.css b/app/global.css index 4388397a8..6be9d5364 100644 --- a/app/global.css +++ b/app/global.css @@ -115,6 +115,15 @@ body { /* Override CSS */ +.recipe-preview > div:first-child { + margin: 0; + height: 185px; +} + +.recipe > div:first-child { + margin: 0; +} + .container.flex.flex-row.gap-6.xl\:gap-12 { padding: 0; margin-top: 0; diff --git a/components/code/clarinet-sdk.tsx b/components/code/clarinet-sdk.tsx index 9e847f0bd..7f4c1d939 100644 --- a/components/code/clarinet-sdk.tsx +++ b/components/code/clarinet-sdk.tsx @@ -3,107 +3,30 @@ import React from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; -import * as Base from "fumadocs-ui/components/codeblock"; +import { Code } from "../docskit/code"; import { initSimnet } from "@hirosystems/clarinet-sdk-browser"; import { Cl } from "@stacks/transactions"; -import { getHighlighter } from "shiki"; - -// TODO: WIP: testing out new Clarinet JS SDK browser lib export const ClarinetSDK: React.FC = () => { - const [simnet, setSimnet] = React.useState<any>(); - const [html, setHtml] = React.useState<any>(); - const [evaluatedResponse, setEvaluatedResponse] = React.useState<any>(); + const [evaluatedResponse, setEvaluatedResponse] = React.useState<string>(); - async function showMe() { - const simnet = await initSimnet(); - await simnet.initEmtpySession(); - - simnet.setEpoch("2.5"); - const result = - simnet.runSnippet(`(define-map Users uint {address: principal}) - (map-insert Users u1 { address: tx-sender }) - (map-get? Users u1) - `) as any; - - const highlighter = await getHighlighter({ - langs: ["bash", "ts", "tsx", "clarity"], - themes: ["github-light", "github-dark"], - }); - const res = highlighter.codeToHtml(Cl.prettyPrint(result, 2), { - lang: "clarity", - defaultColor: false, - themes: { - light: "github-light", - dark: "github-dark", - }, - transformers: [ - { - name: "remove-pre", - root: (root) => { - if (root.children[0].type !== "element") return; - - return { - type: "root", - children: root.children[0].children, - }; - }, - }, - ], - }); - setEvaluatedResponse(res); - } + // Clarity code to be executed + const clarityCode = `(define-map Users uint {address: principal}) +(map-insert Users u1 { address: tx-sender }) +(map-get? Users u1)`; - async function run() { + async function runCode() { const simnet = await initSimnet(); await simnet.initEmtpySession(); - simnet.setEpoch("2.5"); - const result = - simnet.runSnippet(`(define-map Users uint {address: principal}) - (map-insert Users u1 { address: tx-sender }) - (map-get? Users u1) - `) as any; - console.log(Cl.prettyPrint(result, 2)); - setSimnet(simnet); - - const codeResponse = await fetch("/scripts/hello-world.clar"); - const code = await codeResponse.text(); - - const highlighter = await getHighlighter({ - langs: ["bash", "ts", "tsx", "clarity"], - themes: ["github-light", "github-dark"], - }); - - const html = highlighter.codeToHtml(code, { - lang: "clarity", - defaultColor: false, - themes: { - light: "github-light", - dark: "github-dark", - }, - transformers: [ - { - name: "remove-pre", - root: (root) => { - if (root.children[0].type !== "element") return; - return { - type: "root", - children: root.children[0].children, - }; - }, - }, - ], - }); - setHtml(html); + const result = simnet.runSnippet(clarityCode) as any; + const deserializedResult = Cl.deserialize(result); + console.log(deserializedResult); + setEvaluatedResponse(Cl.prettyPrint(deserializedResult, 2)); } - React.useEffect(() => { - run(); - }, []); - return ( <> <Button @@ -113,18 +36,32 @@ export const ClarinetSDK: React.FC = () => { "dark:bg-white dark:text-neutral-900", "hover:bg-neutral-900/90 dark:hover:bg-gray-100/90" )} - onClick={showMe} + onClick={runCode} > Run </Button> - <Base.CodeBlock> - <Base.Pre dangerouslySetInnerHTML={{ __html: html }} /> - </Base.CodeBlock> - {evaluatedResponse ? ( - <Base.CodeBlock allowCopy={false}> - <Base.Pre dangerouslySetInnerHTML={{ __html: evaluatedResponse }} /> - </Base.CodeBlock> - ) : null} + + <Code + codeblocks={[ + { + lang: "clarity", + value: clarityCode, + meta: "", + }, + ]} + /> + + {evaluatedResponse && ( + <Code + codeblocks={[ + { + lang: "clarity", + value: evaluatedResponse, + meta: "", + }, + ]} + /> + )} </> ); }; diff --git a/components/docskit/annotations/hover-line.client.tsx b/components/docskit/annotations/hover-line.client.tsx new file mode 100644 index 000000000..5b4b7a8ac --- /dev/null +++ b/components/docskit/annotations/hover-line.client.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { InnerLine } from "codehike/code"; +import { CustomLineProps } from "codehike/code/types"; +import { useHover } from "@/context/hover"; + +export function HoverLineClient({ annotation, ...props }: CustomLineProps) { + try { + const { hoveredId } = useHover(); + const isHovered = !hoveredId || annotation?.query === hoveredId; + + return ( + <InnerLine + merge={props} + className="transition-opacity duration-200" + style={{ opacity: isHovered ? 1 : 0.5 }} + data-line={annotation?.query || ""} + /> + ); + } catch (error) { + console.warn("Hover context not ready:", error); + return <InnerLine merge={props} />; + } +} diff --git a/components/docskit/annotations/hover.client.tsx b/components/docskit/annotations/hover.client.tsx new file mode 100644 index 000000000..eb788ee7a --- /dev/null +++ b/components/docskit/annotations/hover.client.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useHover } from "@/context/hover"; + +export function HoverLinkClient(props: { + href?: string; + children?: React.ReactNode; + className?: string; +}) { + const { setHoveredId } = useHover(); + const hoverId = props.href?.slice("hover:".length); + + return ( + <span + className={`cursor-default underline decoration-dotted underline-offset-4 ${props.className}`} + onMouseEnter={() => setHoveredId(hoverId ?? null)} + onMouseLeave={() => setHoveredId(null)} + > + {props.children} + </span> + ); +} diff --git a/components/docskit/annotations/hover.tsx b/components/docskit/annotations/hover.tsx new file mode 100644 index 000000000..d62c99e29 --- /dev/null +++ b/components/docskit/annotations/hover.tsx @@ -0,0 +1,14 @@ +import { AnnotationHandler, InnerLine } from "codehike/code"; +import { HoverLinkClient } from "./hover.client"; +import { HoverLineClient } from "./hover-line.client"; + +export const hover: AnnotationHandler = { + name: "hover", + onlyIfAnnotated: true, + Line: ({ annotation, ...props }) => { + // This needs to be a client component to access context + return <HoverLineClient annotation={annotation} {...props} />; + }, +}; + +export { HoverLinkClient as HoverLink }; diff --git a/components/docskit/code.tsx b/components/docskit/code.tsx index 582379bc4..c94aac591 100644 --- a/components/docskit/code.tsx +++ b/components/docskit/code.tsx @@ -15,6 +15,7 @@ import { link } from "./annotations/link"; import { tokenTransitions } from "./annotations/token-transitions"; import { tooltip } from "./annotations/tooltip"; import { callout } from "./annotations/callout"; +import { hover } from "./annotations/hover"; import { CODEBLOCK, CodeGroup, flagsToOptions, TITLEBAR } from "./code-group"; export async function Code(props: { @@ -121,6 +122,7 @@ function getHandlers(options: CodeGroup["options"]) { ...collapse, options.wordWrap && wordWrap, callout, + hover, ].filter(Boolean) as AnnotationHandler[]; } diff --git a/components/table.tsx b/components/table.tsx index f6a59916d..9f6e999db 100644 --- a/components/table.tsx +++ b/components/table.tsx @@ -18,19 +18,17 @@ interface NetworkBadgeProps { } const NetworkBadge = ({ network }: NetworkBadgeProps) => ( - <code className="relative rounded bg-muted p-1.5 font-mono text-sm text-left text-muted-foreground"> - <> - {typeof network === "object" && network !== null ? ( - <code className="relative rounded bg-muted p-1.5 font-mono text-sm text-left text-muted-foreground w-full"> - {network.props.children} - </code> - ) : ( - <span className="text-muted-foreground whitespace-normal break-words text-base"> - {network} - </span> - )} - </> - </code> + <> + {typeof network === "object" && network !== null ? ( + <code className="relative rounded bg-muted p-1.5 font-mono text-sm text-left text-muted-foreground w-full"> + {network.props.children} + </code> + ) : ( + <span className="text-muted-foreground whitespace-normal break-words text-base"> + {network} + </span> + )} + </> ); function CustomTable({ className, ...props }: TableProps) { diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index 28058cd0b..e4284916e 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-data-[state=active]:bg-inverted group-data-[state=active]:text-background", + "inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold font-aeonikFono transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-data-[state=active]:bg-inverted group-data-[state=active]:text-background", { variants: { variant: { diff --git a/components/ui/icon.tsx b/components/ui/icon.tsx index f1e9b1bef..95be0a3c2 100644 --- a/components/ui/icon.tsx +++ b/components/ui/icon.tsx @@ -3676,7 +3676,7 @@ export function X(props: SVGProps<SVGSVGElement>): JSX.Element { > <path d="M6.53071 7.86068L10.6863 13.4687H14.7787L5.71311 1.09106H1.51691L6.53071 7.86068ZM6.53071 7.86068L1.77879 13.4687M9.56868 6.35521L14.0292 1.09106" - stroke="#7A756B" + stroke="var(--icon)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" @@ -3693,10 +3693,11 @@ export function Discord(props: SVGProps<SVGSVGElement>): JSX.Element { viewBox="0 0 20 14" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > <path d="M16.3855 1.40691C15.1742 0.880071 13.8959 0.504383 12.5826 0.289257C12.5706 0.287148 12.5583 0.288658 12.5473 0.293571C12.5364 0.298484 12.5273 0.306551 12.5215 0.316623C12.3572 0.593455 12.1753 0.954525 12.0479 1.23829C10.6119 1.03456 9.18311 1.03456 7.77655 1.23829C7.64919 0.948181 7.46065 0.593455 7.29569 0.316623C7.28958 0.306781 7.28048 0.298911 7.26957 0.294043C7.25867 0.289174 7.24648 0.287532 7.23458 0.289331C5.92111 0.503956 4.6427 0.879613 3.43155 1.40684C3.42119 1.41103 3.41245 1.41817 3.40656 1.42727C0.984397 4.85606 0.320821 8.20055 0.646381 11.5036C0.647302 11.5117 0.649926 11.5195 0.654098 11.5266C0.65827 11.5337 0.663905 11.5399 0.67067 11.545C2.26873 12.657 3.81674 13.3321 5.33602 13.7795C5.34783 13.7829 5.36044 13.7827 5.37216 13.7791C5.38387 13.7754 5.39413 13.7685 5.40157 13.7592C5.76091 13.2942 6.08126 12.8038 6.35598 12.2882C6.35976 12.2811 6.36191 12.2734 6.36231 12.2655C6.3627 12.2576 6.36132 12.2497 6.35826 12.2423C6.3552 12.235 6.35053 12.2283 6.34456 12.2228C6.33858 12.2172 6.33144 12.213 6.3236 12.2102C5.81541 12.0276 5.33158 11.8049 4.86613 11.552C4.85764 11.5473 4.85051 11.5407 4.84537 11.5327C4.84023 11.5248 4.83723 11.5157 4.83665 11.5064C4.83606 11.4971 4.83791 11.4878 4.84202 11.4793C4.84613 11.4709 4.85238 11.4635 4.86021 11.4578C4.95815 11.3883 5.05616 11.316 5.14965 11.2429C5.15796 11.2364 5.16802 11.2322 5.17869 11.2309C5.18936 11.2295 5.20023 11.231 5.21006 11.2352C8.26777 12.5579 11.578 12.5579 14.5996 11.2352C14.6095 11.2307 14.6204 11.229 14.6312 11.2303C14.6421 11.2315 14.6523 11.2356 14.6607 11.2422C14.7543 11.3152 14.8522 11.3883 14.9509 11.4578C14.9588 11.4634 14.9651 11.4708 14.9692 11.4792C14.9734 11.4876 14.9753 11.4969 14.9748 11.5062C14.9743 11.5155 14.9714 11.5245 14.9663 11.5325C14.9613 11.5405 14.9542 11.5472 14.9458 11.552C14.4802 11.8097 13.9924 12.0296 13.4877 12.2095C13.4799 12.2123 13.4728 12.2167 13.4668 12.2223C13.4609 12.228 13.4563 12.2347 13.4533 12.2421C13.4503 12.2496 13.449 12.2575 13.4495 12.2654C13.4499 12.2734 13.4522 12.2811 13.456 12.2882C13.7366 12.8031 14.0569 13.2934 14.4097 13.7584C14.4169 13.768 14.4271 13.7752 14.4388 13.779C14.4506 13.7828 14.4633 13.783 14.4752 13.7795C16.0018 13.332 17.5498 12.6569 19.1479 11.545C19.1548 11.5402 19.1605 11.5341 19.1647 11.5271C19.1689 11.5201 19.1715 11.5123 19.1722 11.5042C19.5618 7.68554 18.5197 4.36849 16.4098 1.42794C16.4046 1.41838 16.396 1.4109 16.3855 1.40684V1.40691ZM6.81256 9.49236C5.89201 9.49236 5.13346 8.69151 5.13346 7.70803C5.13346 6.72463 5.8773 5.92378 6.81264 5.92378C7.75522 5.92378 8.50638 6.73163 8.49166 7.70811C8.49166 8.69151 7.74783 9.49236 6.81256 9.49236ZM13.0208 9.49236C12.1002 9.49236 11.3417 8.69151 11.3417 7.70803C11.3417 6.72463 12.0854 5.92378 13.0208 5.92378C13.9634 5.92378 14.7145 6.73163 14.6998 7.70811C14.6998 8.69151 13.9634 9.49236 13.0208 9.49236Z" - fill="#7A756B" + fill="var(--icon)" /> </svg> ); @@ -3708,12 +3709,13 @@ export function Github(props: SVGProps<SVGSVGElement>): JSX.Element { width="19" height="20" viewBox="0 0 19 20" - fill="none" + fill="currentColor" xmlns="http://www.w3.org/2000/svg" + {...props} > <path d="M9.38443 0.966553C4.2231 0.966553 0.0426636 5.25553 0.0426636 10.5455C0.0426636 14.7786 2.71908 18.3683 6.4301 19.6335C6.89718 19.7237 7.06845 19.4276 7.06845 19.1729C7.06845 18.9454 7.06067 18.3428 7.05677 17.5445C4.45821 18.1225 3.91015 16.2593 3.91015 16.2593C3.4851 15.1538 2.87088 14.8584 2.87088 14.8584C2.02467 14.2645 2.93628 14.2765 2.93628 14.2765C3.87434 14.3436 4.36712 15.2631 4.36712 15.2631C5.2001 16.7279 6.55387 16.3048 7.08791 16.0598C7.17199 15.4403 7.41254 15.0181 7.67956 14.7786C5.60491 14.5391 3.42438 13.7153 3.42438 10.045C3.42438 8.9993 3.78638 8.14518 4.38581 7.47465C4.28071 7.23278 3.96543 6.25892 4.46755 4.93942C4.46755 4.93942 5.24992 4.68239 7.03653 5.92127C7.78387 5.70813 8.57792 5.60277 9.37197 5.59798C10.166 5.60277 10.9601 5.70813 11.7074 5.92127C13.4824 4.68239 14.2647 4.93942 14.2647 4.93942C14.7668 6.25892 14.4516 7.23278 14.3581 7.47465C14.9537 8.14518 15.3157 8.9993 15.3157 10.045C15.3157 13.7249 13.132 14.5351 11.0535 14.7706C11.3805 15.058 11.6841 15.6455 11.6841 16.5427C11.6841 17.8247 11.6724 18.8544 11.6724 19.1658C11.6724 19.4172 11.8359 19.7166 12.3146 19.6208C16.0521 18.3643 18.7262 14.7722 18.7262 10.5455C18.7262 5.25553 14.5434 0.966553 9.38443 0.966553Z" - fill="#7A756B" + fill="currentColor" /> </svg> ); @@ -3727,10 +3729,11 @@ export function Youtube(props: SVGProps<SVGSVGElement>): JSX.Element { viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > <path d="M19.9942 2.53253C19.8818 2.09858 19.6602 1.70252 19.3515 1.38401C19.0428 1.06549 18.6579 0.835705 18.2353 0.717652C16.6844 0.288574 10.4634 0.288574 10.4634 0.288574C10.4634 0.288574 4.24248 0.288574 2.69077 0.717652C2.26836 0.835913 1.88365 1.06579 1.57512 1.38428C1.26659 1.70277 1.04505 2.09872 0.932653 2.53253C0.516541 4.13328 0.516541 7.47245 0.516541 7.47245C0.516541 7.47245 0.516541 10.8116 0.932653 12.4124C1.04504 12.8463 1.26668 13.2424 1.57538 13.5609C1.88408 13.8794 2.269 14.1092 2.6916 14.2272C4.24248 14.6563 10.4634 14.6563 10.4634 14.6563C10.4634 14.6563 16.6844 14.6563 18.2361 14.2272C18.6588 14.1093 19.0437 13.8795 19.3524 13.561C19.6611 13.2424 19.8827 12.8463 19.9951 12.4124C20.4103 10.8116 20.4103 7.47245 20.4103 7.47245C20.4103 7.47245 20.4103 4.13328 19.9942 2.53253ZM8.42847 10.504V4.44086L13.6282 7.47245L8.42847 10.504Z" - fill="#7A756B" + fill="var(--icon)" /> </svg> ); diff --git a/content/_recipes/create-random-number.mdx b/content/_recipes/create-random-number.mdx new file mode 100644 index 000000000..38df3eddf --- /dev/null +++ b/content/_recipes/create-random-number.mdx @@ -0,0 +1,19 @@ +# Create a random number + +```terminal +$ curl -X POST https://api.hover.build/v1/execute \ + -H "Authorization: Bearer $HOVER_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"contract": "get-tenure-for-block.clar", "function": "get-tenure-height", "args": [1234567890]}' +``` + +This is The <HoverLink href="hover:random">read-rnd</HoverLink> returns 1.# Get tenure height by block + +```terminal +$ curl -X POST https://api.hover.build/v1/execute \ + -H "Authorization: Bearer $HOVER_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"contract": "get-tenure-for-block.clar", "function": "get-tenure-height", "args": [1234567890]}' +``` + +This is The <HoverLink href="hover:api-key">API key</HoverLink> returns 1. \ No newline at end of file diff --git a/content/_recipes/get-tenure-height-for-a-block.mdx b/content/_recipes/get-tenure-height-for-a-block.mdx new file mode 100644 index 000000000..baf69d123 --- /dev/null +++ b/content/_recipes/get-tenure-height-for-a-block.mdx @@ -0,0 +1,32 @@ +# Get tenure height for a block + +On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. + +Understanding and accessing this information is really useful when you need to: + +- Track block sequences within minure tenures +- Implement logic that depends on tenure-specific block ordering +- Verify block relationships within a single miner's tenure + +Let's take a look at the following code to better understand how to work with tenure height information for a given block. + +At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. + +Think of it like a block's fingerprint - no two blocks will ever have the same one. + +```terminal +$ clarinet console +$ ::advance_stacks_chain_tip 1 +$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) +[32m(ok u3)[0m [1m[0m +``` + +Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. + +It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. + +Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ + +You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. + +The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. \ No newline at end of file diff --git a/content/docs/stacks/api/architecture.mdx b/content/docs/stacks/api/architecture.mdx index bb6c28187..133eb46af 100644 --- a/content/docs/stacks/api/architecture.mdx +++ b/content/docs/stacks/api/architecture.mdx @@ -45,7 +45,7 @@ Events are HTTP POST requests containing: Byproducts of executed transactions such as: - Asset transfers - Smart-contract log data - - Execution cost data + - Execution cost data The API processes and stores these events as relational data in PostgreSQL. For the "event observer" code, see `/src/event-stream`. diff --git a/content/docs/stacks/api/txs.mdx b/content/docs/stacks/api/txs.mdx index a3df08f80..8c61e7a3c 100644 --- a/content/docs/stacks/api/txs.mdx +++ b/content/docs/stacks/api/txs.mdx @@ -89,7 +89,7 @@ A post-condition includes the following information: | **Attribute** | **Sample** | **Description** | | ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| [Principal](https://docs.stacks.co/clarity/types) | `SP2ZD731ANQZT6J4K3F5N8A40ZXWXC1XFXHVVQFKE` | Original owner of the asset, can be a Stacks address or a smart contract | +| Principal | `SP2ZD731ANQZT6J4K3F5N8A40ZXWXC1XFXHVVQFKE` | Original owner of the asset, can be a Stacks address or a smart contract | | Asset id | `STX` | Asset to apply conditions to (could be STX, fungible, or non-fungible tokens) | | Comparator | `>=` | Compare operation to be applied (could define "how much" or "whether or not the asset is owned") | | Literal | `1000000` | Use a number or true/false value to check if the asset meets the condition | diff --git a/content/docs/stacks/clarinet/index.mdx b/content/docs/stacks/clarinet/index.mdx index 417459d58..dbd028bd1 100644 --- a/content/docs/stacks/clarinet/index.mdx +++ b/content/docs/stacks/clarinet/index.mdx @@ -18,74 +18,28 @@ You can code with <TooltipProvider inline><Tooltip><TooltipTrigger asChild><span ## Installation -<Tabs items={['macOS', 'Windows', 'Pre-built binary', 'Using Cargo']}> - <Tab value="macOS"> - ```terminal - $ brew install clarinet - ``` - </Tab> - - <Tab value="Windows"> - ```terminal - $ winget install clarinet - ``` - </Tab> - - <Tab value="Pre-built binary"> - ```terminal - $ wget -nv https://github.com/hirosystems/clarinet/releases/download/v0.27.0/clarinet-linux-x64-glibc.tar.gz -O clarinet-linux-x64.tar.gz - $ tar -xf clarinet-linux-x64.tar.gz - $ chmod +x ./clarinet - $ mv ./clarinet /usr/local/bin - ``` - - <Callout title="For macOS" type="warn"> - You may receive security errors when running the pre-compiled binary. To resolve the security warning, use the command below and replace the path `/usr/local/bin/clarinet` with your local binary file. - - ```terminal - $ xattr -d com.apple.quarantine /usr/local/bin/clarinet - ``` - </Callout> - </Tab> - - <Tab value="Using Cargo"> - ```terminal - $ sudo apt install build-essential pkg-config libssl-dev - ``` - - <Callout title="Requirements" type="warn"> - If you choose this option, please be aware that you must first install Rust. For more information on installing Rust, please see the Install Rust page for access to Cargo, the Rust package manager. - </Callout> - - <h3 className='scroll-m-20'>Build Clarinet</h3> - - Once you have installed Clarinet using Cargo, you can build Clarinet from the source using Cargo with the following commands: - - ```terminal - $ git clone https://github.com/hirosystems/clarinet.git --recursive - $ cd clarinet - $ cargo clarinet-install - ``` - - By default, you will be placed in our development branch, `develop`, with code that has not yet been released. - - - If you plan to submit any code changes, this is the right branch for you. - - If you prefer the latest stable version, switch to the main branch by entering the command below. - - ```terminal - $ git checkout main - ``` - - If you have previously checked out the source, ensure you have the latest code (including submodules) before building using this command: - - ```terminal - $ git checkout main - $ git pull - $ git submodule update --recursive - ``` - - </Tab> -</Tabs> +<TerminalPicker storage="macOs"> + +```terminal !! macOS +$ brew install clarinet +``` + +```terminal !! Windows +$ winget install clarinet +``` + +```terminal !! Cargo +$ sudo apt install build-essential pkg-config libssl-dev +``` + +```terminal !! Pre-built binary +$ wget -nv https://github.com/hirosystems/clarinet/releases/download/v0.27.0/clarinet-linux-x64-glibc.tar.gz -O clarinet-linux-x64.tar.gz +$ tar -xf clarinet-linux-x64.tar.gz +$ chmod +x ./clarinet +$ mv ./clarinet /usr/local/bin +``` + +</TerminalPicker> ## Set up shell completions diff --git a/context/hover.tsx b/context/hover.tsx new file mode 100644 index 000000000..b9c9834c8 --- /dev/null +++ b/context/hover.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React, { createContext, useContext, useState } from "react"; + +interface HoverContextType { + hoveredId: string | null; + setHoveredId: (id: string | null) => void; +} + +const HoverContext = createContext<HoverContextType | undefined>(undefined); + +export const HoverProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [hoveredId, setHoveredId] = useState<string | null>(null); + + return ( + <HoverContext.Provider value={{ hoveredId, setHoveredId }}> + {children} + </HoverContext.Provider> + ); +}; + +export const useHover = (): HoverContextType => { + const context = useContext(HoverContext); + if (!context) { + throw new Error("useHover must be used within a HoverProvider"); + } + return context; +}; diff --git a/data/recipes.ts b/data/recipes.ts new file mode 100644 index 000000000..7cb260c99 --- /dev/null +++ b/data/recipes.ts @@ -0,0 +1,76 @@ +import { Recipe } from "@/types/recipes"; + +export const recipes: Recipe[] = [ + { + id: "create-random-number", + title: "Create a random number in Clarity using block-height", + description: + "Create a random number based on a block-height using the buff-to-uint-be function in Clarity.", + type: "clarity", + date: "2024.02.28", + tags: ["clarity"], + files: [ + { + name: "random.clar", + path: "contracts/random.clar", + content: `(define-constant ERR_FAIL (err u1000)) + +;; !hover random +(define-read-only (read-rnd (block uint)) + (ok (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? (unwrap! (get-block-info? vrf-seed block) ERR_FAIL) u16 u32)) u16)))) +)`, + }, + ], + }, + { + id: "create-a-multisig-address-using-principal-construct", + title: "Create a multisig address using principal-construct", + description: + "Create a multisig address using the principal-construct function in Clarity.", + type: "clarity", + date: "2024.02.28", + tags: ["clarity"], + files: [ + { + name: "multisig.clar", + path: "contracts/multisig.clar", + content: `(define-read-only (pubkeys-to-principal (pubkeys (list 128 (buff 33))) (m uint)) + (unwrap-panic (principal-construct? + (if is-in-mainnet 0x14 0x15) ;; address version + (pubkeys-to-hash pubkeys m) + )) +)`, + }, + ], + }, + { + id: "get-tenure-height-for-a-block", + title: "Get Tenure Height for a Block", + description: + "Get the tenure height for a specific block height using Clarity.", + type: "clarity", + date: "2024.02.28", + tags: ["clarity"], + files: [ + { + name: "get-tenure-for-block.clar", + path: "contracts/get-tenure-for-block.clar", + content: `(define-read-only (get-tenure-height (block uint)) + (ok + (at-block + (unwrap! + ;; !hover get-stacks-block-info + (get-stacks-block-info? id-header-hash block) + ;; !hover error + (err u404) + ) + ;; !hover tenure-height + tenure-height + ) + ) +)`, + snippet: `(print (ok (at-block (unwrap! (get-stacks-block-info? id-header-hash (- stacks-block-height u1)) (err u404)) tenure-height)))`, + }, + ], + }, +]; diff --git a/mdx-components.tsx b/mdx-components.tsx index e831a4ef4..95a59da17 100644 --- a/mdx-components.tsx +++ b/mdx-components.tsx @@ -12,6 +12,20 @@ import { OrderedList, UnorderedList } from "@/components/lists"; export function useMDXComponents(components: MDXComponents): MDXComponents { return { ...defaultComponents, + h1: (props) => { + const H1 = defaultComponents.h1 as React.ComponentType<any>; + + const id = + typeof props.children === "string" + ? props.children + : (props.children as React.ReactElement)?.props?.children; + + return ( + <H1 id={id} {...props}> + {props.children} + </H1> + ); + }, Accordion, Accordions, blockquote: (props) => <Callout>{props.children}</Callout>, @@ -19,13 +33,13 @@ export function useMDXComponents(components: MDXComponents): MDXComponents { Cards, Card, SecondaryCard, - code: (props) => ( + code: (props: React.PropsWithChildren) => ( <code {...props} - className="border border-border p-1 bg-code text-sm text-muted-foreground" + className={`border border-border rounded-md p-1 bg-code text-sm text-muted-foreground [h1_&]:text-xl`} /> ), - hr: (props) => ( + hr: (props: React.PropsWithChildren) => ( <hr {...props} className="border-t border-border/50 mt-0 mb-6" /> ), Tab, diff --git a/next.config.mjs b/next.config.mjs index a4877f2d0..85537f9bc 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,6 +8,7 @@ import { import rehypeKatex from 'rehype-katex'; import remarkMath from 'remark-math'; import { recmaCodeHike, remarkCodeHike } from "codehike/mdx"; +import theme from "./components/docskit/theme.mjs"; const withAnalyzer = createBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', diff --git a/public/contracts/hello-world.clar b/public/contracts/hello-world.clar deleted file mode 100644 index ad435dc81..000000000 --- a/public/contracts/hello-world.clar +++ /dev/null @@ -1,9 +0,0 @@ -(define-map Users principal {address: principal}) - -(map-insert Users tx-sender { address: tx-sender }) - -(define-read-only (get-user (who principal)) - (unwrap! (map-get? Users tx-sender) (err u404)) -) - -(get-user tx-sender) \ No newline at end of file diff --git a/types/recipes.ts b/types/recipes.ts new file mode 100644 index 000000000..7c0984595 --- /dev/null +++ b/types/recipes.ts @@ -0,0 +1,18 @@ +export type RecipeType = "typescript" | "curl" | "clarity"; +export type RecipeTag = "api" | "stacks.js" | "clarity" | "clarinet"; + +export interface Recipe { + id: string; + title: string; + description: string; + type: RecipeType; + date: string; + tags: RecipeTag[]; + files: { + name: string; + path: string; + content: string; + snippet?: string; + preview?: any; + }[]; +} From 0d00163b5473328e35cf2fdbecd14101157afd3b Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 6 Dec 2024 10:31:03 -0600 Subject: [PATCH 02/30] update content and styling --- app/(docs)/layout.tsx | 16 +- app/api/run/route.ts | 24 --- app/cookbook/[id]/page.tsx | 9 +- app/cookbook/components/cookbook-ui.tsx | 74 +++++--- app/cookbook/components/snippet-result.tsx | 110 +++++++++-- app/cookbook/page.tsx | 43 ++--- app/global.css | 15 ++ .../docskit/annotations/hover-line.client.tsx | 5 +- .../docskit/annotations/hover.client.tsx | 2 +- components/docskit/copy-button.tsx | 3 +- content/_recipes/clarity-bitcoin.mdx | 46 +++++ ...isig-address-using-principal-construct.mdx | 32 ++++ content/_recipes/create-random-number.mdx | 39 ++-- .../fetch-testnet-bitcoin-on-regtest.mdx | 32 ++++ .../get-tenure-height-for-a-block.mdx | 7 +- data/recipes.ts | 177 +++++++++++++++++- types/recipes.ts | 12 +- 17 files changed, 511 insertions(+), 135 deletions(-) delete mode 100644 app/api/run/route.ts create mode 100644 content/_recipes/clarity-bitcoin.mdx create mode 100644 content/_recipes/create-a-multisig-address-using-principal-construct.mdx create mode 100644 content/_recipes/fetch-testnet-bitcoin-on-regtest.mdx diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx index bdd6cc74c..7e772f2af 100644 --- a/app/(docs)/layout.tsx +++ b/app/(docs)/layout.tsx @@ -36,10 +36,10 @@ export const layoutOptions: Omit<DocsLayoutProps, "children"> = { text: "Guides", url: "/guides", }, - // { - // text: "Cookbook", - // url: "/cookbook", - // }, + { + text: "Cookbook", + url: "/cookbook", + }, ], sidebar: { defaultOpenLevel: 0, @@ -74,10 +74,10 @@ export const homeLayoutOptions: Omit<DocsLayoutProps, "children"> = { text: "Guides", url: "/guides", }, - // { - // text: "Cookbook", - // url: "/cookbook", - // }, + { + text: "Cookbook", + url: "/cookbook", + }, ], }; diff --git a/app/api/run/route.ts b/app/api/run/route.ts deleted file mode 100644 index 08739c9db..000000000 --- a/app/api/run/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { initSimnet } from "@hirosystems/clarinet-sdk-browser"; -import { Cl } from "@stacks/transactions"; -import { NextResponse } from "next/server"; - -export async function POST(request: Request) { - try { - const { code } = await request.json(); - - const simnet = await initSimnet(); - await simnet.initEmtpySession(); - simnet.setEpoch("3.0"); - - const result = simnet.runSnippet(code); - const deserializedResult = Cl.deserialize(result); - const prettyResult = Cl.prettyPrint(deserializedResult, 2); - - return NextResponse.json({ result: prettyResult }); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : String(error) }, - { status: 500 } - ); - } -} diff --git a/app/cookbook/[id]/page.tsx b/app/cookbook/[id]/page.tsx index 53ab06ffd..3ded6e563 100644 --- a/app/cookbook/[id]/page.tsx +++ b/app/cookbook/[id]/page.tsx @@ -83,14 +83,17 @@ export default async function Page({ <Code codeblocks={[ { - lang: recipe.type, + lang: recipe.files[0].type, value: recipe.files[0].content, - meta: `${recipe.files[0].name} -cnw`, // filename + flags + meta: `${recipe.files[0].name} -cn`, // filename + flags }, ]} /> </div> - <SnippetResult code={recipe.files[0].content as string} /> + <SnippetResult + code={recipe.files[0].content as string} + type={recipe.files[0].type} + /> </div> </div> </div> diff --git a/app/cookbook/components/cookbook-ui.tsx b/app/cookbook/components/cookbook-ui.tsx index 1dd062f3b..de739bb8e 100644 --- a/app/cookbook/components/cookbook-ui.tsx +++ b/app/cookbook/components/cookbook-ui.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; import { CustomTable } from "@/components/table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { LayoutGrid, List } from "lucide-react"; +import { Filter, LayoutGrid, List } from "lucide-react"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; // Internal components @@ -48,7 +48,14 @@ function ViewToggle({ ); } -const ALL_TAGS: RecipeTag[] = ["api", "stacks.js", "clarity", "clarinet"]; +const ALL_TAGS: RecipeTag[] = [ + "api", + "bitcoin", + "clarity", + "clarinet", + "chainhook", + "stacks.js", +]; function RecipeFilters({ selectedTags, @@ -61,7 +68,7 @@ function RecipeFilters({ }) { return ( <div className="space-y-4"> - <div className="flex flex-wrap gap-2"> + <div className="flex flex-wrap items-center gap-2"> {ALL_TAGS.map((tag) => ( <Badge key={tag} @@ -192,28 +199,45 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { ) : ( <Table> <TableBody> - {initialRecipes.map((recipe) => ( - <TableRow - key={recipe.id} - className="cursor-pointer group" - onClick={() => router.push(`/cookbook/${recipe.id}`)} - > - <TableCell className="py-4 text-primary whitespace-normal break-words text-base"> - <span className="group-hover:underline decoration-primary/50"> - {recipe.title} - </span> - </TableCell> - <TableCell> - <div className="flex flex-wrap gap-2"> - {recipe.tags.map((tag) => ( - <Badge key={tag} variant="outline"> - {tag.toUpperCase()} - </Badge> - ))} - </div> - </TableCell> - </TableRow> - ))} + {initialRecipes + .filter((recipe) => { + const matchesSearch = + search === "" || + recipe.title + .toLowerCase() + .includes(search.toLowerCase()) || + recipe.description + .toLowerCase() + .includes(search.toLowerCase()); + + const matchesTags = + selectedTags.length === 0 || + selectedTags.some((tag) => recipe.tags.includes(tag)); + + return matchesSearch && matchesTags; + }) + .map((recipe) => ( + <TableRow + key={recipe.id} + className="cursor-pointer group hover:bg-transparent" + onClick={() => router.push(`/cookbook/${recipe.id}`)} + > + <TableCell className="py-4 text-primary font-aeonikFono whitespace-normal break-words text-base"> + <span className="group-hover:underline decoration-primary/50"> + {recipe.title} + </span> + </TableCell> + <TableCell> + <div className="flex flex-wrap gap-2"> + {recipe.tags.map((tag) => ( + <Badge key={tag} variant="outline"> + {tag.toUpperCase()} + </Badge> + ))} + </div> + </TableCell> + </TableRow> + ))} </TableBody> </Table> )} diff --git a/app/cookbook/components/snippet-result.tsx b/app/cookbook/components/snippet-result.tsx index 458a985a3..4934b40a1 100644 --- a/app/cookbook/components/snippet-result.tsx +++ b/app/cookbook/components/snippet-result.tsx @@ -2,7 +2,7 @@ import React from "react"; import Link from "next/link"; -import { Play } from "lucide-react"; +import { Play, Terminal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ArrowUpRight } from "lucide-react"; import { Code } from "@/components/docskit/code"; @@ -11,15 +11,27 @@ import { Cl } from "@stacks/transactions"; interface SnippetResultProps { code: string; + type: string; } -export function SnippetResult({ code }: SnippetResultProps) { +type OutputItem = { + type: "command" | "success" | "error" | "log"; + content: string; +}; + +export function SnippetResult({ code, type }: SnippetResultProps) { const [result, setResult] = React.useState<string | null>(null); const [isLoading, setIsLoading] = React.useState(false); - - console.log({ result }); + const [isConsoleOpen, setIsConsoleOpen] = React.useState(false); + const [input, setInput] = React.useState(""); + const [output, setOutput] = React.useState<OutputItem[]>([]); + const outputRef = React.useRef<HTMLDivElement>(null); async function runCode() { + if (isConsoleOpen) { + setIsConsoleOpen(false); + return; + } setIsLoading(true); setResult(null); @@ -28,14 +40,18 @@ export function SnippetResult({ code }: SnippetResultProps) { await simnet.initEmtpySession(); simnet.setEpoch("3.0"); - const result = simnet.runSnippet(code) as string; - const deserializedResult = Cl.deserialize(result); - const prettyResult = Cl.prettyPrint(deserializedResult, 2); + const codeExecution = simnet.execute(code); + const result = codeExecution.result; + const prettyResult = Cl.prettyPrint(result, 2); + // console.log("before :", simnet.execute("stacks-block-height")); + // simnet.executeCommand("::advance_chain_tip 2"); + // console.log("after: ", simnet.execute("stacks-block-height")); - // Add a 2-second delay before updating the result - await new Promise((resolve) => setTimeout(resolve, 1000)); + // Add a 1-second delay before updating the result + // await new Promise((resolve) => setTimeout(resolve, 1000)); setResult(prettyResult); + setIsConsoleOpen(true); } catch (error) { console.error("Error running code snippet:", error); setResult("An error occurred while running the code snippet."); @@ -44,6 +60,24 @@ export function SnippetResult({ code }: SnippetResultProps) { } } + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (input.trim()) { + setOutput([...output, { type: "command", content: input }]); + setOutput((prev) => [...prev, { type: "success", content: `u1` }]); + setInput(""); + } + } + + const getButtonText = () => { + if (type === "clarity") { + if (isLoading) return "Loading..."; + if (isConsoleOpen) return "Hide terminal"; + return "Open in terminal"; + } + return "Run code snippet"; + }; + return ( <div className="space-y-4"> <div className="flex items-center gap-2"> @@ -54,19 +88,25 @@ export function SnippetResult({ code }: SnippetResultProps) { onClick={runCode} disabled={isLoading} > - <Play className="w-4 h-4" /> - {isLoading ? "Running..." : "Run code snippet"} - </Button> - <Button className="gap-2" size="sm" asChild> - <Link - href={`https://play.hiro.so/?epoch=3.0&snippet=KGRlZmluZS1yZWFkLW9ubHkgKGdldC10ZW51cmUtaGVpZ2h0IChibG9jayB1aW50KSkKICAob2sKICAgIChhdC1ibG9jawogICAgICAodW53cmFwIQogICAgICAgIChnZXQtc3RhY2tzLWJsb2NrLWluZm8_IGlkLWhlYWRlci1oYXNoIGJsb2NrKQogICAgICAgIChlcnIgdTQwNCkKICAgICAgKQogICAgICB0ZW51cmUtaGVpZ2h0CiAgICApCiAgKQop`} - target="_blank" - > - Open in Clarity Playground <ArrowUpRight className="w-4 h-4" /> - </Link> + {type === "clarity" ? ( + <Terminal className="w-4 h-4" /> + ) : ( + <Play className="w-4 h-4" /> + )} + {getButtonText()} </Button> + {type === "clarity" && ( + <Button className="gap-2" size="sm" asChild> + <Link + href={`https://play.hiro.so/?epoch=3.0&snippet=KGRlZmluZS1yZWFkLW9ubHkgKGdldC10ZW51cmUtaGVpZ2h0IChibG9jayB1aW50KSkKICAob2sKICAgIChhdC1ibG9jawogICAgICAodW53cmFwIQogICAgICAgIChnZXQtc3RhY2tzLWJsb2NrLWluZm8_IGlkLWhlYWRlci1oYXNoIGJsb2NrKQogICAgICAgIChlcnIgdTQwNCkogICAgICApCiAgICAgIHRlbnVyZS1oZWlnaHQKICAgICkKICApCik=`} + target="_blank" + > + Open in Clarity Playground <ArrowUpRight className="w-4 h-4" /> + </Link> + </Button> + )} </div> - {result && ( + {result && type !== "clarity" && ( <div className="flex bg-ch-code p-2 rounded text-sm leading-6 font-mono whitespace-pre-wrap" data-active="false" @@ -80,6 +120,36 @@ export function SnippetResult({ code }: SnippetResultProps) { </span> </div> )} + {result && isConsoleOpen && ( + <pre className="min-h-auto bg-ch-code p-2 rounded leading-6 font-mono whitespace-pre-wrap"> + <div ref={outputRef} className="h-full p-2 overflow-y-auto"> + {output.map((item, index) => ( + <div key={index} className="whitespace-pre-wrap"> + {item.type === "command" ? ( + <div className="flex items-start space-x-2 text-[var(--ch-26)]"> + <span>$</span> + <span className="text-[var(--ch-1)]">{item.content}</span> + </div> + ) : ( + <div className="pl-0 text-[var(--ch-4)]">{item.content}</div> + )} + </div> + ))} + <form + onSubmit={handleSubmit} + className="flex items-center space-x-2 text-[var(--ch-26)]" + > + <span>$</span> + <input + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + className="flex-1 bg-transparent text-[var(--ch-1)] focus:outline-none leading-6 font-mono whitespace-pre-wrap" + /> + </form> + </div> + </pre> + )} </div> ); } diff --git a/app/cookbook/page.tsx b/app/cookbook/page.tsx index 95ab6a9a3..18c3251b7 100644 --- a/app/cookbook/page.tsx +++ b/app/cookbook/page.tsx @@ -4,8 +4,7 @@ import { Code } from "@/components/docskit/code"; import { Recipe } from "@/types/recipes"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Copy } from "lucide-react"; +import { CopyButton } from "@/components/docskit/copy-button"; // Server Components for Recipe Display function RecipeCard({ @@ -16,9 +15,9 @@ function RecipeCard({ codeElement: React.ReactNode; }) { return ( - <div className="group relative w-full rounded-lg border border-border bg-card overflow-hidden"> + <div className="relative w-full rounded-lg border bg-[#EBE9E6] dark:bg-[#2a2726] overflow-hidden [&:has(a:hover)]:shadow-[0_2px_12px_rgba(89,86,80,0.15)] dark:[&:has(a:hover)]:shadow-[0_2px_20px_rgba(56,52,50,0.4)] [&:has(a:hover)]:scale-[1.01] transition-all duration-200"> <div className="p-4 space-y-2"> - <div className="flex items-start justify-between gap-2"> + <div className="flex items-start justify-between gap-4"> <div className="space-y-2"> <h3 className="text-lg font-medium text-card-foreground"> {recipe.title} @@ -27,42 +26,26 @@ function RecipeCard({ {recipe.description} </p> </div> - <div className="flex gap-2"> - <Button - size="icon" - className="bg-code dark:bg-background hover:bg-accent h-8 w-8" - aria-label="Copy to clipboard" - > - <Copy className="h-3.5 w-3.5 text-primary" /> - </Button> - </div> + <CopyButton text={recipe.files[0].content} /> </div> <div className="flex flex-wrap gap-2 pt-2"> {recipe.tags.map((tag) => ( - <Badge key={tag} variant="secondary"> + <Badge + key={tag} + className="bg-[#f6f5f3] dark:bg-[#181717] text-primary dark:text-[#8c877d]" + > {tag.toUpperCase()} </Badge> ))} </div> </div> - <div className="relative"> - <div className="recipe-preview max-h-[200px] overflow-hidden border-t border-border"> + <Link href={`/cookbook/${recipe.id}`} className="group relative block"> + <div className="recipe-preview max-h-[200px] overflow-hidden border-t border-border bg-[hsl(var(--code))] relative z-0"> {codeElement} - <div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-code via-code/100 to-transparent" /> + <div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-[hsl(var(--code))] to-transparent" /> </div> - <Link - href={`/cookbook/${recipe.id}`} - className="absolute inset-0 flex h-[185px] items-center top-12 justify-center opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-background/25" - > - <Button - variant="outline" - className="bg-transparent hover:bg-transparent" - > - View details - </Button> - </Link> - </div> + </Link> </div> ); } @@ -74,7 +57,7 @@ export default async function Page() { const codeElement = await Code({ codeblocks: [ { - lang: recipe.type, + lang: recipe.files[0].type, value: recipe.files[0].content, meta: "", }, diff --git a/app/global.css b/app/global.css index 7d3d49365..706948068 100644 --- a/app/global.css +++ b/app/global.css @@ -120,10 +120,25 @@ body { height: 185px; } +.recipe-preview > div:first-child pre { + overflow-x: hidden; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +.recipe-preview > div:first-child pre::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ +} + .recipe > div:first-child { margin: 0; } +.recipe > div:first-child pre { + max-height: 425px; + height: auto; +} + .sticky.top-24 { font-family: var(--font-aeonikFono), sans-serif; background: transparent; diff --git a/components/docskit/annotations/hover-line.client.tsx b/components/docskit/annotations/hover-line.client.tsx index 5b4b7a8ac..81a3d57c0 100644 --- a/components/docskit/annotations/hover-line.client.tsx +++ b/components/docskit/annotations/hover-line.client.tsx @@ -13,7 +13,10 @@ export function HoverLineClient({ annotation, ...props }: CustomLineProps) { <InnerLine merge={props} className="transition-opacity duration-200" - style={{ opacity: isHovered ? 1 : 0.5 }} + style={{ + opacity: isHovered ? 1 : 0.5, + filter: isHovered ? "none" : "blur(0.25px)", + }} data-line={annotation?.query || ""} /> ); diff --git a/components/docskit/annotations/hover.client.tsx b/components/docskit/annotations/hover.client.tsx index eb788ee7a..6a0465764 100644 --- a/components/docskit/annotations/hover.client.tsx +++ b/components/docskit/annotations/hover.client.tsx @@ -12,7 +12,7 @@ export function HoverLinkClient(props: { return ( <span - className={`cursor-default underline decoration-dotted underline-offset-4 ${props.className}`} + className={`cursor-help underline decoration-dotted underline-offset-4 ${props.className}`} onMouseEnter={() => setHoveredId(hoverId ?? null)} onMouseLeave={() => setHoveredId(null)} > diff --git a/components/docskit/copy-button.tsx b/components/docskit/copy-button.tsx index 70533f9bd..77d8925fc 100644 --- a/components/docskit/copy-button.tsx +++ b/components/docskit/copy-button.tsx @@ -16,7 +16,8 @@ export function CopyButton({ return ( <button className={cn( - copied && "!bg-[#A6E3A1] hover:bg-[#A6E3A1] !text-dark/70", + copied && + "!bg-[#A6E3A1] hover:bg-[#A6E3A1] !text-[hsl(var(--dark))]/70", `hover:bg-accent -m-1 p-1 rounded hidden sm:block`, className )} diff --git a/content/_recipes/clarity-bitcoin.mdx b/content/_recipes/clarity-bitcoin.mdx new file mode 100644 index 000000000..e5893334d --- /dev/null +++ b/content/_recipes/clarity-bitcoin.mdx @@ -0,0 +1,46 @@ +# Prepare btc data for Clarity verification + +When working with Bitcoin transactions in Clarity smart contracts, we need to prepare the transaction data in a specific way. This process involves gathering several key pieces of information: the transaction hex, merkle proof, and block header. + +This is particularly useful when you need to: + +- Verify Bitcoin deposits or payments in Stacks smart contracts, enabling BTC-backed lending or trading platforms +- Create NFTs that are only minted after verifying specific Bitcoin transactions +- Build payment systems that wait for Bitcoin transaction confirmation before executing Stacks contract logic + +The core of this process uses the _typescript`"@mempool/mempool.js"`_ library to fetch Bitcoin transaction data. + +1. First, we start by getting the raw transaction hex using <HoverLink href="hover:get-tx-hex" className="text-[var(--ch-9)]">getTxHex</HoverLink> +2. Then, we fetch its merkle proof with <HoverLink href="hover:get-tx-merkle-proof" className="text-[var(--ch-9)]">getTxMerkleProof</HoverLink> +3. Finally, we retrieve the block header using <HoverLink href="hover:get-blk-header" className="text-[var(--ch-9)]">getBlkHeader</HoverLink> + +An important step is removing witness data from the transaction hex using `removeWitnessData`, as Clarity's Bitcoin verification expects the legacy transaction format. + +Once we have all these pieces, we format them into Clarity-compatible types using the _typescript`"@stacks/transactions"`_ library. The transaction data, block header, and merkle proof are converted into buffer CVs (Clarity Values), while the proof information is structured as a tuple containing the transaction index, merkle hashes, and tree depth. + +<WithNotes> + +Finally, we call the [`was-tx-mined-compact`](tooltip "clarity-bitcoin") function from the [clarity-bitcoin](https://github.com/friedger/clarity-bitcoin/blob/main) library with our prepared data to verify the transaction. + +This function takes all this information and confirms whether the transaction was actually mined in the Bitcoin blockchain. This verification is crucial for cross-chain functionality between Bitcoin and Stacks. + +## !clarity-bitcoin + +This is from v5 of the [clarity-bitcoin](https://github.com/friedger/clarity-bitcoin/blob/main/contracts/clarity-bitcoin-v5.clar) library. + +```clarity +(define-read-only (was-tx-mined-compact (height uint) (tx (buff 4096)) (header (buff 80)) (proof { tx-index: uint, hashes: (list 14 (buff 32)), tree-depth: uint})) + (let + ( + (block (unwrap! (parse-block-header header) (err ERR-BAD-HEADER))) + ) + (was-tx-mined-internal height tx header (get merkle-root block) proof) + ) +) +``` + +</WithNotes> + +## References + +- [Use `callReadOnlyFunction` to call Clarity functions](/stacks/stacks.js/packages/transactions#smart-contract-function-call-on-chain) \ No newline at end of file diff --git a/content/_recipes/create-a-multisig-address-using-principal-construct.mdx b/content/_recipes/create-a-multisig-address-using-principal-construct.mdx new file mode 100644 index 000000000..b8afbc64a --- /dev/null +++ b/content/_recipes/create-a-multisig-address-using-principal-construct.mdx @@ -0,0 +1,32 @@ +# Create a multisig address using principal-construct? + +On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. + +Understanding and accessing this information is really useful when you need to: + +- Track block sequences within minure tenures +- Implement logic that depends on tenure-specific block ordering +- Verify block relationships within a single miner's tenure + +Let's take a look at the following code to better understand how to work with tenure height information for a given block. + +At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. + +Think of it like a block's fingerprint - no two blocks will ever have the same one. + +```terminal +$ clarinet console +$ ::advance_stacks_chain_tip 1 +$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) +[32m(ok u3)[0m [1m[0m +``` + +Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. + +It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. + +Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ + +You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. + +The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. \ No newline at end of file diff --git a/content/_recipes/create-random-number.mdx b/content/_recipes/create-random-number.mdx index 38df3eddf..6f8b0499c 100644 --- a/content/_recipes/create-random-number.mdx +++ b/content/_recipes/create-random-number.mdx @@ -1,19 +1,32 @@ -# Create a random number +# Create a random number using stacks-block-height -```terminal -$ curl -X POST https://api.hover.build/v1/execute \ - -H "Authorization: Bearer $HOVER_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"contract": "get-tenure-for-block.clar", "function": "get-tenure-height", "args": [1234567890]}' -``` +On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. + +Understanding and accessing this information is really useful when you need to: + +- Track block sequences within minure tenures +- Implement logic that depends on tenure-specific block ordering +- Verify block relationships within a single miner's tenure + +Let's take a look at the following code to better understand how to work with tenure height information for a given block. -This is The <HoverLink href="hover:random">read-rnd</HoverLink> returns 1.# Get tenure height by block +At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. + +Think of it like a block's fingerprint - no two blocks will ever have the same one. ```terminal -$ curl -X POST https://api.hover.build/v1/execute \ - -H "Authorization: Bearer $HOVER_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"contract": "get-tenure-for-block.clar", "function": "get-tenure-height", "args": [1234567890]}' +$ clarinet console +$ ::advance_stacks_chain_tip 1 +$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) +[32m(ok u3)[0m [1m[0m ``` -This is The <HoverLink href="hover:api-key">API key</HoverLink> returns 1. \ No newline at end of file +Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. + +It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. + +Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ + +You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. + +The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. \ No newline at end of file diff --git a/content/_recipes/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/fetch-testnet-bitcoin-on-regtest.mdx new file mode 100644 index 000000000..4d82064c8 --- /dev/null +++ b/content/_recipes/fetch-testnet-bitcoin-on-regtest.mdx @@ -0,0 +1,32 @@ +# Fetch tBTC on regtest + +On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. + +Understanding and accessing this information is really useful when you need to: + +- Track block sequences within minure tenures +- Implement logic that depends on tenure-specific block ordering +- Verify block relationships within a single miner's tenure + +Let's take a look at the following code to better understand how to work with tenure height information for a given block. + +At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. + +Think of it like a block's fingerprint - no two blocks will ever have the same one. + +```terminal +$ clarinet console +$ ::advance_stacks_chain_tip 1 +$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) +[32m(ok u3)[0m [1m[0m +``` + +Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. + +It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. + +Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ + +You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. + +The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. diff --git a/content/_recipes/get-tenure-height-for-a-block.mdx b/content/_recipes/get-tenure-height-for-a-block.mdx index baf69d123..791390144 100644 --- a/content/_recipes/get-tenure-height-for-a-block.mdx +++ b/content/_recipes/get-tenure-height-for-a-block.mdx @@ -29,4 +29,9 @@ Once we have our block's hash, we use it with `at-block` to peek back in time an You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. -The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. \ No newline at end of file +The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. + +## References + +- [`get-stacks-block-info?`](/stacks/clarity/functions/get-stacks-block-info) +- [`unwrap!`](/stacks/clarity/functions/unwrap) diff --git a/data/recipes.ts b/data/recipes.ts index 7cb260c99..676b592cb 100644 --- a/data/recipes.ts +++ b/data/recipes.ts @@ -6,13 +6,13 @@ export const recipes: Recipe[] = [ title: "Create a random number in Clarity using block-height", description: "Create a random number based on a block-height using the buff-to-uint-be function in Clarity.", - type: "clarity", date: "2024.02.28", tags: ["clarity"], files: [ { name: "random.clar", path: "contracts/random.clar", + type: "clarity", content: `(define-constant ERR_FAIL (err u1000)) ;; !hover random @@ -27,13 +27,13 @@ export const recipes: Recipe[] = [ title: "Create a multisig address using principal-construct", description: "Create a multisig address using the principal-construct function in Clarity.", - type: "clarity", date: "2024.02.28", tags: ["clarity"], files: [ { name: "multisig.clar", path: "contracts/multisig.clar", + type: "clarity", content: `(define-read-only (pubkeys-to-principal (pubkeys (list 128 (buff 33))) (m uint)) (unwrap-panic (principal-construct? (if is-in-mainnet 0x14 0x15) ;; address version @@ -45,16 +45,16 @@ export const recipes: Recipe[] = [ }, { id: "get-tenure-height-for-a-block", - title: "Get Tenure Height for a Block", + title: "Get tenure height for a block", description: "Get the tenure height for a specific block height using Clarity.", - type: "clarity", date: "2024.02.28", tags: ["clarity"], files: [ { name: "get-tenure-for-block.clar", path: "contracts/get-tenure-for-block.clar", + type: "clarity", content: `(define-read-only (get-tenure-height (block uint)) (ok (at-block @@ -69,7 +69,174 @@ export const recipes: Recipe[] = [ ) ) )`, - snippet: `(print (ok (at-block (unwrap! (get-stacks-block-info? id-header-hash (- stacks-block-height u1)) (err u404)) tenure-height)))`, + }, + ], + }, + { + id: "fetch-testnet-bitcoin-on-regtest", + title: "Fetch tBTC on regtest", + description: "How to fetch tBTC on regtest.", + date: "2024.02.28", + tags: ["bitcoin"], + files: [ + { + name: "fetch-tbtc-on-regtest.ts", + path: "scripts/fetch-tbtc-on-regtest.ts", + type: "typescript", + content: `import fetch from 'node-fetch'; + +const url = 'https://api.testnet.hiro.so/extended/v1/faucets/btc'; +const fetchTestnetBTC = async (address: string) => { + const response = await fetch(\`\${url}?address=\${address}\`, { + method: 'POST', + }); + const data = await response.json(); + return data; +}; + +// Example usage +const address = 'bcrt1q728h29ejjttmkupwdkyu2x4zcmkuc3q29gvwaa'; +fetchTestnetBTC(address) + .then(console.log) + .catch(console.error);`, + }, + { + name: "fetch-tbtc-on-regtest.sh", + path: "scripts/fetch-tbtc-on-regtest.sh", + type: "bash", + content: `curl -X POST \ + "https://api.testnet.hiro.so/extended/v1/faucets/btc?address=bcrt1q728h29ejjttmkupwdkyu2x4zcmkuc3q29gvwaa"`, + }, + ], + }, + { + id: "clarity-bitcoin", + title: "Prepare btc data for Clarity verification", + description: "How to prepare btc data for Clarity verification.", + date: "2024.02.28", + tags: ["clarity", "bitcoin"], + files: [ + { + name: "prepare-btc-data.ts", + path: "scripts/prepare-btc-data.ts", + type: "typescript", + content: `import mempoolJS from "@mempool/mempool.js"; +import { Transaction } from "bitcoinjs-lib"; +import { + callReadOnlyFunction, + bufferCV, + uintCV, + tupleCV, + listCV, + cvToString, +} from "@stacks/transactions"; +import { StacksMainnet } from "@stacks/network"; +import { hexToBytes } from "@stacks/common"; + +const { + bitcoin: { transactions, blocks }, +} = await mempoolJS({ + hostname: "mempool.space", +}); + +// !hover get-tx-hex +const getTxHex = async (txid) => { + // !hover get-tx-hex + const txHex = await transactions.getTxHex({ txid }); + // !hover get-tx-hex + return txHex; +// !hover get-tx-hex +}; + +// !hover get-tx-merkle-proof +const getTxMerkleProof = async (txid) => { + // !hover get-tx-merkle-proof + const { block_height, merkle, pos } = await transactions.getTxMerkleProof({ + // !hover get-tx-merkle-proof + txid, + // !hover get-tx-merkle-proof + }); + // !hover get-tx-merkle-proof + return { block_height, merkle, pos }; +// !hover get-tx-merkle-proof +}; + +// !hover get-blk-header +const getBlkHeader = async (height) => { + // !hover get-blk-header + let blockHash = await blocks.getBlockHeight({ height }); + // !hover get-blk-header + const blockHeader = await blocks.getBlockHeader({ hash: blockHash }); + // !hover get-blk-header + return { blockHash, blockHeader }; +// !hover get-blk-header +}; + +const removeWitnessData = (txHex) => { + const tx = Transaction.fromHex(txHex); + if (!tx.hasWitnesses()) return txHex; + + const newTx = new Transaction(); + newTx.version = tx.version; + tx.ins.forEach((input) => + newTx.addInput(input.hash, input.index, input.sequence, input.script) + ); + tx.outs.forEach((output) => newTx.addOutput(output.script, output.value)); + newTx.locktime = tx.locktime; + + return newTx.toHex(); +}; + + +const mainnetAddress = "SP2K8BC0PE000000000000000000000000000000000000000"; +const mainnet = new StacksMainnet(); + +// From read-only function of below clarity-bitcoin implementation contract: +const contractAddress = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9"; +const contractName = "clarity-bitcoin-lib-v5"; +const functionName = "was-tx-mined-compact"; + +// fetching btc tx details. You can replace this with any bitcoin transaction id. +let txid = "7ad7414063ab0f7ce7d5b1b6b4a87091094bd0e9be0e6a44925a48e1eb2ca51c"; + +// fetching and returning non-witness tx hex +let fullTxHex = await getTxHex(txid); +let txHex = removeWitnessData(fullTxHex); + +let { block_height, merkle, pos } = await getTxMerkleProof(txid); + +let { blockHeader } = await getBlkHeader(block_height); + +let txIndex = pos; +let hashes = merkle.map((hash) => bufferCV(hexToBytes(hash).reverse())); +let treeDepth = merkle.length; + +let functionArgs = [ + // (height) + uintCV(block_height), + // (tx) + bufferCV(Buffer.from(txHex, "hex")), + // (header) + bufferCV(Buffer.from(blockHeader, "hex")), + // (proof) + tupleCV({ + "tx-index": uintCV(txIndex), + hashes: listCV(hashes), + "tree-depth": uintCV(treeDepth), + }), +]; + +let result = await callReadOnlyFunction({ + contractAddress, + contractName, + functionName, + functionArgs, + network: mainnet, + // this could be any principal address + senderAddress: mainnetAddress, +}); + +console.log(cvToString(result));`, }, ], }, diff --git a/types/recipes.ts b/types/recipes.ts index 7c0984595..a311b5a1e 100644 --- a/types/recipes.ts +++ b/types/recipes.ts @@ -1,16 +1,22 @@ -export type RecipeType = "typescript" | "curl" | "clarity"; -export type RecipeTag = "api" | "stacks.js" | "clarity" | "clarinet"; +export type RecipeType = "typescript" | "bash" | "clarity"; +export type RecipeTag = + | "api" + | "bitcoin" + | "stacks.js" + | "clarity" + | "clarinet" + | "chainhook"; export interface Recipe { id: string; title: string; description: string; - type: RecipeType; date: string; tags: RecipeTag[]; files: { name: string; path: string; + type: RecipeType; content: string; snippet?: string; preview?: any; From c433acb54d9ab794e9bd920dc96698987bc1e401 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Tue, 17 Dec 2024 07:56:46 -0600 Subject: [PATCH 03/30] updates --- app/cookbook/[id]/page.tsx | 27 +- app/cookbook/components/cookbook-ui.tsx | 230 +++++++++++++---- app/cookbook/components/snippet-result.tsx | 214 ++++++++++++--- app/cookbook/page.tsx | 20 +- app/global.css | 2 +- components/ui/dropdown-menu.tsx | 2 +- ...-number.mdx => generate-random-number.mdx} | 2 +- data/recipes.ts | 243 ------------------ data/recipes/clarity-bitcoin.mdx | 133 ++++++++++ ...isig-address-using-principal-construct.mdx | 77 ++++++ .../fetch-testnet-bitcoin-on-regtest.mdx | 35 +++ data/recipes/generate-random-number.mdx | 25 ++ .../recipes/get-tenure-height-for-a-block.mdx | 30 +++ package.json | 1 + types/recipes.ts | 85 ++++-- utils/loader.ts | 62 +++++ 16 files changed, 809 insertions(+), 379 deletions(-) rename content/_recipes/{create-random-number.mdx => generate-random-number.mdx} (82%) delete mode 100644 data/recipes.ts create mode 100644 data/recipes/clarity-bitcoin.mdx create mode 100644 data/recipes/create-a-multisig-address-using-principal-construct.mdx create mode 100644 data/recipes/fetch-testnet-bitcoin-on-regtest.mdx create mode 100644 data/recipes/generate-random-number.mdx create mode 100644 data/recipes/get-tenure-height-for-a-block.mdx create mode 100644 utils/loader.ts diff --git a/app/cookbook/[id]/page.tsx b/app/cookbook/[id]/page.tsx index 3ded6e563..d2a390ef3 100644 --- a/app/cookbook/[id]/page.tsx +++ b/app/cookbook/[id]/page.tsx @@ -1,5 +1,5 @@ import { Code } from "@/components/docskit/code"; -import { recipes } from "@/data/recipes"; +import { loadRecipes } from "@/utils/loader"; import { ArrowUpRight, Play, TestTube } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -24,6 +24,7 @@ export default async function Page({ params: Param; }): Promise<JSX.Element> { const { id } = params; + const recipes = await loadRecipes(); const recipe = recipes.find((r) => r.id === id); if (!recipe) { @@ -36,24 +37,12 @@ export default async function Page({ return { default: () => <div>Content not found</div> }; }); - const snippetCodeResult = (result: string) => { - <Code - codeblocks={[ - { - lang: "bash", - value: result, - meta: `-nw`, - }, - ]} - />; - }; - return ( <HoverProvider> <div className="min-h-screen"> <div className="px-4 py-8"> - <div className="grid grid-cols-12 gap-12"> - <div className="col-span-6"> + <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12"> + <div className="hidden lg:block lg:col-span-6"> <div className="space-y-3"> <div className="flex flex-wrap gap-2"> {recipe.tags.map((tag) => ( @@ -77,9 +66,9 @@ export default async function Page({ </div> {/* Sticky sidebar */} - <div className="col-span-6"> - <div className="sticky top-20 space-y-4"> - <div className="recipe group relative w-full bg-card overflow-hidden"> + <div className="col-span-full lg:col-span-6"> + <div className="lg:sticky lg:top-20 space-y-4"> + <div className="recipe group relative w-full overflow-hidden"> <Code codeblocks={[ { @@ -91,8 +80,10 @@ export default async function Page({ /> </div> <SnippetResult + recipe={recipe} code={recipe.files[0].content as string} type={recipe.files[0].type} + dependencies={{}} /> </div> </div> diff --git a/app/cookbook/components/cookbook-ui.tsx b/app/cookbook/components/cookbook-ui.tsx index de739bb8e..4fad9becd 100644 --- a/app/cookbook/components/cookbook-ui.tsx +++ b/app/cookbook/components/cookbook-ui.tsx @@ -2,13 +2,20 @@ import { useState, useMemo, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Recipe, RecipeTag } from "@/types/recipes"; +import { Recipe, RecipeSubTag } from "@/types/recipes"; import { cn } from "@/lib/utils"; import { CustomTable } from "@/components/table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Filter, LayoutGrid, List } from "lucide-react"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { ChevronDown } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; // Internal components function ViewToggle({ @@ -48,37 +55,116 @@ function ViewToggle({ ); } -const ALL_TAGS: RecipeTag[] = [ - "api", - "bitcoin", - "clarity", - "clarinet", - "chainhook", - "stacks.js", -]; +const TAG_CATEGORIES = { + "stacks-js": { + label: "Stacks.js", + subTags: [ + "web", + "authentication", + "transactions", + "signing", + "smart-contracts", + "utils", + ], + }, + clarity: { + label: "Clarity", + subTags: [ + "hashing", + "lists", + "arithmetic", + "sequences", + "iterators", + "tokens", + ], + }, + bitcoin: { + label: "Bitcoin", + subTags: ["transactions", "signing"], + }, + chainhook: { + label: "Chainhook", + subTags: [], + }, + api: { + label: "API", + subTags: [ + "token-metadata", + "signer-metrics", + "rpc", + "platform", + "ordinals", + "runes", + ], + }, + clarinet: { + label: "Clarinet", + subTags: ["testing", "deployment"], + }, +} as const; + +// Type for our category keys +type CategoryKey = keyof typeof TAG_CATEGORIES; function RecipeFilters({ - selectedTags, - onTagToggle, + search, + onSearchChange, + selectedCategory, + selectedSubTags, + onCategoryChange, + onSubTagToggle, }: { search: string; onSearchChange: (value: string) => void; - selectedTags: RecipeTag[]; - onTagToggle: (tag: RecipeTag) => void; + selectedCategory: CategoryKey | null; + selectedSubTags: string[]; + onCategoryChange: (category: CategoryKey) => void; + onSubTagToggle: (tag: string) => void; }) { return ( - <div className="space-y-4"> - <div className="flex flex-wrap items-center gap-2"> - {ALL_TAGS.map((tag) => ( - <Badge - key={tag} - variant={selectedTags.includes(tag) ? "default" : "outline"} - className="cursor-pointer" - onClick={() => onTagToggle(tag)} - > - {tag.toUpperCase()} - </Badge> - ))} + <div className="flex flex-row gap-2 flex-wrap items-center"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button type="button" className="outline-none"> + <Badge + variant={selectedCategory ? "default" : "outline"} + className={cn( + "cursor-pointer inline-flex items-center", + selectedCategory && + "hover:bg-[#aea498] dark:hover:bg-[#595650] dark:hover:text-[#dcd1d6]" + )} + > + {selectedCategory + ? TAG_CATEGORIES[selectedCategory].label.toUpperCase() + : "FILTER"} + <ChevronDown className="ml-1.5 h-3.5 w-3.5" /> + </Badge> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {Object.entries(TAG_CATEGORIES).map(([key, category]) => ( + <DropdownMenuItem + key={key} + onClick={() => onCategoryChange(key as CategoryKey)} + > + {category.label} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + {selectedCategory && <div className="w-px bg-border h-4" />} + <div className="contents flex-wrap items-center gap-2"> + {selectedCategory && + TAG_CATEGORIES[selectedCategory].subTags.map((tag) => ( + <Badge + key={tag} + variant={selectedSubTags.includes(tag) ? "default" : "outline"} + className="cursor-pointer" + onClick={() => onSubTagToggle(tag)} + > + {tag.toUpperCase()} + </Badge> + ))} </div> </div> ); @@ -97,26 +183,38 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { return (searchParams.get("view") as "grid" | "list") || "grid"; }); const [search, setSearch] = useState(""); - const [selectedTags, setSelectedTags] = useState<RecipeTag[]>(() => { + const [selectedCategory, setSelectedCategory] = useState<CategoryKey | null>( + () => { + const category = searchParams.get("category") as CategoryKey | null; + return category && TAG_CATEGORIES[category] ? category : "clarity"; + } + ); + + const [selectedSubTags, setSelectedSubTags] = useState<string[]>(() => { const tagParam = searchParams.get("tags"); - return tagParam ? (tagParam.split(",") as RecipeTag[]) : []; + return tagParam ? tagParam.split(",") : []; }); // Update URL when filters change - const updateURL = (newView?: "grid" | "list", newTags?: RecipeTag[]) => { + const updateURL = ( + newView?: "grid" | "list", + newCategory?: CategoryKey | null, + newSubTags?: string[] + ) => { const params = new URLSearchParams(); - // Only add view param if it's list (grid is default) if (newView === "list") { params.set("view", newView); } - // Only add tags if there are any selected - if (newTags && newTags.length > 0) { - params.set("tags", newTags.join(",")); + if (newCategory) { + params.set("category", newCategory); + } + + if (newSubTags && newSubTags.length > 0) { + params.set("tags", newSubTags.join(",")); } - // Create the new URL const newURL = params.toString() ? `?${params.toString()}` : window.location.pathname; @@ -127,17 +225,24 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { // Handle view changes const handleViewChange = (newView: "grid" | "list") => { setView(newView); - updateURL(newView, selectedTags); + updateURL(newView, selectedCategory, selectedSubTags); }; // Handle tag changes - const handleTagToggle = (tag: RecipeTag) => { - const newTags = selectedTags.includes(tag) - ? selectedTags.filter((t) => t !== tag) - : [...selectedTags, tag]; + const handleCategoryChange = (category: CategoryKey) => { + setSelectedCategory(category); + setSelectedSubTags([]); // Clear sub-tags when category changes + updateURL(view, category, []); + }; - setSelectedTags(newTags); - updateURL(view, newTags); + // Handle sub-tag toggle + const handleSubTagToggle = (tag: string) => { + const newSubTags = selectedSubTags.includes(tag) + ? selectedSubTags.filter((t) => t !== tag) + : [...selectedSubTags, tag]; + + setSelectedSubTags(newSubTags); + updateURL(view, selectedCategory, newSubTags); }; // Create a map of recipe IDs to their corresponding rendered cards @@ -159,16 +264,25 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { recipe.title.toLowerCase().includes(search.toLowerCase()) || recipe.description.toLowerCase().includes(search.toLowerCase()); + const matchesCategory = + !selectedCategory || recipe.categories.includes(selectedCategory); const matchesTags = - selectedTags.length === 0 || - selectedTags.some((tag) => recipe.tags.includes(tag)); + selectedSubTags.length === 0 || + selectedSubTags.some((tag) => + recipe.tags.includes(tag as RecipeSubTag) + ); - return matchesSearch && matchesTags; + return matchesSearch && matchesCategory && matchesTags; }); - // Return the cards for the filtered recipes return filteredRecipes.map((recipe) => recipeCardMap[recipe.id]); - }, [search, selectedTags, initialRecipes, recipeCardMap]); + }, [ + search, + selectedCategory, + selectedSubTags, + initialRecipes, + recipeCardMap, + ]); return ( <div className="max-w-5xl mx-auto"> @@ -188,8 +302,10 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { <RecipeFilters search={search} onSearchChange={setSearch} - selectedTags={selectedTags} - onTagToggle={handleTagToggle} + selectedCategory={selectedCategory} + selectedSubTags={selectedSubTags} + onCategoryChange={handleCategoryChange} + onSubTagToggle={handleSubTagToggle} /> {view === "grid" ? ( @@ -210,11 +326,17 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { .toLowerCase() .includes(search.toLowerCase()); + const matchesCategory = + !selectedCategory || + recipe.categories.includes(selectedCategory); + const matchesTags = - selectedTags.length === 0 || - selectedTags.some((tag) => recipe.tags.includes(tag)); + selectedSubTags.length === 0 || + selectedSubTags.some((tag) => + recipe.tags.includes(tag as RecipeSubTag) + ); - return matchesSearch && matchesTags; + return matchesSearch && matchesCategory && matchesTags; }) .map((recipe) => ( <TableRow @@ -223,15 +345,15 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { onClick={() => router.push(`/cookbook/${recipe.id}`)} > <TableCell className="py-4 text-primary font-aeonikFono whitespace-normal break-words text-base"> - <span className="group-hover:underline decoration-primary/50"> + <span className="group-hover:underline decoration-primary/70"> {recipe.title} </span> </TableCell> <TableCell> <div className="flex flex-wrap gap-2"> - {recipe.tags.map((tag) => ( - <Badge key={tag} variant="outline"> - {tag.toUpperCase()} + {recipe.categories.map((category) => ( + <Badge key={category}> + {category.toUpperCase()} </Badge> ))} </div> diff --git a/app/cookbook/components/snippet-result.tsx b/app/cookbook/components/snippet-result.tsx index 4934b40a1..447d3a45a 100644 --- a/app/cookbook/components/snippet-result.tsx +++ b/app/cookbook/components/snippet-result.tsx @@ -6,12 +6,20 @@ import { Play, Terminal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ArrowUpRight } from "lucide-react"; import { Code } from "@/components/docskit/code"; -import { initSimnet } from "@hirosystems/clarinet-sdk-browser"; +import { initSimnet, type Simnet } from "@hirosystems/clarinet-sdk-browser"; import { Cl } from "@stacks/transactions"; +import { loadSandpackClient } from "@codesandbox/sandpack-client"; +import type { SandboxSetup } from "@codesandbox/sandpack-client"; + +import type { Recipe } from "@/types/recipes"; interface SnippetResultProps { + recipe: Recipe; code: string; type: string; + dependencies: { + [key: string]: string; + }; } type OutputItem = { @@ -19,52 +27,174 @@ type OutputItem = { content: string; }; -export function SnippetResult({ code, type }: SnippetResultProps) { +export function SnippetResult({ + recipe, + code, + type, + dependencies, +}: SnippetResultProps) { const [result, setResult] = React.useState<string | null>(null); const [isLoading, setIsLoading] = React.useState(false); const [isConsoleOpen, setIsConsoleOpen] = React.useState(false); const [input, setInput] = React.useState(""); const [output, setOutput] = React.useState<OutputItem[]>([]); + const [simnetInstance, setSimnetInstance] = React.useState<Simnet | null>( + null + ); + const [codeHistory, setCodeHistory] = React.useState<string>(""); + // Add these new states near your other state declarations + const [commandHistory, setCommandHistory] = React.useState<string[]>([]); + const [historyIndex, setHistoryIndex] = React.useState<number>(-1); + + const inputRef = React.useRef<HTMLInputElement>(null); const outputRef = React.useRef<HTMLDivElement>(null); + const iframeRef = React.useRef<HTMLIFrameElement>(null); - async function runCode() { - if (isConsoleOpen) { - setIsConsoleOpen(false); - return; + React.useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; } - setIsLoading(true); - setResult(null); - - try { - const simnet = await initSimnet(); - await simnet.initEmtpySession(); - simnet.setEpoch("3.0"); - - const codeExecution = simnet.execute(code); - const result = codeExecution.result; - const prettyResult = Cl.prettyPrint(result, 2); - // console.log("before :", simnet.execute("stacks-block-height")); - // simnet.executeCommand("::advance_chain_tip 2"); - // console.log("after: ", simnet.execute("stacks-block-height")); - - // Add a 1-second delay before updating the result - // await new Promise((resolve) => setTimeout(resolve, 1000)); - - setResult(prettyResult); - setIsConsoleOpen(true); - } catch (error) { - console.error("Error running code snippet:", error); - setResult("An error occurred while running the code snippet."); - } finally { - setIsLoading(false); + }, [output]); + + React.useEffect(() => { + if (isConsoleOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isConsoleOpen]); + + async function runCode() { + if (type === "clarity") { + if (isConsoleOpen) { + setIsConsoleOpen(false); + return; + } + setIsLoading(true); + setResult(null); + + try { + const simnet = await initSimnet(); + await simnet.initEmtpySession(); + simnet.deployer = "ST000000000000000000002AMW42H"; + const deployer = simnet.deployer; + console.log("deployer", deployer); + simnet.setEpoch("3.0"); + + // Store the initialized simnet instance + setSimnetInstance(simnet); + // Store the initial code in history + setCodeHistory(code); + + const contract = simnet.deployContract( + recipe.files[0].name.split(".")[0], + code, + { clarityVersion: 3 }, + deployer + ); + const result = contract.result; + const prettyResult = Cl.prettyPrint(result, 2); + // console.log("before :", simnet.execute("stacks-block-height")); + // simnet.executeCommand("::advance_chain_tip 2"); + // console.log("after: ", simnet.execute("stacks-block-height")); + + // Add a 1-second delay before updating the result + // await new Promise((resolve) => setTimeout(resolve, 1000)); + + setResult(prettyResult); + setIsConsoleOpen(true); + } catch (error) { + console.error("Error running code snippet:", error); + setResult("An error occurred while running the code snippet."); + } finally { + setIsLoading(false); + } + } else { + const content = { + files: { + "/package.json": { + code: JSON.stringify({ + main: "index.js", + dependencies: dependencies || {}, + }), + }, + "/index.js": { + code: code, // This is the content from your recipe file + }, + }, + environment: "vanilla", + }; + + const client = await loadSandpackClient(iframeRef.current!, content); + console.log(client); } } - function handleSubmit(e: React.FormEvent) { + // Add this function to handle keyboard events + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + if (commandHistory.length > 0) { + const newIndex = historyIndex + 1; + if (newIndex < commandHistory.length) { + setHistoryIndex(newIndex); + setInput(commandHistory[commandHistory.length - 1 - newIndex]); + } + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + setInput(commandHistory[commandHistory.length - 1 - newIndex]); + } else if (historyIndex === 0) { + setHistoryIndex(-1); + setInput(""); + } + } + }; + + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (input.trim()) { + // Add command to history + setCommandHistory((prev) => [...prev, input.trim()]); + setHistoryIndex(-1); // Reset history index setOutput([...output, { type: "command", content: input }]); - setOutput((prev) => [...prev, { type: "success", content: `u1` }]); + try { + if (!simnetInstance) { + throw new Error("Please run the code snippet first"); + } + + // Check if input is a command (starts with "::") + if (input.startsWith("::")) { + const commandResult = simnetInstance.executeCommand(input); + + setOutput((prev) => [ + ...prev, + { + type: "success", + content: commandResult, + }, + ]); + } else { + // Regular Clarity code execution + const fullCode = `${codeHistory}\n${input}`; + setCodeHistory(fullCode); + const codeExecution = simnetInstance.execute(fullCode); + const result = codeExecution.result; + const prettyResult = Cl.prettyPrint(result, 2); + + setOutput((prev) => [ + ...prev, + { type: "success", content: prettyResult }, + ]); + } + } catch (error) { + setOutput((prev) => [ + ...prev, + { type: "error", content: String(error) }, + ]); + } + setInput(""); } } @@ -72,7 +202,7 @@ export function SnippetResult({ code, type }: SnippetResultProps) { const getButtonText = () => { if (type === "clarity") { if (isLoading) return "Loading..."; - if (isConsoleOpen) return "Hide terminal"; + if (isConsoleOpen) return "Close terminal"; return "Open in terminal"; } return "Run code snippet"; @@ -80,6 +210,11 @@ export function SnippetResult({ code, type }: SnippetResultProps) { return ( <div className="space-y-4"> + <iframe + ref={iframeRef} + style={{ display: "none" }} + title="code-sandbox" + /> <div className="flex items-center gap-2"> <Button variant="outline" @@ -121,8 +256,11 @@ export function SnippetResult({ code, type }: SnippetResultProps) { </div> )} {result && isConsoleOpen && ( - <pre className="min-h-auto bg-ch-code p-2 rounded leading-6 font-mono whitespace-pre-wrap"> - <div ref={outputRef} className="h-full p-2 overflow-y-auto"> + <pre className="h-auto bg-ch-code p-2 rounded leading-6 font-mono whitespace-pre-wrap"> + <div + ref={outputRef} + className="h-auto max-h-[225px] p-2 overflow-y-auto" + > {output.map((item, index) => ( <div key={index} className="whitespace-pre-wrap"> {item.type === "command" ? ( @@ -141,9 +279,13 @@ export function SnippetResult({ code, type }: SnippetResultProps) { > <span>$</span> <input + ref={inputRef} type="text" value={input} onChange={(e) => setInput(e.target.value)} + onKeyDown={handleKeyDown} + spellCheck="false" + autoComplete="off" className="flex-1 bg-transparent text-[var(--ch-1)] focus:outline-none leading-6 font-mono whitespace-pre-wrap" /> </form> diff --git a/app/cookbook/page.tsx b/app/cookbook/page.tsx index 18c3251b7..b2876e716 100644 --- a/app/cookbook/page.tsx +++ b/app/cookbook/page.tsx @@ -1,4 +1,5 @@ -import { recipes } from "@/data/recipes"; +// import { recipes } from "@/data/recipes"; +import { loadRecipes } from "@/utils/loader"; import { CookbookUI } from "./components/cookbook-ui"; import { Code } from "@/components/docskit/code"; import { Recipe } from "@/types/recipes"; @@ -15,26 +16,26 @@ function RecipeCard({ codeElement: React.ReactNode; }) { return ( - <div className="relative w-full rounded-lg border bg-[#EBE9E6] dark:bg-[#2a2726] overflow-hidden [&:has(a:hover)]:shadow-[0_2px_12px_rgba(89,86,80,0.15)] dark:[&:has(a:hover)]:shadow-[0_2px_20px_rgba(56,52,50,0.4)] [&:has(a:hover)]:scale-[1.01] transition-all duration-200"> + <div className="relative w-full max-h-[300px] rounded-lg border bg-[#EBE9E6] dark:bg-[#2a2726] overflow-hidden [&:has(a:hover)]:shadow-[0_2px_12px_rgba(89,86,80,0.15)] dark:[&:has(a:hover)]:shadow-[0_2px_20px_rgba(56,52,50,0.4)] [&:has(a:hover)]:scale-[1.01] transition-all duration-200"> <div className="p-4 space-y-2"> <div className="flex items-start justify-between gap-4"> <div className="space-y-2"> - <h3 className="text-lg font-medium text-card-foreground"> + <h3 className="text-lg font-regular text-card-foreground"> {recipe.title} </h3> - <p className="text-sm text-muted-foreground mt-1"> + {/* <p className="text-sm text-muted-foreground mt-1"> {recipe.description} - </p> + </p> */} </div> <CopyButton text={recipe.files[0].content} /> </div> <div className="flex flex-wrap gap-2 pt-2"> - {recipe.tags.map((tag) => ( + {recipe.categories.map((category) => ( <Badge - key={tag} + key={category} className="bg-[#f6f5f3] dark:bg-[#181717] text-primary dark:text-[#8c877d]" > - {tag.toUpperCase()} + {category.toUpperCase()} </Badge> ))} </div> @@ -43,7 +44,7 @@ function RecipeCard({ <Link href={`/cookbook/${recipe.id}`} className="group relative block"> <div className="recipe-preview max-h-[200px] overflow-hidden border-t border-border bg-[hsl(var(--code))] relative z-0"> {codeElement} - <div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-[hsl(var(--code))] to-transparent" /> + <div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[hsl(var(--code))] to-transparent" /> </div> </Link> </div> @@ -52,6 +53,7 @@ function RecipeCard({ export default async function Page() { // Pre-render the recipe cards with Code components on the server + const recipes = await loadRecipes(); const recipeCards = await Promise.all( recipes.map(async (recipe) => { const codeElement = await Code({ diff --git a/app/global.css b/app/global.css index 706948068..c2566f488 100644 --- a/app/global.css +++ b/app/global.css @@ -117,7 +117,7 @@ body { .recipe-preview > div:first-child { margin: 0; - height: 185px; + height: 200px; } .recipe-preview > div:first-child pre { diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index 995409e0b..9b152bc5c 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -83,7 +83,7 @@ const DropdownMenuItem = React.forwardRef< <DropdownMenuPrimitive.Item ref={ref} className={cn( - "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-aeonikFono outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className )} diff --git a/content/_recipes/create-random-number.mdx b/content/_recipes/generate-random-number.mdx similarity index 82% rename from content/_recipes/create-random-number.mdx rename to content/_recipes/generate-random-number.mdx index 6f8b0499c..b6dc792dc 100644 --- a/content/_recipes/create-random-number.mdx +++ b/content/_recipes/generate-random-number.mdx @@ -10,7 +10,7 @@ Understanding and accessing this information is really useful when you need to: Let's take a look at the following code to better understand how to work with tenure height information for a given block. -At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. +At its core, we're using <HoverLink href="hover:random" className="text-[var(--ch-7)]">read-rnd</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. Think of it like a block's fingerprint - no two blocks will ever have the same one. diff --git a/data/recipes.ts b/data/recipes.ts deleted file mode 100644 index 676b592cb..000000000 --- a/data/recipes.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { Recipe } from "@/types/recipes"; - -export const recipes: Recipe[] = [ - { - id: "create-random-number", - title: "Create a random number in Clarity using block-height", - description: - "Create a random number based on a block-height using the buff-to-uint-be function in Clarity.", - date: "2024.02.28", - tags: ["clarity"], - files: [ - { - name: "random.clar", - path: "contracts/random.clar", - type: "clarity", - content: `(define-constant ERR_FAIL (err u1000)) - -;; !hover random -(define-read-only (read-rnd (block uint)) - (ok (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? (unwrap! (get-block-info? vrf-seed block) ERR_FAIL) u16 u32)) u16)))) -)`, - }, - ], - }, - { - id: "create-a-multisig-address-using-principal-construct", - title: "Create a multisig address using principal-construct", - description: - "Create a multisig address using the principal-construct function in Clarity.", - date: "2024.02.28", - tags: ["clarity"], - files: [ - { - name: "multisig.clar", - path: "contracts/multisig.clar", - type: "clarity", - content: `(define-read-only (pubkeys-to-principal (pubkeys (list 128 (buff 33))) (m uint)) - (unwrap-panic (principal-construct? - (if is-in-mainnet 0x14 0x15) ;; address version - (pubkeys-to-hash pubkeys m) - )) -)`, - }, - ], - }, - { - id: "get-tenure-height-for-a-block", - title: "Get tenure height for a block", - description: - "Get the tenure height for a specific block height using Clarity.", - date: "2024.02.28", - tags: ["clarity"], - files: [ - { - name: "get-tenure-for-block.clar", - path: "contracts/get-tenure-for-block.clar", - type: "clarity", - content: `(define-read-only (get-tenure-height (block uint)) - (ok - (at-block - (unwrap! - ;; !hover get-stacks-block-info - (get-stacks-block-info? id-header-hash block) - ;; !hover error - (err u404) - ) - ;; !hover tenure-height - tenure-height - ) - ) -)`, - }, - ], - }, - { - id: "fetch-testnet-bitcoin-on-regtest", - title: "Fetch tBTC on regtest", - description: "How to fetch tBTC on regtest.", - date: "2024.02.28", - tags: ["bitcoin"], - files: [ - { - name: "fetch-tbtc-on-regtest.ts", - path: "scripts/fetch-tbtc-on-regtest.ts", - type: "typescript", - content: `import fetch from 'node-fetch'; - -const url = 'https://api.testnet.hiro.so/extended/v1/faucets/btc'; -const fetchTestnetBTC = async (address: string) => { - const response = await fetch(\`\${url}?address=\${address}\`, { - method: 'POST', - }); - const data = await response.json(); - return data; -}; - -// Example usage -const address = 'bcrt1q728h29ejjttmkupwdkyu2x4zcmkuc3q29gvwaa'; -fetchTestnetBTC(address) - .then(console.log) - .catch(console.error);`, - }, - { - name: "fetch-tbtc-on-regtest.sh", - path: "scripts/fetch-tbtc-on-regtest.sh", - type: "bash", - content: `curl -X POST \ - "https://api.testnet.hiro.so/extended/v1/faucets/btc?address=bcrt1q728h29ejjttmkupwdkyu2x4zcmkuc3q29gvwaa"`, - }, - ], - }, - { - id: "clarity-bitcoin", - title: "Prepare btc data for Clarity verification", - description: "How to prepare btc data for Clarity verification.", - date: "2024.02.28", - tags: ["clarity", "bitcoin"], - files: [ - { - name: "prepare-btc-data.ts", - path: "scripts/prepare-btc-data.ts", - type: "typescript", - content: `import mempoolJS from "@mempool/mempool.js"; -import { Transaction } from "bitcoinjs-lib"; -import { - callReadOnlyFunction, - bufferCV, - uintCV, - tupleCV, - listCV, - cvToString, -} from "@stacks/transactions"; -import { StacksMainnet } from "@stacks/network"; -import { hexToBytes } from "@stacks/common"; - -const { - bitcoin: { transactions, blocks }, -} = await mempoolJS({ - hostname: "mempool.space", -}); - -// !hover get-tx-hex -const getTxHex = async (txid) => { - // !hover get-tx-hex - const txHex = await transactions.getTxHex({ txid }); - // !hover get-tx-hex - return txHex; -// !hover get-tx-hex -}; - -// !hover get-tx-merkle-proof -const getTxMerkleProof = async (txid) => { - // !hover get-tx-merkle-proof - const { block_height, merkle, pos } = await transactions.getTxMerkleProof({ - // !hover get-tx-merkle-proof - txid, - // !hover get-tx-merkle-proof - }); - // !hover get-tx-merkle-proof - return { block_height, merkle, pos }; -// !hover get-tx-merkle-proof -}; - -// !hover get-blk-header -const getBlkHeader = async (height) => { - // !hover get-blk-header - let blockHash = await blocks.getBlockHeight({ height }); - // !hover get-blk-header - const blockHeader = await blocks.getBlockHeader({ hash: blockHash }); - // !hover get-blk-header - return { blockHash, blockHeader }; -// !hover get-blk-header -}; - -const removeWitnessData = (txHex) => { - const tx = Transaction.fromHex(txHex); - if (!tx.hasWitnesses()) return txHex; - - const newTx = new Transaction(); - newTx.version = tx.version; - tx.ins.forEach((input) => - newTx.addInput(input.hash, input.index, input.sequence, input.script) - ); - tx.outs.forEach((output) => newTx.addOutput(output.script, output.value)); - newTx.locktime = tx.locktime; - - return newTx.toHex(); -}; - - -const mainnetAddress = "SP2K8BC0PE000000000000000000000000000000000000000"; -const mainnet = new StacksMainnet(); - -// From read-only function of below clarity-bitcoin implementation contract: -const contractAddress = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9"; -const contractName = "clarity-bitcoin-lib-v5"; -const functionName = "was-tx-mined-compact"; - -// fetching btc tx details. You can replace this with any bitcoin transaction id. -let txid = "7ad7414063ab0f7ce7d5b1b6b4a87091094bd0e9be0e6a44925a48e1eb2ca51c"; - -// fetching and returning non-witness tx hex -let fullTxHex = await getTxHex(txid); -let txHex = removeWitnessData(fullTxHex); - -let { block_height, merkle, pos } = await getTxMerkleProof(txid); - -let { blockHeader } = await getBlkHeader(block_height); - -let txIndex = pos; -let hashes = merkle.map((hash) => bufferCV(hexToBytes(hash).reverse())); -let treeDepth = merkle.length; - -let functionArgs = [ - // (height) - uintCV(block_height), - // (tx) - bufferCV(Buffer.from(txHex, "hex")), - // (header) - bufferCV(Buffer.from(blockHeader, "hex")), - // (proof) - tupleCV({ - "tx-index": uintCV(txIndex), - hashes: listCV(hashes), - "tree-depth": uintCV(treeDepth), - }), -]; - -let result = await callReadOnlyFunction({ - contractAddress, - contractName, - functionName, - functionArgs, - network: mainnet, - // this could be any principal address - senderAddress: mainnetAddress, -}); - -console.log(cvToString(result));`, - }, - ], - }, -]; diff --git a/data/recipes/clarity-bitcoin.mdx b/data/recipes/clarity-bitcoin.mdx new file mode 100644 index 000000000..1d53b6860 --- /dev/null +++ b/data/recipes/clarity-bitcoin.mdx @@ -0,0 +1,133 @@ +--- +id: clarity-bitcoin +title: Prepare btc data for Clarity verification +description: How to prepare btc data for Clarity verification. +date: 2024.02.28 +categories: + - clarity + - bitcoin +tags: [] +files: + - name: prepare-btc-data.ts + path: scripts/prepare-btc-data.ts + type: typescript +--- + +```typescript +import mempoolJS from "@mempool/mempool.js"; +import { Transaction } from "bitcoinjs-lib"; +import { + callReadOnlyFunction, + bufferCV, + uintCV, + tupleCV, + listCV, + cvToString, +} from "@stacks/transactions"; +import { StacksMainnet } from "@stacks/network"; +import { hexToBytes } from "@stacks/common"; + +const { + bitcoin: { transactions, blocks }, +} = await mempoolJS({ + hostname: "mempool.space", +}); + +// !hover get-tx-hex +const getTxHex = async (txid) => { + // !hover get-tx-hex + const txHex = await transactions.getTxHex({ txid }); + // !hover get-tx-hex + return txHex; +// !hover get-tx-hex +}; + +// !hover get-tx-merkle-proof +const getTxMerkleProof = async (txid) => { + // !hover get-tx-merkle-proof + const { block_height, merkle, pos } = await transactions.getTxMerkleProof({ + // !hover get-tx-merkle-proof + txid, + // !hover get-tx-merkle-proof + }); + // !hover get-tx-merkle-proof + return { block_height, merkle, pos }; +// !hover get-tx-merkle-proof +}; + +// !hover get-blk-header +const getBlkHeader = async (height) => { + // !hover get-blk-header + let blockHash = await blocks.getBlockHeight({ height }); + // !hover get-blk-header + const blockHeader = await blocks.getBlockHeader({ hash: blockHash }); + // !hover get-blk-header + return { blockHash, blockHeader }; +// !hover get-blk-header +}; + +const removeWitnessData = (txHex) => { + const tx = Transaction.fromHex(txHex); + if (!tx.hasWitnesses()) return txHex; + + const newTx = new Transaction(); + newTx.version = tx.version; + tx.ins.forEach((input) => + newTx.addInput(input.hash, input.index, input.sequence, input.script) + ); + tx.outs.forEach((output) => newTx.addOutput(output.script, output.value)); + newTx.locktime = tx.locktime; + + return newTx.toHex(); +}; + +const mainnetAddress = "SP2K8BC0PE000000000000000000000000000000000000000"; +const mainnet = new StacksMainnet(); + +// From read-only function of below clarity-bitcoin implementation contract: +const contractAddress = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9"; +const contractName = "clarity-bitcoin-lib-v5"; +const functionName = "was-tx-mined-compact"; + +// fetching btc tx details. You can replace this with any bitcoin transaction id. +let txid = "7ad7414063ab0f7ce7d5b1b6b4a87091094bd0e9be0e6a44925a48e1eb2ca51c"; + +// fetching and returning non-witness tx hex +let fullTxHex = await getTxHex(txid); +let txHex = removeWitnessData(fullTxHex); + +let { block_height, merkle, pos } = await getTxMerkleProof(txid); + +let { blockHeader } = await getBlkHeader(block_height); + +let txIndex = pos; +let hashes = merkle.map((hash) => bufferCV(hexToBytes(hash).reverse())); +let treeDepth = merkle.length; + +let functionArgs = [ + // (height) + uintCV(block_height), + // (tx) + bufferCV(Buffer.from(txHex, "hex")), + // (header) + bufferCV(Buffer.from(blockHeader, "hex")), + // (proof) + tupleCV({ + "tx-index": uintCV(txIndex), + hashes: listCV(hashes), + "tree-depth": uintCV(treeDepth), + }), +]; + +let result = await callReadOnlyFunction({ + contractAddress, + contractName, + functionName, + functionArgs, + network: mainnet, + // this could be any principal address + senderAddress: mainnetAddress, +}); + +console.log(cvToString(result)); +``` \ No newline at end of file diff --git a/data/recipes/create-a-multisig-address-using-principal-construct.mdx b/data/recipes/create-a-multisig-address-using-principal-construct.mdx new file mode 100644 index 000000000..8b13d6333 --- /dev/null +++ b/data/recipes/create-a-multisig-address-using-principal-construct.mdx @@ -0,0 +1,77 @@ +--- +id: create-a-multisig-address-using-principal-construct +title: Create a multisig address using principal-construct +description: Create a multisig address using the principal-construct function in Clarity. +date: 2024.02.28 +categories: + - clarity +tags: + - smart-contracts + - hashing +files: + - name: multisig.clar + path: contracts/multisig.clar + type: clarity +--- + +```clarity +(define-read-only (pubkeys-to-principal (pubkeys (list 128 (buff 33))) (m uint)) + (unwrap-panic (principal-construct? + (if is-in-mainnet 0x14 0x15) ;; address version + (pubkeys-to-hash pubkeys m) + )) +) + +(define-read-only (pubkeys-to-hash (pubkeys (list 128 (buff 33))) (m uint)) + (hash160 (pubkeys-to-spend-script pubkeys m)) +) + +(define-read-only (pubkeys-to-spend-script (pubkeys (list 128 (buff 33))) (m uint)) + (concat (uint-to-byte (+ u80 m)) ;; "m" in m-of-n + (concat (pubkeys-to-bytes pubkeys) ;; list of pubkeys with length prefix + (concat (uint-to-byte (+ u80 (len pubkeys))) ;; "n" in m-of-n + 0xae ;; CHECKMULTISIG + ))) +) + +(define-read-only (uint-to-byte (n uint)) + (unwrap-panic (element-at BUFF_TO_BYTE n)) +) + +(define-read-only (pubkeys-to-bytes (pubkeys (list 128 (buff 33)))) + (fold concat-pubkeys-fold pubkeys 0x) +) + +(define-read-only (concat-pubkeys-fold (pubkey (buff 33)) (iterator (buff 510))) + (let + ( + (pubkey-with-len (concat (bytes-len pubkey) pubkey)) + (next (concat iterator pubkey-with-len)) + ) + (unwrap-panic (as-max-len? next u510)) + ) +) + +(define-read-only (bytes-len (bytes (buff 33))) + (unwrap-panic (element-at BUFF_TO_BYTE (len bytes))) +) + +(define-constant BUFF_TO_BYTE (list + 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0a 0x0b 0x0c 0x0d 0x0e 0x0f + 0x10 0x11 0x12 0x13 0x14 0x15 0x16 0x17 0x18 0x19 0x1a 0x1b 0x1c 0x1d 0x1e 0x1f + 0x20 0x21 0x22 0x23 0x24 0x25 0x26 0x27 0x28 0x29 0x2a 0x2b 0x2c 0x2d 0x2e 0x2f + 0x30 0x31 0x33 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x3a 0x3b 0x3c 0x3d 0x3e 0x3f + 0x40 0x41 0x42 0x43 0x44 0x45 0x46 0x47 0x48 0x49 0x4a 0x4b 0x4c 0x4d 0x4e 0x4f + 0x50 0x51 0x52 0x53 0x54 0x55 0x56 0x57 0x58 0x59 0x5a 0x5b 0x5c 0x5d 0x5e 0x5f + 0x60 0x61 0x62 0x63 0x64 0x65 0x66 0x67 0x68 0x69 0x6a 0x6b 0x6c 0x6d 0x6e 0x6f + 0x70 0x71 0x72 0x73 0x74 0x75 0x76 0x77 0x78 0x79 0x7a 0x7b 0x7c 0x7d 0x7e 0x7f + 0x80 0x81 0x82 0x83 0x84 0x85 0x86 0x87 0x88 0x89 0x8a 0x8b 0x8c 0x8d 0x8e 0x8f + 0x90 0x91 0x92 0x93 0x94 0x95 0x96 0x97 0x98 0x99 0x9a 0x9b 0x9c 0x9d 0x9e 0x9f + 0xa0 0xa1 0xa2 0xa3 0xa4 0xa5 0xa6 0xa7 0xa8 0xa9 0xaa 0xab 0xac 0xad 0xae 0xaf + 0xb0 0xb1 0xb2 0xb3 0xb4 0xb5 0xb6 0xb7 0xb8 0xb9 0xba 0xbb 0xbc 0xbd 0xbe 0xbf + 0xc0 0xc1 0xc2 0xc3 0xc4 0xc5 0xc6 0xc7 0xc8 0xc9 0xca 0xcb 0xcc 0xcd 0xce 0xcf + 0xd0 0xd1 0xd2 0xd3 0xd4 0xd5 0xd6 0xd7 0xd8 0xd9 0xda 0xdb 0xdc 0xdd 0xde 0xdf + 0xe0 0xe1 0xe2 0xe3 0xe4 0xe5 0xe6 0xe7 0xe8 0xe9 0xea 0xeb 0xec 0xed 0xee 0xef + 0xf0 0xf1 0xf2 0xf3 0xf4 0xf5 0xf6 0xf7 0xf8 0xf9 0xfa 0xfb 0xfc 0xfd 0xfe 0xff +)) +``` \ No newline at end of file diff --git a/data/recipes/fetch-testnet-bitcoin-on-regtest.mdx b/data/recipes/fetch-testnet-bitcoin-on-regtest.mdx new file mode 100644 index 000000000..35f29f40c --- /dev/null +++ b/data/recipes/fetch-testnet-bitcoin-on-regtest.mdx @@ -0,0 +1,35 @@ +--- +id: fetch-testnet-bitcoin-on-regtest +title: Fetch tBTC on regtest +description: How to fetch tBTC on regtest. +date: 2024.02.28 +categories: + - bitcoin +tags: [] +dependencies: + node-fetch: latest +files: + - name: fetch-tbtc-on-regtest.ts + path: scripts/fetch-tbtc-on-regtest.ts + type: typescript + - name: fetch-tbtc-on-regtest.sh + path: scripts/fetch-tbtc-on-regtest.sh + type: bash +--- + +```typescript +const url = 'https://api.testnet.hiro.so/extended/v1/faucets/btc'; +const fetchTestnetBTC = async (address: string) => { + const response = await fetch(`${url}?address=${address}`, { + method: 'POST', + }); + const data = await response.json(); + return data; +}; + +// Example usage +const address = 'bcrt1q728h29ejjttmkupwdkyu2x4zcmkuc3q29gvwaa'; +fetchTestnetBTC(address) + .then(console.log) + .catch(console.error); +``` \ No newline at end of file diff --git a/data/recipes/generate-random-number.mdx b/data/recipes/generate-random-number.mdx new file mode 100644 index 000000000..c583efd42 --- /dev/null +++ b/data/recipes/generate-random-number.mdx @@ -0,0 +1,25 @@ +--- +id: generate-random-number +title: Create a random number in Clarity using block-height +description: Create a random number based on a block-height using the buff-to-uint-be function in Clarity. +date: 2024.02.28 +categories: + - clarity +tags: + - hashing +files: + - name: random.clar + path: contracts/random.clar + type: clarity +--- + +```clarity +(define-constant ERR_FAIL (err u1000)) + +;; !hover random +(define-read-only (read-rnd (block uint)) + ;; !hover random + (ok (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? (unwrap! (get-stacks-block-info? id-header-hash block) ERR_FAIL) u16 u32)) u16)))) +;; !hover random +) +``` \ No newline at end of file diff --git a/data/recipes/get-tenure-height-for-a-block.mdx b/data/recipes/get-tenure-height-for-a-block.mdx new file mode 100644 index 000000000..a90b6ce39 --- /dev/null +++ b/data/recipes/get-tenure-height-for-a-block.mdx @@ -0,0 +1,30 @@ +--- +id: get-tenure-height-for-a-block +title: Get tenure height for a block +description: Get the tenure height for a specific block height using Clarity. +date: 2024.02.28 +categories: + - clarity +tags: [] +files: + - name: get-tenure-for-block.clar + path: contracts/get-tenure-for-block.clar + type: clarity +--- + +```clarity +(define-read-only (get-tenure-height (block uint)) + (ok + (at-block + (unwrap! + ;; !hover get-stacks-block-info + (get-stacks-block-info? id-header-hash block) + ;; !hover error + (err u404) + ) + ;; !hover tenure-height + tenure-height + ) + ) +) +``` \ No newline at end of file diff --git a/package.json b/package.json index a719bdcca..027f79911 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start": "next start" }, "dependencies": { + "@codesandbox/sandpack-client": "^2.19.8", "@hirosystems/clarinet-sdk-browser": "^2.7.0-beta2", "@next/env": "^14.0.0", "@next/third-parties": "^14.2.4", diff --git a/types/recipes.ts b/types/recipes.ts index a311b5a1e..2cb661d2d 100644 --- a/types/recipes.ts +++ b/types/recipes.ts @@ -1,24 +1,77 @@ export type RecipeType = "typescript" | "bash" | "clarity"; -export type RecipeTag = - | "api" - | "bitcoin" - | "stacks.js" + +export type RecipeCategory = + | "stacks-js" | "clarity" - | "clarinet" - | "chainhook"; + | "bitcoin" + | "chainhook" + | "api" + | "clarinet"; + +export const CategorySubTags = { + "stacks-js": [ + "web", + "authentication", + "transactions", + "signing", + "smart-contracts", + "utils", + ] as const, + + clarity: [ + "hashing", + "lists", + "arithmetic", + "sequences", + "iterators", + "tokens", + ] as const, + + bitcoin: ["transactions", "signing"] as const, + + chainhook: [] as const, -export interface Recipe { + api: [ + "token-metadata", + "signer-metrics", + "rpc", + "platform", + "ordinals", + "runes", + ] as const, + + clarinet: ["testing", "deployment"] as const, +} as const; + +export type SubTagsForCategory = { + [K in RecipeCategory]: (typeof CategorySubTags)[K][number]; +}; + +export type RecipeSubTag = SubTagsForCategory[RecipeCategory]; + +// Base metadata from front matter +export interface RecipeMetadata { id: string; title: string; description: string; date: string; - tags: RecipeTag[]; - files: { - name: string; - path: string; - type: RecipeType; - content: string; - snippet?: string; - preview?: any; - }[]; + categories: RecipeCategory[]; + tags: SubTagsForCategory[RecipeCategory][]; + dependencies?: Record<string, string>; +} + +// Code blocks extracted from markdown content +export interface RecipeFile { + name: string; + path: string; + type: RecipeType; + content: string; + snippet?: string; + preview?: any; +} + +// Complete recipe with content and extracted files +export interface Recipe extends RecipeMetadata { + content: string; // The full markdown content + files: RecipeFile[]; // Extracted code blocks } diff --git a/utils/loader.ts b/utils/loader.ts new file mode 100644 index 000000000..674ee5f49 --- /dev/null +++ b/utils/loader.ts @@ -0,0 +1,62 @@ +import fs from "fs/promises"; +import matter from "gray-matter"; +import path from "path"; +import { Recipe } from "@/types/recipes"; + +function extractCodeAndContent(content: string) { + // Match code blocks with their language + const codeBlockRegex = /```(\w+)\n([\s\S]*?)```/; + const match = content.match(codeBlockRegex); + + if (!match) { + return { code: null, remainingContent: content }; + } + + // Extract the code block + const [fullMatch, lang, code] = match; + + // Remove the code block from content + const remainingContent = content.replace(fullMatch, "").trim(); + + return { + code: code.trim(), + remainingContent, + }; +} + +export async function loadRecipes(): Promise<Recipe[]> { + const recipesDir = path.join(process.cwd(), "data/recipes"); + const files = await fs.readdir(recipesDir); + + const recipes = await Promise.all( + files.map(async (filename) => { + const filePath = path.join(recipesDir, filename); + const source = await fs.readFile(filePath, "utf8"); + + const { data, content } = matter(source); + const { code, remainingContent } = extractCodeAndContent(content); + + // Create the file object from the extracted code + const files = code + ? [ + { + name: data.files?.[0]?.name || "example.clar", + path: data.files?.[0]?.path || "contracts/example.clar", + type: data.files?.[0]?.type || "clarity", + content: code, + }, + ] + : []; + + return { + ...(data as Recipe), + content: remainingContent, + files, + }; + }) + ); + + return recipes.sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + ); +} From 0a5cffd544eeedae3b2bc6dcdb721f9002b09b8c Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Tue, 7 Jan 2025 12:32:18 -0600 Subject: [PATCH 04/30] updates --- .gitignore | 1 + app/(docs)/layout.tsx | 10 +---- app/cookbook/[id]/page.tsx | 14 +++---- app/cookbook/page.tsx | 4 +- app/global.css | 21 ++++++++++ .../_recipes/code-blocks}/clarity-bitcoin.mdx | 0 ...isig-address-using-principal-construct.mdx | 14 ++++--- .../fetch-testnet-bitcoin-on-regtest.mdx | 0 .../code-blocks}/generate-random-number.mdx | 0 .../get-tenure-height-for-a-block.mdx | 0 .../_recipes/{ => guides}/clarity-bitcoin.mdx | 0 ...isig-address-using-principal-construct.mdx | 0 .../fetch-testnet-bitcoin-on-regtest.mdx | 0 .../{ => guides}/generate-random-number.mdx | 42 ++++++++++++++++++- .../get-tenure-height-for-a-block.mdx | 0 15 files changed, 80 insertions(+), 26 deletions(-) rename {data/recipes => content/_recipes/code-blocks}/clarity-bitcoin.mdx (100%) rename {data/recipes => content/_recipes/code-blocks}/create-a-multisig-address-using-principal-construct.mdx (90%) rename {data/recipes => content/_recipes/code-blocks}/fetch-testnet-bitcoin-on-regtest.mdx (100%) rename {data/recipes => content/_recipes/code-blocks}/generate-random-number.mdx (100%) rename {data/recipes => content/_recipes/code-blocks}/get-tenure-height-for-a-block.mdx (100%) rename content/_recipes/{ => guides}/clarity-bitcoin.mdx (100%) rename content/_recipes/{ => guides}/create-a-multisig-address-using-principal-construct.mdx (100%) rename content/_recipes/{ => guides}/fetch-testnet-bitcoin-on-regtest.mdx (100%) rename content/_recipes/{ => guides}/generate-random-number.mdx (73%) rename content/_recipes/{ => guides}/get-tenure-height-for-a-block.mdx (100%) diff --git a/.gitignore b/.gitignore index 23d7ab5f4..fb9d51194 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .env +.env.local .next bun.lockb openapi diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx index 7e772f2af..530830b84 100644 --- a/app/(docs)/layout.tsx +++ b/app/(docs)/layout.tsx @@ -21,10 +21,7 @@ export const layoutOptions: Omit<DocsLayoutProps, "children"> = { href: "https://platform.hiro.so/", icon: ( <div className="flex items-center gap-1 bg-secondary p-1.5 rounded-md"> - <span className="ml-2 font-semibold max-md:hidden"> - Hiro Platform - </span> - <ArrowUpRight /> + <span className="font-semibold max-md:hidden">Hiro Platform</span> </div> ), external: true, @@ -59,10 +56,7 @@ export const homeLayoutOptions: Omit<DocsLayoutProps, "children"> = { href: "https://platform.hiro.so/", icon: ( <div className="flex items-center gap-1 bg-secondary p-1.5 rounded-md"> - <span className="ml-2 font-semibold max-md:hidden"> - Hiro Platform - </span> - <ArrowUpRight /> + <span className="font-semibold max-md:hidden">Hiro Platform</span> </div> ), external: true, diff --git a/app/cookbook/[id]/page.tsx b/app/cookbook/[id]/page.tsx index d2a390ef3..475dd9d61 100644 --- a/app/cookbook/[id]/page.tsx +++ b/app/cookbook/[id]/page.tsx @@ -1,14 +1,10 @@ import { Code } from "@/components/docskit/code"; import { loadRecipes } from "@/utils/loader"; -import { ArrowUpRight, Play, TestTube } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { HoverProvider } from "@/context/hover"; import { HoverLink } from "@/components/docskit/annotations/hover"; -import Link from "next/link"; import { Terminal } from "@/components/docskit/terminal"; import { InlineCode } from "@/components/docskit/inline-code"; -import { Github } from "@/components/ui/icon"; import { WithNotes } from "@/components/docskit/notes"; import { SnippetResult } from "../components/snippet-result"; @@ -32,10 +28,12 @@ export default async function Page({ } // Dynamically import MDX content based on recipe id - const Content = await import(`@/content/_recipes/${id}.mdx`).catch(() => { - console.error(`Failed to load MDX content for recipe: ${id}`); - return { default: () => <div>Content not found</div> }; - }); + const Content = await import(`@/content/_recipes/guides/${id}.mdx`).catch( + () => { + console.error(`Failed to load MDX content for recipe: ${id}`); + return { default: () => <div>Content not found</div> }; + } + ); return ( <HoverProvider> diff --git a/app/cookbook/page.tsx b/app/cookbook/page.tsx index b2876e716..da53a11f6 100644 --- a/app/cookbook/page.tsx +++ b/app/cookbook/page.tsx @@ -1,4 +1,3 @@ -// import { recipes } from "@/data/recipes"; import { loadRecipes } from "@/utils/loader"; import { CookbookUI } from "./components/cookbook-ui"; import { Code } from "@/components/docskit/code"; @@ -7,7 +6,6 @@ import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { CopyButton } from "@/components/docskit/copy-button"; -// Server Components for Recipe Display function RecipeCard({ recipe, codeElement, @@ -16,7 +14,7 @@ function RecipeCard({ codeElement: React.ReactNode; }) { return ( - <div className="relative w-full max-h-[300px] rounded-lg border bg-[#EBE9E6] dark:bg-[#2a2726] overflow-hidden [&:has(a:hover)]:shadow-[0_2px_12px_rgba(89,86,80,0.15)] dark:[&:has(a:hover)]:shadow-[0_2px_20px_rgba(56,52,50,0.4)] [&:has(a:hover)]:scale-[1.01] transition-all duration-200"> + <div className="relative w-full max-h-[300px] rounded-lg border bg-[#EBE9E6] dark:bg-[#2a2726] overflow-hidden [&:has(a:hover)]:shadow-[0_2px_12px_rgba(89,86,80,0.15)] dark:[&:has(a:hover)]:shadow-[0_2px_20px_rgba(56,52,50,0.4)] [&:has(a:hover)]:scale-[1.005] transition-all duration-200"> <div className="p-4 space-y-2"> <div className="flex items-start justify-between gap-4"> <div className="space-y-2"> diff --git a/app/global.css b/app/global.css index c2566f488..47947772e 100644 --- a/app/global.css +++ b/app/global.css @@ -561,3 +561,24 @@ div.divide-y.divide-border.overflow-hidden.rounded-lg.border.bg-card { } /* END Docskit theme */ + +/* Replace the nested selectors with flat ones */ +.light::selection { + background-color: #f4d4a3; + color: #bc812e; +} + +.light::-moz-selection { + background-color: #f4d4a3; + color: #bc812e; +} + +.dark::selection { + background-color: #ffe6f2; + color: #ff9ecf; +} + +.dark::-moz-selection { + background-color: #ffe6f2; + color: #ff9ecf; +} diff --git a/data/recipes/clarity-bitcoin.mdx b/content/_recipes/code-blocks/clarity-bitcoin.mdx similarity index 100% rename from data/recipes/clarity-bitcoin.mdx rename to content/_recipes/code-blocks/clarity-bitcoin.mdx diff --git a/data/recipes/create-a-multisig-address-using-principal-construct.mdx b/content/_recipes/code-blocks/create-a-multisig-address-using-principal-construct.mdx similarity index 90% rename from data/recipes/create-a-multisig-address-using-principal-construct.mdx rename to content/_recipes/code-blocks/create-a-multisig-address-using-principal-construct.mdx index 8b13d6333..e3d4c5e16 100644 --- a/data/recipes/create-a-multisig-address-using-principal-construct.mdx +++ b/content/_recipes/code-blocks/create-a-multisig-address-using-principal-construct.mdx @@ -17,7 +17,7 @@ files: ```clarity (define-read-only (pubkeys-to-principal (pubkeys (list 128 (buff 33))) (m uint)) (unwrap-panic (principal-construct? - (if is-in-mainnet 0x14 0x15) ;; address version + (if is-in-mainnet 0x16 0x1a) ;; address version (pubkeys-to-hash pubkeys m) )) ) @@ -27,11 +27,13 @@ files: ) (define-read-only (pubkeys-to-spend-script (pubkeys (list 128 (buff 33))) (m uint)) - (concat (uint-to-byte (+ u80 m)) ;; "m" in m-of-n - (concat (pubkeys-to-bytes pubkeys) ;; list of pubkeys with length prefix - (concat (uint-to-byte (+ u80 (len pubkeys))) ;; "n" in m-of-n - 0xae ;; CHECKMULTISIG - ))) + (concat + (uint-to-byte (+ u80 m)) ;; "m" in m-of-n + (concat + (pubkeys-to-bytes pubkeys) ;; list of pubkeys with length prefix + (concat (uint-to-byte (+ u80 (len pubkeys))) 0xae) + ) + ) ) (define-read-only (uint-to-byte (n uint)) diff --git a/data/recipes/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx similarity index 100% rename from data/recipes/fetch-testnet-bitcoin-on-regtest.mdx rename to content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx diff --git a/data/recipes/generate-random-number.mdx b/content/_recipes/code-blocks/generate-random-number.mdx similarity index 100% rename from data/recipes/generate-random-number.mdx rename to content/_recipes/code-blocks/generate-random-number.mdx diff --git a/data/recipes/get-tenure-height-for-a-block.mdx b/content/_recipes/code-blocks/get-tenure-height-for-a-block.mdx similarity index 100% rename from data/recipes/get-tenure-height-for-a-block.mdx rename to content/_recipes/code-blocks/get-tenure-height-for-a-block.mdx diff --git a/content/_recipes/clarity-bitcoin.mdx b/content/_recipes/guides/clarity-bitcoin.mdx similarity index 100% rename from content/_recipes/clarity-bitcoin.mdx rename to content/_recipes/guides/clarity-bitcoin.mdx diff --git a/content/_recipes/create-a-multisig-address-using-principal-construct.mdx b/content/_recipes/guides/create-a-multisig-address-using-principal-construct.mdx similarity index 100% rename from content/_recipes/create-a-multisig-address-using-principal-construct.mdx rename to content/_recipes/guides/create-a-multisig-address-using-principal-construct.mdx diff --git a/content/_recipes/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx similarity index 100% rename from content/_recipes/fetch-testnet-bitcoin-on-regtest.mdx rename to content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx diff --git a/content/_recipes/generate-random-number.mdx b/content/_recipes/guides/generate-random-number.mdx similarity index 73% rename from content/_recipes/generate-random-number.mdx rename to content/_recipes/guides/generate-random-number.mdx index b6dc792dc..2c9760212 100644 --- a/content/_recipes/generate-random-number.mdx +++ b/content/_recipes/guides/generate-random-number.mdx @@ -29,4 +29,44 @@ Once we have our block's hash, we use it with `at-block` to peek back in time an You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. -The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. \ No newline at end of file +The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. + +<WithNotes> + +```js demo.js +// !tooltip[/lorem/] install +import { read, write } from "lorem"; + +// !tooltip[/data.json/] data +var data = read("data.json"); + +// !tooltip[/test-123/] apikey +write({ x: 1 }, { apiKey: "test-123" }); +``` + +We can also use tooltips [here](tooltip "install") in [prose](tooltip "data") text. + +```json !data data.json +{ + "lorem": "ipsum dolor sit amet", + "foo": [4, 8, 15, 16] +} +``` + +## !install + +This is a **fake library**. You can install it with: + +```terminal +$ npm install lorem +``` + +It lets you read and write data. + +## !apikey + +This is a public sample test mode [API key](https://example.com). Don’t submit any personally information using this key. + +Replace this with your secret key found on the [API Keys page](https://example.com) in the dashboard. + +</WithNotes> \ No newline at end of file diff --git a/content/_recipes/get-tenure-height-for-a-block.mdx b/content/_recipes/guides/get-tenure-height-for-a-block.mdx similarity index 100% rename from content/_recipes/get-tenure-height-for-a-block.mdx rename to content/_recipes/guides/get-tenure-height-for-a-block.mdx From 4b363af820ea65c0c4af6e7ff759b0fdb46e3f2d Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Tue, 7 Jan 2025 12:32:35 -0600 Subject: [PATCH 05/30] add clarity icon --- components/docskit/code-icon.tsx | 13 +++++++++++++ components/ui/icon.tsx | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/components/docskit/code-icon.tsx b/components/docskit/code-icon.tsx index 297d0dc4a..d5086d9a8 100644 --- a/components/docskit/code-icon.tsx +++ b/components/docskit/code-icon.tsx @@ -1,4 +1,5 @@ import { themeIcons } from "seti-icons"; +import { Clarity } from "@/components/ui/icon"; export function CodeIcon({ title, @@ -9,6 +10,18 @@ export function CodeIcon({ lang: string; className?: string; }) { + if (lang === "clarity") { + return ( + <span className={className}> + <Clarity + height="28" + style={{ margin: "-8px" }} + color="hsl(var(--muted-foreground))" + /> + </span> + ); + } + let filename = title || "x"; if (!filename.includes(".")) { filename += "." + lang; diff --git a/components/ui/icon.tsx b/components/ui/icon.tsx index 95be0a3c2..98b69a1e0 100644 --- a/components/ui/icon.tsx +++ b/components/ui/icon.tsx @@ -3739,6 +3739,32 @@ export function Youtube(props: SVGProps<SVGSVGElement>): JSX.Element { ); } +export function Clarity(props: SVGProps<SVGSVGElement>): JSX.Element { + return ( + <svg + width="20" + height="20" + viewBox="0 0 91.49 91.49" + fill="none" + xmlns="http://www.w3.org/2000/svg" + {...props} + > + <path + d="M27.47,22.73c2.32-2.25,5.5-3.37,9.57-3.37V23.6q-4.2.11-6.27,2.58c-1.38,1.65-2.08,4.12-2.08,7.42V57.89c0,3.3.7,5.77,2.08,7.42s3.47,2.51,6.27,2.57v4.25c-4.07,0-7.25-1.12-9.57-3.37S24,63,24,58.29V33.2Q24,26.1,27.47,22.73Z" + fill="#7e858b" + /> + <path + d="M42.69,22.73q3.48-3.37,9.57-3.37V23.6q-4.18.11-6.27,2.58T43.92,33.6V57.89q0,4.95,2.07,7.42t6.27,2.57v4.25q-6.09,0-9.57-3.37T39.22,58.29V33.2Q39.22,26.1,42.69,22.73Z" + fill="var(--icon)" + /> + <path + d="M57.92,22.73q3.46-3.37,9.57-3.37V23.6q-4.2.11-6.27,2.58T59.14,33.6V57.89q0,4.95,2.08,7.42t6.27,2.57v4.25q-6.1,0-9.57-3.37T54.45,58.29V33.2Q54.45,26.1,57.92,22.73Z" + fill="#7e858b" + /> + </svg> + ); +} + export function StacksCardIcon(props: SVGProps<SVGSVGElement>): JSX.Element { return ( <svg From d671eac3249b986544f9348d735b9621164ed23d Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Tue, 7 Jan 2025 12:32:51 -0600 Subject: [PATCH 06/30] update path --- utils/loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/loader.ts b/utils/loader.ts index 674ee5f49..19781108b 100644 --- a/utils/loader.ts +++ b/utils/loader.ts @@ -25,7 +25,7 @@ function extractCodeAndContent(content: string) { } export async function loadRecipes(): Promise<Recipe[]> { - const recipesDir = path.join(process.cwd(), "data/recipes"); + const recipesDir = path.join(process.cwd(), "content/_recipes/code-blocks"); const files = await fs.readdir(recipesDir); const recipes = await Promise.all( From 4be96eec2a24c7c073c43144cef82c502e1a0b44 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Tue, 7 Jan 2025 12:57:23 -0600 Subject: [PATCH 07/30] update clarinet sdk browser version and function name --- app/cookbook/components/snippet-result.tsx | 2 +- components/code/clarinet-sdk.tsx | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/cookbook/components/snippet-result.tsx b/app/cookbook/components/snippet-result.tsx index 447d3a45a..c6ceb2584 100644 --- a/app/cookbook/components/snippet-result.tsx +++ b/app/cookbook/components/snippet-result.tsx @@ -73,7 +73,7 @@ export function SnippetResult({ try { const simnet = await initSimnet(); - await simnet.initEmtpySession(); + await simnet.initEmptySession(); simnet.deployer = "ST000000000000000000002AMW42H"; const deployer = simnet.deployer; console.log("deployer", deployer); diff --git a/components/code/clarinet-sdk.tsx b/components/code/clarinet-sdk.tsx index 7f4c1d939..21abc0f81 100644 --- a/components/code/clarinet-sdk.tsx +++ b/components/code/clarinet-sdk.tsx @@ -18,7 +18,7 @@ export const ClarinetSDK: React.FC = () => { async function runCode() { const simnet = await initSimnet(); - await simnet.initEmtpySession(); + await simnet.initEmptySession(); simnet.setEpoch("2.5"); const result = simnet.runSnippet(clarityCode) as any; diff --git a/package.json b/package.json index 027f79911..3e5a60420 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@codesandbox/sandpack-client": "^2.19.8", - "@hirosystems/clarinet-sdk-browser": "^2.7.0-beta2", + "@hirosystems/clarinet-sdk-browser": "^2.12.0", "@next/env": "^14.0.0", "@next/third-parties": "^14.2.4", "@radix-ui/react-avatar": "^1.0.4", From 7d64b67366c97eb081a5d8f7c87169585e8e7b1f Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Tue, 7 Jan 2025 15:33:48 -0600 Subject: [PATCH 08/30] add multi-select --- components/multi-select.tsx | 364 ++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 components/multi-select.tsx diff --git a/components/multi-select.tsx b/components/multi-select.tsx new file mode 100644 index 000000000..29ebdb2c4 --- /dev/null +++ b/components/multi-select.tsx @@ -0,0 +1,364 @@ +// src/components/multi-select.tsx + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + CheckIcon, + XCircle, + ChevronDown, + XIcon, + WandSparkles, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva( + "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", + { + variants: { + variant: { + default: + "border-foreground/10 text-foreground bg-card hover:bg-card/80", + secondary: + "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + inverted: "inverted", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof multiSelectVariants> { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + }[]; + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; +} + +export const MultiSelect = React.forwardRef< + HTMLButtonElement, + MultiSelectProps +>( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = "Select options", + animation = 0, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = + React.useState<string[]>(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + + const handleInputKeyDown = ( + event: React.KeyboardEvent<HTMLInputElement> + ) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + const newSelectedValues = selectedValues.slice(0, maxCount); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + }; + + return ( + <Popover + open={isPopoverOpen} + onOpenChange={setIsPopoverOpen} + modal={modalPopover} + > + <PopoverTrigger asChild> + <Button + ref={ref} + {...props} + onClick={handleTogglePopover} + className={cn( + "flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto", + className + )} + > + {selectedValues.length > 0 ? ( + <div className="flex justify-between items-center w-full"> + <div className="flex flex-wrap items-center"> + {selectedValues.slice(0, maxCount).map((value) => { + const option = options.find((o) => o.value === value); + const IconComponent = option?.icon; + return ( + <Badge + key={value} + className={cn( + isAnimating ? "animate-bounce" : "", + multiSelectVariants({ variant }) + )} + style={{ animationDuration: `${animation}s` }} + > + {IconComponent && ( + <IconComponent className="h-4 w-4 mr-2" /> + )} + {option?.label} + <XCircle + className="ml-2 h-4 w-4 cursor-pointer" + onClick={(event) => { + event.stopPropagation(); + toggleOption(value); + }} + /> + </Badge> + ); + })} + {selectedValues.length > maxCount && ( + <Badge + className={cn( + "bg-transparent text-foreground border-foreground/1 hover:bg-transparent", + isAnimating ? "animate-bounce" : "", + multiSelectVariants({ variant }) + )} + style={{ animationDuration: `${animation}s` }} + > + {`+ ${selectedValues.length - maxCount} more`} + <XCircle + className="ml-2 h-4 w-4 cursor-pointer" + onClick={(event) => { + event.stopPropagation(); + clearExtraOptions(); + }} + /> + </Badge> + )} + </div> + <div className="flex items-center justify-between"> + <XIcon + className="h-4 mx-2 cursor-pointer text-muted-foreground" + onClick={(event) => { + event.stopPropagation(); + handleClear(); + }} + /> + <Separator + orientation="vertical" + className="flex min-h-6 h-full" + /> + <ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" /> + </div> + </div> + ) : ( + <div className="flex items-center justify-between w-full mx-auto"> + <span className="text-sm text-muted-foreground mx-3"> + {placeholder} + </span> + <ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" /> + </div> + )} + </Button> + </PopoverTrigger> + <PopoverContent + className="w-auto p-0" + align="start" + onEscapeKeyDown={() => setIsPopoverOpen(false)} + > + <Command className="min-w-[315px]"> + <CommandInput + placeholder="Search..." + onKeyDown={handleInputKeyDown} + /> + <CommandList> + <CommandEmpty>No results found.</CommandEmpty> + <CommandGroup> + {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + <CommandItem + key={option.value} + onSelect={() => toggleOption(option.value)} + className="cursor-pointer" + > + <div + className={cn( + "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", + isSelected + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + > + <CheckIcon className="h-4 w-4" /> + </div> + {option.icon && ( + <option.icon className="mr-2 h-4 w-4 text-muted-foreground" /> + )} + <span>{option.label}</span> + </CommandItem> + ); + })} + </CommandGroup> + <CommandSeparator /> + <CommandGroup> + <div className="flex items-center justify-between"> + {selectedValues.length > 0 && ( + <> + <CommandItem + onSelect={handleClear} + className="flex-1 justify-center cursor-pointer" + > + Clear + </CommandItem> + <Separator + orientation="vertical" + className="flex min-h-6 h-full" + /> + </> + )} + <CommandItem + onSelect={() => setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full" + > + Close + </CommandItem> + </div> + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + {animation > 0 && selectedValues.length > 0 && ( + <WandSparkles + className={cn( + "cursor-pointer my-2 text-foreground bg-background w-3 h-3", + isAnimating ? "" : "text-muted-foreground" + )} + onClick={() => setIsAnimating(!isAnimating)} + /> + )} + </Popover> + ); + } +); + +MultiSelect.displayName = "MultiSelect"; From 3997c65cad9761e8ae1b9c58012a4b0dba44bd9f Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Tue, 7 Jan 2025 15:33:58 -0600 Subject: [PATCH 09/30] update filter to use multiselect --- app/cookbook/components/cookbook-ui.tsx | 252 +++++++----------------- components/ui/command.tsx | 1 - 2 files changed, 74 insertions(+), 179 deletions(-) diff --git a/app/cookbook/components/cookbook-ui.tsx b/app/cookbook/components/cookbook-ui.tsx index 4fad9becd..d0f786b31 100644 --- a/app/cookbook/components/cookbook-ui.tsx +++ b/app/cookbook/components/cookbook-ui.tsx @@ -4,10 +4,10 @@ import { useState, useMemo, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Recipe, RecipeSubTag } from "@/types/recipes"; import { cn } from "@/lib/utils"; -import { CustomTable } from "@/components/table"; +import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Filter, LayoutGrid, List } from "lucide-react"; +import { LayoutGrid, List, Search } from "lucide-react"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import { ChevronDown } from "lucide-react"; import { @@ -16,6 +16,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { MultiSelect } from "@/components/multi-select"; // Internal components function ViewToggle({ @@ -29,142 +30,75 @@ function ViewToggle({ <div className="flex items-center gap-1 rounded-lg p-1 border border-code bg-code"> <Button size="sm" - onClick={() => onChange("grid")} + onClick={() => onChange("list")} className={cn( "px-2", - view === "grid" + view === "list" ? "bg-card hover:bg-card text-primary" : "bg-code text-muted-foreground hover:bg-card" )} > - <LayoutGrid className="h-4 w-4" /> + <List className="h-4 w-4" /> </Button> <Button size="sm" - onClick={() => onChange("list")} + onClick={() => onChange("grid")} className={cn( "px-2", - view === "list" + view === "grid" ? "bg-card hover:bg-card text-primary" : "bg-code text-muted-foreground hover:bg-card" )} > - <List className="h-4 w-4" /> + <LayoutGrid className="h-4 w-4" /> </Button> </div> ); } -const TAG_CATEGORIES = { - "stacks-js": { - label: "Stacks.js", - subTags: [ - "web", - "authentication", - "transactions", - "signing", - "smart-contracts", - "utils", - ], - }, - clarity: { - label: "Clarity", - subTags: [ - "hashing", - "lists", - "arithmetic", - "sequences", - "iterators", - "tokens", - ], - }, - bitcoin: { - label: "Bitcoin", - subTags: ["transactions", "signing"], - }, - chainhook: { - label: "Chainhook", - subTags: [], - }, - api: { - label: "API", - subTags: [ - "token-metadata", - "signer-metrics", - "rpc", - "platform", - "ordinals", - "runes", - ], - }, - clarinet: { - label: "Clarinet", - subTags: ["testing", "deployment"], - }, -} as const; +const CATEGORIES = [ + { label: "Stacks.js", value: "stacks-js" }, + { label: "Clarity", value: "clarity" }, + { label: "Bitcoin", value: "bitcoin" }, + { label: "Chainhook", value: "chainhook" }, + { label: "API", value: "api" }, + { label: "Clarinet", value: "clarinet" }, +]; -// Type for our category keys -type CategoryKey = keyof typeof TAG_CATEGORIES; +type CategoryKey = (typeof CATEGORIES)[number]["value"]; function RecipeFilters({ search, onSearchChange, - selectedCategory, - selectedSubTags, - onCategoryChange, - onSubTagToggle, + selectedCategories, + onCategoriesChange, }: { search: string; onSearchChange: (value: string) => void; - selectedCategory: CategoryKey | null; - selectedSubTags: string[]; - onCategoryChange: (category: CategoryKey) => void; - onSubTagToggle: (tag: string) => void; + selectedCategories: string[]; + onCategoriesChange: (categories: string[]) => void; }) { return ( - <div className="flex flex-row gap-2 flex-wrap items-center"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <button type="button" className="outline-none"> - <Badge - variant={selectedCategory ? "default" : "outline"} - className={cn( - "cursor-pointer inline-flex items-center", - selectedCategory && - "hover:bg-[#aea498] dark:hover:bg-[#595650] dark:hover:text-[#dcd1d6]" - )} - > - {selectedCategory - ? TAG_CATEGORIES[selectedCategory].label.toUpperCase() - : "FILTER"} - <ChevronDown className="ml-1.5 h-3.5 w-3.5" /> - </Badge> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {Object.entries(TAG_CATEGORIES).map(([key, category]) => ( - <DropdownMenuItem - key={key} - onClick={() => onCategoryChange(key as CategoryKey)} - > - {category.label} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - {selectedCategory && <div className="w-px bg-border h-4" />} - <div className="contents flex-wrap items-center gap-2"> - {selectedCategory && - TAG_CATEGORIES[selectedCategory].subTags.map((tag) => ( - <Badge - key={tag} - variant={selectedSubTags.includes(tag) ? "default" : "outline"} - className="cursor-pointer" - onClick={() => onSubTagToggle(tag)} - > - {tag.toUpperCase()} - </Badge> - ))} + <div className="flex flex-row gap-2 flex-wrap items-start"> + <div className="relative flex-1 max-w-sm"> + <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + type="search" + placeholder="Search recipes..." + className="pl-8" + value={search} + onChange={(e) => onSearchChange(e.target.value)} + /> + </div> + <div className="min-w-[315px]"> + <MultiSelect + options={CATEGORIES} + placeholder="Filter by product" + onValueChange={onCategoriesChange} + defaultValue={selectedCategories} + className="h-10" + maxCount={2} + /> </div> </div> ); @@ -180,39 +114,24 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { const searchParams = useSearchParams(); // Initialize state from URL params const [view, setView] = useState<"grid" | "list">(() => { - return (searchParams.get("view") as "grid" | "list") || "grid"; + return (searchParams.get("view") as "grid" | "list") || "list"; }); const [search, setSearch] = useState(""); - const [selectedCategory, setSelectedCategory] = useState<CategoryKey | null>( - () => { - const category = searchParams.get("category") as CategoryKey | null; - return category && TAG_CATEGORIES[category] ? category : "clarity"; - } - ); - - const [selectedSubTags, setSelectedSubTags] = useState<string[]>(() => { - const tagParam = searchParams.get("tags"); - return tagParam ? tagParam.split(",") : []; + const [selectedCategories, setSelectedCategories] = useState<string[]>(() => { + const categories = searchParams.get("categories"); + return categories ? categories.split(",") : []; }); // Update URL when filters change - const updateURL = ( - newView?: "grid" | "list", - newCategory?: CategoryKey | null, - newSubTags?: string[] - ) => { + const updateURL = (newView?: "grid" | "list", newCategories?: string[]) => { const params = new URLSearchParams(); if (newView === "list") { params.set("view", newView); } - if (newCategory) { - params.set("category", newCategory); - } - - if (newSubTags && newSubTags.length > 0) { - params.set("tags", newSubTags.join(",")); + if (newCategories && newCategories.length > 0) { + params.set("categories", newCategories.join(",")); } const newURL = params.toString() @@ -222,27 +141,18 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { router.push(newURL, { scroll: false }); }; - // Handle view changes const handleViewChange = (newView: "grid" | "list") => { setView(newView); - updateURL(newView, selectedCategory, selectedSubTags); + updateURL(newView, selectedCategories); }; - // Handle tag changes - const handleCategoryChange = (category: CategoryKey) => { - setSelectedCategory(category); - setSelectedSubTags([]); // Clear sub-tags when category changes - updateURL(view, category, []); + const handleCategoriesChange = (categories: string[]) => { + setSelectedCategories(categories); + updateURL(view, categories); }; - // Handle sub-tag toggle - const handleSubTagToggle = (tag: string) => { - const newSubTags = selectedSubTags.includes(tag) - ? selectedSubTags.filter((t) => t !== tag) - : [...selectedSubTags, tag]; - - setSelectedSubTags(newSubTags); - updateURL(view, selectedCategory, newSubTags); + const handleSearchChange = (value: string) => { + setSearch(value); }; // Create a map of recipe IDs to their corresponding rendered cards @@ -264,25 +174,17 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { recipe.title.toLowerCase().includes(search.toLowerCase()) || recipe.description.toLowerCase().includes(search.toLowerCase()); - const matchesCategory = - !selectedCategory || recipe.categories.includes(selectedCategory); - const matchesTags = - selectedSubTags.length === 0 || - selectedSubTags.some((tag) => - recipe.tags.includes(tag as RecipeSubTag) + const matchesCategories = + selectedCategories.length === 0 || + recipe.categories.some((category) => + selectedCategories.includes(category) ); - return matchesSearch && matchesCategory && matchesTags; + return matchesSearch && matchesCategories; }); return filteredRecipes.map((recipe) => recipeCardMap[recipe.id]); - }, [ - search, - selectedCategory, - selectedSubTags, - initialRecipes, - recipeCardMap, - ]); + }, [search, selectedCategories, initialRecipes, recipeCardMap]); return ( <div className="max-w-5xl mx-auto"> @@ -301,18 +203,12 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { <div className="space-y-6"> <RecipeFilters search={search} - onSearchChange={setSearch} - selectedCategory={selectedCategory} - selectedSubTags={selectedSubTags} - onCategoryChange={handleCategoryChange} - onSubTagToggle={handleSubTagToggle} + onSearchChange={handleSearchChange} + selectedCategories={selectedCategories} + onCategoriesChange={handleCategoriesChange} /> - {view === "grid" ? ( - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - {filteredRecipeCards} - </div> - ) : ( + {view === "list" ? ( <Table> <TableBody> {initialRecipes @@ -326,17 +222,13 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { .toLowerCase() .includes(search.toLowerCase()); - const matchesCategory = - !selectedCategory || - recipe.categories.includes(selectedCategory); - - const matchesTags = - selectedSubTags.length === 0 || - selectedSubTags.some((tag) => - recipe.tags.includes(tag as RecipeSubTag) + const matchesCategories = + selectedCategories.length === 0 || + recipe.categories.some((category) => + selectedCategories.includes(category) ); - return matchesSearch && matchesCategory && matchesTags; + return matchesSearch && matchesCategories; }) .map((recipe) => ( <TableRow @@ -362,6 +254,10 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { ))} </TableBody> </Table> + ) : ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + {filteredRecipeCards} + </div> )} </div> </div> diff --git a/components/ui/command.tsx b/components/ui/command.tsx index 4a136373b..012c90c9f 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -42,7 +42,6 @@ const CommandInput = React.forwardRef< React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> >(({ className, ...props }, ref) => ( <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> - <MagnetIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <CommandPrimitive.Input ref={ref} className={cn( From b50c85242cc83e0e90f03962575097aaa7251f0a Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Wed, 8 Jan 2025 06:20:46 -0600 Subject: [PATCH 10/30] update syntax --- content/docs/stacks/clarinet-js-sdk/quickstart.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/stacks/clarinet-js-sdk/quickstart.mdx b/content/docs/stacks/clarinet-js-sdk/quickstart.mdx index 452ec8196..6ad67b6cb 100644 --- a/content/docs/stacks/clarinet-js-sdk/quickstart.mdx +++ b/content/docs/stacks/clarinet-js-sdk/quickstart.mdx @@ -35,7 +35,7 @@ Check out the [methods](/stacks/clarinet-js-sdk/references/methods) page for the <File name="vitest.config.js" /> </Files> - ```ts title="counter.test.ts" + ```ts counter.test.ts import { describe, expect, it } from "vitest"; import { Cl } from "@stacks/transactions"; ``` From 719fbdf2ae451a07a1e00f18407520e0cfb36a05 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Thu, 16 Jan 2025 13:26:46 -0600 Subject: [PATCH 11/30] add carousel --- components/filter-popover.tsx | 100 +++++++++++++ components/recipe-carousel.tsx | 77 ++++++++++ components/ui/carousel.tsx | 261 +++++++++++++++++++++++++++++++++ components/ui/popover.tsx | 2 +- 4 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 components/filter-popover.tsx create mode 100644 components/recipe-carousel.tsx create mode 100644 components/ui/carousel.tsx diff --git a/components/filter-popover.tsx b/components/filter-popover.tsx new file mode 100644 index 000000000..8aa752e78 --- /dev/null +++ b/components/filter-popover.tsx @@ -0,0 +1,100 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useId } from "react"; + +import { ListFilter } from "lucide-react"; + +const CATEGORIES = [ + { label: "Stacks.js", value: "stacks-js" }, + { label: "Clarity", value: "clarity" }, + { label: "Bitcoin", value: "bitcoin" }, + // { label: "Chainhook", value: "chainhook" }, + { label: "API", value: "api" }, + // { label: "Clarinet", value: "clarinet" }, +]; + +interface FilterPopoverProps { + selectedCategories: string[]; + onCategoriesChange: (categories: string[]) => void; +} + +function FilterPopover({ + selectedCategories, + onCategoriesChange, +}: FilterPopoverProps) { + const id = useId(); + + const handleCheckboxChange = (category: string, checked: boolean) => { + if (checked) { + onCategoriesChange([...selectedCategories, category]); + } else { + onCategoriesChange(selectedCategories.filter((c) => c !== category)); + } + }; + + const handleClear = () => { + onCategoriesChange([]); + }; + + return ( + <div className="flex flex-col gap-4"> + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" size="icon" aria-label="Filter by product"> + <ListFilter size={16} strokeWidth={2} aria-hidden="true" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-48 p-3"> + <div className="space-y-3"> + <div className="text-xs font-medium text-muted-foreground"> + Filter by product + </div> + <form className="space-y-3" onSubmit={(e) => e.preventDefault()}> + {CATEGORIES.map((category) => ( + <div key={category.value} className="flex items-center gap-2"> + <Checkbox + id={`${id}-${category.value}`} + checked={selectedCategories.includes(category.value)} + onCheckedChange={(checked) => + handleCheckboxChange(category.value, checked as boolean) + } + /> + <Label + htmlFor={`${id}-${category.value}`} + className="font-normal" + > + {category.label} + </Label> + </div> + ))} + <div + role="separator" + aria-orientation="horizontal" + className="-mx-3 my-1 h-px bg-border" + /> + <div className="flex justify-between gap-2"> + <Button + type="button" + size="sm" + variant="outline" + className="h-7 px-2" + onClick={handleClear} + > + Clear + </Button> + </div> + </form> + </div> + </PopoverContent> + </Popover> + </div> + ); +} + +export { FilterPopover }; diff --git a/components/recipe-carousel.tsx b/components/recipe-carousel.tsx new file mode 100644 index 000000000..138639a28 --- /dev/null +++ b/components/recipe-carousel.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; +import type { Recipe } from "@/types/recipes"; + +interface RecipeCarouselProps { + currentRecipeId: string; // To exclude current recipe from carousel + data: Recipe[]; +} + +function RecipeCarousel({ currentRecipeId, data }: RecipeCarouselProps) { + const recipes = data.filter((recipe) => recipe.id !== currentRecipeId); + + return ( + <div className="w-full p-4 rounded-lg"> + <Carousel + opts={{ + align: "start", + loop: true, + }} + className="w-full" + > + <div className="flex justify-between items-center mb-4"> + <h2 className="text-xl font-mono text-muted-foreground"> + More recipes + </h2> + <div className="flex gap-2"> + <CarouselPrevious className="relative inset-0 translate-x-0 translate-y-0 h-7 w-7 border-border bg-background hover:bg-code" /> + <CarouselNext className="relative inset-0 translate-x-0 translate-y-0 h-7 w-7 border-border bg-background hover:bg-code" /> + </div> + </div> + + <CarouselContent className="w-[1100px] -ml-4"> + {recipes.map((recipe, index) => ( + <CarouselItem key={index} className="pl-4 basis-[75%]"> + <Link href={`/cookbook/${recipe.id}`} className="group"> + <Card className="bg-code border-0"> + <CardContent className="p-6 flex flex-col h-full"> + <div className="space-y-4 flex flex-col flex-grow"> + <div className="flex items-baseline gap-4 flex-wrap"> + <h3 className="text-xl font-mono text-primary group-hover:underline decoration-2 underline-offset-4"> + {recipe.title} + </h3> + <div className="flex gap-2 flex-wrap uppercase"> + {recipe.categories.map((category, i) => ( + <Badge key={category} variant="secondary"> + {category} + </Badge> + ))} + </div> + </div> + <p className="text-sm text-muted-foreground leading-relaxed flex-grow"> + {recipe.description} + </p> + </div> + </CardContent> + </Card> + </Link> + </CarouselItem> + ))} + </CarouselContent> + </Carousel> + </div> + ); +} + +export { RecipeCarousel }; diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx new file mode 100644 index 000000000..5a3636676 --- /dev/null +++ b/components/ui/carousel.tsx @@ -0,0 +1,261 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters<typeof useEmblaCarousel> +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType<typeof useEmblaCarousel>[0] + api: ReturnType<typeof useEmblaCarousel>[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext<CarouselContextProps | null>(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className + )} + {...props} + /> + </div> + ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className + )} + {...props} + /> + ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ArrowLeftIcon className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ArrowRightIcon className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx index f14fc6bbd..3d437e6aa 100644 --- a/components/ui/popover.tsx +++ b/components/ui/popover.tsx @@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 w-72 font-aeonikFono rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} From 91bb7655bbf0714c1494946d58048865bd6fdcd4 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Thu, 16 Jan 2025 13:27:09 -0600 Subject: [PATCH 12/30] add cursor rules --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fb9d51194..243d4f104 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ openapi .DS_Store **/.DS_Store tmp -prompt.txt \ No newline at end of file +.cursorrules \ No newline at end of file From 2b2b8b7048af8303f8cac1bee58efb8b21a28635 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Thu, 16 Jan 2025 13:27:27 -0600 Subject: [PATCH 13/30] update ring color --- app/global.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/global.css b/app/global.css index 47947772e..c53b87456 100644 --- a/app/global.css +++ b/app/global.css @@ -22,7 +22,7 @@ --destructive-foreground: 0 0% 98%; --border: 32 13.8% 78.6%; --input: 240 5.9% 90%; - --ring: 346.8 77.2% 49.8%; + --ring: 240 5.9% 90%; --radius: 0.5rem; --hiro: 21 100% 67.5%; --icon: #383432; @@ -67,7 +67,7 @@ --destructive-foreground: 0 85.7% 97.3%; --border: 17 6.5% 21%; --input: 240 3.7% 15.9%; - --ring: 346.8 77.2% 49.8%; + --ring: 17 6.5% 21%; --hiro: 24 100% 51.4%; --card-hover: 15 5% 16%; --icon: #ffffff; From ead7917882aa82ef1a7713a319c6158232fc4493 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Thu, 16 Jan 2025 13:27:57 -0600 Subject: [PATCH 14/30] update page layout with filters and carousel for more recipes --- app/cookbook/[id]/page.tsx | 108 ++++++++++++++---------- app/cookbook/components/cookbook-ui.tsx | 79 ++++++++--------- app/cookbook/layout.tsx | 2 +- 3 files changed, 98 insertions(+), 91 deletions(-) diff --git a/app/cookbook/[id]/page.tsx b/app/cookbook/[id]/page.tsx index 475dd9d61..b19bd4616 100644 --- a/app/cookbook/[id]/page.tsx +++ b/app/cookbook/[id]/page.tsx @@ -7,6 +7,9 @@ import { Terminal } from "@/components/docskit/terminal"; import { InlineCode } from "@/components/docskit/inline-code"; import { WithNotes } from "@/components/docskit/notes"; import { SnippetResult } from "../components/snippet-result"; +import Link from "next/link"; +import { RecipeCarousel } from "@/components/recipe-carousel"; +import { MoveLeft } from "lucide-react"; interface Param { id: string; @@ -36,58 +39,71 @@ export default async function Page({ ); return ( - <HoverProvider> - <div className="min-h-screen"> - <div className="px-4 py-8"> - <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12"> - <div className="hidden lg:block lg:col-span-6"> - <div className="space-y-3"> - <div className="flex flex-wrap gap-2"> - {recipe.tags.map((tag) => ( - <Badge key={tag} variant="secondary"> - {tag.toUpperCase()} - </Badge> - ))} - </div> - <div className="prose max-w-none"> - <Content.default - components={{ - HoverLink, - Terminal, - Code, - InlineCode, - WithNotes, - }} - /> - </div> - </div> + <> + <HoverProvider> + <div className="min-h-screen"> + <div className="space-y-2"> + <div className="px-4"> + <Link + href="/cookbook" + className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4" + > + <MoveLeft size={32} /> + </Link> </div> + <div className="px-4"> + <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12"> + <div className="hidden lg:block lg:col-span-6"> + <div className="space-y-3"> + <div className="flex flex-wrap gap-2 uppercase"> + {recipe.categories.map((category) => ( + <Badge key={category} variant="secondary"> + {category} + </Badge> + ))} + </div> + <div className="prose max-w-none"> + <Content.default + components={{ + HoverLink, + Terminal, + Code, + InlineCode, + WithNotes, + }} + /> + </div> + </div> + </div> - {/* Sticky sidebar */} - <div className="col-span-full lg:col-span-6"> - <div className="lg:sticky lg:top-20 space-y-4"> - <div className="recipe group relative w-full overflow-hidden"> - <Code - codeblocks={[ - { - lang: recipe.files[0].type, - value: recipe.files[0].content, - meta: `${recipe.files[0].name} -cn`, // filename + flags - }, - ]} - /> + {/* Sticky sidebar */} + <div className="col-span-full lg:col-span-6"> + <div className="lg:sticky lg:top-20 space-y-4"> + <div className="recipe group relative w-full overflow-hidden"> + <Code + codeblocks={[ + { + lang: recipe.files[0].type, + value: recipe.files[0].content, + meta: `${recipe.files[0].name} -cn`, // filename + flags + }, + ]} + /> + </div> + <SnippetResult + recipe={recipe} + code={recipe.files[0].content as string} + type={recipe.files[0].type} + dependencies={{}} + /> + </div> </div> - <SnippetResult - recipe={recipe} - code={recipe.files[0].content as string} - type={recipe.files[0].type} - dependencies={{}} - /> </div> </div> </div> </div> - </div> - </HoverProvider> + <RecipeCarousel currentRecipeId={id} data={recipes} /> + </HoverProvider> + </> ); } diff --git a/app/cookbook/components/cookbook-ui.tsx b/app/cookbook/components/cookbook-ui.tsx index d0f786b31..d3e129879 100644 --- a/app/cookbook/components/cookbook-ui.tsx +++ b/app/cookbook/components/cookbook-ui.tsx @@ -17,6 +17,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MultiSelect } from "@/components/multi-select"; +import { FilterPopover } from "@/components/filter-popover"; // Internal components function ViewToggle({ @@ -56,17 +57,6 @@ function ViewToggle({ ); } -const CATEGORIES = [ - { label: "Stacks.js", value: "stacks-js" }, - { label: "Clarity", value: "clarity" }, - { label: "Bitcoin", value: "bitcoin" }, - { label: "Chainhook", value: "chainhook" }, - { label: "API", value: "api" }, - { label: "Clarinet", value: "clarinet" }, -]; - -type CategoryKey = (typeof CATEGORIES)[number]["value"]; - function RecipeFilters({ search, onSearchChange, @@ -79,27 +69,21 @@ function RecipeFilters({ onCategoriesChange: (categories: string[]) => void; }) { return ( - <div className="flex flex-row gap-2 flex-wrap items-start"> - <div className="relative flex-1 max-w-sm"> - <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> + <div className="flex flex-row gap-2 flex-wrap items-start justify-between"> + <div className="relative w-1/3"> + <Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" /> <Input type="search" - placeholder="Search recipes..." - className="pl-8" + placeholder="Search by keywords..." + className="font-aeonikFono text-md pl-8" value={search} onChange={(e) => onSearchChange(e.target.value)} /> </div> - <div className="min-w-[315px]"> - <MultiSelect - options={CATEGORIES} - placeholder="Filter by product" - onValueChange={onCategoriesChange} - defaultValue={selectedCategories} - className="h-10" - maxCount={2} - /> - </div> + <FilterPopover + selectedCategories={selectedCategories} + onCategoriesChange={onCategoriesChange} + /> </div> ); } @@ -169,15 +153,20 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { // Filter recipes and get their corresponding cards const filteredRecipeCards = useMemo(() => { const filteredRecipes = initialRecipes.filter((recipe) => { + const searchText = search.toLowerCase(); const matchesSearch = - search === "" || - recipe.title.toLowerCase().includes(search.toLowerCase()) || - recipe.description.toLowerCase().includes(search.toLowerCase()); + recipe.title.toLowerCase().includes(searchText) || + recipe.description.toLowerCase().includes(searchText) || + recipe.categories.some((category) => + category.toLowerCase().includes(searchText) + ) || + recipe.tags.some((tag) => tag.toLowerCase().includes(searchText)); + // Add category filtering const matchesCategories = - selectedCategories.length === 0 || + selectedCategories.length === 0 || // Show all if no categories selected recipe.categories.some((category) => - selectedCategories.includes(category) + selectedCategories.includes(category.toLowerCase()) ); return matchesSearch && matchesCategories; @@ -188,13 +177,13 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { return ( <div className="max-w-5xl mx-auto"> - <div className="flex flex-col gap-6 space-y-6"> + <div className="flex flex-col gap-6 space-y-4"> <div className="flex items-start justify-between gap-4"> <div className="space-y-1"> - <h1 className="text-3xl font-semibold">Cookbook</h1> - <p className="text-lg text-muted-foreground w-2/3"> - An open-source collection of copy & paste code recipes for - building on Stacks and Bitcoin. + <h1 className="text-4xl font-semibold">Cookbook</h1> + <p className="text-lg text-muted-foreground w-full"> + Explore ready-to-use code recipes for building applications on + Stacks. </p> </div> <ViewToggle view={view} onChange={handleViewChange} /> @@ -213,19 +202,21 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { <TableBody> {initialRecipes .filter((recipe) => { + const searchText = search.toLowerCase(); const matchesSearch = - search === "" || - recipe.title - .toLowerCase() - .includes(search.toLowerCase()) || - recipe.description - .toLowerCase() - .includes(search.toLowerCase()); + recipe.title.toLowerCase().includes(searchText) || + recipe.description.toLowerCase().includes(searchText) || + recipe.categories.some((category) => + category.toLowerCase().includes(searchText) + ) || + recipe.tags.some((tag) => + tag.toLowerCase().includes(searchText) + ); const matchesCategories = selectedCategories.length === 0 || recipe.categories.some((category) => - selectedCategories.includes(category) + selectedCategories.includes(category.toLowerCase()) ); return matchesSearch && matchesCategories; diff --git a/app/cookbook/layout.tsx b/app/cookbook/layout.tsx index 58df7d647..51be6614b 100644 --- a/app/cookbook/layout.tsx +++ b/app/cookbook/layout.tsx @@ -11,7 +11,7 @@ export default function CookbookLayout({ <div className="px-10 *:border-none"> <Layout {...homeLayoutOptions}> <div className="min-h-screen py-8"> - <div className="space-y-6">{children}</div> + <div className="space-y-4">{children}</div> </div> </Layout> </div> From c13f20ad22eb7fe34d5d7f111b3c91d40aca5c0b Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Thu, 16 Jan 2025 13:28:12 -0600 Subject: [PATCH 15/30] add carousel dependencies --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e5a60420..ce869adfa 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", @@ -45,6 +45,7 @@ "cobe": "^0.6.3", "codehike": "1.0.4", "date-fns": "^3.3.1", + "embla-carousel-react": "^8.5.2", "eslint": "^9.16.0", "eslint-config-next": "^15.0.3", "framer-motion": "^11.0.25", From eb84516546fae890e4be8f4c9cfe2cb7609e64b2 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Thu, 16 Jan 2025 13:28:26 -0600 Subject: [PATCH 16/30] change recipe types --- types/recipes.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/types/recipes.ts b/types/recipes.ts index 2cb661d2d..097b33c45 100644 --- a/types/recipes.ts +++ b/types/recipes.ts @@ -18,14 +18,7 @@ export const CategorySubTags = { "utils", ] as const, - clarity: [ - "hashing", - "lists", - "arithmetic", - "sequences", - "iterators", - "tokens", - ] as const, + clarity: [] as const, bitcoin: ["transactions", "signing"] as const, From 8fc4a4c8ac0da07f8f4515ad59cf9b9cc8454f7e Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Thu, 16 Jan 2025 13:28:49 -0600 Subject: [PATCH 17/30] update recipe files --- .../_recipes/code-blocks/clarity-bitcoin.mdx | 133 ------------------ ...isig-address-using-principal-construct.mdx | 79 ----------- .../create-a-random-burn-address.mdx | 28 ++++ .../fetch-testnet-bitcoin-on-regtest.mdx | 31 ++-- .../code-blocks/generate-random-number.mdx | 11 +- .../get-tenure-height-for-a-block.mdx | 30 ---- ...er-function-to-restrict-contract-calls.mdx | 22 +++ .../return-an-entry-from-a-map.mdx | 32 +++++ content/_recipes/guides/clarity-bitcoin.mdx | 46 ------ ...isig-address-using-principal-construct.mdx | 32 ----- .../guides/create-a-random-burn-address.mdx | 19 +++ .../fetch-testnet-bitcoin-on-regtest.mdx | 34 +---- .../guides/generate-random-number.mdx | 79 +++-------- .../guides/get-tenure-height-for-a-block.mdx | 37 ----- ...er-function-to-restrict-contract-calls.mdx | 32 +++++ .../guides/return-an-entry-from-a-map.mdx | 15 ++ 16 files changed, 193 insertions(+), 467 deletions(-) delete mode 100644 content/_recipes/code-blocks/clarity-bitcoin.mdx delete mode 100644 content/_recipes/code-blocks/create-a-multisig-address-using-principal-construct.mdx create mode 100644 content/_recipes/code-blocks/create-a-random-burn-address.mdx delete mode 100644 content/_recipes/code-blocks/get-tenure-height-for-a-block.mdx create mode 100644 content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx create mode 100644 content/_recipes/code-blocks/return-an-entry-from-a-map.mdx delete mode 100644 content/_recipes/guides/clarity-bitcoin.mdx delete mode 100644 content/_recipes/guides/create-a-multisig-address-using-principal-construct.mdx create mode 100644 content/_recipes/guides/create-a-random-burn-address.mdx delete mode 100644 content/_recipes/guides/get-tenure-height-for-a-block.mdx create mode 100644 content/_recipes/guides/helper-function-to-restrict-contract-calls.mdx create mode 100644 content/_recipes/guides/return-an-entry-from-a-map.mdx diff --git a/content/_recipes/code-blocks/clarity-bitcoin.mdx b/content/_recipes/code-blocks/clarity-bitcoin.mdx deleted file mode 100644 index 1d53b6860..000000000 --- a/content/_recipes/code-blocks/clarity-bitcoin.mdx +++ /dev/null @@ -1,133 +0,0 @@ ---- -id: clarity-bitcoin -title: Prepare btc data for Clarity verification -description: How to prepare btc data for Clarity verification. -date: 2024.02.28 -categories: - - clarity - - bitcoin -tags: [] -files: - - name: prepare-btc-data.ts - path: scripts/prepare-btc-data.ts - type: typescript ---- - -```typescript -import mempoolJS from "@mempool/mempool.js"; -import { Transaction } from "bitcoinjs-lib"; -import { - callReadOnlyFunction, - bufferCV, - uintCV, - tupleCV, - listCV, - cvToString, -} from "@stacks/transactions"; -import { StacksMainnet } from "@stacks/network"; -import { hexToBytes } from "@stacks/common"; - -const { - bitcoin: { transactions, blocks }, -} = await mempoolJS({ - hostname: "mempool.space", -}); - -// !hover get-tx-hex -const getTxHex = async (txid) => { - // !hover get-tx-hex - const txHex = await transactions.getTxHex({ txid }); - // !hover get-tx-hex - return txHex; -// !hover get-tx-hex -}; - -// !hover get-tx-merkle-proof -const getTxMerkleProof = async (txid) => { - // !hover get-tx-merkle-proof - const { block_height, merkle, pos } = await transactions.getTxMerkleProof({ - // !hover get-tx-merkle-proof - txid, - // !hover get-tx-merkle-proof - }); - // !hover get-tx-merkle-proof - return { block_height, merkle, pos }; -// !hover get-tx-merkle-proof -}; - -// !hover get-blk-header -const getBlkHeader = async (height) => { - // !hover get-blk-header - let blockHash = await blocks.getBlockHeight({ height }); - // !hover get-blk-header - const blockHeader = await blocks.getBlockHeader({ hash: blockHash }); - // !hover get-blk-header - return { blockHash, blockHeader }; -// !hover get-blk-header -}; - -const removeWitnessData = (txHex) => { - const tx = Transaction.fromHex(txHex); - if (!tx.hasWitnesses()) return txHex; - - const newTx = new Transaction(); - newTx.version = tx.version; - tx.ins.forEach((input) => - newTx.addInput(input.hash, input.index, input.sequence, input.script) - ); - tx.outs.forEach((output) => newTx.addOutput(output.script, output.value)); - newTx.locktime = tx.locktime; - - return newTx.toHex(); -}; - -const mainnetAddress = "SP2K8BC0PE000000000000000000000000000000000000000"; -const mainnet = new StacksMainnet(); - -// From read-only function of below clarity-bitcoin implementation contract: -const contractAddress = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9"; -const contractName = "clarity-bitcoin-lib-v5"; -const functionName = "was-tx-mined-compact"; - -// fetching btc tx details. You can replace this with any bitcoin transaction id. -let txid = "7ad7414063ab0f7ce7d5b1b6b4a87091094bd0e9be0e6a44925a48e1eb2ca51c"; - -// fetching and returning non-witness tx hex -let fullTxHex = await getTxHex(txid); -let txHex = removeWitnessData(fullTxHex); - -let { block_height, merkle, pos } = await getTxMerkleProof(txid); - -let { blockHeader } = await getBlkHeader(block_height); - -let txIndex = pos; -let hashes = merkle.map((hash) => bufferCV(hexToBytes(hash).reverse())); -let treeDepth = merkle.length; - -let functionArgs = [ - // (height) - uintCV(block_height), - // (tx) - bufferCV(Buffer.from(txHex, "hex")), - // (header) - bufferCV(Buffer.from(blockHeader, "hex")), - // (proof) - tupleCV({ - "tx-index": uintCV(txIndex), - hashes: listCV(hashes), - "tree-depth": uintCV(treeDepth), - }), -]; - -let result = await callReadOnlyFunction({ - contractAddress, - contractName, - functionName, - functionArgs, - network: mainnet, - // this could be any principal address - senderAddress: mainnetAddress, -}); - -console.log(cvToString(result)); -``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/create-a-multisig-address-using-principal-construct.mdx b/content/_recipes/code-blocks/create-a-multisig-address-using-principal-construct.mdx deleted file mode 100644 index e3d4c5e16..000000000 --- a/content/_recipes/code-blocks/create-a-multisig-address-using-principal-construct.mdx +++ /dev/null @@ -1,79 +0,0 @@ ---- -id: create-a-multisig-address-using-principal-construct -title: Create a multisig address using principal-construct -description: Create a multisig address using the principal-construct function in Clarity. -date: 2024.02.28 -categories: - - clarity -tags: - - smart-contracts - - hashing -files: - - name: multisig.clar - path: contracts/multisig.clar - type: clarity ---- - -```clarity -(define-read-only (pubkeys-to-principal (pubkeys (list 128 (buff 33))) (m uint)) - (unwrap-panic (principal-construct? - (if is-in-mainnet 0x16 0x1a) ;; address version - (pubkeys-to-hash pubkeys m) - )) -) - -(define-read-only (pubkeys-to-hash (pubkeys (list 128 (buff 33))) (m uint)) - (hash160 (pubkeys-to-spend-script pubkeys m)) -) - -(define-read-only (pubkeys-to-spend-script (pubkeys (list 128 (buff 33))) (m uint)) - (concat - (uint-to-byte (+ u80 m)) ;; "m" in m-of-n - (concat - (pubkeys-to-bytes pubkeys) ;; list of pubkeys with length prefix - (concat (uint-to-byte (+ u80 (len pubkeys))) 0xae) - ) - ) -) - -(define-read-only (uint-to-byte (n uint)) - (unwrap-panic (element-at BUFF_TO_BYTE n)) -) - -(define-read-only (pubkeys-to-bytes (pubkeys (list 128 (buff 33)))) - (fold concat-pubkeys-fold pubkeys 0x) -) - -(define-read-only (concat-pubkeys-fold (pubkey (buff 33)) (iterator (buff 510))) - (let - ( - (pubkey-with-len (concat (bytes-len pubkey) pubkey)) - (next (concat iterator pubkey-with-len)) - ) - (unwrap-panic (as-max-len? next u510)) - ) -) - -(define-read-only (bytes-len (bytes (buff 33))) - (unwrap-panic (element-at BUFF_TO_BYTE (len bytes))) -) - -(define-constant BUFF_TO_BYTE (list - 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0a 0x0b 0x0c 0x0d 0x0e 0x0f - 0x10 0x11 0x12 0x13 0x14 0x15 0x16 0x17 0x18 0x19 0x1a 0x1b 0x1c 0x1d 0x1e 0x1f - 0x20 0x21 0x22 0x23 0x24 0x25 0x26 0x27 0x28 0x29 0x2a 0x2b 0x2c 0x2d 0x2e 0x2f - 0x30 0x31 0x33 0x33 0x34 0x35 0x36 0x37 0x38 0x39 0x3a 0x3b 0x3c 0x3d 0x3e 0x3f - 0x40 0x41 0x42 0x43 0x44 0x45 0x46 0x47 0x48 0x49 0x4a 0x4b 0x4c 0x4d 0x4e 0x4f - 0x50 0x51 0x52 0x53 0x54 0x55 0x56 0x57 0x58 0x59 0x5a 0x5b 0x5c 0x5d 0x5e 0x5f - 0x60 0x61 0x62 0x63 0x64 0x65 0x66 0x67 0x68 0x69 0x6a 0x6b 0x6c 0x6d 0x6e 0x6f - 0x70 0x71 0x72 0x73 0x74 0x75 0x76 0x77 0x78 0x79 0x7a 0x7b 0x7c 0x7d 0x7e 0x7f - 0x80 0x81 0x82 0x83 0x84 0x85 0x86 0x87 0x88 0x89 0x8a 0x8b 0x8c 0x8d 0x8e 0x8f - 0x90 0x91 0x92 0x93 0x94 0x95 0x96 0x97 0x98 0x99 0x9a 0x9b 0x9c 0x9d 0x9e 0x9f - 0xa0 0xa1 0xa2 0xa3 0xa4 0xa5 0xa6 0xa7 0xa8 0xa9 0xaa 0xab 0xac 0xad 0xae 0xaf - 0xb0 0xb1 0xb2 0xb3 0xb4 0xb5 0xb6 0xb7 0xb8 0xb9 0xba 0xbb 0xbc 0xbd 0xbe 0xbf - 0xc0 0xc1 0xc2 0xc3 0xc4 0xc5 0xc6 0xc7 0xc8 0xc9 0xca 0xcb 0xcc 0xcd 0xce 0xcf - 0xd0 0xd1 0xd2 0xd3 0xd4 0xd5 0xd6 0xd7 0xd8 0xd9 0xda 0xdb 0xdc 0xdd 0xde 0xdf - 0xe0 0xe1 0xe2 0xe3 0xe4 0xe5 0xe6 0xe7 0xe8 0xe9 0xea 0xeb 0xec 0xed 0xee 0xef - 0xf0 0xf1 0xf2 0xf3 0xf4 0xf5 0xf6 0xf7 0xf8 0xf9 0xfa 0xfb 0xfc 0xfd 0xfe 0xff -)) -``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/create-a-random-burn-address.mdx b/content/_recipes/code-blocks/create-a-random-burn-address.mdx new file mode 100644 index 000000000..dd2156aa6 --- /dev/null +++ b/content/_recipes/code-blocks/create-a-random-burn-address.mdx @@ -0,0 +1,28 @@ +--- +id: create-a-random-burn-address +title: Create a random burn address +description: Create a random burn address using the `principal-construct?` function in Clarity. +date: 2025.01.16 +categories: + - clarity +tags: + - hashes + - principals + - burn addresses +files: + - name: random.clar + path: contracts/random.clar + type: clarity +--- + +```clarity +(define-read-only (generate-burn-address (entropyString (string-ascii 15))) + (let + ( + (hash (hash160 (unwrap-panic (to-consensus-buff? entropyString)))) + ) + ;; !hover principal-construct? + (principal-construct? (if is-in-mainnet 0x16 0x1a) hash) + ) +) +``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx index 35f29f40c..1e3fbf479 100644 --- a/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx +++ b/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx @@ -1,9 +1,10 @@ --- id: fetch-testnet-bitcoin-on-regtest -title: Fetch tBTC on regtest -description: How to fetch tBTC on regtest. -date: 2024.02.28 +title: Fetch testnet Bitcoin on regtest +description: How to fetch testnet Bitcoin on regtest. +date: 2025.01.16 categories: + - api - bitcoin tags: [] dependencies: @@ -18,18 +19,18 @@ files: --- ```typescript -const url = 'https://api.testnet.hiro.so/extended/v1/faucets/btc'; -const fetchTestnetBTC = async (address: string) => { - const response = await fetch(`${url}?address=${address}`, { +const TESTNET_ADDRESS = 'bcrt1q728h29ejjttmkupwdkyu2x4zcmkuc3q29gvwaa'; + +try { + const response = await fetch(`https://api.testnet.hiro.so/extended/v1/faucets/btc?address=${TESTNET_ADDRESS}`, { method: 'POST', + headers: { + "Content-Type": "application/json", + }, }); - const data = await response.json(); - return data; -}; - -// Example usage -const address = 'bcrt1q728h29ejjttmkupwdkyu2x4zcmkuc3q29gvwaa'; -fetchTestnetBTC(address) - .then(console.log) - .catch(console.error); + const result = await response.json(); + console.log(result); +} catch (error) { + console.error(error); +} ``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/generate-random-number.mdx b/content/_recipes/code-blocks/generate-random-number.mdx index c583efd42..2767b2d1d 100644 --- a/content/_recipes/code-blocks/generate-random-number.mdx +++ b/content/_recipes/code-blocks/generate-random-number.mdx @@ -1,12 +1,12 @@ --- id: generate-random-number -title: Create a random number in Clarity using block-height +title: Generate a random number with block-height description: Create a random number based on a block-height using the buff-to-uint-be function in Clarity. -date: 2024.02.28 +date: 2025.01.16 categories: - clarity tags: - - hashing + - blocks files: - name: random.clar path: contracts/random.clar @@ -16,10 +16,9 @@ files: ```clarity (define-constant ERR_FAIL (err u1000)) -;; !hover random (define-read-only (read-rnd (block uint)) - ;; !hover random + ;; !hover id-header-hash + ;; !mark[/id-header-hash/] (ok (buff-to-uint-be (unwrap-panic (as-max-len? (unwrap-panic (slice? (unwrap! (get-stacks-block-info? id-header-hash block) ERR_FAIL) u16 u32)) u16)))) -;; !hover random ) ``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/get-tenure-height-for-a-block.mdx b/content/_recipes/code-blocks/get-tenure-height-for-a-block.mdx deleted file mode 100644 index a90b6ce39..000000000 --- a/content/_recipes/code-blocks/get-tenure-height-for-a-block.mdx +++ /dev/null @@ -1,30 +0,0 @@ ---- -id: get-tenure-height-for-a-block -title: Get tenure height for a block -description: Get the tenure height for a specific block height using Clarity. -date: 2024.02.28 -categories: - - clarity -tags: [] -files: - - name: get-tenure-for-block.clar - path: contracts/get-tenure-for-block.clar - type: clarity ---- - -```clarity -(define-read-only (get-tenure-height (block uint)) - (ok - (at-block - (unwrap! - ;; !hover get-stacks-block-info - (get-stacks-block-info? id-header-hash block) - ;; !hover error - (err u404) - ) - ;; !hover tenure-height - tenure-height - ) - ) -) -``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx b/content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx new file mode 100644 index 000000000..2e4cd83e5 --- /dev/null +++ b/content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx @@ -0,0 +1,22 @@ +--- +id: helper-function-to-restrict-contract-calls +title: Create a helper function to restrict contract calls +description: Ensure functions can only be called directly by standard principals (users) and not by other contracts, maintaining transaction visibility. +date: 2025.01.16 +categories: + - clarity +tags: + - access control + - security +files: + - name: helper.clar + path: contracts/helper.clar + type: clarity +--- + +```clarity +(define-private (is-standard-principal-call) + ;; !hover principal-destruct? + (is-none (get name (unwrap! (principal-destruct? contract-caller) false))) +) +``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/return-an-entry-from-a-map.mdx b/content/_recipes/code-blocks/return-an-entry-from-a-map.mdx new file mode 100644 index 000000000..1744bcb6e --- /dev/null +++ b/content/_recipes/code-blocks/return-an-entry-from-a-map.mdx @@ -0,0 +1,32 @@ +--- +id: return-an-entry-from-a-map +title: Return an entry from a map +description: Return an entry from a map using the `map_entry` API endpoint. +date: 2025.01.16 +categories: + - api +tags: + - maps +files: + - name: return-an-entry-from-a-map.ts + path: scripts/return-an-entry-from-a-map.ts + type: typescript +--- + +```typescript +import { Cl, cvToHex } from "@stacks/transactions"; + +const response = await fetch( + "https://api.hiro.so/v2/map_entry/{contractAddress}/{contractName}/{mapName}", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(cvToHex(Cl.stringAscii("{mapName}"))), + } +); +const res = await response.json(); +const data = Cl.stringify(Cl.deserialize(res.data)); +console.log(data); +``` \ No newline at end of file diff --git a/content/_recipes/guides/clarity-bitcoin.mdx b/content/_recipes/guides/clarity-bitcoin.mdx deleted file mode 100644 index e5893334d..000000000 --- a/content/_recipes/guides/clarity-bitcoin.mdx +++ /dev/null @@ -1,46 +0,0 @@ -# Prepare btc data for Clarity verification - -When working with Bitcoin transactions in Clarity smart contracts, we need to prepare the transaction data in a specific way. This process involves gathering several key pieces of information: the transaction hex, merkle proof, and block header. - -This is particularly useful when you need to: - -- Verify Bitcoin deposits or payments in Stacks smart contracts, enabling BTC-backed lending or trading platforms -- Create NFTs that are only minted after verifying specific Bitcoin transactions -- Build payment systems that wait for Bitcoin transaction confirmation before executing Stacks contract logic - -The core of this process uses the _typescript`"@mempool/mempool.js"`_ library to fetch Bitcoin transaction data. - -1. First, we start by getting the raw transaction hex using <HoverLink href="hover:get-tx-hex" className="text-[var(--ch-9)]">getTxHex</HoverLink> -2. Then, we fetch its merkle proof with <HoverLink href="hover:get-tx-merkle-proof" className="text-[var(--ch-9)]">getTxMerkleProof</HoverLink> -3. Finally, we retrieve the block header using <HoverLink href="hover:get-blk-header" className="text-[var(--ch-9)]">getBlkHeader</HoverLink> - -An important step is removing witness data from the transaction hex using `removeWitnessData`, as Clarity's Bitcoin verification expects the legacy transaction format. - -Once we have all these pieces, we format them into Clarity-compatible types using the _typescript`"@stacks/transactions"`_ library. The transaction data, block header, and merkle proof are converted into buffer CVs (Clarity Values), while the proof information is structured as a tuple containing the transaction index, merkle hashes, and tree depth. - -<WithNotes> - -Finally, we call the [`was-tx-mined-compact`](tooltip "clarity-bitcoin") function from the [clarity-bitcoin](https://github.com/friedger/clarity-bitcoin/blob/main) library with our prepared data to verify the transaction. - -This function takes all this information and confirms whether the transaction was actually mined in the Bitcoin blockchain. This verification is crucial for cross-chain functionality between Bitcoin and Stacks. - -## !clarity-bitcoin - -This is from v5 of the [clarity-bitcoin](https://github.com/friedger/clarity-bitcoin/blob/main/contracts/clarity-bitcoin-v5.clar) library. - -```clarity -(define-read-only (was-tx-mined-compact (height uint) (tx (buff 4096)) (header (buff 80)) (proof { tx-index: uint, hashes: (list 14 (buff 32)), tree-depth: uint})) - (let - ( - (block (unwrap! (parse-block-header header) (err ERR-BAD-HEADER))) - ) - (was-tx-mined-internal height tx header (get merkle-root block) proof) - ) -) -``` - -</WithNotes> - -## References - -- [Use `callReadOnlyFunction` to call Clarity functions](/stacks/stacks.js/packages/transactions#smart-contract-function-call-on-chain) \ No newline at end of file diff --git a/content/_recipes/guides/create-a-multisig-address-using-principal-construct.mdx b/content/_recipes/guides/create-a-multisig-address-using-principal-construct.mdx deleted file mode 100644 index b8afbc64a..000000000 --- a/content/_recipes/guides/create-a-multisig-address-using-principal-construct.mdx +++ /dev/null @@ -1,32 +0,0 @@ -# Create a multisig address using principal-construct? - -On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. - -Understanding and accessing this information is really useful when you need to: - -- Track block sequences within minure tenures -- Implement logic that depends on tenure-specific block ordering -- Verify block relationships within a single miner's tenure - -Let's take a look at the following code to better understand how to work with tenure height information for a given block. - -At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. - -Think of it like a block's fingerprint - no two blocks will ever have the same one. - -```terminal -$ clarinet console -$ ::advance_stacks_chain_tip 1 -$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) -[32m(ok u3)[0m [1m[0m -``` - -Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. - -It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. - -Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ - -You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. - -The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. \ No newline at end of file diff --git a/content/_recipes/guides/create-a-random-burn-address.mdx b/content/_recipes/guides/create-a-random-burn-address.mdx new file mode 100644 index 000000000..743078575 --- /dev/null +++ b/content/_recipes/guides/create-a-random-burn-address.mdx @@ -0,0 +1,19 @@ +# Create a random burn address + +A read-only function that generates a random burn address using <HoverLink href="hover:principal-construct?" className="text-[var(--ch-7)]">principal-construct?</HoverLink> from a hash of user-provided input, using different version bytes for mainnet (0x16) or testnet (0x1a). + +## Use cases + +- Creating verifiable burn addresses for token burning mechanisms +- Implementing proof-of-burn protocols +- Generating unique, unreclaimable addresses for protocol-level operations +- Setting up permanent token removal systems + +## Resources + +**Functions** + +- [`hash160`](/stacks/clarity/functions/hash160) +- [`principal-construct?`](/stacks/clarity/functions/principal-construct) +- [`to-consensus-buff?`](/stacks/clarity/functions/to-consensus-buff) +- [`unwrap-panic`](/stacks/clarity/functions/unwrap-panic) \ No newline at end of file diff --git a/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx index 4d82064c8..c6a4e12ef 100644 --- a/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx +++ b/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx @@ -1,32 +1,8 @@ -# Fetch tBTC on regtest +# Fetch testnet Bitcoin on regtest -On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. +A script that requests test Bitcoin from the Hiro testnet faucet API, allowing developers to receive testnet BTC to a specified address when testing on regtest mode. -Understanding and accessing this information is really useful when you need to: +## Use cases -- Track block sequences within minure tenures -- Implement logic that depends on tenure-specific block ordering -- Verify block relationships within a single miner's tenure - -Let's take a look at the following code to better understand how to work with tenure height information for a given block. - -At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. - -Think of it like a block's fingerprint - no two blocks will ever have the same one. - -```terminal -$ clarinet console -$ ::advance_stacks_chain_tip 1 -$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) -[32m(ok u3)[0m [1m[0m -``` - -Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. - -It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. - -Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ - -You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. - -The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. +- Bootstrapping sBTC projects for local development +- Setting up initial test wallets for development \ No newline at end of file diff --git a/content/_recipes/guides/generate-random-number.mdx b/content/_recipes/guides/generate-random-number.mdx index 2c9760212..fdd5c2378 100644 --- a/content/_recipes/guides/generate-random-number.mdx +++ b/content/_recipes/guides/generate-random-number.mdx @@ -1,72 +1,31 @@ -# Create a random number using stacks-block-height - -On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. - -Understanding and accessing this information is really useful when you need to: - -- Track block sequences within minure tenures -- Implement logic that depends on tenure-specific block ordering -- Verify block relationships within a single miner's tenure - -Let's take a look at the following code to better understand how to work with tenure height information for a given block. - -At its core, we're using <HoverLink href="hover:random" className="text-[var(--ch-7)]">read-rnd</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. - -Think of it like a block's fingerprint - no two blocks will ever have the same one. - -```terminal -$ clarinet console -$ ::advance_stacks_chain_tip 1 -$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) -[32m(ok u3)[0m [1m[0m -``` - -Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. - -It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. - -Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ - -You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. - -The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. +# Generate a random number with block-height <WithNotes> -```js demo.js -// !tooltip[/lorem/] install -import { read, write } from "lorem"; - -// !tooltip[/data.json/] data -var data = read("data.json"); - -// !tooltip[/test-123/] apikey -write({ x: 1 }, { apiKey: "test-123" }); -``` - -We can also use tooltips [here](tooltip "install") in [prose](tooltip "data") text. +A read-only function that generates a deterministic [_random_](tooltip "random") number by extracting and converting a portion of a block's <HoverLink href="hover:id-header-hash" className="text-[var(--ch-11)]">id-header-hash</HoverLink> to an unsigned integer. -```json !data data.json -{ - "lorem": "ipsum dolor sit amet", - "foo": [4, 8, 15, 16] -} -``` +## !random -## !install +This method produces deterministic pseudo-randomness, not true randomness. -This is a **fake library**. You can install it with: +Since blockchain transactions **must** be reproducible and verifiable by all nodes, true randomness isn't possible. -```terminal -$ npm install lorem -``` +</WithNotes> -It lets you read and write data. +## Use cases -## !apikey +- Creating verifiable random selections for on-chain games +- Implementing fair distribution mechanisms for NFT minting +- Building lottery or raffle systems that need deterministic randomness +- Generating pseudo-random seeds for other contract operations -This is a public sample test mode [API key](https://example.com). Don’t submit any personally information using this key. +## Resources -Replace this with your secret key found on the [API Keys page](https://example.com) in the dashboard. +**Functions** -</WithNotes> \ No newline at end of file +- [`get-stacks-block-info?`](/stacks/clarity/functions/get-stacks-block-info) +- [`buff-to-uint-be`](/stacks/clarity/functions/buff-to-uint-be) +- [`as-max-len?`](/stacks/clarity/functions/as-max-len) +- [`slice?`](/stacks/clarity/functions/slice) +- [`unwrap-panic`](/stacks/clarity/functions/unwrap-panic) +- [`unwrap!`](/stacks/clarity/functions/unwrap) diff --git a/content/_recipes/guides/get-tenure-height-for-a-block.mdx b/content/_recipes/guides/get-tenure-height-for-a-block.mdx deleted file mode 100644 index 791390144..000000000 --- a/content/_recipes/guides/get-tenure-height-for-a-block.mdx +++ /dev/null @@ -1,37 +0,0 @@ -# Get tenure height for a block - -On the Stacks blockchain, each block has a tenure height - a number indicating its position within a miner's tenure period. - -Understanding and accessing this information is really useful when you need to: - -- Track block sequences within minure tenures -- Implement logic that depends on tenure-specific block ordering -- Verify block relationships within a single miner's tenure - -Let's take a look at the following code to better understand how to work with tenure height information for a given block. - -At its core, we're using <HoverLink href="hover:get-stacks-block-info" className="text-[var(--ch-7)]">get-stacks-block-info?</HoverLink> to fetch information about a specific block. This function is particularly looking for something called the <InlineCode codeblock={{language: "clarity", value: "id-header-hash"}}>id-header-hash</InlineCode>, which is essentially a unique identifier for the block. - -Think of it like a block's fingerprint - no two blocks will ever have the same one. - -```terminal -$ clarinet console -$ ::advance_stacks_chain_tip 1 -$ (contract-call? .get-tenure-for-block get-tenure-height burn-block-height) -[32m(ok u3)[0m [1m[0m -``` - -Now, sometimes when we ask for a block's information, it might not exist (maybe the block height is invalid or hasn't been mined yet). That's where `unwrap!` comes into play. - -It's like a safety net - if we can't find the block, instead of crashing, it'll return a nice clean <HoverLink href="hover:error" className="text-[var(--ch-6)]">error response</HoverLink>. - -Once we have our block's hash, we use it with `at-block` to peek back in time and grab the <HoverLink href="hover:tenure-height" className="text-[var(--ch-7)]">tenure-height</HoverLink> for that specific block. _The tenure height is an interesting piece of data - it tells us where this block sits in sequence during a particular miner's tenure._ - -You can think of a tenure as a miner's _"shift"_ where they're responsible for producing blocks, and the tenure height helps us keep track of the order of blocks during their shift. - -The function wraps everything up nicely with `ok`, following Clarity's pattern of being explicit about successful operations. This makes it clear to anyone using the function whether they got what they asked for or hit an error. - -## References - -- [`get-stacks-block-info?`](/stacks/clarity/functions/get-stacks-block-info) -- [`unwrap!`](/stacks/clarity/functions/unwrap) diff --git a/content/_recipes/guides/helper-function-to-restrict-contract-calls.mdx b/content/_recipes/guides/helper-function-to-restrict-contract-calls.mdx new file mode 100644 index 000000000..e26c8b8c5 --- /dev/null +++ b/content/_recipes/guides/helper-function-to-restrict-contract-calls.mdx @@ -0,0 +1,32 @@ +# Create a helper function to restrict contract calls + +A pattern that uses <HoverLink href="hover:principal-destruct?" className="text-[var(--ch-7)]">principal-destruct?</HoverLink> to verify the _clarity`contract-caller`_ is a standard principal (user address) rather than a contract principal (contract address), ensuring transactions remain visible in explorers and APIs. + +When contracts call other contracts, these transactions can be harder to track and monitor through standard blockchain explorers and APIs. By enforcing direct user calls, you ensure all interactions are easily auditable and visible in the blockchain's history. + +This becomes especially important in scenarios involving treasury operations, voting mechanisms, or other critical protocol operations. + +## Use cases + +- Preventing "_hidden_" transactions in governance functions +- Protecting sensitive functions from potential contract-based exploits + +## Example usage + +```clarity -n +(define-public (important-function) + (begin + ;; !mark[/(is-standard-principal-call)/] + (asserts! (is-standard-principal-call) (err u403)) + (ok true) + ) +) +``` + +## Resources + +**Functions** + +- [`principal-destruct?`](/stacks/clarity/functions/principal-destruct) +- [`get`](/stacks/clarity/functions/get) +- [`is-none`](/stacks/clarity/functions/is-none) diff --git a/content/_recipes/guides/return-an-entry-from-a-map.mdx b/content/_recipes/guides/return-an-entry-from-a-map.mdx new file mode 100644 index 000000000..129de5914 --- /dev/null +++ b/content/_recipes/guides/return-an-entry-from-a-map.mdx @@ -0,0 +1,15 @@ +# Return an entry from a map + +A script that fetches and deserializes map entries from Clarity smart contracts using the Hiro API and Stacks.js, converting between Clarity values and their hexadecimal representations. + +## Use cases + +- Reading user balances or state from on-chain maps +- Retrieving NFT metadata stored in contract maps +- Checking governance voting records or parameters +- Fetching configuration data stored in contract maps + +## Resources + +- [Stacks Node RPC](/stacks/rpc-api/smart-contracts/map-entry) +- [Stacks.js / Transactions](/stacks/stacks.js/packages/transactions) \ No newline at end of file From 758fccdd4aa31c4483fc87c4e49cd0adbae91101 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 11:35:03 -0600 Subject: [PATCH 18/30] add truncate helper function for long text --- lib/utils.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/utils.ts b/lib/utils.ts index a5ef19350..ad0045014 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function truncate(text: string, maxLength: number = 180) { + if (text.length <= maxLength) return text; + // Find the last space before maxLength to avoid cutting words + const lastSpace = text.lastIndexOf(" ", maxLength); + return text.slice(0, lastSpace > 0 ? lastSpace : maxLength) + "..."; +} From fcd03166627585dc3c3573a8e365962bdb274ea5 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 11:37:31 -0600 Subject: [PATCH 19/30] add infinite load to index page --- app/cookbook/components/cookbook-ui.tsx | 141 +++++++++++++++------ app/cookbook/components/snippet-result.tsx | 8 +- 2 files changed, 104 insertions(+), 45 deletions(-) diff --git a/app/cookbook/components/cookbook-ui.tsx b/app/cookbook/components/cookbook-ui.tsx index d3e129879..bd5cbe6cf 100644 --- a/app/cookbook/components/cookbook-ui.tsx +++ b/app/cookbook/components/cookbook-ui.tsx @@ -1,22 +1,14 @@ "use client"; -import { useState, useMemo, Suspense } from "react"; +import { useState, useMemo, Suspense, useEffect, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Recipe, RecipeSubTag } from "@/types/recipes"; +import { Recipe } from "@/types/recipes"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { LayoutGrid, List, Search } from "lucide-react"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; -import { ChevronDown } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { MultiSelect } from "@/components/multi-select"; import { FilterPopover } from "@/components/filter-popover"; // Internal components @@ -96,7 +88,10 @@ interface CookbookProps { function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { const router = useRouter(); const searchParams = useSearchParams(); - // Initialize state from URL params + + const ITEMS_PER_PAGE = 10; + const [currentPage, _] = useState(1); + const [view, setView] = useState<"grid" | "list">(() => { return (searchParams.get("view") as "grid" | "list") || "list"; }); @@ -106,7 +101,6 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { return categories ? categories.split(",") : []; }); - // Update URL when filters change const updateURL = (newView?: "grid" | "list", newCategories?: string[]) => { const params = new URLSearchParams(); @@ -150,7 +144,6 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { ); }, [initialRecipes, recipeCards]); - // Filter recipes and get their corresponding cards const filteredRecipeCards = useMemo(() => { const filteredRecipes = initialRecipes.filter((recipe) => { const searchText = search.toLowerCase(); @@ -162,9 +155,8 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { ) || recipe.tags.some((tag) => tag.toLowerCase().includes(searchText)); - // Add category filtering const matchesCategories = - selectedCategories.length === 0 || // Show all if no categories selected + selectedCategories.length === 0 || recipe.categories.some((category) => selectedCategories.includes(category.toLowerCase()) ); @@ -172,8 +164,67 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { return matchesSearch && matchesCategories; }); - return filteredRecipes.map((recipe) => recipeCardMap[recipe.id]); - }, [search, selectedCategories, initialRecipes, recipeCardMap]); + const startIndex = 0; + const endIndex = currentPage * ITEMS_PER_PAGE; + + return filteredRecipes + .slice(startIndex, endIndex) + .map((recipe) => recipeCardMap[recipe.id]); + }, [search, selectedCategories, initialRecipes, recipeCardMap, currentPage]); + + // Add total pages calculation + const totalPages = useMemo(() => { + const filteredLength = initialRecipes.filter((recipe) => { + const searchText = search.toLowerCase(); + const matchesSearch = + recipe.title.toLowerCase().includes(searchText) || + recipe.description.toLowerCase().includes(searchText) || + recipe.categories.some((category) => + category.toLowerCase().includes(searchText) + ) || + recipe.tags.some((tag) => tag.toLowerCase().includes(searchText)); + + const matchesCategories = + selectedCategories.length === 0 || + recipe.categories.some((category) => + selectedCategories.includes(category.toLowerCase()) + ); + + return matchesSearch && matchesCategories; + }).length; + + return Math.ceil(filteredLength / ITEMS_PER_PAGE); + }, [initialRecipes, search, selectedCategories]); + + const [isLoading, setIsLoading] = useState(false); + + const lastItemRef = useRef<HTMLTableRowElement>(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + const lastEntry = entries[0]; + if (lastEntry.isIntersecting && !isLoading) { + // Check if we have more pages to load + if (currentPage < totalPages) { + setIsLoading(true); + } + } + }, + { threshold: 0.1 } + ); + + const currentRef = lastItemRef.current; + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef); + } + }; + }, [currentPage, totalPages, isLoading]); return ( <div className="max-w-5xl mx-auto"> @@ -200,32 +251,16 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { {view === "list" ? ( <Table> <TableBody> - {initialRecipes - .filter((recipe) => { - const searchText = search.toLowerCase(); - const matchesSearch = - recipe.title.toLowerCase().includes(searchText) || - recipe.description.toLowerCase().includes(searchText) || - recipe.categories.some((category) => - category.toLowerCase().includes(searchText) - ) || - recipe.tags.some((tag) => - tag.toLowerCase().includes(searchText) - ); - - const matchesCategories = - selectedCategories.length === 0 || - recipe.categories.some((category) => - selectedCategories.includes(category.toLowerCase()) - ); - - return matchesSearch && matchesCategories; - }) - .map((recipe) => ( + {filteredRecipeCards.map((recipeCard, index) => { + const recipe = initialRecipes[index]; + const isLastItem = index === filteredRecipeCards.length - 1; + + return ( <TableRow key={recipe.id} className="cursor-pointer group hover:bg-transparent" onClick={() => router.push(`/cookbook/${recipe.id}`)} + ref={isLastItem ? lastItemRef : null} > <TableCell className="py-4 text-primary font-aeonikFono whitespace-normal break-words text-base"> <span className="group-hover:underline decoration-primary/70"> @@ -242,16 +277,40 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { </div> </TableCell> </TableRow> - ))} + ); + })} </TableBody> </Table> ) : ( <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - {filteredRecipeCards} + {filteredRecipeCards.map((card, index) => ( + <div + key={index} + ref={ + index === filteredRecipeCards.length - 1 + ? lastItemRef + : null + } + > + {card} + </div> + ))} </div> )} </div> </div> + + {isLoading && ( + <div className="w-full text-center py-4">Loading more recipes...</div> + )} + + {!isLoading && + currentPage === totalPages && + filteredRecipeCards.length > 0 && ( + <div className="w-full text-center py-4 text-muted-foreground"> + No more recipes + </div> + )} </div> ); } diff --git a/app/cookbook/components/snippet-result.tsx b/app/cookbook/components/snippet-result.tsx index c6ceb2584..717d8d53a 100644 --- a/app/cookbook/components/snippet-result.tsx +++ b/app/cookbook/components/snippet-result.tsx @@ -215,8 +215,8 @@ export function SnippetResult({ style={{ display: "none" }} title="code-sandbox" /> - <div className="flex items-center gap-2"> - <Button + <div className="flex items-center justify-end gap-2"> + {/* <Button variant="outline" className="gap-2" size="sm" @@ -229,9 +229,9 @@ export function SnippetResult({ <Play className="w-4 h-4" /> )} {getButtonText()} - </Button> + </Button> */} {type === "clarity" && ( - <Button className="gap-2" size="sm" asChild> + <Button variant="link" className="gap-2 self-end" size="sm" asChild> <Link href={`https://play.hiro.so/?epoch=3.0&snippet=KGRlZmluZS1yZWFkLW9ubHkgKGdldC10ZW51cmUtaGVpZ2h0IChibG9jayB1aW50KSkKICAob2sKICAgIChhdC1ibG9jawogICAgICAodW53cmFwIQogICAgICAgIChnZXQtc3RhY2tzLWJsb2NrLWluZm8_IGlkLWhlYWRlci1oYXNoIGJsb2NrKQogICAgICAgIChlcnIgdTQwNCkogICAgICApCiAgICAgIHRlbnVyZS1oZWlnaHQKICAgICkKICApCik=`} target="_blank" From 5963cb6c3505b63bd771e1aed222bb5e688498b1 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 11:38:03 -0600 Subject: [PATCH 20/30] add scroll effect when hovering --- .../docskit/annotations/hover-line.client.tsx | 75 ++++++++++++++++--- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/components/docskit/annotations/hover-line.client.tsx b/components/docskit/annotations/hover-line.client.tsx index 81a3d57c0..c3555d8e4 100644 --- a/components/docskit/annotations/hover-line.client.tsx +++ b/components/docskit/annotations/hover-line.client.tsx @@ -1,24 +1,81 @@ "use client"; +import { useEffect, useRef } from "react"; import { InnerLine } from "codehike/code"; import { CustomLineProps } from "codehike/code/types"; import { useHover } from "@/context/hover"; export function HoverLineClient({ annotation, ...props }: CustomLineProps) { + const lineRef = useRef<HTMLDivElement>(null); try { const { hoveredId } = useHover(); const isHovered = !hoveredId || annotation?.query === hoveredId; + useEffect(() => { + // Add scrollable effect to the line when hovered + if ( + hoveredId && + annotation?.query && + annotation.query === hoveredId && + lineRef.current + ) { + const recipeContainer = lineRef.current.closest(".recipe"); + + if (recipeContainer) { + // Find the first scrollable child + const scrollableContainers = [ + ...recipeContainer.querySelectorAll("*"), + ].filter((el) => { + const style = window.getComputedStyle(el); + return ( + style.overflow === "auto" || + style.overflow === "scroll" || + style.overflowY === "auto" || + style.overflowY === "scroll" + ); + }); + + const codeContainer = scrollableContainers[0]; + + if (codeContainer) { + const offset = codeContainer.clientHeight / 3; + const lineRect = lineRef.current.getBoundingClientRect(); + const containerRect = codeContainer.getBoundingClientRect(); + + // Calculate relative position considering current scroll + const relativeTop = + lineRect.top - containerRect.top + codeContainer.scrollTop; + + console.log({ + offset, + lineTop: lineRect.top, + containerTop: containerRect.top, + relativeTop, + currentScroll: codeContainer.scrollTop, + containerHeight: codeContainer.clientHeight, + }); + + codeContainer.scrollTo({ + top: relativeTop - offset, + behavior: "smooth", + }); + } + } + } + }, [hoveredId, annotation?.query]); + return ( - <InnerLine - merge={props} - className="transition-opacity duration-200" - style={{ - opacity: isHovered ? 1 : 0.5, - filter: isHovered ? "none" : "blur(0.25px)", - }} - data-line={annotation?.query || ""} - /> + <div ref={lineRef}> + <InnerLine + merge={props} + className="transition-opacity duration-200" + style={{ + opacity: isHovered ? 1 : 0.5, + filter: isHovered ? "none" : "blur(0.25px)", + }} + data-line={annotation?.query || ""} + /> + </div> ); } catch (error) { console.warn("Hover context not ready:", error); From 3cc82fd6b6471e4676dfd39f3da37e6e66b448b6 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 11:38:21 -0600 Subject: [PATCH 21/30] updates --- components/filter-popover.tsx | 8 ++++---- components/recipe-carousel.tsx | 11 ++++++----- components/ui/checkbox.tsx | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/components/filter-popover.tsx b/components/filter-popover.tsx index 8aa752e78..3cd313af6 100644 --- a/components/filter-popover.tsx +++ b/components/filter-popover.tsx @@ -11,7 +11,7 @@ import { useId } from "react"; import { ListFilter } from "lucide-react"; const CATEGORIES = [ - { label: "Stacks.js", value: "stacks-js" }, + { label: "Stacks.js", value: "stacks.js" }, { label: "Clarity", value: "clarity" }, { label: "Bitcoin", value: "bitcoin" }, // { label: "Chainhook", value: "chainhook" }, @@ -46,14 +46,14 @@ function FilterPopover({ <div className="flex flex-col gap-4"> <Popover> <PopoverTrigger asChild> - <Button variant="outline" size="icon" aria-label="Filter by product"> + <Button variant="outline" size="icon" aria-label="Filter by category"> <ListFilter size={16} strokeWidth={2} aria-hidden="true" /> </Button> </PopoverTrigger> <PopoverContent className="w-48 p-3"> <div className="space-y-3"> - <div className="text-xs font-medium text-muted-foreground"> - Filter by product + <div className="text-sm font-medium text-muted-foreground"> + Filter by category </div> <form className="space-y-3" onSubmit={(e) => e.preventDefault()}> {CATEGORIES.map((category) => ( diff --git a/components/recipe-carousel.tsx b/components/recipe-carousel.tsx index 138639a28..7aee11c5a 100644 --- a/components/recipe-carousel.tsx +++ b/components/recipe-carousel.tsx @@ -12,6 +12,7 @@ import { CarouselPrevious, } from "@/components/ui/carousel"; import type { Recipe } from "@/types/recipes"; +import { truncate } from "@/lib/utils"; interface RecipeCarouselProps { currentRecipeId: string; // To exclude current recipe from carousel @@ -44,11 +45,11 @@ function RecipeCarousel({ currentRecipeId, data }: RecipeCarouselProps) { {recipes.map((recipe, index) => ( <CarouselItem key={index} className="pl-4 basis-[75%]"> <Link href={`/cookbook/${recipe.id}`} className="group"> - <Card className="bg-code border-0"> + <Card className="bg-code border-0 h-[150px]"> <CardContent className="p-6 flex flex-col h-full"> - <div className="space-y-4 flex flex-col flex-grow"> + <div className="space-y-4 flex flex-col h-full"> <div className="flex items-baseline gap-4 flex-wrap"> - <h3 className="text-xl font-mono text-primary group-hover:underline decoration-2 underline-offset-4"> + <h3 className="text-xl font-mono text-primary group-hover:underline decoration-2 underline-offset-4 line-clamp-1"> {recipe.title} </h3> <div className="flex gap-2 flex-wrap uppercase"> @@ -59,8 +60,8 @@ function RecipeCarousel({ currentRecipeId, data }: RecipeCarouselProps) { ))} </div> </div> - <p className="text-sm text-muted-foreground leading-relaxed flex-grow"> - {recipe.description} + <p className="text-md text-muted-foreground leading-relaxed line-clamp-3"> + {truncate(recipe.description)} </p> </div> </CardContent> diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx index 1a2a4593a..4d83c0ce5 100644 --- a/components/ui/checkbox.tsx +++ b/components/ui/checkbox.tsx @@ -13,7 +13,7 @@ const Checkbox = React.forwardRef< <CheckboxPrimitive.Root ref={ref} className={cn( - "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-background", className )} {...props} From 9f65ceeb7ad08a10977ae1eb5345d07c3d13f089 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 11:38:42 -0600 Subject: [PATCH 22/30] add new recipes --- .../code-blocks/build-an-unsigned-tx.mdx | 34 ++++++++++ .../derive-stacks-address-from-keys.mdx | 33 ++++++++++ .../integrate-api-keys-using-stacksjs.mdx | 27 ++++++++ .../code-blocks/transfer-a-sip10-token.mdx | 66 +++++++++++++++++++ .../_recipes/guides/build-an-unsigned-tx.mdx | 34 ++++++++++ .../derive-stacks-address-from-keys.mdx | 12 ++++ .../integrate-api-keys-using-stacksjs.mdx | 34 ++++++++++ .../guides/transfer-a-sip10-token.mdx | 18 +++++ 8 files changed, 258 insertions(+) create mode 100644 content/_recipes/code-blocks/build-an-unsigned-tx.mdx create mode 100644 content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx create mode 100644 content/_recipes/code-blocks/integrate-api-keys-using-stacksjs.mdx create mode 100644 content/_recipes/code-blocks/transfer-a-sip10-token.mdx create mode 100644 content/_recipes/guides/build-an-unsigned-tx.mdx create mode 100644 content/_recipes/guides/derive-stacks-address-from-keys.mdx create mode 100644 content/_recipes/guides/integrate-api-keys-using-stacksjs.mdx create mode 100644 content/_recipes/guides/transfer-a-sip10-token.mdx diff --git a/content/_recipes/code-blocks/build-an-unsigned-tx.mdx b/content/_recipes/code-blocks/build-an-unsigned-tx.mdx new file mode 100644 index 000000000..defef6675 --- /dev/null +++ b/content/_recipes/code-blocks/build-an-unsigned-tx.mdx @@ -0,0 +1,34 @@ +--- +id: build-an-unsigned-tx +title: Build an unsigned transaction +description: Build an unsigned transaction using Stacks.js. +date: 2025.01.17 +categories: + - stacks.js +tags: + - unsigned transactions + - public keys +files: + - name: build-an-unsigned-tx.ts + path: scripts/build-an-unsigned-tx.ts + type: typescript +--- + +```typescript +import { getPublicKeyFromPrivate } from "@stacks/encryption"; +import { Cl, makeUnsignedSTXTokenTransfer } from "@stacks/transactions"; + +const privateKey = "<your-private-key>"; // KEEP THIS SECRET! + +const publicKey = getPublicKeyFromPrivate(privateKey); + +// !mark[/makeUnsignedSTXTokenTransfer/] +const transaction = await makeUnsignedSTXTokenTransfer({ + network: "testnet", + recipient: Cl.standardPrincipal("<recipient-address>"), + amount: 42_000_000n, + fee: 500_000n, + memo: "<message-here>", // optional + publicKey, +}); +``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx b/content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx new file mode 100644 index 000000000..b104bedb1 --- /dev/null +++ b/content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx @@ -0,0 +1,33 @@ +--- +id: derive-stacks-address-from-keys +title: Derive a Stacks address from private and public keys +description: Derive a Stacks address from a private key and public key using Stacks.js. +date: 2025.01.17 +categories: + - stacks.js +tags: + - private keys + - addresses + - public keys +files: + - name: derive-address-from-keys.ts + path: scripts/derive-address-from-keys.ts + type: typescript +--- + +```typescript +import { getPublicKeyFromPrivate } from "@stacks/encryption"; +import { getAddressFromPrivateKey, getAddressFromPublicKey } from "@stacks/transactions"; + +// !hover private-keys +const privateKey = "<your-private-key>"; // KEEP THIS SECRET! +// !hover private-keys +const addressFromPrivateKey = getAddressFromPrivateKey(privateKey, "testnet"); + +// !hover public-keys +const publicKey = getPublicKeyFromPrivate(privateKey); +// !hover public-keys +const addressFromPublicKey = getAddressFromPublicKey(publicKey, "testnet"); + +console.log(addressFromPrivateKey, addressFromPublicKey); +``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/integrate-api-keys-using-stacksjs.mdx b/content/_recipes/code-blocks/integrate-api-keys-using-stacksjs.mdx new file mode 100644 index 000000000..2cffe588b --- /dev/null +++ b/content/_recipes/code-blocks/integrate-api-keys-using-stacksjs.mdx @@ -0,0 +1,27 @@ +--- +id: integrate-api-keys-using-stacksjs +title: Integrate your API keys using Stacks.js +description: Integrate your API keys using Stacks.js. +date: 2025.01.17 +categories: + - stacks.js +tags: + - api keys +files: + - name: integrate-api-keys.ts + path: scripts/integrate-api-keys.ts + type: typescript +--- + +```typescript +import { createApiKeyMiddleware, createFetchFn } from "@stacks/common"; + +// !hover middleware +const apiMiddleware = createApiKeyMiddleware({ + // !hover middleware + apiKey: "<your-middleware>", +// !hover middleware +}); + +const customFetchFn = createFetchFn(apiMiddleware); +``` \ No newline at end of file diff --git a/content/_recipes/code-blocks/transfer-a-sip10-token.mdx b/content/_recipes/code-blocks/transfer-a-sip10-token.mdx new file mode 100644 index 000000000..725cb9f78 --- /dev/null +++ b/content/_recipes/code-blocks/transfer-a-sip10-token.mdx @@ -0,0 +1,66 @@ +--- +id: transfer-a-sip10-token +title: Transfer a SIP10 token using Stacks.js +description: Transfer a SIP10 token with post conditions using Stacks.js. +date: 2025.01.16 +categories: + - stacks.js +tags: + - sip10 + - tokens + - transfer + - post conditions +files: + - name: sip10-transfer.ts + path: scripts/sip10-transfer.ts + type: typescript +--- + +```typescript +import { STACKS_MAINNET } from "@stacks/network"; +import { + AnchorMode, + broadcastTransaction, + Cl, + makeContractCall, + Pc, + PostConditionMode, +} from "@stacks/transactions"; + +// Define the sBTC token address and the post condition for your transfer +const tokenContract = "<token-contract-address>.<token-contract-name>"; +// !hover Pc +const postConditions = Pc.principal(tokenContract) + // !hover Pc + .willSendEq(1000) + // !hover Pc + .ft(tokenContract, "<token-contract-name>"); + +const txOptions = { + contractAddress: "<token-contract-address>", + contractName: "<token-contract-name>", + functionName: "transfer", + functionArgs: [ + Cl.uint(1000), // amount to transfer + Cl.principal("<your-sender-address>"), // sender address + Cl.principal("<recipients-address>"), // recipient address + Cl.none(), // optional memo - passing none + ], + senderKey: "<your-private-key>", + validateWithAbi: true, + network: STACKS_MAINNET, + postConditions: [postConditions], + // !hover deny + postConditionMode: PostConditionMode.Deny, + anchorMode: AnchorMode.Any, +}; + +const transaction = await makeContractCall(txOptions); + +const broadcastResponse = await broadcastTransaction({ + transaction, + network: STACKS_MAINNET, +}); +const txId = broadcastResponse.txid; +console.log({ txId }); +``` \ No newline at end of file diff --git a/content/_recipes/guides/build-an-unsigned-tx.mdx b/content/_recipes/guides/build-an-unsigned-tx.mdx new file mode 100644 index 000000000..a910ffc77 --- /dev/null +++ b/content/_recipes/guides/build-an-unsigned-tx.mdx @@ -0,0 +1,34 @@ +# Build an unsigned transaction + +A script that constructs an unsigned STX token transfer transaction using Stacks.js, allowing for transaction preparation without immediate signing. This pattern is particularly useful for multi-party transactions where signatures need to be collected separately. + +## Use cases + +- Implementing offline signing workflows +- Creating sponsored transactions to be signed by a third party +- Setting up transaction proposals for DAO governance + +## Signing the transaction + +After building your transaction, you can <HoverLink href="hover:sign">sign</HoverLink> and then <HoverLink href="hover:broadcast">broadcast</HoverLink> the newly signed transaction to the network. + +```typescript -c +import { + broadcastTransaction, + TransactionSigner, +} from "@stacks/transactions"; + +const privateKey = "<your-private-key>"; // KEEP THIS SECRET! + +// !hover sign +const signer = new TransactionSigner(privateKey); +// !hover sign +const signedTx = await signer.signTransaction(transaction); + +// !hover broadcast +const broadcastedTx = await broadcastTransaction(signedTx); +``` + +## Resources + +- [Stacks.js / Transactions](/stacks/stacks.js/packages/transactions) diff --git a/content/_recipes/guides/derive-stacks-address-from-keys.mdx b/content/_recipes/guides/derive-stacks-address-from-keys.mdx new file mode 100644 index 000000000..a7c53f6bf --- /dev/null +++ b/content/_recipes/guides/derive-stacks-address-from-keys.mdx @@ -0,0 +1,12 @@ +# Derive a Stacks address from private and public keys + +A utility function that derives a Stacks address from a <HoverLink href="hover:private-keys">private key</HoverLink> and <HoverLink href="hover:public-keys">public key</HoverLink>, with network specification (testnet/mainnet) to ensure correct address format. + +## Use cases + +- Message authentication and signing is one of the most common use cases for proving ownership or authenticity without exposing private keys +- Multisignature wallets when participants need to share their public keys + +## Resources + +- [Stacks.js / Transactions](/stacks/stacks.js/packages/transactions) diff --git a/content/_recipes/guides/integrate-api-keys-using-stacksjs.mdx b/content/_recipes/guides/integrate-api-keys-using-stacksjs.mdx new file mode 100644 index 000000000..775b5f852 --- /dev/null +++ b/content/_recipes/guides/integrate-api-keys-using-stacksjs.mdx @@ -0,0 +1,34 @@ +# Integrate your API keys using Stacks.js + +A script that integrates your API key authentication into Stacks.js network calls using <HoverLink href="hover:middleware">middleware</HoverLink>, allowing for authenticated requests to Stacks APIs that require rate limiting or premium access. + +This example is showing a simple STX transfer transaction, but the same approach can be used for any Stacks API request that requires authentication - so long as you are passing in your _typescript`customFetchFn`_ to the client within your transaction details. + +## Use cases + +- Accessing premium API endpoints with higher rate limits +- Building production applications that require reliable API access + +## Example usage + +```typescript -nc +import { AnchorMode } from "@stacks/transactions"; + +const txOptions = { + network: "testnet", + recipient: "<recipient-address>", + amount: 12345n, + senderKey: "<your-private-key>", // KEEP THIS SECRET! + memo: "some memo", // optional + anchorMode: AnchorMode.Any, + // !mark(1:3) + client: { + fetch: customFetchFn, + }, +}; +``` + +## Resources + +- [API keys](/stacks/api-keys) +- [Stacks.js / Transactions](/stacks/stacks.js/packages/transactions) \ No newline at end of file diff --git a/content/_recipes/guides/transfer-a-sip10-token.mdx b/content/_recipes/guides/transfer-a-sip10-token.mdx new file mode 100644 index 000000000..78df596ef --- /dev/null +++ b/content/_recipes/guides/transfer-a-sip10-token.mdx @@ -0,0 +1,18 @@ +# Transfer a SIP10 token using Stacks.js + +A script that transfers SIP010 fungible tokens using Stacks.js, implementing post-conditions to ensure the exact amount is transferred. + +The example uses <HoverLink href="hover:Pc" className="text-[var(--ch-11)]">_typescript`Pc.principal(...)`_</HoverLink> builder to create a condition for fungible tokens that verifies the token contract will send the exact amount of tokens as expected. + +Setting <HoverLink href="hover:deny" className="text-[var(--ch-11)]">_typescript`postConditionMode: PostConditionMode.Deny`_</HoverLink> prevents the transaction from succeeding when the provided post-condition(s) are not met. + +## Use cases + +- Implementing secure token transfer functionality in dApps +- Building DEX or token swap interfaces +- Setting up automated token distribution mechanisms + +## Resources + +- [Stacks.js / Transactions](/stacks/stacks.js/packages/transactions) +- [Post Conditions](/stacks/stacks.js/guides/post-conditions) From be2bbd2ac5837a660a340ed4c69b404c3c273d85 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 11:51:38 -0600 Subject: [PATCH 23/30] update infinite scroll feedback and sort recipes in descending order --- app/cookbook/components/cookbook-ui.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/cookbook/components/cookbook-ui.tsx b/app/cookbook/components/cookbook-ui.tsx index bd5cbe6cf..d268d0f01 100644 --- a/app/cookbook/components/cookbook-ui.tsx +++ b/app/cookbook/components/cookbook-ui.tsx @@ -145,7 +145,13 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { }, [initialRecipes, recipeCards]); const filteredRecipeCards = useMemo(() => { - const filteredRecipes = initialRecipes.filter((recipe) => { + // First sort by date + const sortedRecipes = [...initialRecipes].sort((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime(); + }); + + // Then apply filters + const filteredRecipes = sortedRecipes.filter((recipe) => { const searchText = search.toLowerCase(); const matchesSearch = recipe.title.toLowerCase().includes(searchText) || @@ -301,16 +307,10 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { </div> {isLoading && ( - <div className="w-full text-center py-4">Loading more recipes...</div> + <p className="w-full text-center font-aeonikFono py-4"> + Loading more recipes... + </p> )} - - {!isLoading && - currentPage === totalPages && - filteredRecipeCards.length > 0 && ( - <div className="w-full text-center py-4 text-muted-foreground"> - No more recipes - </div> - )} </div> ); } From ce7355ca44f811916e3cfc4e78c6f378bd1fe5d0 Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 11:51:48 -0600 Subject: [PATCH 24/30] update date fields --- content/_recipes/code-blocks/build-an-unsigned-tx.mdx | 2 +- content/_recipes/code-blocks/create-a-random-burn-address.mdx | 2 +- .../_recipes/code-blocks/derive-stacks-address-from-keys.mdx | 2 +- .../_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx | 2 +- content/_recipes/code-blocks/generate-random-number.mdx | 2 +- .../code-blocks/helper-function-to-restrict-contract-calls.mdx | 2 +- content/_recipes/code-blocks/return-an-entry-from-a-map.mdx | 2 +- content/_recipes/code-blocks/transfer-a-sip10-token.mdx | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/content/_recipes/code-blocks/build-an-unsigned-tx.mdx b/content/_recipes/code-blocks/build-an-unsigned-tx.mdx index defef6675..805282259 100644 --- a/content/_recipes/code-blocks/build-an-unsigned-tx.mdx +++ b/content/_recipes/code-blocks/build-an-unsigned-tx.mdx @@ -2,7 +2,7 @@ id: build-an-unsigned-tx title: Build an unsigned transaction description: Build an unsigned transaction using Stacks.js. -date: 2025.01.17 +date: 2025.01.16 categories: - stacks.js tags: diff --git a/content/_recipes/code-blocks/create-a-random-burn-address.mdx b/content/_recipes/code-blocks/create-a-random-burn-address.mdx index dd2156aa6..17b9ad221 100644 --- a/content/_recipes/code-blocks/create-a-random-burn-address.mdx +++ b/content/_recipes/code-blocks/create-a-random-burn-address.mdx @@ -2,7 +2,7 @@ id: create-a-random-burn-address title: Create a random burn address description: Create a random burn address using the `principal-construct?` function in Clarity. -date: 2025.01.16 +date: 2025.01.13 categories: - clarity tags: diff --git a/content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx b/content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx index b104bedb1..c576e1313 100644 --- a/content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx +++ b/content/_recipes/code-blocks/derive-stacks-address-from-keys.mdx @@ -2,7 +2,7 @@ id: derive-stacks-address-from-keys title: Derive a Stacks address from private and public keys description: Derive a Stacks address from a private key and public key using Stacks.js. -date: 2025.01.17 +date: 2025.01.13 categories: - stacks.js tags: diff --git a/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx index 1e3fbf479..da35c6e90 100644 --- a/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx +++ b/content/_recipes/code-blocks/fetch-testnet-bitcoin-on-regtest.mdx @@ -2,7 +2,7 @@ id: fetch-testnet-bitcoin-on-regtest title: Fetch testnet Bitcoin on regtest description: How to fetch testnet Bitcoin on regtest. -date: 2025.01.16 +date: 2025.01.11 categories: - api - bitcoin diff --git a/content/_recipes/code-blocks/generate-random-number.mdx b/content/_recipes/code-blocks/generate-random-number.mdx index 2767b2d1d..a6d7cd837 100644 --- a/content/_recipes/code-blocks/generate-random-number.mdx +++ b/content/_recipes/code-blocks/generate-random-number.mdx @@ -2,7 +2,7 @@ id: generate-random-number title: Generate a random number with block-height description: Create a random number based on a block-height using the buff-to-uint-be function in Clarity. -date: 2025.01.16 +date: 2025.01.13 categories: - clarity tags: diff --git a/content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx b/content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx index 2e4cd83e5..ef0c2a0ed 100644 --- a/content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx +++ b/content/_recipes/code-blocks/helper-function-to-restrict-contract-calls.mdx @@ -2,7 +2,7 @@ id: helper-function-to-restrict-contract-calls title: Create a helper function to restrict contract calls description: Ensure functions can only be called directly by standard principals (users) and not by other contracts, maintaining transaction visibility. -date: 2025.01.16 +date: 2025.01.14 categories: - clarity tags: diff --git a/content/_recipes/code-blocks/return-an-entry-from-a-map.mdx b/content/_recipes/code-blocks/return-an-entry-from-a-map.mdx index 1744bcb6e..3b580632a 100644 --- a/content/_recipes/code-blocks/return-an-entry-from-a-map.mdx +++ b/content/_recipes/code-blocks/return-an-entry-from-a-map.mdx @@ -2,7 +2,7 @@ id: return-an-entry-from-a-map title: Return an entry from a map description: Return an entry from a map using the `map_entry` API endpoint. -date: 2025.01.16 +date: 2025.01.11 categories: - api tags: diff --git a/content/_recipes/code-blocks/transfer-a-sip10-token.mdx b/content/_recipes/code-blocks/transfer-a-sip10-token.mdx index 725cb9f78..2aa4a0cfe 100644 --- a/content/_recipes/code-blocks/transfer-a-sip10-token.mdx +++ b/content/_recipes/code-blocks/transfer-a-sip10-token.mdx @@ -2,7 +2,7 @@ id: transfer-a-sip10-token title: Transfer a SIP10 token using Stacks.js description: Transfer a SIP10 token with post conditions using Stacks.js. -date: 2025.01.16 +date: 2025.01.13 categories: - stacks.js tags: From de60a5d26675e058e32048d7a4950440af15b2db Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 12:09:36 -0600 Subject: [PATCH 25/30] update cursor style type for tooltips --- components/docskit/notes.tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/docskit/notes.tooltip.tsx b/components/docskit/notes.tooltip.tsx index 17d0a20ff..d04c56b10 100644 --- a/components/docskit/notes.tooltip.tsx +++ b/components/docskit/notes.tooltip.tsx @@ -30,7 +30,7 @@ export function NoteTooltip({ <TooltipProvider delayDuration={100}> <Tooltip> <TooltipTrigger asChild> - <span className="underline decoration-dotted underline-offset-4 cursor-help"> + <span className="underline decoration-dotted underline-offset-4 cursor-default"> {children} </span> </TooltipTrigger> From c9922cccb9afadeff56d1b1e84aa043912f34634 Mon Sep 17 00:00:00 2001 From: max-crawford <102705427+max-crawford@users.noreply.github.com> Date: Fri, 17 Jan 2025 10:21:07 -0800 Subject: [PATCH 26/30] Update transfer-a-sip10-token.mdx copy tweaks --- content/_recipes/guides/transfer-a-sip10-token.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/_recipes/guides/transfer-a-sip10-token.mdx b/content/_recipes/guides/transfer-a-sip10-token.mdx index 78df596ef..502277767 100644 --- a/content/_recipes/guides/transfer-a-sip10-token.mdx +++ b/content/_recipes/guides/transfer-a-sip10-token.mdx @@ -1,8 +1,8 @@ -# Transfer a SIP10 token using Stacks.js +# Transfer a SIP-10 token using Stacks.js -A script that transfers SIP010 fungible tokens using Stacks.js, implementing post-conditions to ensure the exact amount is transferred. +A script that transfers SIP-010 fungible tokens using Stacks.js, implementing post-conditions to ensure the exact amount is transferred. -The example uses <HoverLink href="hover:Pc" className="text-[var(--ch-11)]">_typescript`Pc.principal(...)`_</HoverLink> builder to create a condition for fungible tokens that verifies the token contract will send the exact amount of tokens as expected. +The example uses <HoverLink href="hover:Pc" className="text-[var(--ch-11)]">_typescript`Pc.principal(...)`_</HoverLink> builder to create a post-condition for fungible tokens that verifies the token contract will send the exact amount of tokens as expected. Setting <HoverLink href="hover:deny" className="text-[var(--ch-11)]">_typescript`postConditionMode: PostConditionMode.Deny`_</HoverLink> prevents the transaction from succeeding when the provided post-condition(s) are not met. @@ -15,4 +15,4 @@ Setting <HoverLink href="hover:deny" className="text-[var(--ch-11)]">_typescript ## Resources - [Stacks.js / Transactions](/stacks/stacks.js/packages/transactions) -- [Post Conditions](/stacks/stacks.js/guides/post-conditions) +- [Post-conditions](/stacks/stacks.js/guides/post-conditions) From 8d27743956ddba464c5454857adead3a1d4f4839 Mon Sep 17 00:00:00 2001 From: max-crawford <102705427+max-crawford@users.noreply.github.com> Date: Fri, 17 Jan 2025 10:22:17 -0800 Subject: [PATCH 27/30] Update transfer-a-sip10-token.mdx copy tweak --- content/_recipes/guides/transfer-a-sip10-token.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/_recipes/guides/transfer-a-sip10-token.mdx b/content/_recipes/guides/transfer-a-sip10-token.mdx index 502277767..5e72f06e1 100644 --- a/content/_recipes/guides/transfer-a-sip10-token.mdx +++ b/content/_recipes/guides/transfer-a-sip10-token.mdx @@ -1,4 +1,4 @@ -# Transfer a SIP-10 token using Stacks.js +# Transfer a SIP-10 fungible token using Stacks.js A script that transfers SIP-010 fungible tokens using Stacks.js, implementing post-conditions to ensure the exact amount is transferred. From 476b7aa2a2cc9de0355e08d8d0e08d01cd168b2b Mon Sep 17 00:00:00 2001 From: max-crawford <102705427+max-crawford@users.noreply.github.com> Date: Fri, 17 Jan 2025 11:17:19 -0800 Subject: [PATCH 28/30] Update fetch-testnet-bitcoin-on-regtest.mdx --- content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx index c6a4e12ef..bf18b3fca 100644 --- a/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx +++ b/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx @@ -1,8 +1,8 @@ # Fetch testnet Bitcoin on regtest -A script that requests test Bitcoin from the Hiro testnet faucet API, allowing developers to receive testnet BTC to a specified address when testing on regtest mode. +A script that requests test Bitcoin via API from Hiro'd testnet faucet, allowing developers to receive testnet BTC to a specified address when testing on regtest mode. ## Use cases - Bootstrapping sBTC projects for local development -- Setting up initial test wallets for development \ No newline at end of file +- Setting up initial test wallets for development From b352b31ddd8a09620b1bb534633e52a783d31cb8 Mon Sep 17 00:00:00 2001 From: max-crawford <102705427+max-crawford@users.noreply.github.com> Date: Fri, 17 Jan 2025 11:17:31 -0800 Subject: [PATCH 29/30] Update fetch-testnet-bitcoin-on-regtest.mdx --- content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx b/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx index bf18b3fca..38b1a4a59 100644 --- a/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx +++ b/content/_recipes/guides/fetch-testnet-bitcoin-on-regtest.mdx @@ -1,6 +1,6 @@ # Fetch testnet Bitcoin on regtest -A script that requests test Bitcoin via API from Hiro'd testnet faucet, allowing developers to receive testnet BTC to a specified address when testing on regtest mode. +A script that requests test Bitcoin via API from Hiro's testnet faucet, allowing developers to receive testnet BTC to a specified address when testing on regtest mode. ## Use cases From 19a30de6fbbe734359d40e54f7a422c2085fed8b Mon Sep 17 00:00:00 2001 From: Ryan Waits <ryan.waits@gmail.com> Date: Fri, 17 Jan 2025 13:51:46 -0600 Subject: [PATCH 30/30] fix mobile responsiveness --- app/cookbook/[id]/page.tsx | 56 ++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/app/cookbook/[id]/page.tsx b/app/cookbook/[id]/page.tsx index b19bd4616..ccc2e8700 100644 --- a/app/cookbook/[id]/page.tsx +++ b/app/cookbook/[id]/page.tsx @@ -10,6 +10,12 @@ import { SnippetResult } from "../components/snippet-result"; import Link from "next/link"; import { RecipeCarousel } from "@/components/recipe-carousel"; import { MoveLeft } from "lucide-react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; interface Param { id: string; @@ -41,8 +47,8 @@ export default async function Page({ return ( <> <HoverProvider> - <div className="min-h-screen"> - <div className="space-y-2"> + <div className="min-h-screen flex flex-col"> + <div className="space-y-2 flex-grow"> <div className="px-4"> <Link href="/cookbook" @@ -53,8 +59,40 @@ export default async function Page({ </div> <div className="px-4"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12"> - <div className="hidden lg:block lg:col-span-6"> - <div className="space-y-3"> + <div className="col-span-full lg:col-span-6 lg:order-1"> + <div className="block lg:hidden"> + <Accordion type="single" collapsible> + <AccordionItem value="content"> + <AccordionTrigger className="text-xl font-semibold"> + {recipe.title} + </AccordionTrigger> + <AccordionContent> + <div className="space-y-3"> + <div className="flex flex-wrap gap-2 uppercase"> + {recipe.categories.map((category) => ( + <Badge key={category} variant="secondary"> + {category} + </Badge> + ))} + </div> + <div className="prose max-w-none"> + <Content.default + components={{ + HoverLink, + Terminal, + Code, + InlineCode, + WithNotes, + }} + /> + </div> + </div> + </AccordionContent> + </AccordionItem> + </Accordion> + </div> + + <div className="hidden lg:block space-y-3"> <div className="flex flex-wrap gap-2 uppercase"> {recipe.categories.map((category) => ( <Badge key={category} variant="secondary"> @@ -76,8 +114,7 @@ export default async function Page({ </div> </div> - {/* Sticky sidebar */} - <div className="col-span-full lg:col-span-6"> + <div className="col-span-full lg:col-span-6 lg:order-2"> <div className="lg:sticky lg:top-20 space-y-4"> <div className="recipe group relative w-full overflow-hidden"> <Code @@ -85,7 +122,7 @@ export default async function Page({ { lang: recipe.files[0].type, value: recipe.files[0].content, - meta: `${recipe.files[0].name} -cn`, // filename + flags + meta: `${recipe.files[0].name} -cn`, }, ]} /> @@ -101,8 +138,11 @@ export default async function Page({ </div> </div> </div> + + <div className="mt-0 md:mt-16"> + <RecipeCarousel currentRecipeId={id} data={recipes} /> + </div> </div> - <RecipeCarousel currentRecipeId={id} data={recipes} /> </HoverProvider> </> );