- Updated toolbar styles for improved layout and appearance, including padding and margin adjustments. - Introduced new styles for scale menu and compact dropdowns. - Enhanced map pin tooltips with better positioning and hover effects. - Added new styles for map sheet filters, including search and date range controls. - Improved editor styles for a more cohesive look and feel, including transport controls for video editing. - Expanded GeoLocation interface to include additional address fields and formatted address. - Updated MediaItem interface to include storage reference and uploadedBy fields. - Enhanced MediaComment interface with author details for better user identification. - Introduced GalleryUser interface to represent signed-in users in the host app.
@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 1000–1300 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 ofsave()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 overputBlob. It returns{ ref, url }: the store setsitem.src = url(renderable now, may be a short-lived signed URL) anditem.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.