feat: enhance styling and layout for toolbar, map components, and editor
- 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.
This commit is contained in:
@@ -13,6 +13,269 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user