diff --git a/.gitignore b/.gitignore index 23d7ab5f4..243d4f104 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ node_modules .env +.env.local .next bun.lockb openapi .DS_Store **/.DS_Store tmp -prompt.txt \ No newline at end of file +.cursorrules \ No newline at end of file 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 c59435578..530830b84 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, @@ -21,10 +21,7 @@ export const layoutOptions: Omit = { href: "https://platform.hiro.so/", icon: (
- - Hiro Platform - - + Hiro Platform
), external: true, @@ -36,10 +33,10 @@ export const layoutOptions: Omit = { text: "Guides", url: "/guides", }, - // { - // text: "Cookbook", - // url: "/cookbook", - // }, + { + text: "Cookbook", + url: "/cookbook", + }, ], sidebar: { defaultOpenLevel: 0, @@ -59,10 +56,7 @@ export const homeLayoutOptions: Omit = { href: "https://platform.hiro.so/", icon: (
- - Hiro Platform - - + Hiro Platform
), external: true, @@ -74,10 +68,10 @@ export const homeLayoutOptions: Omit = { text: "Guides", url: "/guides", }, - // { - // text: "Cookbook", - // url: "/cookbook", - // }, + { + text: "Cookbook", + url: "/cookbook", + }, ], }; diff --git a/app/cookbook/[id]/page.tsx b/app/cookbook/[id]/page.tsx new file mode 100644 index 000000000..b19bd4616 --- /dev/null +++ b/app/cookbook/[id]/page.tsx @@ -0,0 +1,109 @@ +import { Code } from "@/components/docskit/code"; +import { loadRecipes } from "@/utils/loader"; +import { Badge } from "@/components/ui/badge"; +import { HoverProvider } from "@/context/hover"; +import { HoverLink } from "@/components/docskit/annotations/hover"; +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; +} + +export const dynamicParams = false; + +export default async function Page({ + params, +}: { + params: Param; +}): Promise { + const { id } = params; + const recipes = await loadRecipes(); + 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/guides/${id}.mdx`).catch( + () => { + console.error(`Failed to load MDX content for recipe: ${id}`); + return { default: () =>
Content not found
}; + } + ); + + return ( + <> + +
+
+
+ + + +
+
+
+
+
+
+ {recipe.categories.map((category) => ( + + {category} + + ))} +
+
+ +
+
+
+ + {/* 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..d268d0f01 --- /dev/null +++ b/app/cookbook/components/cookbook-ui.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { useState, useMemo, Suspense, useEffect, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +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 { FilterPopover } from "@/components/filter-popover"; + +// Internal components +function ViewToggle({ + view, + onChange, +}: { + view: "grid" | "list"; + onChange: (view: "grid" | "list") => void; +}) { + return ( +
+ + +
+ ); +} + +function RecipeFilters({ + search, + onSearchChange, + selectedCategories, + onCategoriesChange, +}: { + search: string; + onSearchChange: (value: string) => void; + selectedCategories: string[]; + onCategoriesChange: (categories: string[]) => void; +}) { + return ( +
+
+ + onSearchChange(e.target.value)} + /> +
+ +
+ ); +} + +interface CookbookProps { + initialRecipes: Recipe[]; + recipeCards: React.ReactNode[]; +} + +function CookbookContent({ initialRecipes, recipeCards }: CookbookProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const ITEMS_PER_PAGE = 10; + const [currentPage, _] = useState(1); + + const [view, setView] = useState<"grid" | "list">(() => { + return (searchParams.get("view") as "grid" | "list") || "list"; + }); + const [search, setSearch] = useState(""); + const [selectedCategories, setSelectedCategories] = useState(() => { + const categories = searchParams.get("categories"); + return categories ? categories.split(",") : []; + }); + + const updateURL = (newView?: "grid" | "list", newCategories?: string[]) => { + const params = new URLSearchParams(); + + if (newView === "list") { + params.set("view", newView); + } + + if (newCategories && newCategories.length > 0) { + params.set("categories", newCategories.join(",")); + } + + const newURL = params.toString() + ? `?${params.toString()}` + : window.location.pathname; + + router.push(newURL, { scroll: false }); + }; + + const handleViewChange = (newView: "grid" | "list") => { + setView(newView); + updateURL(newView, selectedCategories); + }; + + const handleCategoriesChange = (categories: string[]) => { + setSelectedCategories(categories); + updateURL(view, categories); + }; + + const handleSearchChange = (value: string) => { + setSearch(value); + }; + + // 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]); + + const filteredRecipeCards = useMemo(() => { + // 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) || + 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; + }); + + 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(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 ( +
+
+
+
+

Cookbook

+

+ Explore ready-to-use code recipes for building applications on + Stacks. +

+
+ +
+ +
+ + + {view === "list" ? ( + + + {filteredRecipeCards.map((recipeCard, index) => { + const recipe = initialRecipes[index]; + const isLastItem = index === filteredRecipeCards.length - 1; + + return ( + router.push(`/cookbook/${recipe.id}`)} + ref={isLastItem ? lastItemRef : null} + > + + + {recipe.title} + + + +
+ {recipe.categories.map((category) => ( + + {category.toUpperCase()} + + ))} +
+
+
+ ); + })} +
+
+ ) : ( +
+ {filteredRecipeCards.map((card, index) => ( +
+ {card} +
+ ))} +
+ )} +
+
+ + {isLoading && ( +

+ Loading more recipes... +

+ )} +
+ ); +} + +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..717d8d53a --- /dev/null +++ b/app/cookbook/components/snippet-result.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +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, 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 = { + type: "command" | "success" | "error" | "log"; + content: string; +}; + +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); + + React.useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [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.initEmptySession(); + 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); + } + } + + // 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 }]); + 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(""); + } + } + + const getButtonText = () => { + if (type === "clarity") { + if (isLoading) return "Loading..."; + if (isConsoleOpen) return "Close terminal"; + return "Open in terminal"; + } + return "Run code snippet"; + }; + + return ( +
+