From 9c9d07db9caf33b60329449d9326c1c1fb1e1bf0 Mon Sep 17 00:00:00 2001 From: Ryan Waits Date: Wed, 4 Dec 2024 12:58:07 -0600 Subject: [PATCH 01/29] 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 ( -
+

Back

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 = { 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 { + const { id } = params; + const recipe = recipes.find((r) => r.id === id); + + if (!recipe) { + return
Recipe not found
; + } + + // 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: () =>
Content not found
}; + }); + + const snippetCodeResult = (result: string) => { + ; + }; + + return ( + +
+
+
+
+
+
+ {recipe.tags.map((tag) => ( + + {tag.toUpperCase()} + + ))} +
+
+ +
+
+
+ + {/* Sticky sidebar */} +
+
+
+ +
+ +
+
+
+
+
+
+ ); +} 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 ( + + ); +} 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 ( +
+ + +
+ ); +} + +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 ( +
+
+ {ALL_TAGS.map((tag) => ( + onTagToggle(tag)} + > + {tag.toUpperCase()} + + ))} +
+
+ ); +} + +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(() => { + 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 + ); + }, [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 ( +
+
+
+
+

Cookbook

+

+ An open-source collection of copy & paste code recipes for + building on Stacks and Bitcoin. +

+
+ +
+ +
+ + + {view === "grid" ? ( +
+ {filteredRecipeCards} +
+ ) : ( + + + {initialRecipes.map((recipe) => ( + router.push(`/cookbook/${recipe.id}`)} + > + + + {recipe.title} + + + +
+ {recipe.tags.map((tag) => ( + + {tag.toUpperCase()} + + ))} +
+
+
+ ))} +
+
+ )} +
+
+
+ ); +} + +export function CookbookUI({ initialRecipes, recipeCards }: CookbookProps) { + return ( + Loading...
}> + + + ); +} 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(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 ( +
+
+ + +
+ {result && ( +
+ +
+
+ {result} +
+
+
+
+ )} +
+ ); +} 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 ( +
+ +
+
{children}
+
+
+
+ ); +} 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 ( +
+
+
+
+

+ {recipe.title} +

+

+ {recipe.description} +

+
+
+ +
+
+
+ {recipe.tags.map((tag) => ( + + {tag.toUpperCase()} + + ))} +
+
+ +
+
+ {codeElement} +
+
+ + + +
+
+ ); +} + +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 ( + + ); + }) + ); + return ; +} 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(); - const [html, setHtml] = React.useState(); - const [evaluatedResponse, setEvaluatedResponse] = React.useState(); + const [evaluatedResponse, setEvaluatedResponse] = React.useState(); - 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 ( <> - - - - {evaluatedResponse ? ( - - - - ) : null} + + + + {evaluatedResponse && ( + + )} ); }; 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 ( + + ); + } catch (error) { + console.warn("Hover context not ready:", error); + return ; + } +} 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 ( + setHoveredId(hoverId ?? null)} + onMouseLeave={() => setHoveredId(null)} + > + {props.children} + + ); +} 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 ; + }, +}; + +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) => ( - - <> - {typeof network === "object" && network !== null ? ( - - {network.props.children} - - ) : ( - - {network} - - )} - - + <> + {typeof network === "object" && network !== null ? ( + + {network.props.children} + + ) : ( + + {network} + + )} + ); 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): JSX.Element { > ): JSX.Element { viewBox="0 0 20 14" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > ); @@ -3708,12 +3709,13 @@ export function Github(props: SVGProps): JSX.Element { width="19" height="20" viewBox="0 0 19 20" - fill="none" + fill="currentColor" xmlns="http://www.w3.org/2000/svg" + {...props} > ); @@ -3727,10 +3729,11 @@ export function Youtube(props: SVGProps): JSX.Element { viewBox="0 0 21 15" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > ); 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 read-rnd 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 API key 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 get-stacks-block-info? to fetch information about a specific block. This function is particularly looking for something called the id-header-hash, 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) +(ok u3)  +``` + +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 error response. + +Once we have our block's hash, we use it with `at-block` to peek back in time and grab the tenure-height 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 - - ```terminal - $ brew install clarinet - ``` - - - - ```terminal - $ winget install clarinet - ``` - - - - ```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 - ``` - - - 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 - ``` - - - - - ```terminal - $ sudo apt install build-essential pkg-config libssl-dev - ``` - - - 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. - - -

Build Clarinet

- - 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 - ``` - -
- + + +```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 +``` + + ## 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(undefined); + +export const HoverProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [hoveredId, setHoveredId] = useState(null); + + return ( + + {children} + + ); +}; + +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; + + const id = + typeof props.children === "string" + ? props.children + : (props.children as React.ReactElement)?.props?.children; + + return ( +

+ {props.children} +

+ ); + }, Accordion, Accordions, blockquote: (props) => {props.children}, @@ -19,13 +33,13 @@ export function useMDXComponents(components: MDXComponents): MDXComponents { Cards, Card, SecondaryCard, - code: (props) => ( + code: (props: React.PropsWithChildren) => ( ), - hr: (props) => ( + hr: (props: React.PropsWithChildren) => (
), 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 Date: Fri, 6 Dec 2024 10:31:03 -0600 Subject: [PATCH 02/29] 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 = { text: "Guides", url: "/guides", }, - // { - // text: "Cookbook", - // url: "/cookbook", - // }, + { + text: "Cookbook", + url: "/cookbook", + }, ], sidebar: { defaultOpenLevel: 0, @@ -74,10 +74,10 @@ export const homeLayoutOptions: Omit = { 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({
- +
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 (
-
+
{ALL_TAGS.map((tag) => ( - {initialRecipes.map((recipe) => ( - router.push(`/cookbook/${recipe.id}`)} - > - - - {recipe.title} - - - -
- {recipe.tags.map((tag) => ( - - {tag.toUpperCase()} - - ))} -
-
-
- ))} + {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) => ( + router.push(`/cookbook/${recipe.id}`)} + > + + + {recipe.title} + + + +
+ {recipe.tags.map((tag) => ( + + {tag.toUpperCase()} + + ))} +
+
+
+ ))}
)} 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(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([]); + const outputRef = React.useRef(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 (
@@ -54,19 +88,25 @@ export function SnippetResult({ code }: SnippetResultProps) { onClick={runCode} disabled={isLoading} > - - {isLoading ? "Running..." : "Run code snippet"} - - + {type === "clarity" && ( + + )}
- {result && ( + {result && type !== "clarity" && (
)} + {result && isConsoleOpen && ( +
+          
+ {output.map((item, index) => ( +
+ {item.type === "command" ? ( +
+ $ + {item.content} +
+ ) : ( +
{item.content}
+ )} +
+ ))} +
+ $ + setInput(e.target.value)} + className="flex-1 bg-transparent text-[var(--ch-1)] focus:outline-none leading-6 font-mono whitespace-pre-wrap" + /> +
+
+
+ )}
); } 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 ( -
+
-
+

{recipe.title} @@ -27,42 +26,26 @@ function RecipeCard({ {recipe.description}

-
- -
+
{recipe.tags.map((tag) => ( - + {tag.toUpperCase()} ))}
-
-
+ +
{codeElement} -
+
- - - -
+
); } @@ -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) { ); 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 ( 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 ( + + + {Object.entries(TAG_CATEGORIES).map(([key, category]) => ( + onCategoryChange(key as CategoryKey)} + > + {category.label} + + ))} + + + {selectedCategory &&
} +
+ {selectedCategory && + TAG_CATEGORIES[selectedCategory].subTags.map((tag) => ( + onSubTagToggle(tag)} + > + {tag.toUpperCase()} + + ))}
); @@ -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(() => { + const [selectedCategory, setSelectedCategory] = useState( + () => { + const category = searchParams.get("category") as CategoryKey | null; + return category && TAG_CATEGORIES[category] ? category : "clarity"; + } + ); + + const [selectedSubTags, setSelectedSubTags] = useState(() => { 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 (
@@ -188,8 +302,10 @@ function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { {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) => ( router.push(`/cookbook/${recipe.id}`)} > - + {recipe.title}
- {recipe.tags.map((tag) => ( - - {tag.toUpperCase()} + {recipe.categories.map((category) => ( + + {category.toUpperCase()} ))}
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(null); const [isLoading, setIsLoading] = React.useState(false); const [isConsoleOpen, setIsConsoleOpen] = React.useState(false); const [input, setInput] = React.useState(""); const [output, setOutput] = React.useState([]); + const [simnetInstance, setSimnetInstance] = React.useState( + null + ); + const [codeHistory, setCodeHistory] = React.useState(""); + // Add these new states near your other state declarations + const [commandHistory, setCommandHistory] = React.useState([]); + const [historyIndex, setHistoryIndex] = React.useState(-1); + + const inputRef = React.useRef(null); const outputRef = React.useRef(null); + const iframeRef = React.useRef(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) => { + 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 (
+