diff --git a/.changeset/few-dolphins-know.md b/.changeset/few-dolphins-know.md new file mode 100644 index 00000000..c9314647 --- /dev/null +++ b/.changeset/few-dolphins-know.md @@ -0,0 +1,15 @@ +--- +"@mod-protocol/react-ui-shadcn": minor +"web": minor +"@mod-protocol/react": minor +"@mod-protocol/core": minor +"@miniapps/zora-nft-minter": patch +"@miniapps/giphy-picker": patch +"@mod-protocol/react-editor": patch +"@miniapps/nft-minter": patch +"@miniapps/chatgpt": patch +"api": patch +"docs": patch +--- + +Adds the combobox, select, textarea mod elements. Adds `Model Definitions` to Manifests. Adds the `ethPersonalSign` action. Adds `json-ld` indexing to metadata. Adds a `loadingLabel` prop to buttons diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..40eeba45 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.disableAutomaticTypeAcquisition": true +} \ No newline at end of file diff --git a/docs/pages/create-mini-app/reference.mdx b/docs/pages/create-mini-app/reference.mdx index 5c109b28..80db0741 100644 --- a/docs/pages/create-mini-app/reference.mdx +++ b/docs/pages/create-mini-app/reference.mdx @@ -27,10 +27,21 @@ type ModManifest = { custodyAddress: string; /** A valid url pointing to an image file, it should be a square */ logo: string; + /** should be the same as the package version */ version: string; + /** + * A Map of unique ids to json-schema.org definitions. Used to define a new standard data model for use in this or other Mini-apps. + * Most useful when used in conjunction with json-ld that utilizes these data models + */ + modelDefinitions?: Record; + /** Interface this Mini-app exposes, if any, for Content Creation */ creationEntrypoints?: ModElement[]; + /** Interface this Mini-app exposes, if any, for Content Rendering */ contentEntrypoints?: ModConditionalElement[]; + /** A definition map of reusable elements, using their id as the key */ elements?: Record; + /** Permissions requested by the Mini-app */ + permissions?: Array<"user.wallet.address" | "web3.eth.personal.sign">; }; ``` @@ -72,11 +83,38 @@ export type ModElement = elements?: string | ElementOrConditionalFlow[]; onload?: ModEvent; } + | { + ref?: string; + type: "select"; + options: Array<{ label: string; value: any }>; + placeholder?: string; + isClearable?: boolean; + onchange?: ModEvent; + onsubmit?: ModEvent; + } + | { + type: "combobox"; + ref?: string; + isClearable?: boolean; + placeholder?: string; + optionsRef?: string; + valueRef?: string; + onload?: ModEvent; + onpick?: ModEvent; + onchange?: ModEvent; + } + | { + ref?: string; + type: "textarea"; + placeholder?: string; + onchange?: ModEvent; + onsubmit?: ModEvent; + } | { ref?: string; type: "input"; placeholder?: string; - clearable?: boolean; + isClearable?: boolean; onchange?: ModEvent; onsubmit?: ModEvent; } diff --git a/docs/pages/metadata-cache.mdx b/docs/pages/metadata-cache.mdx index e953445f..e05d0e3f 100644 --- a/docs/pages/metadata-cache.mdx +++ b/docs/pages/metadata-cache.mdx @@ -6,7 +6,9 @@ The metadata cache can be used to retrieve Open Graph metadata for embeds in cas It makes use of the [Metadata Indexer](https://github.com/mod-protocol/mod/tree/main/examples/metadata-indexer), an open source and self-hostable service that indexes casts, embeds, and their metadata. -## Usage +We are hosting a free instance of the Metadata indexer that can be reached at https://api.modprotocol.org/api/cast-embeds-metadata + +## `/api/cast-embeds-metadata` Fetching metadata from the cache is as simple as making a POST request to the following endpoint with a list of cast hashes in the body. @@ -58,8 +60,8 @@ This will return a JSON object with the following structure: "metadata": { "title": "Example Title", "description": "Example Description", - "image": { - url: "https://example.com/image.png" + "image": { + "url": "https://example.com/image.png" } // ... } @@ -73,8 +75,86 @@ Returned metadata objects conform to the `UrlMetadata` type. This can then be us ```typescript import { UrlMetadata } from "@mod-protocol/core"; -cast.embeds.map((embed, index) => { - const embedData: UrlMetadata = metadataResponse[cast.hash][index] - return -}) -``` \ No newline at end of file +cast.embeds.map((embed) => { + const metadata: UrlMetadata = metadataResponse[cast.hash][embed.url]; + return ; +}); +``` + +## `/api/cast-embeds-metadata/by-url` + +Fetching metadata from the cache by url is as simple as making a POST request to the following endpoint with a list of urls in the body. + + + + ```bash + curl --request POST \ + --url https://api.modprotocol.org/api/cast-embeds-metadata/by-url \ + --header 'Content-Type: application/json' \ + --data '["https://google.com","https://twitter.com"]' + ``` + + + ```js + const request = await fetch("https://api.modprotocol.org/api/cast-embeds-metadata/by-url", { + body: JSON.stringify(["https://google.com","https://twitter.com"]), + method: 'POST', + headers: { + 'Content-Type': "application/json" + } + }); + const metadata = await request.json(); + ``` + + + ```js + const request = await fetch("https://api.modprotocol.org/api/cast-embeds-metadata/by-url", { + body: JSON.stringify(["https://google.com","https://twitter.com"]), + method: 'POST', + headers: { + 'Content-Type': "application/json" + } + }); + const metadata = await request.json(); + ``` + + + +This will return a JSON object with the following structure: + +```json +{ + "https://google.com": { + "image": { + "url": "https://www.google.com/images/branding/googleg/1x/googleg_standard_color_128dp.png", + "height": 128, + "width": 128 + }, + "description": "Celebrate Native American Heritage Month with Google", + "title": "Google", + "publisher": "google.com", + "logo": { + "url": "https://www.google.com/favicon.ico" + }, + "mimeType": "text/html; charset=UTF-8" + }, + "https://twitter.com": { + "description": "From breaking news and entertainment to sports and politics, get the full story with all the live commentary.", + "title": "@ on Twitter", + "publisher": "Twitter", + "logo": { + "url": "https://abs.twimg.com/favicons/twitter.3.ico" + }, + "mimeType": "text/html; charset=utf-8" + } +} +``` + +Returned metadata objects conform to the `UrlMetadata` type. This can then be used to render embeds in a cast. + +```typescript +import { UrlMetadata } from "@mod-protocol/core"; + +const metadata: UrlMetadata = metadataResponse[embed.url]; +return ; +``` diff --git a/examples/api/.env.example b/examples/api/.env.example index b7ad61ee..66ef90a4 100644 --- a/examples/api/.env.example +++ b/examples/api/.env.example @@ -6,4 +6,7 @@ MICROLINK_API_KEY="REQUIRED" OPENSEA_API_KEY="REQUIRED" CHATGPT_API_SECRET="REQUIRED" NEYNAR_API_SECRET="REQUIRED" -LIVEPEER_API_SECRET="REQUIRED" \ No newline at end of file +LIVEPEER_API_SECRET="REQUIRED" +DATABASE_URL="REQUIRED" +# Must be funded with MATIC on Mumbai for Irys https://mumbaifaucet.com/ +PRIVATE_KEY="REQUIRED" \ No newline at end of file diff --git a/examples/api/package.json b/examples/api/package.json index 37d512ce..49013269 100644 --- a/examples/api/package.json +++ b/examples/api/package.json @@ -10,16 +10,22 @@ "lint": "next lint" }, "dependencies": { + "@irys/sdk": "^0.0.4", + "@lit-protocol/lit-node-client": "^3.0.24", + "@lit-protocol/types": "^2.2.61", "@mod-protocol/core": "^0.0.2", "@reservoir0x/reservoir-sdk": "^1.8.4", "@vercel/postgres-kysely": "^0.6.0", "chatgpt": "^5.2.5", + "cheerio": "^1.0.0-rc.12", "kysely": "^0.26.3", "next": "^13.5.6", "open-graph-scraper": "^6.3.2", "pg": "^8.11.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "siwe": "^1.1.6", + "uint8arrays": "^3.0.0" }, "devDependencies": { "@types/node": "^17.0.12", @@ -35,4 +41,4 @@ "tsconfig": "*", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/examples/api/src/app/api/cast-embeds-metadata/by-url/route.ts b/examples/api/src/app/api/cast-embeds-metadata/by-url/route.ts new file mode 100644 index 00000000..5d7941dc --- /dev/null +++ b/examples/api/src/app/api/cast-embeds-metadata/by-url/route.ts @@ -0,0 +1,130 @@ +export const dynamic = "force-dynamic"; + +import { NextResponse, NextRequest } from "next/server"; +import { NFTMetadata, UrlMetadata } from "@mod-protocol/core"; +import { db } from "../lib/db"; +import { chainById } from "../lib/chain-index"; + +export async function POST(request: NextRequest) { + try { + const urls = await request.json(); + + // todo: consider normalizing urls here? currently clients are responsible for doing this. + + // Fetch metadata for each url + const metadata = await db + .selectFrom("url_metadata") + .where("url_metadata.url", "in", urls) + .leftJoin( + "nft_metadata", + "nft_metadata.id", + "url_metadata.nft_metadata_id" + ) + .leftJoin( + "nft_collections", + "url_metadata.nft_collection_id", + "nft_collections.id" + ) + .select([ + /* Select all columns with aliases to prevent collisions */ + + // URL metadata + "url_metadata.image_url as url_image_url", + "url_metadata.image_height as url_image_height", + "url_metadata.image_width as url_image_width", + "url_metadata.alt as url_alt", + "url_metadata.url as url_url", + "url_metadata.description as url_description", + "url_metadata.title as url_title", + "url_metadata.publisher as url_publisher", + "url_metadata.logo_url as url_logo_url", + "url_metadata.mime_type as url_mime_type", + "url_metadata.nft_collection_id as nft_collection_id", + "url_metadata.nft_metadata_id as nft_metadata_id", + + // NFT Collection metadata + "nft_collections.creator_address as collection_creator_address", + "nft_collections.description as collection_description", + "nft_collections.image_url as collection_image_url", + "nft_collections.item_count as collection_item_count", + "nft_collections.mint_url as collection_mint_url", + "nft_collections.name as collection_name", + "nft_collections.open_sea_url as collection_open_sea_url", + "nft_collections.owner_count as collection_owner_count", + + // NFT metadata + "nft_metadata.token_id as nft_token_id", + "nft_metadata.media_url as nft_media_url", + ]) + .execute(); + + const rowsFormatted = metadata.map((row) => { + let nftMetadata: NFTMetadata | undefined; + + if (row.nft_collection_id) { + const [, , prefixAndChainId, prefixAndContractAddress, tokenId] = + row.nft_collection_id.split("/"); + + const [, chainId] = prefixAndChainId.split(":"); + const [, contractAddress] = prefixAndContractAddress.split(":"); + + const chain = chainById[chainId]; + + nftMetadata = { + mediaUrl: row.nft_media_url || undefined, + tokenId: row.nft_token_id || undefined, + collection: { + chain: chain.network, + contractAddress, + creatorAddress: row.collection_creator_address, + description: row.collection_description, + id: row.nft_collection_id, + imageUrl: row.collection_image_url, + itemCount: row.collection_item_count, + mintUrl: row.collection_mint_url, + name: row.collection_name, + openSeaUrl: row.collection_open_sea_url || undefined, + ownerCount: row.collection_owner_count || undefined, + creator: undefined, // TODO: Look up farcaster user by FID + }, + }; + } + + const urlMetadata: UrlMetadata = { + image: row.url_image_url + ? { + url: row.url_image_url, + height: row.url_image_height || undefined, + width: row.url_image_width || undefined, + } + : undefined, + alt: row.url_alt || undefined, + description: row.url_description || undefined, + title: row.url_title || undefined, + publisher: row.url_publisher || undefined, + logo: row.url_logo_url ? { url: row.url_logo_url } : undefined, + mimeType: row.url_mime_type || undefined, + nft: nftMetadata, + }; + + return { + url: row.url_url, + urlMetadata, + }; + }); + + const metadataByUrl: { + [key: string]: UrlMetadata; + } = rowsFormatted.reduce((acc, cur) => { + return { + ...acc, + [cur.url]: cur.urlMetadata, + }; + }, {}); + + return NextResponse.json(metadataByUrl); + } catch (err) { + console.error(err); + return NextResponse.json({ message: err.message }, { status: err.status }); + } +} diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/arweave.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/arweave.ts new file mode 100644 index 00000000..ba97e734 --- /dev/null +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/arweave.ts @@ -0,0 +1,53 @@ +import { UrlMetadata } from "@mod-protocol/core"; +import { UrlHandler } from "../../types/url-handler"; + +async function handleArweave(url: string): Promise { + // `https://gateway.irys.xyz/${irysTransactionId}` + const transactionId = url.split(":")[2]; + + const reformattedUrl = `https://gateway.irys.xyz/${transactionId}`; + + const response = await fetch(reformattedUrl, { + method: "HEAD", + }); + + // Get content-type + const mimeType = response.headers.get("content-type"); + + if (mimeType === "application/json") { + try { + const arweaveData = await fetch(reformattedUrl); + + const body = await arweaveData.json(); + + // Check for schema + if (body["@type"] === "WebPage") + return { + image: { + url: body.image, + }, + "json-ld": { WebPage: [body] }, + description: body.description, + alt: body.name, + title: body.name, + logo: { + url: body.image, + }, + mimeType: "application/json", + }; + } catch (err) { + console.error(err); + } + } + // TODO: handle html + + return null; +} + +const urlHandler: UrlHandler = { + name: "Arweave", + matchers: ["arweave:7wIU:*"], + handler: handleArweave, +}; + +export default urlHandler; diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts index 52a83454..3f2fe8b8 100644 --- a/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts @@ -3,6 +3,18 @@ import ogs from "open-graph-scraper"; import { UrlHandler } from "../../types/url-handler"; import { chainById } from "../chains/chain-index"; import { fetchNFTMetadata } from "../util"; +import * as cheerio from "cheerio"; + +export function groupLinkedDataByType( + linkedData: T[] +): Record { + return linkedData.reduce((prev, next): Record => { + return { + ...prev, + [next["@type"]]: [...(prev[next["@type"]] ?? []), next], + }; + }, {}); +} async function localFetchHandler(url: string): Promise { // A versatile user agent for which most sites will return opengraph data @@ -14,6 +26,21 @@ async function localFetchHandler(url: string): Promise { const html = await response.text(); + const $ = cheerio.load(html, { decodeEntities: false, xmlMode: true }, false); + const jsonLdScripts = $('script[type="application/ld+json"]'); + + const linkedData = jsonLdScripts + .map((i, el) => { + try { + const html = $(el).text(); + return JSON.parse(html); + } catch (e) { + console.error("Error parsing JSON-LD:", e); + return null; + } + }) + .get(); + const { result: data } = await ogs({ html, customMetaTags: [ @@ -85,6 +112,7 @@ async function localFetchHandler(url: string): Promise { url: data.ogLogo, } : undefined, + "json-ld": groupLinkedDataByType(linkedData), publisher: data.ogSiteName, mimeType: response["headers"]["content-type"], nft: nftMetadata, diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/metascraper.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/metascraper.ts index d92cb874..9176e55a 100644 --- a/examples/api/src/app/api/open-graph/lib/url-handlers/metascraper.ts +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/metascraper.ts @@ -2,6 +2,7 @@ import { UrlMetadata } from "@mod-protocol/core"; import { UrlHandler } from "../../types/url-handler"; import { chainById } from "../chains/chain-index"; import { fetchNFTMetadata } from "../util"; +import { groupLinkedDataByType } from "./local-fetch"; const ethDataSelectors: { selectorAll: string; @@ -21,6 +22,12 @@ const ethDataSelectors: { }, ]; +const jsonLdDataSelector = new URLSearchParams({ + "data.json-ld.selectorAll": 'script[type="application/ld+json"]', + "data.json-ld.attr": "text", + "data.json-ld.type": "string", +}).toString(); + // Open Graph NFT extension // https://warpcast.notion.site/NFT-extended-open-graph-5a64ca22d2374f99832bc4b91c386c46 // https://microlink.io/docs/api/parameters/data @@ -55,6 +62,8 @@ async function metascraperHandler( metadataUrl += `&${ethSearchParams.join("&")}`; } + metadataUrl += `&${jsonLdDataSelector}`; + const response = await fetch( // To self host, use https://github.com/microlinkhq/metascraper metadataUrl, @@ -77,6 +86,28 @@ async function metascraperHandler( return null; } + let formattedJsonLd = []; + if (data["jsonLd"]) { + if (Array.isArray(data["jsonLd"])) { + formattedJsonLd = data["jsonLd"] + .map((el) => { + try { + return JSON.parse(el); + } catch (err) { + console.error(err); + return false; + } + }) + .filter((truthy) => truthy); + } else { + try { + formattedJsonLd = [JSON.parse(data["jsonLd"])]; + } catch (err) { + console.error(err); + } + } + } + const urlMetadata: UrlMetadata = { image: data.image ? { @@ -87,6 +118,9 @@ async function metascraperHandler( : undefined, description: data.description, alt: data.alt, + "json-ld": formattedJsonLd + ? groupLinkedDataByType(formattedJsonLd) + : undefined, title: data.title, publisher: data.publisher, logo: data.logo diff --git a/examples/nextjs-shadcn/.env.example b/examples/nextjs-shadcn/.env.example index 36de561f..4a7db9d6 100644 --- a/examples/nextjs-shadcn/.env.example +++ b/examples/nextjs-shadcn/.env.example @@ -1,2 +1,4 @@ NEXT_PUBLIC_API_URL="http://localhost:3001/api" -NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= \ No newline at end of file +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID="" +NEXT_PUBLIC_URL="http://localhost:3000" +NEXT_PUBLIC_HOST="localhost:3000" \ No newline at end of file diff --git a/examples/nextjs-shadcn/next.config.js b/examples/nextjs-shadcn/next.config.js index 7cae711b..77bf1e82 100644 --- a/examples/nextjs-shadcn/next.config.js +++ b/examples/nextjs-shadcn/next.config.js @@ -1,6 +1,7 @@ module.exports = { reactStrictMode: true, transpilePackages: ["@mod-protocol/react"], + images: { domains: [ "*.i.imgur.com", diff --git a/examples/nextjs-shadcn/package.json b/examples/nextjs-shadcn/package.json index 30ba9a77..63bdfb34 100644 --- a/examples/nextjs-shadcn/package.json +++ b/examples/nextjs-shadcn/package.json @@ -37,4 +37,4 @@ "tsconfig": "*", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/examples/nextjs-shadcn/src/app/editor-example.tsx b/examples/nextjs-shadcn/src/app/editor-example.tsx index a66382f4..dc2d7a16 100644 --- a/examples/nextjs-shadcn/src/app/editor-example.tsx +++ b/examples/nextjs-shadcn/src/app/editor-example.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { getAddress } from "viem"; // Core import { @@ -15,12 +16,15 @@ import { useEditor, EditorContent } from "@mod-protocol/react-editor"; import { creationMiniApps } from "@mod-protocol/miniapp-registry"; import { Embed, + EthPersonalSignActionResolverInit, ModManifest, fetchUrlMetadata, handleAddEmbed, handleOpenFile, handleSetInput, } from "@mod-protocol/core"; +import { SiweMessage } from "siwe"; +import { useAccount, useSignMessage } from "wagmi"; // UI implementation import { createRenderMentionsSuggestionConfig } from "@mod-protocol/react-ui-shadcn/dist/lib/mentions"; @@ -94,9 +98,65 @@ export default function EditorExample() { }), }); + const { address: unchecksummedAddress } = useAccount(); + const checksummedAddress = React.useMemo(() => { + if (!unchecksummedAddress) return null; + return getAddress(unchecksummedAddress); + }, [unchecksummedAddress]); + + const { signMessageAsync } = useSignMessage(); + + const getAuthSig = React.useCallback( + async ( + { + data: { statement, version, chainId }, + }: EthPersonalSignActionResolverInit, + { onSuccess, onError } + ): Promise => { + if (!checksummedAddress) { + window.alert("please connect your wallet"); + return; + } + try { + const siweMessage = new SiweMessage({ + domain: process.env.NEXT_PUBLIC_HOST, + address: checksummedAddress, + statement, + uri: process.env.NEXT_PUBLIC_URL, + version, + chainId: Number(chainId), + }); + const messageToSign = siweMessage.prepareMessage(); + + // Sign the message and format the authSig + const signature = await signMessageAsync({ message: messageToSign }); + const authSig = { + signature, + // derivedVia: "web3.eth.personal.sign", + signedMessage: messageToSign, + address: checksummedAddress, + }; + + onSuccess(authSig); + } catch (err) { + console.error(err); + onError(err); + } + }, + [signMessageAsync, checksummedAddress] + ); + const [currentMiniapp, setCurrentMiniapp] = React.useState(null); + const user = React.useMemo(() => { + return { + wallet: { + address: checksummedAddress, + }, + }; + }, [checksummedAddress]); + return (
@@ -134,6 +194,7 @@ export default function EditorExample() { input={getText()} embeds={getEmbeds()} api={API_URL} + user={user} variant="creation" manifest={currentMiniapp} renderers={renderers} @@ -141,6 +202,7 @@ export default function EditorExample() { onExitAction={() => setCurrentMiniapp(null)} onSetInputAction={handleSetInput(setText)} onAddEmbedAction={handleAddEmbed(addEmbed)} + onEthPersonalSignAction={getAuthSig} />
diff --git a/examples/nextjs-shadcn/src/app/embeds.tsx b/examples/nextjs-shadcn/src/app/embeds.tsx index 242d0a97..e4e1a548 100644 --- a/examples/nextjs-shadcn/src/app/embeds.tsx +++ b/examples/nextjs-shadcn/src/app/embeds.tsx @@ -1,6 +1,11 @@ "use client"; -import { ContextType, Embed } from "@mod-protocol/core"; +import { + ContextType, + Embed, + SendEthTransactionActionResolverEvents, + SendEthTransactionActionResolverInit, +} from "@mod-protocol/core"; import { contentMiniApps, defaultContentMiniApp, @@ -20,7 +25,14 @@ export function Embeds(props: { embeds: Array }) { const onSendEthTransactionAction = useMemo( () => - async ({ data, chainId }, { onConfirmed, onError, onSubmitted }) => { + async ( + { data, chainId }: SendEthTransactionActionResolverInit, + { + onConfirmed, + onError, + onSubmitted, + }: SendEthTransactionActionResolverEvents + ) => { try { const parsedChainId = parseInt(chainId); diff --git a/miniapps/chatgpt/src/action.ts b/miniapps/chatgpt/src/action.ts index 9ea1f3f4..cdd2bb51 100644 --- a/miniapps/chatgpt/src/action.ts +++ b/miniapps/chatgpt/src/action.ts @@ -7,7 +7,7 @@ const action: ModElement[] = [ { type: "input", placeholder: "Ask the AI anything", - clearable: true, + isClearable: true, ref: "prompt", // onchange: { // ref: "mySearchQueryRequest", diff --git a/miniapps/giphy-picker/src/error.ts b/miniapps/giphy-picker/src/error.ts index 4d6f4c5d..f22cb81f 100644 --- a/miniapps/giphy-picker/src/error.ts +++ b/miniapps/giphy-picker/src/error.ts @@ -22,7 +22,7 @@ const error: ModElement[] = [ ref: "myInput", type: "input", placeholder: "Search", - clearable: true, + isClearable: true, onchange: { ref: "mySearchQueryRequest", type: "GET", diff --git a/miniapps/giphy-picker/src/loading.ts b/miniapps/giphy-picker/src/loading.ts index 75775ac8..7f9312c7 100644 --- a/miniapps/giphy-picker/src/loading.ts +++ b/miniapps/giphy-picker/src/loading.ts @@ -22,7 +22,7 @@ const loading: ModElement[] = [ ref: "myInput", type: "input", placeholder: "Search", - clearable: true, + isClearable: true, onchange: { ref: "mySearchQueryRequest", type: "GET", diff --git a/miniapps/giphy-picker/src/success.ts b/miniapps/giphy-picker/src/success.ts index a3a328f9..2a697c55 100644 --- a/miniapps/giphy-picker/src/success.ts +++ b/miniapps/giphy-picker/src/success.ts @@ -22,7 +22,7 @@ const success: ModElement[] = [ ref: "myInput", type: "input", placeholder: "Search", - clearable: true, + isClearable: true, onchange: { ref: "mySearchQueryRequest", type: "GET", diff --git a/miniapps/nft-minter/src/view.ts b/miniapps/nft-minter/src/view.ts index dfd6cffd..66863811 100644 --- a/miniapps/nft-minter/src/view.ts +++ b/miniapps/nft-minter/src/view.ts @@ -5,7 +5,8 @@ const view: ModElement[] = [ type: "card", imageSrc: "{{embed.metadata.image.url}}", aspectRatio: 16 / 11, - topLeftBadge: "@{{embed.metadata.nft.collection.creator.username}}", + // fixme: may be undefined, in that case dont render. + topLeftBadge: "{{embed.metadata.nft.collection.creator.username}}", onclick: { type: "OPENLINK", url: "{{embed.metadata.nft.collection.openSeaUrl}}", diff --git a/miniapps/zora-nft-minter/src/view.ts b/miniapps/zora-nft-minter/src/view.ts index 015d777e..301d2726 100644 --- a/miniapps/zora-nft-minter/src/view.ts +++ b/miniapps/zora-nft-minter/src/view.ts @@ -5,11 +5,7 @@ const view: ModElement[] = [ type: "card", imageSrc: "{{embed.metadata.image.url}}", aspectRatio: 16 / 11, - topLeftBadge: "@{{embed.metadata.nft.collection.creator.username}}", - onclick: { - type: "OPENLINK", - url: "{{embed.url}}", - }, + topLeftBadge: "{{embed.metadata.nft.collection.creator.username}}", elements: [ { type: "horizontal-layout", diff --git a/packages/core/package.json b/packages/core/package.json index a9dcd362..e92e4689 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@mod-protocol/farcaster": "^0.0.1", + "json-schema": "^0.4.0", "lodash.get": "^4.4.2", "lodash.isarray": "^4.0.0", "lodash.isstring": "^4.0.1", @@ -34,4 +35,4 @@ "lodash.tonumber": "^4.0.3", "lodash.tostring": "^4.1.4" } -} \ No newline at end of file +} diff --git a/packages/core/src/embeds.ts b/packages/core/src/embeds.ts index d338fd3b..e19c171c 100644 --- a/packages/core/src/embeds.ts +++ b/packages/core/src/embeds.ts @@ -16,7 +16,8 @@ export function isVideoEmbed(embed: Embed) { export function hasFullSizedImage(embed: Embed) { return ( embed.metadata?.image?.url && - embed.metadata?.image.width !== embed.metadata.image.height + (embed.metadata?.image.width !== embed.metadata.image.height || + (!embed.metadata?.image.width && !embed.metadata.image.height)) ); } @@ -55,6 +56,8 @@ export type UrlMetadata = { width?: number; height?: number; }; + // map of schema.org types to arrays of schema.org definitions + "json-ld"?: Record; description?: string; alt?: string; title?: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e07c8cf6..69b326e8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,10 @@ -export type { ModElement, ModAction, ModManifest, ModEvent } from "./manifest"; +export type { + ModElement, + ModAction, + ModManifest, + ModEvent, + ModConditionalElement, +} from "./manifest"; export type { ModElementRef, HttpActionResolver, @@ -16,6 +22,9 @@ export type { OpenLinkActionResolver, OpenLinkActionResolverInit, OpenLinkActionResolverEvents, + EthPersonalSignActionResolver, + EthPersonalSignActionResolverEvents, + EthPersonalSignActionResolverInit, SendEthTransactionActionResolverInit, SendEthTransactionActionResolverEvents, SendEthTransactionActionResolver, diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index 7894670e..a5a4fe63 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -1,4 +1,6 @@ -type ModConditionalElement = { +import type { JSONSchema7 } from "json-schema"; + +export type ModConditionalElement = { element: ModElement[]; if: ValueOp; }; @@ -14,11 +16,21 @@ export type ModManifest = { custodyAddress: string; /** A valid url pointing to an image file, it should be a square */ logo: string; + /** should be the same as the package version */ version: string; + /** + * A Map of unique ids to json-schema.org definitions. Used to define a new standard data model for use in this or other Mini-apps. + * Most useful when used in conjunction with json-ld that utilizes these data models + */ + modelDefinitions?: Record; + /** Interface this Mini-app exposes, if any, for Content Creation */ creationEntrypoints?: ModElement[]; + /** Interface this Mini-app exposes, if any, for Content Rendering */ contentEntrypoints?: ModConditionalElement[]; + /** A definition map of reusable elements, using their id as the key */ elements?: Record; - permissions?: string[]; + /** Permissions requested by the Mini-app */ + permissions?: Array<"user.wallet.address" | "web3.eth.personal.sign">; }; export type ModEvent = @@ -77,9 +89,10 @@ type HTTPBody = formData: Record; }; -type HTTPAction = BaseAction & { url: string } & ( +export type HTTPAction = BaseAction & { url: string } & ( | { type: "GET"; + searchParams?: Record; } | { type: "POST"; @@ -117,6 +130,12 @@ type OpenLinkAction = BaseAction & { url: string; }; +export type EthPersonalSignData = { + statement: string; + version: string; + chainId: string; +}; + export type EthTransactionData = { to: string; from: string; @@ -124,6 +143,11 @@ export type EthTransactionData = { value?: string; }; +type EthPersonalSignAction = BaseAction & { + type: "web3.eth.personal.sign"; + data: EthPersonalSignData; +}; + type SendEthTransactionAction = BaseAction & { type: "SENDETHTRANSACTION"; chainId: string; @@ -147,6 +171,7 @@ export type ModAction = | AddEmbedAction | SetInputAction | OpenLinkAction + | EthPersonalSignAction | SendEthTransactionAction | ExitAction; @@ -171,6 +196,7 @@ export type ModElement = | { type: "button"; label: string; + loadingLabel?: string; variant?: "primary" | "secondary" | "destructive"; onclick: ModEvent; } @@ -188,10 +214,25 @@ export type ModElement = onload?: ModEvent; } | { + type: "textarea"; ref?: string; + placeholder?: string; + onchange?: ModEvent; + onsubmit?: ModEvent; + } + | { + type: "select"; + options: Array<{ label: string; value: any }>; + ref?: string; + placeholder?: string; + isClearable?: boolean; + onchange?: ModEvent; + } + | { type: "input"; + ref?: string; placeholder?: string; - clearable?: boolean; + isClearable?: boolean; onchange?: ModEvent; onsubmit?: ModEvent; } @@ -200,16 +241,27 @@ export type ModElement = videoSrc: string; } | { - ref?: string; type: "tabs"; + ref?: string; values: string[]; names: string[]; onload?: ModEvent; onchange?: ModEvent; } - | ({ + | { + type: "combobox"; ref?: string; + isClearable?: boolean; + placeholder?: string; + optionsRef?: string; + valueRef?: string; + onload?: ModEvent; + onpick?: ModEvent; + onchange?: ModEvent; + } + | ({ type: "image-grid-list"; + ref?: string; onload?: ModEvent; onpick?: ModEvent; } & ( diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index e89d9ad1..1c058b46 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -12,6 +12,8 @@ import { Op, ConditionalFlow, EthTransactionData, + EthPersonalSignData, + HTTPAction, } from "./manifest"; import { Embed } from "./embeds"; @@ -41,6 +43,7 @@ export type ModElementRef = | { type: "button"; label: string; + loadingLabel?: string; variant?: "primary" | "secondary" | "destructive"; isLoading: boolean; isDisabled: boolean; @@ -66,6 +69,34 @@ export type ModElementRef = }; elements?: T[]; } + | { + type: "combobox"; + isClearable?: boolean; + placeholder?: string; + options: Array<{ label: string; value: any }> | null; + events: { + onLoad: () => void; + onChange: (input: string) => void; + onPick: (newValue: any) => void; + }; + } + | { + type: "textarea"; + placeholder?: string; + events: { + onChange: (input: string) => void; + onSubmit: (input: string) => void; + }; + } + | { + type: "select"; + isClearable: boolean; + placeholder?: string; + options: Array<{ label: string; value: any }>; + events: { + onChange: (input: string) => void; + }; + } | { type: "input"; isClearable: boolean; @@ -128,6 +159,11 @@ export type ModElementRef = export type CreationContext = { input: any; + user?: { + wallet?: { + address: string; + }; + }; embeds: Embed[]; /** The url of the api hosting the mini-app backends. (including /api) **/ api: string; @@ -235,6 +271,26 @@ export interface OpenLinkActionResolver { ): void; } +export type EthPersonalSignActionResolverInit = { + data: EthPersonalSignData; +}; + +export type EthPersonalSignActionResolverEvents = { + onSuccess: (data: { + signature: string; + signedMessage: string; + address: string; + }) => void; + onError(error: { message: string }): void; +}; + +export interface EthPersonalSignActionResolver { + ( + init: EthPersonalSignActionResolverInit, + events: EthPersonalSignActionResolverEvents + ): void; +} + export type SendEthTransactionActionResolverInit = { data: EthTransactionData; chainId: string; @@ -370,6 +426,7 @@ export type RendererOptions = { onSetInputAction: SetInputActionResolver; onAddEmbedAction: AddEmbedActionResolver; onOpenLinkAction: OpenLinkActionResolver; + onEthPersonalSignAction: EthPersonalSignActionResolver; onSendEthTransactionAction: SendEthTransactionActionResolver; onExitAction: ExitActionResolver; } & ( @@ -401,6 +458,7 @@ export class Renderer { private onAddEmbedAction: AddEmbedActionResolver; private onOpenLinkAction: OpenLinkActionResolver; private onSendEthTransactionAction: SendEthTransactionActionResolver; + private onEthPersonalSignAction: EthPersonalSignActionResolver; private onExitAction: ExitActionResolver; constructor(options: RendererOptions) { @@ -412,6 +470,7 @@ export class Renderer { this.onSetInputAction = options.onSetInputAction; this.onAddEmbedAction = options.onAddEmbedAction; this.onOpenLinkAction = options.onOpenLinkAction; + this.onEthPersonalSignAction = options.onEthPersonalSignAction; this.onSendEthTransactionAction = options.onSendEthTransactionAction; this.onExitAction = options.onExitAction; @@ -453,6 +512,10 @@ export class Renderer { this.onAddEmbedAction = resolver; } + setEthPersonalSignActionResolver(resolver: EthPersonalSignActionResolver) { + this.onEthPersonalSignAction = resolver; + } + setSendEthTransactionActionResolver( resolver: SendEthTransactionActionResolver ) { @@ -478,7 +541,59 @@ export class Renderer { refs: this.refs, }); } + private constructHttpAction(action: HTTPAction) { + const url = new URL(this.replaceInlineContext(action.url)); + // set query params + if (action.type === "GET" && action.searchParams) { + Object.entries(action.searchParams).forEach(([key, value]) => { + url.searchParams.set(key, this.replaceInlineContext(value)); + }); + } + let options: HttpActionResolverInit = { + url: url.toString(), + method: action.type, + }; + if ( + (action.type === "POST" || + action.type === "PUT" || + action.type === "PATCH") && + action.body + ) { + if ("json" in action.body) { + const interpretJson = (json: JsonType): any => { + if (json.type === "object") { + return mapValues(json.value, (val) => interpretJson(val)); + } + if (json.type === "array") { + return json.value.map((val) => interpretJson(val)); + } + if (json.type === "string") { + return this.replaceInlineContext(json.value); + } + return json.value; + }; + options.body = JSON.stringify(interpretJson(action.body.json)); + options.headers = { + "content-type": "application/json", + }; + } else { + const formData = new FormData(); + for (const name in action.body.formData) { + const l = action.body.formData[name]; + + if (l.type === "string") { + formData.append(name, this.replaceInlineContext(l.value)); + } else { + formData.append(name, get({ refs: this.refs }, l.value)); + } + } + + options.body = formData; + } + } + return options; + } private executeAction(action: ModAction) { switch (action.type) { case "GET": @@ -486,49 +601,7 @@ export class Renderer { case "PUT": case "PATCH": case "DELETE": { - let options: HttpActionResolverInit = { - url: this.replaceInlineContext(action.url), - method: action.type, - }; - if ( - (action.type === "POST" || - action.type === "PUT" || - action.type === "PATCH") && - action.body - ) { - if ("json" in action.body) { - const interpretJson = (json: JsonType): any => { - if (json.type === "object") { - return mapValues(json.value, (val) => interpretJson(val)); - } - if (json.type === "array") { - return json.value.map((val) => interpretJson(val)); - } - if (json.type === "string") { - return this.replaceInlineContext(json.value); - } - return json.value; - }; - options.body = JSON.stringify(interpretJson(action.body.json)); - options.headers = { - "content-type": "application/json", - }; - } else { - const formData = new FormData(); - - for (const name in action.body.formData) { - const l = action.body.formData[name]; - - if (l.type === "string") { - formData.append(name, this.replaceInlineContext(l.value)); - } else { - formData.append(name, get({ refs: this.refs }, l.value)); - } - } - - options.body = formData; - } - } + const options = this.constructHttpAction(action); if (action.ref) { set(this.refs, action.ref, { progress: 0 }); @@ -543,7 +616,6 @@ export class Renderer { if (this.asyncAction?.promise !== promise) { return; } - // CODE GOES HERE }, onSuccess: (response) => { resolve(); @@ -779,6 +851,73 @@ export class Renderer { }; break; } + case "web3.eth.personal.sign": { + const promise = new Promise((resolve) => { + setTimeout(() => { + this.onEthPersonalSignAction( + { + data: { + // domain: this.replaceInlineContext(action.data.domain), + // address: this.replaceInlineContext(action.data.address), + statement: this.replaceInlineContext(action.data.statement), + // uri: this.replaceInlineContext(action.data.uri), + version: this.replaceInlineContext(action.data.version), + chainId: this.replaceInlineContext(action.data.chainId), + }, + }, + { + onSuccess: ({ signature, signedMessage, address }) => { + resolve(); + + if (this.asyncAction?.promise !== promise) { + return; + } + + this.asyncAction = null; + + if (action.ref) { + set(this.refs, action.ref, { + signature, + signedMessage, + address, + }); + } + + if (action.onsuccess) { + this.stepIntoOrTriggerAction(action.onsuccess); + } + }, + onError: (error) => { + resolve(); + + if (this.asyncAction?.promise !== promise) { + return; + } + + if (action.ref) { + set(this.refs, action.ref, { error }); + } + + this.asyncAction = null; + + if (action.onerror) { + this.stepIntoOrTriggerAction(action.onerror); + } + + this.onTreeChange(); + }, + } + ); + }, 1); + }); + + this.asyncAction = { + promise, + ref: action, + }; + + break; + } case "SENDETHTRANSACTION": { const promise = new Promise((resolve) => { setTimeout(() => { @@ -1001,6 +1140,7 @@ export class Renderer { return fn( { type: "button", + loadingLabel: this.replaceInlineContext(el.loadingLabel ?? ""), label: this.replaceInlineContext(el.label), isLoading: this.asyncAction?.ref === el.onclick, isDisabled: Boolean(this.asyncAction), @@ -1060,11 +1200,113 @@ export class Renderer { key ); } + case "select": { + return fn( + { + type: "select", + isClearable: el.isClearable || false, + placeholder: el.placeholder + ? this.replaceInlineContext(el.placeholder) + : el.placeholder, + options: el.options.map( + ( + option + ): { + label: string; + value: any; + } => ({ + value: option.value, + label: this.replaceInlineContext(option.label), + }) + ), + events: { + onChange: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + }, + }, + key + ); + } + case "textarea": { + return fn( + { + type: "textarea", + placeholder: el.placeholder, + events: { + onChange: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + onSubmit: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + }, + }, + key + ); + } + case "combobox": { + const resolvedResults: Array<{ label: string; value: any }> | null = + isString(el.optionsRef) + ? get({ refs: this.refs }, el.optionsRef, null) + : null; + return fn( + { + type: "combobox", + isClearable: el.isClearable, + placeholder: this.replaceInlineContext(el.placeholder ?? ""), + options: resolvedResults, + events: { + onLoad: () => { + if (el.onload) { + this.stepIntoOrTriggerAction(el.onload); + } + }, + onChange: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + onPick: (value: any) => { + if (el.valueRef) { + set(this.refs, el.valueRef, { value }); + } + if (el.onpick) { + this.stepIntoOrTriggerAction(el.onpick); + } + }, + }, + }, + key + ); + } case "input": { return fn( { type: "input", - isClearable: el.clearable || false, + isClearable: el.isClearable || false, placeholder: el.placeholder, events: { onChange: (value: string) => { diff --git a/packages/miniapp-registry/src/index.ts b/packages/miniapp-registry/src/index.ts index 7300e2d5..8ed63840 100644 --- a/packages/miniapp-registry/src/index.ts +++ b/packages/miniapp-registry/src/index.ts @@ -9,12 +9,14 @@ import NFTMinter from "@miniapps/nft-minter"; import UrlRender from "@miniapps/url-render"; import ImageRender from "@miniapps/image-render"; import ChatGPTShorten from "@miniapps/chatgpt-shorten"; +// import ZoraNftMinter from "@miniapps/zora-nft-minter"; export const allMiniApps = [ InfuraIPFSUpload, LivepeerVideo, GiphyPicker, VideoRender, + // ZoraNftMinter, NFTMinter, ImageRender, ChatGPTShorten, diff --git a/packages/react-editor/package.json b/packages/react-editor/package.json index 8cf43732..9115107f 100644 --- a/packages/react-editor/package.json +++ b/packages/react-editor/package.json @@ -11,8 +11,8 @@ }, "dependencies": { "@tiptap/core": "^2.0.4", - "@mod-protocol/core": "^0.0.2", - "@mod-protocol/farcaster": "^0.0.1", + "@mod-protocol/core": "*", + "@mod-protocol/farcaster": "*", "@tiptap/extension-document": "^2.0.4", "@tiptap/extension-hard-break": "^2.0.4", "@tiptap/extension-history": "^2.0.4", diff --git a/packages/react-editor/src/create-editor-config.tsx b/packages/react-editor/src/create-editor-config.tsx index b294ccbe..f552f081 100644 --- a/packages/react-editor/src/create-editor-config.tsx +++ b/packages/react-editor/src/create-editor-config.tsx @@ -29,7 +29,7 @@ export function createEditorConfig({ editorProps: { attributes: { // min-height allows clicking in the box and creating focus on the input - // FIXME: configurable/options + // TODO: configurable/options style: "outline: 0; min-height: 200px;", }, // attributes: { diff --git a/packages/react-ui-shadcn/package.json b/packages/react-ui-shadcn/package.json index 7bd71088..abc529f6 100644 --- a/packages/react-ui-shadcn/package.json +++ b/packages/react-ui-shadcn/package.json @@ -8,10 +8,10 @@ "dev": "npm run build -- --watch" }, "dependencies": { - "@mod-protocol/core": "^0.0.2", - "@mod-protocol/farcaster": "^0.0.1", - "@mod-protocol/react": "^0.0.2", - "@mod-protocol/react-editor": "^0.0.2", + "@mod-protocol/core": "*", + "@mod-protocol/farcaster": "*", + "@mod-protocol/react": "*", + "@mod-protocol/react-editor": "*", "@primer/octicons-react": "^19.5.0", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", @@ -19,6 +19,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-popover": "^1.0.6", "@radix-ui/react-scroll-area": "^1.0.4", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "class-variance-authority": "^0.7.0", @@ -42,4 +43,4 @@ "tsconfig": "*", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/packages/react-ui-shadcn/src/components/channel-picker.tsx b/packages/react-ui-shadcn/src/components/channel-picker.tsx index d6ffd4b2..65b00180 100644 --- a/packages/react-ui-shadcn/src/components/channel-picker.tsx +++ b/packages/react-ui-shadcn/src/components/channel-picker.tsx @@ -23,7 +23,6 @@ type Props = { export function ChannelPicker(props: Props) { const { getChannels, onSelect } = props; - const [query, setQuery] = React.useState(""); const [open, setOpen] = React.useState(false); const [channelResults, setChannelResults] = React.useState( @@ -32,12 +31,12 @@ export function ChannelPicker(props: Props) { React.useEffect(() => { async function getChannelResults() { - const channels = await getChannels(query); + const channels = await getChannels(""); setChannelResults(channels); } getChannelResults(); - }, [query, setChannelResults, getChannels]); + }, [setChannelResults, getChannels]); const handleSelect = React.useCallback( (channel: Channel) => { diff --git a/packages/react-ui-shadcn/src/components/ui/select.tsx b/packages/react-ui-shadcn/src/components/ui/select.tsx new file mode 100644 index 00000000..5fbaa513 --- /dev/null +++ b/packages/react-ui-shadcn/src/components/ui/select.tsx @@ -0,0 +1,120 @@ +"use client"; + +import * as React from "react"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import * as SelectPrimitive from "@radix-ui/react-select"; + +import { cn } from "lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + {children} + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, +}; diff --git a/packages/react-ui-shadcn/src/components/ui/textarea.tsx b/packages/react-ui-shadcn/src/components/ui/textarea.tsx new file mode 100644 index 00000000..f45c5891 --- /dev/null +++ b/packages/react-ui-shadcn/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +