forked from Goutam/lynkeduppro-crm
289 lines
16 KiB
Markdown
289 lines
16 KiB
Markdown
# @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.
|
||
|
||
```tsx
|
||
import { PhotoGallery } from '@photo-gallery/sdk';
|
||
import '@photo-gallery/sdk/styles.css';
|
||
|
||
export default () => <PhotoGallery photos={myPhotos} theme="system" />;
|
||
```
|
||
|
||
See the [repository README](../../README.md) 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.
|
||
|
||
```tsx
|
||
<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.
|
||
|
||
```tsx
|
||
<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,
|
||
}}
|
||
/>
|
||
```
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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
|
||
|
||
```bash
|
||
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.
|