npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@inovusmedical/avatar

v0.4.0

Published

Shared avatar components and hooks for the Totum app ecosystem

Readme

@inovusmedical/avatar

React components and hooks for user avatars and a per-user address book in the Totum app ecosystem.

Uses the shared user_data Supabase project directly via Third-Party Auth with Cognito.

Installation

npm install @inovusmedical/avatar
# or
pnpm add @inovusmedical/avatar

Peer Dependencies

  • react ^18.0.0 || ^19.0.0
  • react-dom ^18.0.0 || ^19.0.0
  • @tanstack/react-query ^5.0.0
  • @supabase/supabase-js ^2.0.0

Quick Start

1. Add the Provider

Wrap your app with AvatarProvider:

import { AvatarProvider } from '@inovusmedical/avatar';
import '@inovusmedical/avatar/styles';
import { getAccessToken } from '@inovusmedical/unified-auth';

function App() {
  return (
    <AvatarProvider
      getAccessToken={getAccessToken}
    >
      <YourApp />
    </AvatarProvider>
  );
}

The supabaseUrl defaults to the user_data project. Only override if using a different Supabase instance.

2. Display Avatars

import { UserAvatar } from '@inovusmedical/avatar';

function UserCard({ userId, name }) {
  return (
    <div>
      <UserAvatar userId={userId} name={name} size="lg" />
      <span>{name}</span>
    </div>
  );
}

3. Upload Avatars

import { AvatarUploader } from '@inovusmedical/avatar';

function ProfileSettings() {
  return (
    <AvatarUploader
      onUploadSuccess={(url) => console.log('Uploaded:', url)}
      onUploadError={(error) => console.error('Error:', error)}
      allowWebcam={true}
      showRemoveButton={true}
    />
  );
}

Integration Guide for totum-live

Step 1: Install the package

pnpm add @inovusmedical/avatar @supabase/supabase-js

Step 2: No environment variable needed

Since 0.4.0 the package ships the user_data project's URL and public anon key (DEFAULT_SUPABASE_URL, DEFAULT_SUPABASE_ANON_KEY). Don't add a VITE_USER_DATA_ANON_KEY env var; only pass supabaseUrl/supabaseAnonKey to target a different project.

Step 3: Set up the provider

In your main App.tsx or layout component:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AvatarProvider } from '@inovusmedical/avatar';
import '@inovusmedical/avatar/styles';
import { getAccessToken } from '@inovusmedical/unified-auth';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AvatarProvider
          getAccessToken={getAccessToken}
      >
        {/* Your app */}
      </AvatarProvider>
    </QueryClientProvider>
  );
}

Step 4: Replace Header avatar

In your Header.tsx:

import { UserAvatar, AvatarMenu } from '@inovusmedical/avatar';
import { useLoggedInUser } from '@inovusmedical/unified-auth';

function Header() {
  const { user } = useLoggedInUser();

  return (
    <header>
      {/* Option A: Just the avatar */}
      <UserAvatar
        userId={user.sub}
        name={user.name}
        size="sm"
      />

      {/* Option B: Avatar with dropdown menu */}
      <AvatarMenu
        userId={user.sub}
        name={user.name}
        size="sm"
        menuItems={
          <>
            <a href="/profile">My Profile</a>
            <button onClick={logout}>Sign Out</button>
          </>
        }
      />
    </header>
  );
}

Step 5: Use batch lookups for lists

For chat panels, attendee lists, and people grids:

import { useAvatars, UserAvatar } from '@inovusmedical/avatar';

function ChatPanel({ messages }) {
  // Extract unique user IDs
  const userIds = [...new Set(messages.map(m => m.sender_guid))];
  
  // Batch fetch all avatars (single Supabase query)
  const { data: avatars } = useAvatars(userIds);

  return (
    <div>
      {messages.map(msg => (
        <div key={msg.id}>
          <UserAvatar
            userId={msg.sender_guid}
            name={msg.sender_name}
            size="sm"
          />
          <span>{msg.content}</span>
        </div>
      ))}
    </div>
  );
}

Step 6: Add profile page uploader

import { UserAvatar, AvatarUploader } from '@inovusmedical/avatar';
import { useLoggedInUser } from '@inovusmedical/unified-auth';

function ProfilePage() {
  const { user } = useLoggedInUser();
  const [showUploader, setShowUploader] = useState(false);

  return (
    <div>
      <UserAvatar userId={user.sub} name={user.name} size="xl" />
      
      <button onClick={() => setShowUploader(true)}>
        Change Photo
      </button>

      {showUploader && (
        <AvatarUploader
          onUploadSuccess={() => setShowUploader(false)}
          onRemoveSuccess={() => setShowUploader(false)}
        />
      )}
    </div>
  );
}

Address Book (0.2.0+)

Users can save multiple named addresses (e.g. Work, Personal, US Office), edit and delete them, and mark one as the default. Addresses live in the user_addresses table of the user_data project, keyed by the same legacy Totum userGuid as profile_images, and are only ever visible to their owner (RLS).

Prerequisite: migrations/003_user_addresses.sql and 004_user_addresses_anon_grants.sql must be applied to the user_data project (they are, in production). Without 003 every call fails with "The user_addresses table does not exist…"; without 004 every call fails with "Permission denied…", because Totum/Cognito tokens carry no role claim and Supabase runs the request as anon.

The address book reuses AvatarProvider — no extra provider is needed. It only fetches the token once auth is ready (see authReady), and it never fails silently: every failure is an AddressBookError that the components render inline and pass to onError.

Fields

| Column | Type | Notes | |--------|------|-------| | label | string | Required. Unique per user (case-insensitive). | | recipient_name | string | Required. | | company | string \| null | | | line1 | string | Required. | | line2 | string \| null | | | city | string | Required. | | region | string \| null | UK county (optional) / US state code (required, e.g. CA). | | postcode | string \| null | UK postcode / US ZIP (ZIP+4 ok). Required and format-checked for GB/US. | | country_code | string | ISO 3166-1 alpha-2, upper-cased (GB, US, …). | | phone | string \| null | | | is_default | boolean | At most one per user; the first address created becomes the default. |

<AddressBookModal>

Full management UI: list, add, edit, delete (with confirmation), set default.

import { AddressBookModal } from '@inovusmedical/avatar';

function ProfilePage() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Manage addresses</button>
      <AddressBookModal
        open={open}
        onClose={() => setOpen(false)}
        onError={(err) => toast.error(err.message)}
      />
    </>
  );
}

| Prop | Type | Default | Description | |------|------|---------|-------------| | open | boolean | — | Required. | | onClose | () => void | — | Required. Also called on Escape / backdrop click. | | title | string | 'Address book' | | | selectable | boolean | false | Adds a "Use this address" action to every card. | | onSelect | (address: UserAddress) => void | — | Called from the select action. | | selectLabel | string | 'Use this address' | | | closeOnSelect | boolean | true | | | initialView | 'list' \| 'create' | 'list' | Open straight onto the add form. | | defaultCountry | string | 'GB' | Pre-selected country for new addresses. | | countries | CountryOption[] | COUNTRIES | Countries offered in the dropdown. | | onError | (error: AddressBookError) => void | — | Every error the modal shows inline. | | onChange | (addresses: UserAddress[]) => void | — | After any successful mutation. | | enabled | boolean | true | Extra gate for the query (e.g. isAuthenticated). | | container | Element | document.body | Portal target. | | className | string | — | |

<AddressPicker>

Dropdown + formatted preview + "Manage addresses" button, for forms such as inventory checkout. Auto-selects the user's default (or first) address.

import { AddressPicker, toShippingAddressSnapshot, type UserAddress } from '@inovusmedical/avatar';

function CheckoutForm() {
  const [shipTo, setShipTo] = useState<UserAddress | null>(null);

  const submit = async () => {
    if (!shipTo) return;
    await createTransaction({
      // immutable copy stored on the transaction row (jsonb)
      shipping_address: toShippingAddressSnapshot(shipTo),
    });
  };

  return (
    <>
      <AddressPicker
        value={shipTo?.id ?? null}
        onChange={setShipTo}
        label="Ship to"
        required
        onError={(err) => toast.error(err.message)}
      />
      <button onClick={submit} disabled={!shipTo}>Check out</button>
    </>
  );
}

| Prop | Type | Default | Description | |------|------|---------|-------------| | value | string \| null | — | Selected address id. | | onChange | (address: UserAddress \| null) => void | — | Required. Receives the full row (or null if cleared / deleted elsewhere). | | label | string | 'Shipping address' | | | hideLabel | boolean | false | Visually hide the label. | | placeholder | string | 'Select an address' | | | autoSelectDefault | boolean | true | Select default/first address when nothing is selected. | | allowManage | boolean | true | Show "Manage addresses" / "Add address" (opens AddressBookModal). | | manageLabel / addLabel | string | | Button labels. | | showPreview | boolean | true | Render the formatted address under the select. | | defaultCountry, countries, enabled, onError | | | As for AddressBookModal. | | required, disabled, id, name, className | | | Passed to the <select> / wrapper. |

<AddressForm>

The country-aware form used by the modal, exported for custom layouts. Labels switch between County / Postcode (GB) and State / ZIP code (US, with a state dropdown); validation runs client-side before onSubmit is called with a normalised UserAddressInput.

Hooks

useAddressBook(options?)

One hook with everything:

const {
  addresses,        // UserAddress[] – default first, then by label
  defaultAddress,   // UserAddress | null
  isLoading, isFetching, error, refetch,
  createAddress,    // (input) => Promise<UserAddress>
  updateAddress,    // (id, patch) => Promise<UserAddress>
  deleteAddress,    // (id) => Promise<void>
  setDefault,       // (id) => Promise<UserAddress>
  getDefault,       // () => Promise<UserAddress | null>  (bypasses cache)
  isMutating, mutationError, clearError,
  api,              // AddressBookApi bound to the provider config
} = useAddressBook({ enabled: isAuthenticated });

error and mutationError are AddressBookError | null. Mutations also throw the same error, so await createAddress(...) can be wrapped in try/catch.

Individual hooks are available too: useAddresses, useDefaultAddress, useCreateAddress, useUpdateAddress, useDeleteAddress, useSetDefaultAddress.

If you keep one QueryClient across sign-in/sign-out, clear the cache on sign-out:

queryClient.removeQueries({ queryKey: ADDRESS_BOOK_QUERY_KEY });

Headless API (no React)

import { createAddressBookApi } from '@inovusmedical/avatar';

const api = createAddressBookApi({ getAccessToken });
const addresses = await api.list();
const shipTo = await api.getDefault();          // null if none
const created = await api.create({ label: 'Work', recipient_name: 'Ada', line1: '…', city: '…', postcode: 'WA9 1AB', country_code: 'GB' });
await api.update(created.id, { phone: '+44 …' });
await api.setDefault(created.id);
await api.remove(created.id);

Each call fetches the token once via getAccessToken, derives the user key from its sub claim and talks to Supabase directly. Lower-level functions (listAddresses, createAddress, … taking a SupabaseClient + userGuid) are exported for advanced use.

Errors

import { AddressBookError } from '@inovusmedical/avatar';

try {
  await api.create(input);
} catch (e) {
  if (AddressBookError.isAddressBookError(e)) {
    switch (e.code) {
      case 'NOT_AUTHENTICATED': // no token – sign in first
      case 'INVALID_TOKEN':     // token has no `sub`
      case 'VALIDATION':        // e.fieldErrors: { postcode: 'Enter a valid postcode…' }
      case 'DUPLICATE_LABEL':   // label already used by this user
      case 'NOT_FOUND':         // row missing or not owned by the user
      case 'SUPABASE':          // anything else, message includes the Supabase error
    }
  }
}

Formatting helpers

import { formatAddressLines, formatAddressOneLine, getAddressFormat, COUNTRIES, US_STATES } from '@inovusmedical/avatar';

formatAddressLines(ukAddress);
// ['Ada Lovelace', 'Inovus Medical', 'Unit 1', 'St Helens', 'Merseyside', 'WA9 1AB', 'United Kingdom']
formatAddressLines(usAddress);
// ['Grace Hopper', '1 Infinite Loop', 'Cupertino, CA 95014', 'United States']
formatAddressOneLine(usAddress);        // '1 Infinite Loop, Cupertino, CA 95014'
getAddressFormat('US').postcodeLabel;   // 'ZIP code'

authReady

AvatarProvider accepts an optional authReady prop (default true). While it is false the address queries stay idle instead of calling getAccessToken and reporting "not signed in" during auth bootstrap:

const { isLoading: authLoading } = useLoggedInUser();

<AvatarProvider getAccessToken={getAccessToken} authReady={!authLoading}>

Avatar lookups are unaffected by authReady.

Components

<AvatarProvider>

Context provider that configures the avatar service.

| Prop | Type | Required | Description | |------|------|----------|-------------| | supabaseUrl | string | No | Supabase project URL (defaults to user_data) | | supabaseAnonKey | string | No | Supabase anon/public key. Defaults to the user_data project's public anon key (DEFAULT_SUPABASE_ANON_KEY), so apps don't need a VITE_USER_DATA_ANON_KEY env var. | | getAccessToken | () => Promise<string \| null> | Yes | Function to get Cognito token | | authReady | boolean | No | Gate for address-book queries (default: true). See Address Book | | batchDelay | number | No | Debounce delay for batch requests (default: 50ms) |

<UserAvatar>

Displays a user's avatar with automatic batched lookups.

| Prop | Type | Required | Description | |------|------|----------|-------------| | userId | string | Yes | User's Cognito sub (user_guid) | | size | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| number | No | Avatar size (default: 'md') | | name | string | No | User's name for initials fallback | | fallback | 'initials' \| 'icon' \| ReactNode | No | Fallback when no avatar (default: 'initials') | | className | string | No | Additional CSS class | | onClick | () => void | No | Click handler (makes avatar interactive) |

<AvatarUploader>

Upload interface with file picker, webcam capture, and cropping.

| Prop | Type | Required | Description | |------|------|----------|-------------| | onUploadSuccess | (url: string) => void | No | Called after successful upload | | onUploadError | (error: Error) => void | No | Called on upload error | | onRemoveSuccess | () => void | No | Called after successful removal | | onRemoveError | (error: Error) => void | No | Called on removal error | | showRemoveButton | boolean | No | Show remove photo button (default: true) | | allowWebcam | boolean | No | Enable webcam capture (default: true) | | cropShape | 'round' \| 'rect' | No | Crop area shape (default: 'round') | | maxFileSizeMB | number | No | Max file size in MB (default: 10) | | className | string | No | Additional CSS class |

<AvatarMenu>

Drop-in header component with avatar and change photo dropdown.

| Prop | Type | Required | Description | |------|------|----------|-------------| | userId | string | Yes | User's Cognito sub | | name | string | No | User's name | | size | AvatarSize | No | Avatar size (default: 'md') | | menuItems | ReactNode | No | Additional menu items | | className | string | No | Additional CSS class |

Hooks

useAvatar(userId)

Fetch a single user's avatar URL.

const { data: url, isLoading, error } = useAvatar('user-id');

useAvatars(userIds)

Fetch multiple avatar URLs in one batched Supabase query.

const { data: avatarMap, isLoading } = useAvatars(['user1', 'user2', 'user3']);
// avatarMap is Map<string, string | null>

useCurrentUserId()

Get the current user's ID from their Cognito token.

const { data: userId } = useCurrentUserId();

useUploadAvatar()

Upload a new avatar.

const { mutateAsync: upload, isPending } = useUploadAvatar();

const blob = await cropAndResizeImage(imageSrc, cropArea);
const result = await upload(blob);
console.log('New URL:', result.url);

useDeleteAvatar()

Delete the current user's avatar.

const { mutateAsync: deleteAvatar, isPending } = useDeleteAvatar();

await deleteAvatar();

useDeleteAvatarAdmin()

Admin: delete any user's avatar (requires Admin Cognito group).

const { mutateAsync: adminDelete } = useDeleteAvatarAdmin();

await adminDelete('target-user-id');

Styling

Import the default styles:

import '@inovusmedical/avatar/styles';

Address book theme (Inovus design system)

AddressBookModal, AddressPicker and AddressForm are styled with the Inovus design-system tokens: Navy #141729 text, Inovus Blue #00BBFF primary actions (with navy text for contrast), Grey 100 #E9EBEE borders/dividers/secondary buttons, 8px radius on inputs and cards, 16px on the modal panel, 36px pill buttons with Medium 14px text, 11px uppercase grey field labels, a visible blue focus ring, and the navy-tinted overlay shadow. All of it is scoped to the .inovus-address-* classes, so host styles are not touched.

Font. The styles reference Spartan MB by name with a system fallback ('SpartanMB', system-ui, …); they do not load it. Either your app already loads Spartan MB, or import the optional font bundle once:

import '@inovusmedical/avatar/fonts'; // Spartan MB Regular/Medium/Bold (woff2, ~57 KB)

Overrides. Every token has a public CSS custom property you can set on :root (or any ancestor of the component / document.body for the portalled modal):

:root {
  --inovus-address-font-family: 'Inter', system-ui, sans-serif;
  --inovus-address-color-blue: #00bbff;
  --inovus-address-color-blue-light: #7bc4eb;
  --inovus-address-color-navy: #141729;
  --inovus-address-color-grey-50: #f7f9fa;   /* …-grey-100 … -grey-600 */
  --inovus-address-color-error: #d40c1a;
  --inovus-address-radius-sm: 4px;
  --inovus-address-radius-md: 8px;
  --inovus-address-radius-lg: 16px;
  --inovus-address-shadow-hover: 0 12px 32px rgba(20, 23, 41, 0.14);
  --inovus-address-shadow-overlay: 0 24px 56px rgba(20, 23, 41, 0.22);
}

Tailwind Integration

The components use BEM-style classes that won't conflict with Tailwind:

<UserAvatar
  userId={user.sub}
  className="ring-2 ring-blue-500"
/>

Architecture

┌─────────────────┐     ┌─────────────────────────┐
│  Totum Apps     │     │  user_data Supabase     │
│  (React)        │────▶│  - profile_images table │
│                 │     │  - user_addresses table │
│  @inovusmedical │     │  - profile-images bucket│
│  /avatar        │     │  - Third-Party Auth     │
│                 │     │    (Cognito)            │
└─────────────────┘     └─────────────────────────┘
        │
        ▼
┌─────────────────┐
│  unified-auth   │
│  (Cognito JWT)  │
└─────────────────┘

The package connects directly to the user_data Supabase project using the Cognito JWT from unified-auth. No separate backend service is needed.

Backwards Compatibility

This package is compatible with existing widgets-dashboard avatars:

  • Reads from the same profile_images table
  • Uses the same profile-images storage bucket
  • Existing image paths continue to work
  • New uploads use versioned paths to fix stale-cache issues

Stale Cache Fix

New uploads use versioned file paths ({user_guid}/{timestamp}-{random}.webp) instead of overwriting the same file. This ensures browsers and CDNs always show the latest image.

Version

0.3.0 — see CHANGELOG.