Files
lynkeduppro-crm/vendor/photo-gallery-sdk
maaz519 904d003d32 feat(search): global conversation search in the topbar with deep-link nav
Topbar search box → debounced crm.search → dropdown of hits (chat/mail, highlighted
snippet). Clicking a hit switches to the right tab and focuses the exact thread:
mail → Inbox, chat → Messenger (via the SDK's new focusThreadId, 0.1.6). Snippet HTML
is escaped except the <em> highlight. Demo mode searches an in-memory set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:07:26 +05:30
..

@photo-gallery/sdk

A reusable, macOS Photos-style photo gallery for React / Next.js — light & dark, fully responsive, pluggable storage and AI. One component, no required CSS framework.

import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';

export default () => <PhotoGallery photos={myPhotos} theme="system" />;

See the repository README for full documentation, props, adapters and the AI-provider interface.

Embedding in a host app

By default <PhotoGallery> behaves like a standalone app: it owns the sidebar, the toolbar, the theme switcher and the global keyboard shortcuts, and it assumes a full-viewport parent. All of that is opt-out, so the gallery can be dropped into an existing product's shell instead.

<div style={{ height: 'calc(100vh - 64px)' }}>
  <PhotoGallery
    embedded
    adapter={dataDoorAdapter}
    currentUser={{ id: user.id, name: user.name, avatarUrl: user.avatar }}
    shareBaseUrl="https://app.example.com/gallery"
    chrome={{ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: false }}
    keyboardShortcuts={false}
    theme={hostTheme}
    themeTokens={hostTokens}
  />
</div>

New props

Prop Type Default What it does
embedded boolean false Lays out at height: 100% inside the host container instead of assuming a full-viewport parent, and switches interactive elements to cursor: pointer.
currentUser GalleryUser The host's signed-in user ({ id, name, email?, avatarUrl? }). Comments are stamped with this identity; the free-text author field disappears.
shareBaseUrl string ${location.origin}/gallery Base URL for generated share links (?shared=<token> is appended).
chrome Partial<GalleryChrome> DEFAULT_CHROME Suppress chrome the host already provides: { titlebar, sidebar, toolbar, themeSwitcher }.
hiddenViews ViewId[] [] Sidebar rows + Collections cards to hide, e.g. ['screenshots', 'sys:documents']. A section label drops when all its rows are hidden; if the active view becomes hidden the gallery falls back to library.
keyboardShortcuts boolean true Bind global shortcuts (⌘A, Delete, F, Esc, Enter) to window.
defaultFullscreen boolean false Start maximised over the host page.
lockProvider LockProvider Server-backed, per-user lock for Recently Deleted. Replaces the device-local localStorage hash entirely.

showWindowChrome still works and maps onto chrome.titlebar; an explicit chrome.titlebar wins. DEFAULT_CHROME is exported ({ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: true }).

Turning chrome.themeSwitcher off removes the Appearance items from the "More" menu, so a host that owns light/dark cannot be overridden from inside the gallery. The config is now live: changing theme, themeTokens, currentUser or chrome re-applies without remounting the gallery.

Full screen / maximise

The toolbar carries a maximise/restore button (right-hand group, next to Info). Turning it on adds apg--fullscreen to the root element, which goes position: fixed; inset: 0; z-index: 1400; height: 100dvh; border-radius: 0 — so the gallery fills the viewport no matter what height the host container gave it, and sits above the host's own chrome. Escape leaves full screen, but only once nothing else owns Escape: the lightbox, the photo/video editors, the camera, modals and context menus all get it first, and it still clears an object focus or a selection before it un-maximises. Escape is part of the shortcut set, so keyboardShortcuts={false} opts out of that too — the button always works.

The SDK's own overlays needed no change. They are position: fixed; inset: 0, which resolves against the viewport — and in full screen the gallery covers exactly the viewport, so they still land right. z-index: 1400 opens a stacking context, so their existing 10001300 z-indexes now stack inside it: above the gallery's content and, through 1400, above the host's chrome.

Headless control lives on the store: fullscreen, setFullscreen(next), toggleFullscreen(). defaultFullscreen only seeds the initial value; the user's toggle owns it afterwards.

Server-backed Recently Deleted lock

By default the Recently Deleted lock is a password hash in localStorage — device-local, and invisible to your backend. Pass a lockProvider and the SDK delegates every lock operation to it instead, so the lock belongs to the user and follows them across devices. The localStorage path is untouched when no provider is given.

<PhotoGallery
  embedded
  lockProvider={{
    status: () => call('crm.gallery.lock.status', {}),
    set: async (password) => { await call('crm.gallery.lock.set', { password }); },
    verify: async (password) => (await call('crm.gallery.lock.verify', { password })).ok,
  }}
/>
interface LockProvider {
  status(): Promise<{ hasPassword: boolean }>;
  set(password: string | null): Promise<void>; // null clears it
  verify(password: string): Promise<boolean>;
}

Drive your UI off lockConfigured: boolean (refreshed from status() during init(), and again if the provider arrives late), not lock.hash — that field is meaningless on the provider path. Failures are distinguished: a verify() that resolves false sets lockError: 'wrong-password' ("Incorrect password."), while one that rejects sets lockError: 'unavailable' ("Couldn't check the password. Please try again.") so the user retries instead of doubting what they typed. clearLockError() resets it; refreshLockStatus() re-reads status() on demand.

Theme tokens → CSS variables

Every value is optional. Tokens ending in Light/Dark are theme-paired (the matching one is applied for the resolved theme); bare names are theme-independent. Numbers are emitted as px. Values are rejected if they contain url(, expression(, javascript: or <>{}.

Token CSS variable Notes
accent --apg-accent Also overrides the accentColor prop.
accentStrongLight / accentStrongDark --apg-accent-strong Accent behind white text (AA contrast).
accentContrast --apg-accent-contrast Foreground on top of the accent.
dangerLight / dangerDark --apg-danger Destructive actions.
bgLight / bgDark --apg-bg + --apg-bg-content App / content background.
elevatedLight / elevatedDark --apg-bg-elevated Raised surfaces.
cardLight / cardDark --apg-card Collection cards.
cardHoverLight / cardHoverDark --apg-card-hover Card hover state.
sidebarBgLight / sidebarBgDark --apg-sidebar-bg Sidebar glass (semi-dark always uses the dark value).
toolbarBgLight / toolbarBgDark --apg-toolbar-bg Top toolbar glass.
menuBgLight / menuBgDark --apg-menu-bg Context menus, Info panel, popovers.
separatorLight / separatorDark --apg-separator Hairlines.
separatorStrongLight / separatorStrongDark --apg-separator-strong Input borders, scrollbars.
hoverLight / hoverDark --apg-hover Row / icon-button hover wash.
activeLight / activeDark --apg-active Pressed state.
sidebarSelectedLight / sidebarSelectedDark --apg-sidebar-selected Selected sidebar row.
textLight / textDark --apg-text Primary text.
textSecondaryLight / textSecondaryDark --apg-text-secondary Labels, captions.
textTertiaryLight / textTertiaryDark --apg-text-tertiary Hints, timestamps.
glassBorderLight / glassBorderDark --apg-glass-border Border on glass surfaces.
fontFamily --apg-font Font stack for the whole gallery.
sidebarRadius --apg-sidebar-radius px.
radiusMenu --apg-radius-menu px.
shadowSm --apg-shadow-sm
shadowMdLight / shadowMdDark --apg-shadow-md Menus, action bar.
shadowLgLight / shadowLgDark --apg-shadow-lg Modals.
tileFav --apg-tile-fav Favourite heart on a tile (default #ff3b30).
overlayBg --apg-overlay-bg Lightbox backdrop (default rgba(0,0,0,0.97)).
editorBg --apg-editor-bg Editor chrome (default #161617).
segmentedActive --apg-segmented-active Active segmented pill.
sidebarWidth --apg-sidebar-w px.
toolbarHeight --apg-toolbar-h px. Also drives --apg-overlay-top.

The Info panel is positioned at top: var(--apg-overlay-top, 64px) — set --apg-overlay-top via the style prop to push it below the host's own header. Full-screen overlays (lightbox, editor, camera, modals, context menus) stay position: fixed, which is correct for a modal over a host app.

Incremental storage adapters

StorageAdapter gained two optional members. Both are additive — existing save/putBlob adapters keep working unchanged.

  • applyChanges(changes: StateChanges) — when present the store calls this instead of save() on every (debounced, 400 ms) change, sending only what differs from the last persisted snapshot: { upsertMedia?, removeMedia?, upsertAlbums?, removeAlbums?, upsertPeople?, removePeople?, labelAliases?, deletedLabels? }. Overlapping writes are serialized, and a rejection rolls the snapshot back so the next persist retries the same diff. Only non-system albums are ever persisted.
  • putMedia(id, blob, { name, mime }): Promise<StoredBlob> — preferred over putBlob. It returns { ref, url }: the store sets item.src = url (renderable now, may be a short-lived signed URL) and item.storageRef = ref (the durable reference that survives a reload).

Worked example against a data-door backend:

import type { StorageAdapter, StateChanges, StoredBlob } from '@photo-gallery/sdk';

export function createDataDoorAdapter(call: (a: string, p: unknown) => Promise<any>): StorageAdapter {
  return {
    name: 'data-door',

    async load() {
      return await call('crm.gallery.state.load', {});
    },

    // Kept as a fallback for callers that don't use applyChanges.
    async save(state) {
      await call('crm.gallery.state.apply', {
        upsertMedia: state.media,
        upsertAlbums: state.albums,
        upsertPeople: state.people,
        labelAliases: state.labelAliases,
        deletedLabels: state.deletedLabels,
      } satisfies StateChanges);
    },

    async applyChanges(changes: StateChanges) {
      await call('crm.gallery.state.apply', changes);
    },

    async putMedia(id, blob, meta): Promise<StoredBlob> {
      const { ref, uploadUrl, method, headers } = await call('crm.gallery.media.presignUpload', {
        mediaId: id,
        mime: meta.mime,
        sizeBytes: blob.size,
        filename: meta.name,
      });
      await fetch(uploadUrl, { method, headers, body: blob });
      const { urls } = await call('crm.gallery.media.presignDownload', { refs: [ref] });
      return { ref, url: urls[ref] };
    },
  };
}

Identity-aware comments

With currentUser set, addComment(id, text) stamps { authorId, author, authorAvatar } from the configured user, the Info panel renders the real avatar/name instead of a name input (and stops using the apg:comment-author localStorage key), and delete affordances appear only on comments whose authorId matches. deleteComment is a client-side no-op for someone else's comment — the backend remains the authority. Without currentUser, behaviour is exactly as before.

Uploader & version identity

With currentUser set, imports (importFiles) and camera captures stamp MediaItem.uploadedBy (a GalleryUser) — a re-import never overwrites an existing value. Saving an edit (addVersion) or restoring one (restoreVersion) stamps { authorId, author, authorAvatar } onto the MediaVersion. The Info panel shows an "Uploaded by" block (avatar + name + muted email) and the author beside each version's timestamp; the Versions & Audit view shows the latest editor. All fields are optional — with no currentUser, nothing is stamped and the UI renders exactly as before. be-crm still owns owner_principal_id; uploadedBy is display identity only.

Editable caption & notes

The Info panel's caption and a new multi-line MediaItem.note are click-to-edit: they save on blur or Enter (⌘/Ctrl+Enter for the note) via updateMedia, so they persist through the adapter. note is folded into the search haystack, so notes are searchable.

Info panel media & comments

Video items render a real inline <video controls> preview (.apg-info__thumb--video) at the top of the panel instead of a frozen poster <img>, so a video is playable straight from Info. Comments render as cards (.apg-comment) with generous spacing around the "Uploaded by" block, the "Analyzing…" line and between comments, and a divider separates the thread from the compose form.

Full-address reverse geocoding

When a located item's Info panel opens and location.formatted isn't cached yet, the SDK reverse- geocodes the coordinates once via free OpenStreetMap Nominatim (addressdetails=1), parses the result into GeoLocation (road, neighbourhood, suburb, city, county, state, postcode, country, countryCode, formatted), and persists it with updateMedia so it survives a reload and isn't re-fetched. The panel shows the one-line address plus a City / State / Postcode / Country grid and the raw lat/lng; clicking the address opens the Map. A hardened host CSP must allow nominatim.openstreetmap.org in connect-src.

Floating toolbar

The top toolbar now floats as a rounded glass bar (margins on all sides, --apg-radius-lg, --apg-shadow-md) to match the sidebar, in embedded and full-screen alike. It stays in normal flow, so the viewport begins below it and scrolling is unaffected. The wide Years/Months/All-Photos segmented is replaced by a compact "All Photos ▾" dropdown (.apg-scalemenu, reusing the .apg-menu popover with its menu role, checkmarks and arrow-key nav) — this clears the zoom-out () button that the segmented used to overlap on narrow toolbars.

Map date-range control

The Map location sheet's filter replaces the two bare From/To date inputs with a single date range button (.apg-daterange) that opens a popover of quick presets (All dates, Last 7 days, Last 30 days, This year) plus a custom From/To pair. It drives the same underlying from/to state, so the AND-combine with search + object chips, the live "N of M" count, and Clear all keep working.

Map pins fly-to on click (flyTo, never zooming out), carry a per-location count badge and a hover mini-slider strip; the single-photo hover tooltip (.apg-pin__tip) is user-select:none and edge-clamped — MapView sets --tip-dx to slide it back inside the map and adds .apg-pin__tip--below to flip it under the pin near the top edge — so it can't overflow or get clipped.

Video editor transport

The video editor applies its rotate / flip / crop transform to the video frame only, never to a native <video controls> bar (which used to rotate with the frame and look broken). Playback is driven by a custom transport rendered outside the transformed element — play/pause, a scrubber and a time readout (.apg-vedit__transport / .apg-vedit__scrub / .apg-vedit__time).

Build

pnpm build   # → dist/ : index.js (ESM), index.cjs (CJS), index.d.ts, styles.css

The package exports source from src/ for zero-build use inside the monorepo (via transpilePackages), and dist/ is produced for external publishing.

MIT · Original implementation, not affiliated with Apple.