Pine Documentation
WebsiteChangelog
  • Overview
  • Basics
    • Create an Integration
    • Install an Integration
    • Setup OAuth Authentication
  • Backend
    • Server Library
    • Best Practices
  • Frontend
    • Tutorial
    • Client Library
    • Best Practices
Powered by GitBook
On this page
  • Create a TypeScript app with Vite
  • Coordinate app state with @pinecards/client
  • Compose the interface with @radix-ui/themes
  • Preview your development app in Pine
  • Bundle and upload your app to production
  • Reference Code
  1. Frontend

Tutorial

Create a frontend Pine extension from scratch.

PreviousBest PracticesNextClient Library

Last updated 8 months ago

Extensions are sandboxed, client-side applications that extend Pine's capabilities. Extensions can be used to display information from third-party tools directly within the Pine interface.

Pine's platform supports standard web technologies, meaning that creating extensions only requires knowledge of JavaScript, HTML, and CSS. The only limitation is that all of your code must be self-contained and bundled into a single index.html file.

Pine runs extensions within a sandboxed iframe. This means that functionality such as intrusive popups and access to the main Pine window are disallowed. Communication with Pine is handled via IPC messages, which you can learn about in the documentation.

In this tutorial, we'll create a Pine extension from scratch. The extension will feature multiple routes and will simply display the ID of the currently selected card.

Before getting started, please ensure that you meet the following prerequisites:

Create a TypeScript app with Vite

Using your terminal, run the following to create your project:

npm create vite@latest pine-extension-tutorial -- --template react-ts

Once complete, navigate to your project and install the dependencies for two additional libraries:

cd pine-extension-tutorial
npm install @pinecards/client @radix-ui/themes
  • @radix-ui/themes provides fully-styled components for building the application (Pine uses this library internally, so this will ensure consistency with the main interface).

To start with a clean slate, let's delete the existing App.css file and replace the contents of the App.tsx file with the following:

App.tsx
function App() {
  return <div>todo</div>;
}

export default App;

We'll also replace the contents of the index.css file with just the following:

html {
  color-scheme: normal !important;
}

body {
  margin: 0;
}

.radix-themes {
  background: transparent;
}

You can then start your application in development using:

npm run dev

Coordinate app state with @pinecards/client

At the top of your App.tsx file, import the following:

App.tsx
import { connectSDKClient, Theme, Route } from "@pinecards/client";
import { useCallback, useEffect, useState } from "react";
App.tsx
const client = connectSDKClient();

It's important to do this early because the client will establish a secure channel with Pine as soon as it is initialized.

Next, we'll define what our application state should look like:

App.tsx
interface AppState {
  path: "overview" | "card";
  theme: Theme;
  result: string | null;
}
  • The path will be used to navigate between our extension's routes.

  • The theme property will be used to keep the application theme in sync with the theme data received from Pine.

  • The result property will be used to store the card identifier that we extract whenever Pine changes routes.

We'll hook this up inside of the App component by using the previously imported useState hook to define the state and the useCallback hook to allow for partial state updates:

App.tsx
function App() {
  const [state, setState] = useState<AppState>({
    path: "overview",
    theme: { mode: "light", color: "gold" },
    result: null,
  });

  const onStateChange = useCallback((partialState: Partial<AppState>) => {
    setState((state) => ({ ...state, ...partialState }));
  }, []);

  return <div>todo</div>;
}

Next, we'll create a useEffect block and use the client to communicate with Pine to get the current theme data and listen for future changes.

App.tsx
useEffect(() => {
  client.context
    .getTheme()
    .then((theme) => onStateChange({ theme }))
    .catch(() => {
      // handle error
    });

  const promise = client.context.on("themeChange", (event) => {
    onStateChange({ theme: event.data });
  });

  return () => {
    promise.then((unsubscribe) => unsubscribe());
  };
}, [onStateChange]);

It's important to add a catch handler when sending/requesting data from Pine.

We'll create a separate useEffect block to listen for route changes.

Instead of returning a URL that needs to be manually parsed, Pine returns the path template and the params that were used to populate that template.

We can take advantage of this by implementing a switch statement that handles paths that involve card identifiers, and assign the route.params.id to our result.

App.tsx
useEffect(() => {
  const promise = client.routes.on("routeChange", async (event) => {
    const route = event.data;
    switch (route.path) {
      case "/dates/:dateId/cards/:id":
      case "/decks/:deckId/cards/:id":
      case "/home/cards/:id":
      case "/inbox/cards/:id":
      case "/review/cards/:id": {
        onStateChange({ result: route.params.id });
        break;
      }
      default:
        onStateChange({ result: null });
    }
  });

  return () => {
    promise.then((unsubscribe) => unsubscribe());
  };
}, [onStateChange]);

Compose the interface with @radix-ui/themes

Let's import the Radix CSS file and the necessary components that we'll need:

App.tsx
import "@radix-ui/themes/styles.css";
import {
  Button,
  Callout,
  Flex,
  Theme as ThemeProvider,
} from "@radix-ui/themes";

Next, we'll render the ThemeProvider and pass through the following properties:

  • scaling, which we'll set to a value of "90%" to ensure that the default Radix components are appropriately scaled to the rest of the interface.

  • appearance, which will be assigned state.theme.mode to keep the user's theme preferences in sync with the extensions.

  • accentColor, which will be assigned state.theme.color to keep the user's color preferences in sync with the extension.

App.tsx
  return (
    <ThemeProvider
      scaling="90%"
      appearance={state.theme.mode}
      accentColor={state.theme.color}
    >
      
    </ThemeProvider>
  );
App.tsx
return (
  <ThemeProvider
    scaling="90%"
    appearance={state.theme.mode}
    accentColor={state.theme.color}
  >
    {state.path === "overview" ? (
      <Button onClick={() => onStateChange({ path: "card" })}>
        Start card tracking
      </Button>
    ) : (
      <Flex direction="column" align="start" gap="3">
        <Button
          variant="ghost"
          onClick={() => onStateChange({ path: "overview" })}
        >
          &#x2190; Back to overview
        </Button>
        <Callout.Root variant="surface">
          <Callout.Text>
            {state.result
              ? `The card ID is ${state.result}`
              : "Navigate to a card to see its ID"}
          </Callout.Text>
        </Callout.Root>
      </Flex>
    )}
  </ThemeProvider>
);

Preview your development app in Pine

While we've been previewing our changes in the web browser, it's also possible to test our extensions inside of Pine against workspace data.

We strongly recommend creating a separate workspace for previewing extensions to avoid accidentally messing up your own data.

At the bottom of this view, enter the URL of your local development server to preview your application inside the sidebar.

If you encounter issues while running your app, you can click on the vertical ellipsis again to reveal options for reloading and removing the preview extension.

Bundle and upload your app to production

npm install vite-plugin-singlefile -D

Once installed, update your vite.config.ts and include viteSingleFile as a plugin:

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [react(), viteSingleFile()],
});

You can then finally bundle your project into a single HTML file by running the following:

npm run build

Next, we'll upload the bundled dist/index.html file to Pine.

Congratulations! You have now made a simple extension that displays the card ID of any currently selected card! You now have the foundations to make complex projects extending Pine.

Reference Code

main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);
index.css
html {
  color-scheme: normal !important;
}

body {
  margin: 0;
}

.radix-themes {
  background: transparent;
}
App.tsx
import "@radix-ui/themes/styles.css";
import {
  Button,
  Callout,
  Flex,
  Theme as ThemeProvider,
} from "@radix-ui/themes";
import { connectSDKClient, Theme } from "@pinecards/client";
import { useCallback, useEffect, useState } from "react";

const client = connectSDKClient();

interface AppState {
  path: "overview" | "card";
  theme: Theme;
  result: string | null;
}

function App() {
  const [state, setState] = useState<AppState>({
    path: "overview",
    theme: { mode: "light", color: "gold" },
    result: null,
  });

  const onStateChange = useCallback((partialState: Partial<AppState>) => {
    setState((state) => ({ ...state, ...partialState }));
  }, []);

  useEffect(() => {
    client.context
      .getTheme()
      .then((theme) => onStateChange({ theme }))
      .catch(() => {
        // handle error
      });

    const promise = client.context.on("themeChange", (event) => {
      onStateChange({ theme: event.data });
    });

    return () => {
      promise.then((unsubscribe) => unsubscribe());
    };
  }, [onStateChange]);

  useEffect(() => {
    const promise = client.routes.on("routeChange", async (event) => {
      const route = event.data;
      switch (route.path) {
        case "/dates/:dateId/cards/:id":
        case "/decks/:deckId/cards/:id":
        case "/home/cards/:id":
        case "/inbox/cards/:id":
        case "/review/cards/:id": {
          onStateChange({ result: route.params.id });
          break;
        }
        default:
          onStateChange({ result: null });
      }
    });

    return () => {
      promise.then((unsubscribe) => unsubscribe());
    };
  }, [onStateChange]);

return (
  <ThemeProvider
    scaling="90%"
    appearance={state.theme.mode}
    accentColor={state.theme.color}
  >
    {state.path === "overview" ? (
      <Button onClick={() => onStateChange({ path: "card" })}>
        Start card tracking
      </Button>
    ) : (
      <Flex direction="column" align="start" gap="3">
        <Button
          variant="ghost"
          onClick={() => onStateChange({ path: "overview" })}
        >
          &#x2190; Back to overview
        </Button>
        <Callout.Root variant="surface">
          <Callout.Text>
            {state.result
              ? `The card ID is ${state.result}`
              : "Navigate to a card to see its ID"}
          </Callout.Text>
        </Callout.Root>
      </Flex>
    )}
  </ThemeProvider>
);
}

export default App;
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [react(), viteSingleFile()],
});

You have the latest stable version installed.

You have the latest stable version of installed.

You are familiar with and .

@pinecards/client contains logic for communicating with Pine via .

We'll want to execute connectSDKClient as soon as possible in the global scope of the application. Calling this function will create a client that we can use to communicate with Pine.

By default, Pine will throw an IPCError if a response hasn't been received within 5 seconds (due to a possible failed connection). You can learn more in .

From this point, you can freely use the components described in Radix's . For example, we'll add a Button for navigating between routes and we'll use the Callout component to display the Card ID that we retrieved in our useEffect block.

To preview an extension, navigate to and click the sidebar icon in the top-right corner to open the extensions sidebar. Then click the vertical ellipsis next to the integrations title to reveal a dropdown menu. Select the preview integration option.

Once you're satisfied with how your extension behaves, we'll now bundle into a single HTML file that is suitable for production. We'll use the plugin to take care of this. Install it in your project:

To upload the index.html file contained with the dist directory, navigate to the integration settings in Pine (if you haven't created an integration, follow the steps outlined ). Navigate to the bottom of the integration page until you reach the extensions section. Under sidebar extension, click the upload button and select your index.html file.

The bundled index.html file should not exceed 2 megabytes. Learn more in .

Click the create/update button at the bottom of the page to finalize your upload. Once you have installed the integration (outlined ), it will appear in your Pine's integrations sidebar.

Node.js
NPM
React
TypeScript
IPC messaging
singleton
best practices
website
Pine
vite-plugin-singlefile
here
best practices
here
library