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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Album, MediaItem, Person } from '../types';
|
||||
import type { Album, AlbumId, MediaId, MediaItem, Person, PersonId } from '../types';
|
||||
|
||||
export interface PersistedState {
|
||||
media: MediaItem[];
|
||||
@@ -13,6 +13,42 @@ export interface PersistedState {
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An incremental diff of the library, produced by the store on every persist.
|
||||
*
|
||||
* Only the entities that actually changed since the previous successful persist
|
||||
* are included: an entity appears in `upsert*` when it is new or its object
|
||||
* reference changed, and in `remove*` when it disappeared from state. The label
|
||||
* maps are included only when they differ from the last persisted values.
|
||||
* Every field is optional — an empty object means "nothing changed".
|
||||
*/
|
||||
export interface StateChanges {
|
||||
/** Media items that were created or modified since the last persist. */
|
||||
upsertMedia?: MediaItem[];
|
||||
/** Ids of media items that no longer exist (permanently deleted). */
|
||||
removeMedia?: MediaId[];
|
||||
/** User albums (never system albums) created or modified since the last persist. */
|
||||
upsertAlbums?: Album[];
|
||||
/** Ids of user albums that no longer exist. */
|
||||
removeAlbums?: AlbumId[];
|
||||
/** People/pet clusters created or modified since the last persist. */
|
||||
upsertPeople?: Person[];
|
||||
/** Ids of people/pet clusters that no longer exist. */
|
||||
removePeople?: PersonId[];
|
||||
/** Whole label-alias map — sent only when it changed (shallow compare). */
|
||||
labelAliases?: Record<string, string>;
|
||||
/** Whole deleted-label list — sent only when it changed (shallow compare). */
|
||||
deletedLabels?: string[];
|
||||
}
|
||||
|
||||
/** The result of a durable byte upload: an adapter-owned ref plus a readable URL. */
|
||||
export interface StoredBlob {
|
||||
/** Durable, adapter-owned reference for the bytes. Survives reloads. */
|
||||
ref: string;
|
||||
/** A URL the browser can render right now (may be a short-lived signed URL). */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage adapter contract. Implement this to back the gallery with anything:
|
||||
* localStorage (default), IndexedDB, a REST/GraphQL API, S3, Postgres, etc.
|
||||
@@ -28,4 +64,21 @@ export interface StorageAdapter {
|
||||
/** Persist a binary blob and return a stable URL to it. */
|
||||
putBlob?(id: string, blob: Blob): Promise<string>;
|
||||
clear?(): Promise<void>;
|
||||
/**
|
||||
* Incremental persistence. When present the store calls this INSTEAD of
|
||||
* `save()` on every (debounced) change, passing only the entities that
|
||||
* differ from the previously persisted snapshot. Implement it when the
|
||||
* backend can apply partial writes — it avoids re-uploading the whole
|
||||
* library on every favourite toggle. If this rejects, the store rolls its
|
||||
* snapshot back so the next persist retries the same changes.
|
||||
*/
|
||||
applyChanges?(changes: StateChanges): Promise<void>;
|
||||
/**
|
||||
* Durable byte storage. When present it is preferred over `putBlob()` for
|
||||
* imports and camera captures: the store sets `item.src = url` (renderable
|
||||
* now) and `item.storageRef = ref` (what survives a reload, so the host can
|
||||
* re-sign the URL later). Falls back to `putBlob()` and finally to an
|
||||
* inlined `data:` URL when absent.
|
||||
*/
|
||||
putMedia?(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob>;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,22 @@ import { ViewRouter } from './ViewRouter';
|
||||
export function AppShell() {
|
||||
const api = useGalleryStoreApi();
|
||||
const sidebarOpen = useGallery((s) => s.sidebarOpen);
|
||||
const showSidebar = useGallery((s) => s.config.chrome.sidebar);
|
||||
const showToolbar = useGallery((s) => s.config.chrome.toolbar);
|
||||
const shortcutsEnabled = useGallery((s) => s.config.keyboardShortcuts);
|
||||
const view = useGallery((s) => s.view);
|
||||
const hiddenViews = useGallery((s) => s.config.hiddenViews);
|
||||
const isMobile = useIsMobile();
|
||||
useKeyboardShortcuts();
|
||||
|
||||
// If the active view becomes hidden (host toggled `hiddenViews`, or a deep link
|
||||
// landed on one), fall back to the Library instead of rendering a dead view.
|
||||
useEffect(() => {
|
||||
if (hiddenViews && hiddenViews.includes(view) && view !== 'library') {
|
||||
api.getState().setView('library');
|
||||
}
|
||||
}, [api, view, hiddenViews]);
|
||||
// The hook is always called (stable hook order); it binds nothing when disabled.
|
||||
useKeyboardShortcuts(shortcutsEnabled);
|
||||
|
||||
// Collapse the sidebar by default on phones; expand on larger screens.
|
||||
const lastMobile = useRef<boolean | null>(null);
|
||||
@@ -26,15 +40,17 @@ export function AppShell() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<div
|
||||
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => api.getState().setSidebar(false)}
|
||||
/>
|
||||
{showSidebar ? <Sidebar /> : null}
|
||||
{showSidebar ? (
|
||||
<div
|
||||
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => api.getState().setSidebar(false)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="apg-main">
|
||||
<TopToolbar />
|
||||
{showToolbar ? <TopToolbar /> : null}
|
||||
<div className="apg-viewport">
|
||||
<ViewRouter />
|
||||
<SelectionBar />
|
||||
|
||||
@@ -211,6 +211,8 @@ export function Camera() {
|
||||
duration,
|
||||
location,
|
||||
exif,
|
||||
// Stamp who captured this (display identity) when the host supplies a user.
|
||||
uploadedBy: api.getState().config.currentUser,
|
||||
edits: annotations.length ? { adjustments: {}, annotations } : undefined,
|
||||
});
|
||||
api.getState().addMedia([item]);
|
||||
|
||||
@@ -47,6 +47,11 @@ export function closeContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a context menu is open (it owns Escape while it is). */
|
||||
export function isContextMenuOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { sourceLabel } from '../lib/classify';
|
||||
import { editFilterCss, editTransformCss } from '../lib/edits';
|
||||
@@ -9,7 +9,7 @@ import { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from
|
||||
import { Icon } from '../icons';
|
||||
import { useAIProvider } from './aiContext';
|
||||
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||||
import type { MediaItem } from '../types';
|
||||
import type { GalleryUser, GeoLocation, MediaItem } from '../types';
|
||||
|
||||
export function InfoPanel() {
|
||||
const api = useGalleryStoreApi();
|
||||
@@ -45,12 +45,44 @@ export function InfoPanel() {
|
||||
<div className="apg-info__empty">Select a single photo to see its details.</div>
|
||||
) : (
|
||||
<div className="apg-info__body">
|
||||
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
|
||||
{item.kind === 'video' ? (
|
||||
// Videos get a real, playable preview at the top of the panel — the same
|
||||
// affordance images have, not a frozen poster frame.
|
||||
<video
|
||||
className="apg-info__thumb apg-info__thumb--video"
|
||||
src={item.src}
|
||||
poster={item.poster ?? item.thumbnail}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
|
||||
)}
|
||||
<div className="apg-info__name">{item.name}</div>
|
||||
<div className="apg-info__sub">
|
||||
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
|
||||
</div>
|
||||
|
||||
{/* Editable caption (single line) + note (multi-line) — persisted via updateMedia. */}
|
||||
<EditableField
|
||||
key={`cap-${item.id}`}
|
||||
label="Caption"
|
||||
value={item.caption}
|
||||
placeholder="Add a caption…"
|
||||
onSave={(v) => api.getState().updateMedia(item.id, { caption: v })}
|
||||
/>
|
||||
<EditableField
|
||||
key={`note-${item.id}`}
|
||||
label="Note"
|
||||
value={item.note}
|
||||
placeholder="Add a note…"
|
||||
multiline
|
||||
onSave={(v) => api.getState().updateMedia(item.id, { note: v })}
|
||||
/>
|
||||
|
||||
{item.uploadedBy ? <UploadedBy user={item.uploadedBy} /> : null}
|
||||
|
||||
{item.kind === 'image' && !item.analyzedAt ? (
|
||||
<div className="apg-info__analyzing">
|
||||
<span className="apg-info__spinner" aria-hidden />
|
||||
@@ -114,21 +146,18 @@ export function InfoPanel() {
|
||||
<Row
|
||||
label="Location"
|
||||
value={
|
||||
item.location.formatted ??
|
||||
item.location.place ??
|
||||
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
|
||||
}
|
||||
/>
|
||||
<div className="apg-info__coords">
|
||||
{item.location.lat.toFixed(5)}, {item.location.lng.toFixed(5)}
|
||||
</div>
|
||||
<MiniMap
|
||||
lat={item.location.lat}
|
||||
lng={item.location.lng}
|
||||
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||||
/>
|
||||
<AddressLookup
|
||||
lat={item.location.lat}
|
||||
lng={item.location.lng}
|
||||
<AddressBlock
|
||||
item={item}
|
||||
onOpenMap={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--apg-text-tertiary)', marginTop: 4 }}>
|
||||
@@ -198,6 +227,18 @@ function Versions({ item }: { item: MediaItem }) {
|
||||
<span className="apg-version__time">
|
||||
{formatDate(v.createdAt)} · {formatTime(v.createdAt)}
|
||||
</span>
|
||||
{v.author ? (
|
||||
<span className="apg-version__author">
|
||||
{v.authorAvatar ? (
|
||||
<img className="apg-version__author-avatar" src={v.authorAvatar} alt="" />
|
||||
) : (
|
||||
<span className="apg-version__author-avatar apg-version__author-avatar--initial" aria-hidden>
|
||||
{v.author.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="apg-version__author-name">{v.author}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
|
||||
</button>
|
||||
@@ -232,8 +273,11 @@ function Versions({ item }: { item: MediaItem }) {
|
||||
/** Threaded comments on a photo/video. */
|
||||
function Comments({ item }: { item: MediaItem }) {
|
||||
const api = useGalleryStoreApi();
|
||||
const currentUser = useGallery((s) => s.config.currentUser);
|
||||
const comments = item.comments ?? [];
|
||||
const [text, setText] = useState('');
|
||||
// Without a host-provided user we keep the legacy free-text author field
|
||||
// (remembered in localStorage). With one, identity comes from the host.
|
||||
const [author, setAuthor] = useState(() => {
|
||||
if (typeof window === 'undefined') return 'You';
|
||||
return window.localStorage.getItem('apg:comment-author') || 'You';
|
||||
@@ -285,6 +329,12 @@ function Comments({ item }: { item: MediaItem }) {
|
||||
const post = () => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
if (currentUser) {
|
||||
// The store stamps authorId/author/avatar from config.currentUser.
|
||||
api.getState().addComment(item.id, t);
|
||||
setText('');
|
||||
return;
|
||||
}
|
||||
const name = author.trim() || 'You';
|
||||
try {
|
||||
window.localStorage.setItem('apg:comment-author', name);
|
||||
@@ -307,42 +357,66 @@ function Comments({ item }: { item: MediaItem }) {
|
||||
<div className="apg-info__hint">No comments yet. Start the conversation below.</div>
|
||||
) : (
|
||||
<ul className="apg-comments">
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="apg-comment">
|
||||
<div className="apg-comment__avatar" aria-hidden>
|
||||
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="apg-comment__body">
|
||||
<div className="apg-comment__meta">
|
||||
<span className="apg-comment__author">{c.author ?? 'You'}</span>
|
||||
<span className="apg-comment__time">
|
||||
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
|
||||
</span>
|
||||
{comments.map((c) => {
|
||||
// With a configured user, only their OWN comments are deletable
|
||||
// (mirrors the server rule enforced on authorId).
|
||||
const canDelete = !currentUser || c.authorId === currentUser.id;
|
||||
return (
|
||||
<li key={c.id} className="apg-comment">
|
||||
{c.authorAvatar ? (
|
||||
<img className="apg-comment__avatar-img" src={c.authorAvatar} alt="" />
|
||||
) : (
|
||||
<div className="apg-comment__avatar" aria-hidden>
|
||||
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="apg-comment__body">
|
||||
<div className="apg-comment__meta">
|
||||
<span className="apg-comment__author">{c.author ?? 'You'}</span>
|
||||
<span className="apg-comment__time">
|
||||
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="apg-comment__text">{c.text}</div>
|
||||
</div>
|
||||
<div className="apg-comment__text">{c.text}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-comment__delete"
|
||||
aria-label="Delete comment"
|
||||
onClick={() => api.getState().deleteComment(item.id, c.id)}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-comment__delete"
|
||||
aria-label="Delete comment"
|
||||
onClick={() => api.getState().deleteComment(item.id, c.id)}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="apg-comment-form">
|
||||
<input
|
||||
className="apg-comment-form__author"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder="Your name"
|
||||
aria-label="Your name"
|
||||
maxLength={40}
|
||||
/>
|
||||
{currentUser ? (
|
||||
<div className="apg-comment-form__identity">
|
||||
{currentUser.avatarUrl ? (
|
||||
<img className="apg-comment__avatar-img" src={currentUser.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="apg-comment__avatar" aria-hidden>
|
||||
{(currentUser.name || '?').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="apg-comment__author">{currentUser.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="apg-comment-form__author"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder="Your name"
|
||||
aria-label="Your name"
|
||||
maxLength={40}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
className="apg-comment-form__input"
|
||||
value={text}
|
||||
@@ -452,68 +526,253 @@ function Chips({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Show address" button → reverse-geocodes the GPS coords to a human-readable
|
||||
* address (free OpenStreetMap Nominatim). Clicking the resolved address opens the
|
||||
* full Map at that location.
|
||||
*/
|
||||
function AddressLookup({ lat, lng, onOpenMap }: { lat: number; lng: number; onOpenMap: () => void }) {
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
|
||||
// Cross-instance guards so re-opening a panel (or a second panel) never double-
|
||||
// fetches the same item: ids currently in flight, and ids that already failed
|
||||
// (so the auto-fetch doesn't loop — the user must press Retry).
|
||||
const geocodeInFlight = new Set<string>();
|
||||
const geocodeFailed = new Set<string>();
|
||||
|
||||
const lookup = async () => {
|
||||
setState('loading');
|
||||
/** Shape of Nominatim's `address` object (addressdetails=1) — all fields optional. */
|
||||
interface NominatimAddress {
|
||||
road?: string;
|
||||
neighbourhood?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
village?: string;
|
||||
county?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-geocodes an item's GPS coords to a full postal address via free
|
||||
* OpenStreetMap Nominatim, then PERSISTS the parsed fields onto the item
|
||||
* (`updateMedia`) so it saves through the adapter and survives a reload. Runs
|
||||
* automatically the first time a located item's Info panel opens (only when
|
||||
* `location.formatted` isn't already cached); manual Retry on error.
|
||||
*
|
||||
* NOTE: a hardened host CSP must allow `nominatim.openstreetmap.org` in
|
||||
* `connect-src` for this fetch to succeed.
|
||||
*/
|
||||
function AddressBlock({ item, onOpenMap }: { item: MediaItem; onOpenMap: () => void }) {
|
||||
const api = useGalleryStoreApi();
|
||||
const loc = item.location!;
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
|
||||
|
||||
const lookup = useCallback(async () => {
|
||||
// One request per id at a time (etiquette + no double-fetch across panels).
|
||||
if (geocodeInFlight.has(item.id)) return;
|
||||
geocodeInFlight.add(item.id);
|
||||
setStatus('loading');
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`,
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${loc.lat}&lon=${loc.lng}&format=json&zoom=18&addressdetails=1`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
);
|
||||
const data = (await res.json()) as { display_name?: string };
|
||||
if (data?.display_name) {
|
||||
setAddress(data.display_name);
|
||||
setState('idle');
|
||||
const data = (await res.json()) as { display_name?: string; address?: NominatimAddress };
|
||||
const a = data.address ?? {};
|
||||
if (data.display_name) {
|
||||
const parsed: Partial<GeoLocation> = {
|
||||
road: a.road,
|
||||
neighbourhood: a.neighbourhood,
|
||||
suburb: a.suburb,
|
||||
city: a.city ?? a.town ?? a.village,
|
||||
county: a.county,
|
||||
state: a.state,
|
||||
postcode: a.postcode,
|
||||
country: a.country,
|
||||
countryCode: a.country_code ? a.country_code.toUpperCase() : undefined,
|
||||
formatted: data.display_name,
|
||||
};
|
||||
// Persist through the adapter (→ be-crm) so it survives reload + isn't refetched.
|
||||
api.getState().updateMedia(item.id, { location: { ...loc, ...parsed } });
|
||||
geocodeFailed.delete(item.id);
|
||||
setStatus('idle');
|
||||
} else {
|
||||
setState('error');
|
||||
geocodeFailed.add(item.id);
|
||||
setStatus('error');
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
geocodeFailed.add(item.id);
|
||||
setStatus('error');
|
||||
} finally {
|
||||
geocodeInFlight.delete(item.id);
|
||||
}
|
||||
}, [api, item.id, loc]);
|
||||
|
||||
// Auto-fetch once when a located item without a cached address opens. The
|
||||
// `!loc.formatted` guard (and the failed-set) prevent a re-fetch loop: once
|
||||
// persisted, the item re-renders WITH `formatted` and this no-ops.
|
||||
useEffect(() => {
|
||||
if (!loc.formatted && !geocodeFailed.has(item.id) && !geocodeInFlight.has(item.id)) {
|
||||
void lookup();
|
||||
}
|
||||
}, [item.id, loc.formatted, lookup]);
|
||||
|
||||
const retry = () => {
|
||||
geocodeFailed.delete(item.id);
|
||||
void lookup();
|
||||
};
|
||||
|
||||
if (address) {
|
||||
if (loc.formatted) {
|
||||
const cells: Array<[string, string | undefined]> = [
|
||||
['City', loc.city],
|
||||
['State', loc.state],
|
||||
['Postcode', loc.postcode],
|
||||
['Country', loc.country],
|
||||
];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMap}
|
||||
title="Open in Map"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
marginTop: 6,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--apg-bg-elevated)',
|
||||
border: '1px solid var(--apg-glass-border, rgba(255,255,255,0.1))',
|
||||
borderRadius: 8,
|
||||
color: 'var(--apg-text)',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
📍 {address}
|
||||
</button>
|
||||
<div className="apg-address">
|
||||
<button type="button" className="apg-address__line" onClick={onOpenMap} title="Open in Map">
|
||||
<Icon name="map" size={14} />
|
||||
<span>{loc.formatted}</span>
|
||||
</button>
|
||||
<dl className="apg-address__grid">
|
||||
{cells
|
||||
.filter(([, v]) => Boolean(v))
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="apg-address__cell">
|
||||
<dt>{k}</dt>
|
||||
<dd>{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="apg-info__coords">
|
||||
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--small"
|
||||
onClick={() => void lookup()}
|
||||
disabled={state === 'loading'}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
{state === 'loading' ? 'Looking up…' : state === 'error' ? 'Retry address' : 'Show address'}
|
||||
<div className="apg-address">
|
||||
<div className="apg-info__coords">
|
||||
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||||
</div>
|
||||
{status === 'loading' ? (
|
||||
<div className="apg-info__hint" aria-live="polite">
|
||||
Looking up address…
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<button type="button" className="apg-btn apg-btn--small" onClick={retry} style={{ marginTop: 6 }}>
|
||||
Retry address
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Uploaded by" identity block: avatar + name + (muted) email. */
|
||||
function UploadedBy({ user }: { user: GalleryUser }) {
|
||||
return (
|
||||
<div className="apg-info__uploader">
|
||||
<span className="apg-info__uploader-label">Uploaded by</span>
|
||||
<div className="apg-info__uploader-row">
|
||||
{user.avatarUrl ? (
|
||||
<img className="apg-info__uploader-avatar" src={user.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="apg-info__uploader-avatar apg-info__uploader-avatar--initial" aria-hidden>
|
||||
{(user.name || '?').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="apg-info__uploader-meta">
|
||||
<span className="apg-info__uploader-name">{user.name}</span>
|
||||
{user.email ? <span className="apg-info__uploader-email">{user.email}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-edit metadata field (caption / note). Saves on blur or Enter (Cmd/Ctrl+
|
||||
* Enter for the multi-line note) via the supplied `onSave`, which persists through
|
||||
* `updateMedia`. Shows an unobtrusive "edited" hint once a value is present.
|
||||
*/
|
||||
function EditableField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
multiline,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
placeholder: string;
|
||||
multiline?: boolean;
|
||||
onSave: (value: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
|
||||
// Re-sync the draft when the underlying value changes and we're not editing.
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(value ?? '');
|
||||
}, [value, editing]);
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
const next = draft.trim();
|
||||
if (next !== (value ?? '').trim()) onSave(next);
|
||||
};
|
||||
|
||||
const hasValue = Boolean((value ?? '').trim());
|
||||
|
||||
if (editing) {
|
||||
const shared = {
|
||||
className: 'apg-editable__input',
|
||||
value: draft,
|
||||
autoFocus: true,
|
||||
placeholder,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setDraft(e.target.value),
|
||||
onBlur: commit,
|
||||
};
|
||||
return (
|
||||
<div className="apg-editable apg-editable--editing">
|
||||
<span className="apg-editable__label">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
{...shared}
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') commit();
|
||||
else if (e.key === 'Escape') {
|
||||
setDraft(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...shared}
|
||||
maxLength={280}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
else if (e.key === 'Escape') {
|
||||
setDraft(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="apg-editable" onClick={() => setEditing(true)}>
|
||||
<span className="apg-editable__label">
|
||||
{label}
|
||||
{hasValue ? <span className="apg-editable__edited">edited</span> : null}
|
||||
<Icon name="pencil" size={12} />
|
||||
</span>
|
||||
<span className={['apg-editable__value', hasValue ? '' : 'apg-editable__value--empty'].filter(Boolean).join(' ')}>
|
||||
{hasValue ? value : placeholder}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ export function closeModal() {
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Whether a modal is currently mounted. Read by global Escape handling so the
|
||||
* modal (which owns Escape) is never fought over by another handler. */
|
||||
export function isModalOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
|
||||
@@ -9,13 +9,14 @@ import { normalizeMediaItem, type MediaInput } from '../lib/media';
|
||||
import { GalleryStoreContext, useGallery, useGalleryStoreApi } from '../store/context';
|
||||
import {
|
||||
createGalleryStore,
|
||||
DEFAULT_CHROME,
|
||||
DEFAULT_FEATURES,
|
||||
type GalleryConfig,
|
||||
type GalleryFeatures,
|
||||
type GalleryStore,
|
||||
type ThemeTokens,
|
||||
} from '../store/store';
|
||||
import type { Album, MediaItem, ThemePreference } from '../types';
|
||||
import type { Album, GalleryUser, MediaItem, ThemePreference, ViewId } from '../types';
|
||||
import { AIAnalyzer } from './AIAnalyzer';
|
||||
import { SemanticSearch } from './SemanticSearch';
|
||||
import { AIProviderContext } from './aiContext';
|
||||
@@ -44,9 +45,42 @@ export interface PhotoGalleryProps {
|
||||
/** Per-theme color / gradient / radius overrides (mapped to CSS variables). */
|
||||
themeTokens?: ThemeTokens;
|
||||
features?: Partial<GalleryFeatures>;
|
||||
/** Render the macOS-style traffic-light title bar. */
|
||||
/** Render the macOS-style traffic-light title bar. Maps onto `chrome.titlebar`. */
|
||||
showWindowChrome?: boolean;
|
||||
title?: string;
|
||||
/**
|
||||
* The host app's signed-in user. When set, comments are stamped with this
|
||||
* identity (avatar + name + id) instead of a free-text author field, and only
|
||||
* the user's own comments show a delete affordance.
|
||||
*/
|
||||
currentUser?: GalleryUser;
|
||||
/** Base URL for generated share links (default `${location.origin}/gallery`). */
|
||||
shareBaseUrl?: string;
|
||||
/** Suppress pieces of the gallery's own chrome that the host already provides. */
|
||||
chrome?: Partial<GalleryConfig['chrome']>;
|
||||
/**
|
||||
* Sidebar rows + Collections sections to hide (by ViewId).
|
||||
* e.g. `['screenshots', 'sys:documents']`. Default `[]` (nothing hidden).
|
||||
*/
|
||||
hiddenViews?: ViewId[];
|
||||
/** Bind global keyboard shortcuts to `window` (default true). */
|
||||
keyboardShortcuts?: boolean;
|
||||
/**
|
||||
* Embedded mode: the gallery fills its host container (height 100%) instead of
|
||||
* assuming a full-viewport parent, and interactive elements use `cursor: pointer`.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/**
|
||||
* Server-backed, per-user lock for the Recently Deleted view. When supplied the
|
||||
* SDK uses this INSTEAD of its device-local localStorage hash.
|
||||
*/
|
||||
lockProvider?: {
|
||||
status(): Promise<{ hasPassword: boolean }>;
|
||||
set(password: string | null): Promise<void>; // null clears it
|
||||
verify(password: string): Promise<boolean>;
|
||||
};
|
||||
/** Start the gallery maximised (default false). */
|
||||
defaultFullscreen?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onReady?: () => void;
|
||||
@@ -65,6 +99,14 @@ export function PhotoGallery(props: PhotoGalleryProps) {
|
||||
features,
|
||||
showWindowChrome = false,
|
||||
title = 'Photos',
|
||||
currentUser,
|
||||
shareBaseUrl,
|
||||
chrome,
|
||||
hiddenViews,
|
||||
keyboardShortcuts = true,
|
||||
embedded = false,
|
||||
lockProvider,
|
||||
defaultFullscreen = false,
|
||||
className,
|
||||
style,
|
||||
onReady,
|
||||
@@ -80,16 +122,67 @@ export function PhotoGallery(props: PhotoGalleryProps) {
|
||||
[ai],
|
||||
);
|
||||
|
||||
// Destructure chrome to primitives so an inline `chrome={{…}}` literal doesn't
|
||||
// recompute the config on every host render. `showWindowChrome` is the legacy
|
||||
// spelling of `chrome.titlebar`; an explicit `chrome.titlebar` wins.
|
||||
const chromeTitlebar = chrome?.titlebar ?? showWindowChrome;
|
||||
const chromeSidebar = chrome?.sidebar ?? DEFAULT_CHROME.sidebar;
|
||||
const chromeToolbar = chrome?.toolbar ?? DEFAULT_CHROME.toolbar;
|
||||
const chromeThemeSwitcher = chrome?.themeSwitcher ?? DEFAULT_CHROME.themeSwitcher;
|
||||
const userId = currentUser?.id;
|
||||
const userName = currentUser?.name;
|
||||
const userEmail = currentUser?.email;
|
||||
const userAvatar = currentUser?.avatarUrl;
|
||||
// Stable key so an inline `hiddenViews={['screenshots']}` literal doesn't
|
||||
// recompute the config on every host render.
|
||||
const hiddenViewsKey = (hiddenViews ?? []).join(',');
|
||||
|
||||
const config: GalleryConfig = useMemo(
|
||||
() => ({
|
||||
features: { ...DEFAULT_FEATURES, ...features },
|
||||
accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff',
|
||||
borderRadius: borderRadius ?? 10,
|
||||
showWindowChrome,
|
||||
showWindowChrome: chromeTitlebar,
|
||||
title,
|
||||
themeTokens,
|
||||
currentUser:
|
||||
userId !== undefined
|
||||
? { id: userId, name: userName ?? '', email: userEmail, avatarUrl: userAvatar }
|
||||
: undefined,
|
||||
shareBaseUrl,
|
||||
chrome: {
|
||||
titlebar: chromeTitlebar,
|
||||
sidebar: chromeSidebar,
|
||||
toolbar: chromeToolbar,
|
||||
themeSwitcher: chromeThemeSwitcher,
|
||||
},
|
||||
hiddenViews: hiddenViewsKey ? (hiddenViewsKey.split(',') as ViewId[]) : [],
|
||||
keyboardShortcuts,
|
||||
embedded,
|
||||
lockProvider,
|
||||
defaultFullscreen,
|
||||
}),
|
||||
[features, accentColor, borderRadius, themeTokens, showWindowChrome, title],
|
||||
[
|
||||
features,
|
||||
accentColor,
|
||||
borderRadius,
|
||||
themeTokens,
|
||||
title,
|
||||
userId,
|
||||
userName,
|
||||
userEmail,
|
||||
userAvatar,
|
||||
shareBaseUrl,
|
||||
chromeTitlebar,
|
||||
chromeSidebar,
|
||||
chromeToolbar,
|
||||
chromeThemeSwitcher,
|
||||
hiddenViewsKey,
|
||||
keyboardShortcuts,
|
||||
embedded,
|
||||
lockProvider,
|
||||
defaultFullscreen,
|
||||
],
|
||||
);
|
||||
|
||||
// Create the store exactly once for this gallery instance.
|
||||
@@ -109,6 +202,12 @@ export function PhotoGallery(props: PhotoGalleryProps) {
|
||||
store.getState().setTheme(theme);
|
||||
}, [store, theme]);
|
||||
|
||||
// Keep the live config in sync with the props. The store is created once, so
|
||||
// without this a host changing theme tokens / user / chrome would have no effect.
|
||||
useEffect(() => {
|
||||
store.getState().setConfig(config);
|
||||
}, [store, config]);
|
||||
|
||||
return (
|
||||
<GalleryStoreContext.Provider value={store}>
|
||||
<GalleryRoot
|
||||
@@ -138,7 +237,9 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
|
||||
const accent = useGallery((s) => s.config.accentColor);
|
||||
const radius = useGallery((s) => s.config.borderRadius);
|
||||
const tokens = useGallery((s) => s.config.themeTokens);
|
||||
const showChrome = useGallery((s) => s.config.showWindowChrome);
|
||||
const showChrome = useGallery((s) => s.config.chrome.titlebar);
|
||||
const embedded = useGallery((s) => s.config.embedded);
|
||||
const fullscreen = useGallery((s) => s.fullscreen);
|
||||
const aiEnabled = useGallery((s) => s.config.features.ai);
|
||||
const cameraEnabled = useGallery((s) => s.config.features.camera);
|
||||
|
||||
@@ -184,22 +285,73 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
|
||||
const set = (v: string | undefined, name: string) => {
|
||||
if (v && !CSS_UNSAFE.test(v)) tokenVars[name] = v;
|
||||
};
|
||||
const bg = dark ? tokens.bgDark : tokens.bgLight;
|
||||
/** Pick the light/dark member of a token pair for the active theme. */
|
||||
const pick = (light: string | undefined, darkValue: string | undefined) =>
|
||||
dark ? darkValue : light;
|
||||
const px = (v: number | undefined, name: string) => {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) tokenVars[name] = `${v}px`;
|
||||
};
|
||||
|
||||
const bg = pick(tokens.bgLight, tokens.bgDark);
|
||||
set(bg, '--apg-bg');
|
||||
set(bg, '--apg-bg-content');
|
||||
set(dark ? tokens.elevatedDark : tokens.elevatedLight, '--apg-bg-elevated');
|
||||
set(pick(tokens.elevatedLight, tokens.elevatedDark), '--apg-bg-elevated');
|
||||
// In semi-dark the sidebar is always the dark glass value.
|
||||
set(resolvedTheme === 'semi-dark' ? tokens.sidebarBgDark : dark ? tokens.sidebarBgDark : tokens.sidebarBgLight, '--apg-sidebar-bg');
|
||||
set(dark ? tokens.textDark : tokens.textLight, '--apg-text');
|
||||
if (typeof tokens.sidebarRadius === 'number') tokenVars['--apg-sidebar-radius'] = `${tokens.sidebarRadius}px`;
|
||||
set(
|
||||
resolvedTheme === 'semi-dark'
|
||||
? tokens.sidebarBgDark
|
||||
: pick(tokens.sidebarBgLight, tokens.sidebarBgDark),
|
||||
'--apg-sidebar-bg',
|
||||
);
|
||||
set(pick(tokens.textLight, tokens.textDark), '--apg-text');
|
||||
px(tokens.sidebarRadius, '--apg-sidebar-radius');
|
||||
|
||||
// ---- extended token map ----
|
||||
set(pick(tokens.accentStrongLight, tokens.accentStrongDark), '--apg-accent-strong');
|
||||
set(tokens.accentContrast, '--apg-accent-contrast');
|
||||
set(pick(tokens.dangerLight, tokens.dangerDark), '--apg-danger');
|
||||
set(pick(tokens.cardLight, tokens.cardDark), '--apg-card');
|
||||
set(pick(tokens.cardHoverLight, tokens.cardHoverDark), '--apg-card-hover');
|
||||
set(pick(tokens.toolbarBgLight, tokens.toolbarBgDark), '--apg-toolbar-bg');
|
||||
set(pick(tokens.menuBgLight, tokens.menuBgDark), '--apg-menu-bg');
|
||||
set(pick(tokens.separatorLight, tokens.separatorDark), '--apg-separator');
|
||||
set(
|
||||
pick(tokens.separatorStrongLight, tokens.separatorStrongDark),
|
||||
'--apg-separator-strong',
|
||||
);
|
||||
set(pick(tokens.hoverLight, tokens.hoverDark), '--apg-hover');
|
||||
set(pick(tokens.activeLight, tokens.activeDark), '--apg-active');
|
||||
set(
|
||||
pick(tokens.sidebarSelectedLight, tokens.sidebarSelectedDark),
|
||||
'--apg-sidebar-selected',
|
||||
);
|
||||
set(pick(tokens.textSecondaryLight, tokens.textSecondaryDark), '--apg-text-secondary');
|
||||
set(pick(tokens.textTertiaryLight, tokens.textTertiaryDark), '--apg-text-tertiary');
|
||||
set(pick(tokens.glassBorderLight, tokens.glassBorderDark), '--apg-glass-border');
|
||||
set(tokens.fontFamily, '--apg-font');
|
||||
px(tokens.radiusMenu, '--apg-radius-menu');
|
||||
set(tokens.shadowSm, '--apg-shadow-sm');
|
||||
set(pick(tokens.shadowMdLight, tokens.shadowMdDark), '--apg-shadow-md');
|
||||
set(pick(tokens.shadowLgLight, tokens.shadowLgDark), '--apg-shadow-lg');
|
||||
set(tokens.tileFav, '--apg-tile-fav');
|
||||
set(tokens.overlayBg, '--apg-overlay-bg');
|
||||
set(tokens.editorBg, '--apg-editor-bg');
|
||||
set(tokens.segmentedActive, '--apg-segmented-active');
|
||||
px(tokens.sidebarWidth, '--apg-sidebar-w');
|
||||
px(tokens.toolbarHeight, '--apg-toolbar-h');
|
||||
}
|
||||
|
||||
// Where full-screen overlays (the Info panel) start. A host with its own header
|
||||
// bar can push this down; default matches the standalone toolbar offset.
|
||||
const overlayTop = tokens?.toolbarHeight ? `${tokens.toolbarHeight + 12}px` : '64px';
|
||||
|
||||
const rootStyle: CSSProperties = {
|
||||
['--apg-accent' as string]: accent,
|
||||
['--apg-radius' as string]: `${radius}px`,
|
||||
['--apg-radius-sm' as string]: `${Math.max(2, Math.round(radius * 0.6))}px`,
|
||||
['--apg-radius-lg' as string]: `${Math.round(radius * 1.4)}px`,
|
||||
['--apg-radius-xl' as string]: `${Math.round(radius * 2)}px`,
|
||||
['--apg-overlay-top' as string]: overlayTop,
|
||||
...tokenVars,
|
||||
...style,
|
||||
};
|
||||
@@ -207,7 +359,14 @@ function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProp
|
||||
return (
|
||||
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
|
||||
<div
|
||||
className={['apg', className].filter(Boolean).join(' ')}
|
||||
className={[
|
||||
'apg',
|
||||
embedded ? 'apg--embedded' : '',
|
||||
fullscreen ? 'apg--fullscreen' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
data-theme={resolvedTheme}
|
||||
style={rootStyle}
|
||||
>
|
||||
|
||||
@@ -116,8 +116,15 @@ export function Sidebar() {
|
||||
const api = useGalleryStoreApi();
|
||||
const sidebarOpen = useGallery((s) => s.sidebarOpen);
|
||||
const albums = useGallery((s) => s.albums);
|
||||
// Views the host asked to hide (e.g. Screenshots, Documents). A row is dropped
|
||||
// when its `view` is listed; a section label is dropped when every row under it
|
||||
// would be hidden.
|
||||
const hiddenViews = useGallery((s) => s.config.hiddenViews);
|
||||
const hidden = new Set<ViewId>(hiddenViews ?? []);
|
||||
const shown = (view: ViewId) => !hidden.has(view);
|
||||
const anyShown = (...views: ViewId[]) => views.some(shown);
|
||||
// Recently Deleted lock state → closed lock when protected & not yet opened.
|
||||
const locked = useGallery((s) => s.lock.hash !== null && !s.lockUnlocked);
|
||||
const locked = useGallery((s) => s.lockConfigured && !s.lockUnlocked);
|
||||
const lockIcon: IconName = locked ? 'lock' : 'unlock';
|
||||
const [sharingOpen, setSharingOpen] = useState(true);
|
||||
const [albumsOpen, setAlbumsOpen] = useState(true);
|
||||
@@ -126,7 +133,7 @@ export function Sidebar() {
|
||||
// Sidebar lock toggle: no password → set one; unlocked → re-lock; locked → go unlock.
|
||||
const toggleLock = () => {
|
||||
const s = api.getState();
|
||||
if (!s.lock.hash) openSecuritySettings();
|
||||
if (!s.lockConfigured) openSecuritySettings();
|
||||
else if (s.lockUnlocked) s.relock();
|
||||
else s.setView('recently-deleted');
|
||||
};
|
||||
@@ -207,22 +214,41 @@ export function Sidebar() {
|
||||
<Row icon="library" label="Library" view="library" />
|
||||
<Row icon="collections" label="Collections" view="collections" />
|
||||
|
||||
<SectionLabel>Pinned</SectionLabel>
|
||||
<Row icon="heart" label="Favourites" view="favourites" />
|
||||
<Row icon="download" label="Recently Saved" view="recently-saved" />
|
||||
<Row icon="map" label="Map" view="map" />
|
||||
<Row icon="video" label="Videos" view="videos" />
|
||||
<Row icon="screenshot" label="Screenshots" view="screenshots" />
|
||||
<Row icon="document" label="Documents" view="sys:documents" />
|
||||
<Row icon="person-circle" label="People" view="people" />
|
||||
<Row
|
||||
icon="trash"
|
||||
label="Recently Deleted"
|
||||
view="recently-deleted"
|
||||
trailing={lockIcon}
|
||||
onTrailingClick={toggleLock}
|
||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||
/>
|
||||
{anyShown(
|
||||
'favourites',
|
||||
'recently-saved',
|
||||
'map',
|
||||
'videos',
|
||||
'screenshots',
|
||||
'sys:documents',
|
||||
'people',
|
||||
'recently-deleted',
|
||||
) ? (
|
||||
<SectionLabel>Pinned</SectionLabel>
|
||||
) : null}
|
||||
{shown('favourites') ? <Row icon="heart" label="Favourites" view="favourites" /> : null}
|
||||
{shown('recently-saved') ? (
|
||||
<Row icon="download" label="Recently Saved" view="recently-saved" />
|
||||
) : null}
|
||||
{shown('map') ? <Row icon="map" label="Map" view="map" /> : null}
|
||||
{shown('videos') ? <Row icon="video" label="Videos" view="videos" /> : null}
|
||||
{shown('screenshots') ? (
|
||||
<Row icon="screenshot" label="Screenshots" view="screenshots" />
|
||||
) : null}
|
||||
{shown('sys:documents') ? (
|
||||
<Row icon="document" label="Documents" view="sys:documents" />
|
||||
) : null}
|
||||
{shown('people') ? <Row icon="person-circle" label="People" view="people" /> : null}
|
||||
{shown('recently-deleted') ? (
|
||||
<Row
|
||||
icon="trash"
|
||||
label="Recently Deleted"
|
||||
view="recently-deleted"
|
||||
trailing={lockIcon}
|
||||
onTrailingClick={toggleLock}
|
||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
@@ -304,19 +330,23 @@ export function Sidebar() {
|
||||
/>
|
||||
{sharingOpen ? <Row icon="chat" label="Activity" view="activity" indent /> : null}
|
||||
|
||||
<SectionLabel>Utilities</SectionLabel>
|
||||
<Row
|
||||
icon="trash"
|
||||
label="Recently Deleted"
|
||||
view="recently-deleted"
|
||||
trailing={lockIcon}
|
||||
noActive
|
||||
onTrailingClick={toggleLock}
|
||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||
/>
|
||||
<Row icon="duplicates" label="Duplicates" view="duplicates" />
|
||||
<Row icon="clock" label="Versions & Audit" view="versions" />
|
||||
<Row icon="map" label="Map" view="map" noActive />
|
||||
{anyShown('recently-deleted', 'duplicates', 'versions', 'map') ? (
|
||||
<SectionLabel>Utilities</SectionLabel>
|
||||
) : null}
|
||||
{shown('recently-deleted') ? (
|
||||
<Row
|
||||
icon="trash"
|
||||
label="Recently Deleted"
|
||||
view="recently-deleted"
|
||||
trailing={lockIcon}
|
||||
noActive
|
||||
onTrailingClick={toggleLock}
|
||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||
/>
|
||||
) : null}
|
||||
{shown('duplicates') ? <Row icon="duplicates" label="Duplicates" view="duplicates" /> : null}
|
||||
{shown('versions') ? <Row icon="clock" label="Versions & Audit" view="versions" /> : null}
|
||||
{shown('map') ? <Row icon="map" label="Map" view="map" noActive /> : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ const GRID_VIEWS = new Set<string>([
|
||||
'search',
|
||||
]);
|
||||
|
||||
const SCALE_OPTIONS: Array<{ value: LibraryScale; label: string }> = [
|
||||
{ value: 'years', label: 'Years' },
|
||||
{ value: 'months', label: 'Months' },
|
||||
{ value: 'all', label: 'All Photos' },
|
||||
];
|
||||
|
||||
const FILTER_OPTIONS: Array<{ value: GridFilter; label: string }> = [
|
||||
{ value: 'all', label: 'All Items' },
|
||||
{ value: 'favourites', label: 'Favourites' },
|
||||
@@ -72,8 +78,11 @@ export function TopToolbar() {
|
||||
const zoomIndex = useGallery((s) => s.zoomIndex);
|
||||
const features = useGallery((s) => s.config.features);
|
||||
const theme = useGallery((s) => s.theme);
|
||||
// A host that owns light/dark must not be overridden from inside the gallery.
|
||||
const showThemeSwitcher = useGallery((s) => s.config.chrome.themeSwitcher);
|
||||
const aiStatus = useGallery((s) => s.aiStatus);
|
||||
const infoOpen = useGallery((s) => s.infoOpen);
|
||||
const fullscreen = useGallery((s) => s.fullscreen);
|
||||
const selectionCount = useGallery((s) => s.selection.size);
|
||||
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
@@ -96,6 +105,23 @@ export function TopToolbar() {
|
||||
);
|
||||
};
|
||||
|
||||
// Library-scale as a compact dropdown (replaces the wide 3-item segmented that
|
||||
// overlapped the zoom-out button on narrow toolbars). Reuses the `.apg-menu`
|
||||
// popover, which supplies the menu role + checkmarks + arrow-key navigation.
|
||||
const scaleLabel = SCALE_OPTIONS.find((o) => o.value === libraryScale)?.label ?? 'All Photos';
|
||||
const openScaleMenu = (e: React.MouseEvent) => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
openContextMenu(
|
||||
r.left,
|
||||
r.bottom + 4,
|
||||
SCALE_OPTIONS.map((o) => ({
|
||||
label: o.label,
|
||||
checked: libraryScale === o.value,
|
||||
onClick: () => api.getState().setLibraryScale(o.value),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const openMoreMenu = (e: React.MouseEvent) => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const state = api.getState();
|
||||
@@ -116,21 +142,25 @@ export function TopToolbar() {
|
||||
...(features.import
|
||||
? [{ label: 'Import…', icon: 'download' as const, onClick: () => openUploadModal(currentAlbumId) }]
|
||||
: []),
|
||||
{ type: 'separator' as const },
|
||||
{ type: 'label' as const, label: 'Appearance' },
|
||||
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
|
||||
{ label: 'Dark', icon: 'adjust' as const, checked: theme === 'dark', onClick: () => state.setTheme('dark') },
|
||||
{
|
||||
label: 'Semi-Dark (Glass)',
|
||||
icon: 'adjust' as const,
|
||||
checked: theme === 'semi-dark',
|
||||
onClick: () => state.setTheme('semi-dark'),
|
||||
},
|
||||
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
|
||||
...(showThemeSwitcher
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ type: 'label' as const, label: 'Appearance' },
|
||||
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
|
||||
{ label: 'Dark', icon: 'adjust' as const, checked: theme === 'dark', onClick: () => state.setTheme('dark') },
|
||||
{
|
||||
label: 'Semi-Dark (Glass)',
|
||||
icon: 'adjust' as const,
|
||||
checked: theme === 'semi-dark',
|
||||
onClick: () => state.setTheme('semi-dark'),
|
||||
},
|
||||
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
|
||||
]
|
||||
: []),
|
||||
{ type: 'separator' as const },
|
||||
{
|
||||
label: state.lock.hash ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
|
||||
icon: state.lock.hash ? 'lock' : 'unlock',
|
||||
label: state.lockConfigured ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
|
||||
icon: state.lockConfigured ? 'lock' : 'unlock',
|
||||
onClick: () => openSecuritySettings(),
|
||||
},
|
||||
...(view === 'recently-deleted'
|
||||
@@ -195,18 +225,22 @@ export function TopToolbar() {
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Center segmented control (context dependent). */}
|
||||
{/* Center control (context dependent). Flanked by two flex spacers so it centres on
|
||||
wide toolbars and simply sits in flow (never overlapping the right group) when the
|
||||
toolbar is narrow — e.g. embedded, where the host + gallery sidebars eat the width. */}
|
||||
<div className="apg-toolbar__spacer" />
|
||||
<div className="apg-toolbar__center">
|
||||
{view === 'library' || view === 'search' ? (
|
||||
<Segmented<LibraryScale>
|
||||
value={libraryScale}
|
||||
onChange={(v) => api.getState().setLibraryScale(v)}
|
||||
options={[
|
||||
{ value: 'years', label: 'Years' },
|
||||
{ value: 'months', label: 'Months' },
|
||||
{ value: 'all', label: 'All Photos' },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-scalemenu"
|
||||
aria-haspopup="menu"
|
||||
aria-label={`Library scale: ${scaleLabel}`}
|
||||
onClick={openScaleMenu}
|
||||
>
|
||||
<span className="apg-scalemenu__label">{scaleLabel}</span>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
) : view === 'map' ? (
|
||||
<Segmented<MapMode>
|
||||
value={mapMode}
|
||||
@@ -267,6 +301,16 @@ export function TopToolbar() {
|
||||
<button type="button" className="apg-iconbtn" aria-label="More" onClick={openMoreMenu}>
|
||||
<Icon name="ellipsis" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={['apg-iconbtn', fullscreen ? 'apg-iconbtn--on' : ''].join(' ')}
|
||||
aria-label={fullscreen ? 'Exit full screen' : 'Enter full screen'}
|
||||
title={fullscreen ? 'Exit full screen' : 'Enter full screen'}
|
||||
aria-pressed={fullscreen}
|
||||
onClick={() => api.getState().toggleFullscreen()}
|
||||
>
|
||||
<Icon name={fullscreen ? 'minimize' : 'maximize'} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={['apg-iconbtn', infoOpen ? 'apg-iconbtn--on' : ''].join(' ')}
|
||||
@@ -295,7 +339,9 @@ export function TopToolbar() {
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
aria-label="Search"
|
||||
placeholder="Search"
|
||||
// Discoverability only: `searchMedia` already matches objectLabels,
|
||||
// OCR text, tags, captions and place names.
|
||||
placeholder="Search photos, objects, text…"
|
||||
value={searchQuery}
|
||||
onFocus={() => setSearchExpanded(true)}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -93,6 +93,7 @@ export function VideoEditor() {
|
||||
const [annColor, setAnnColor] = useState('#ff3b30');
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [playhead, setPlayhead] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [selOverlay, setSelOverlay] = useState<string | null>(null);
|
||||
const provider = useAIProvider();
|
||||
const [denoiseBusy, setDenoiseBusy] = useState(false);
|
||||
@@ -141,8 +142,18 @@ export function VideoEditor() {
|
||||
const hi = segs ? segs[segs.length - 1]!.end : (edits.trim?.end ?? (duration || v.duration));
|
||||
if (v.currentTime >= hi) v.currentTime = lo;
|
||||
};
|
||||
// The preview <video> has no native controls (they would rotate with the frame),
|
||||
// so the custom control bar drives it — keep its play/pause icon in sync here.
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
v.addEventListener('timeupdate', onTime);
|
||||
return () => v.removeEventListener('timeupdate', onTime);
|
||||
v.addEventListener('play', onPlay);
|
||||
v.addEventListener('pause', onPause);
|
||||
return () => {
|
||||
v.removeEventListener('timeupdate', onTime);
|
||||
v.removeEventListener('play', onPlay);
|
||||
v.removeEventListener('pause', onPause);
|
||||
};
|
||||
}, [edits.trim, edits.segments, duration]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -398,9 +409,12 @@ export function VideoEditor() {
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={item.src}
|
||||
controls
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
onClick={() => {
|
||||
const v = videoRef.current;
|
||||
if (v) (v.paused ? v.play() : v.pause());
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
@@ -476,6 +490,39 @@ export function VideoEditor() {
|
||||
onChange={(annotations) => update({ annotations })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom transport — OUTSIDE the transformed <video>, so rotate/flip/crop
|
||||
only affect the frame, never the controls (the old native `controls`
|
||||
bar rotated with the video, which looked broken). */}
|
||||
<div className="apg-vedit__transport">
|
||||
<button
|
||||
type="button"
|
||||
className="apg-iconbtn"
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
onClick={() => {
|
||||
const v = videoRef.current;
|
||||
if (v) (v.paused ? v.play() : v.pause());
|
||||
}}
|
||||
>
|
||||
<Icon name={playing ? 'pause' : 'play'} size={18} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="apg-vedit__scrub"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.01}
|
||||
value={Math.min(playhead, duration || 0)}
|
||||
aria-label="Seek"
|
||||
onChange={(e) => {
|
||||
const v = videoRef.current;
|
||||
if (v) v.currentTime = Number(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<span className="apg-vedit__time">
|
||||
{fmt(playhead)} / {fmt(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="apg-editor__panel apg-scroll">
|
||||
|
||||
@@ -184,19 +184,39 @@ export function confirmAction(opts: {
|
||||
|
||||
function SecuritySettingsModal() {
|
||||
const api = useGalleryStoreApi();
|
||||
const hasPw = useGallery((s) => s.lock.hash !== null);
|
||||
const hasPw = useGallery((s) => s.lockConfigured);
|
||||
// A server-backed lock reads/writes the host's backend, not this device.
|
||||
const serverBacked = useGallery((s) => Boolean(s.config.lockProvider));
|
||||
const [p1, setP1] = useState('');
|
||||
const [p2, setP2] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
|
||||
if (p1 !== p2) return setErr('Passwords do not match.');
|
||||
await api.getState().setLockPassword(p1.trim());
|
||||
setBusy(true);
|
||||
const state = api.getState();
|
||||
state.clearLockError();
|
||||
await state.setLockPassword(p1.trim());
|
||||
setBusy(false);
|
||||
// The store reports an unreachable provider rather than throwing.
|
||||
if (api.getState().lockError === 'unavailable') {
|
||||
setErr("Couldn't reach the server. Try again.");
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
const remove = () => {
|
||||
api.getState().removeLockPassword();
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
const state = api.getState();
|
||||
state.clearLockError();
|
||||
await state.removeLockPassword();
|
||||
setBusy(false);
|
||||
if (api.getState().lockError === 'unavailable') {
|
||||
setErr("Couldn't reach the server. Try again.");
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
@@ -206,9 +226,13 @@ function SecuritySettingsModal() {
|
||||
{hasPw ? 'Recently Deleted is Locked' : 'Lock Recently Deleted'}
|
||||
</div>
|
||||
<div className="apg-empty-card__text" style={{ marginBottom: 4 }}>
|
||||
{hasPw
|
||||
? 'Set a new password, or remove the lock. The password is stored only on this device.'
|
||||
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
|
||||
{serverBacked
|
||||
? hasPw
|
||||
? 'Set a new password, or remove the lock. It applies to your account on every device.'
|
||||
: 'Protect Recently Deleted with a password. It applies to your account on every device.'
|
||||
: hasPw
|
||||
? 'Set a new password, or remove the lock. The password is stored only on this device.'
|
||||
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
|
||||
</div>
|
||||
<input
|
||||
className="apg-modal__input"
|
||||
@@ -241,7 +265,8 @@ function SecuritySettingsModal() {
|
||||
type="button"
|
||||
className="apg-btn"
|
||||
style={{ marginRight: 'auto', color: 'var(--apg-danger)' }}
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
Remove Lock
|
||||
</button>
|
||||
@@ -249,7 +274,12 @@ function SecuritySettingsModal() {
|
||||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void save()}>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary"
|
||||
disabled={busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{hasPw ? 'Change' : 'Set Password'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { formatDay } from '../../lib/format';
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { resolveLabel } from '../../lib/smartAlbums';
|
||||
@@ -102,12 +100,10 @@ export function CollectionsView() {
|
||||
const labelAliases = useGallery((s) => s.labelAliases);
|
||||
const live = liveMedia(media);
|
||||
|
||||
const first = (pred: (m: MediaItem) => boolean) => live.find(pred);
|
||||
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
|
||||
const go = (v: ViewId) => () => api.getState().setView(v);
|
||||
|
||||
const recentDays = groupByTime(live, 'day').slice(0, 8);
|
||||
const featured = [...live].sort((a, b) => b.takenAt - a.takenAt).slice(0, 12);
|
||||
const objectEntries = [...objectLabelCounts(media, labelAliases).entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 14);
|
||||
@@ -143,31 +139,6 @@ export function CollectionsView() {
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<div className="apg-collections">
|
||||
{/* Memories */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Memories" />
|
||||
<EmptyCard
|
||||
icon="clock"
|
||||
title="No Memories Available"
|
||||
text="Memories will appear here when more photos and videos are added to the library."
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Pinned */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Pinned" chevronTo={go('collections')} />
|
||||
<div className="apg-pinned-row">
|
||||
<PinnedCard label="Favourites" badge="heart-fill" cover={first((m) => m.favorite)} onClick={go('favourites')} />
|
||||
<PinnedCard label="Recently Saved" cover={first((m) => m.source === 'download' || m.source === 'social')} onClick={go('recently-saved')} />
|
||||
<PinnedCard label="Map" cover={first((m) => Boolean(m.location))} onClick={go('map')} />
|
||||
<PinnedCard label="Videos" cover={first((m) => m.kind === 'video')} onClick={go('videos')} />
|
||||
<PinnedCard label="Screenshots" cover={first((m) => m.source === 'screenshot')} onClick={go('screenshots')} />
|
||||
<PinnedCard label="Documents" badge="document" cover={first((m) => Boolean(m.ocrText && m.ocrText.trim()))} onClick={go('sys:documents')} />
|
||||
<PinnedCard label="People & Pets" onClick={go('people')} />
|
||||
<PinnedCard label="Recently Deleted" badge="trash" onClick={go('recently-deleted')} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Albums */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader
|
||||
@@ -236,34 +207,6 @@ export function CollectionsView() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* People & Pets */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="People & Pets" chevronTo={go('people')} />
|
||||
<EmptyCard
|
||||
icon="person-circle"
|
||||
title="Finding People…"
|
||||
text="Photos creates albums and groups of people and pets found in your library."
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Featured Photos */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Featured Photos" />
|
||||
{featured.length === 0 ? (
|
||||
<EmptyCard
|
||||
icon="image"
|
||||
title="No Featured Photos Available"
|
||||
text="Featured Photos will appear here when more photos and videos are added."
|
||||
/>
|
||||
) : (
|
||||
<div className="apg-pinned-row">
|
||||
{featured.map((m) => (
|
||||
<PinnedCard key={m.id} label="" cover={m} onClick={() => api.getState().openLightbox(m.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Shared Albums */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Shared Albums" action={{ label: 'Start Sharing', onClick: () => api.getState().setView('shared-albums') }} />
|
||||
@@ -297,36 +240,6 @@ export function CollectionsView() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Trips */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Trips" />
|
||||
<EmptyCard
|
||||
icon="suitcase"
|
||||
title="No Trips Available"
|
||||
text="Trips will appear here when more photos and videos are added to the library."
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Utilities */}
|
||||
<section className="apg-collections__section">
|
||||
<SectionHeader title="Utilities" chevronTo={go('recently-deleted')} />
|
||||
<button type="button" className="apg-list-row" onClick={go('recently-deleted')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
|
||||
<Icon name="trash" size={18} />
|
||||
Recently Deleted
|
||||
<span className="apg-list-row__trail">
|
||||
<Icon name="lock" size={15} />
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="apg-list-row" onClick={go('duplicates')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
|
||||
<Icon name="duplicates" size={18} />
|
||||
Duplicates
|
||||
<span className="apg-list-row__trail">{live.length ? 0 : 0}</span>
|
||||
</button>
|
||||
<button type="button" className="apg-list-row" onClick={go('map')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
|
||||
<Icon name="map" size={18} />
|
||||
Map
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,16 +10,19 @@ import { liveMedia } from '../../store/selectors';
|
||||
import { MediaGrid } from '../MediaGrid';
|
||||
|
||||
function Welcome({ onImport }: { onImport: () => void }) {
|
||||
// Host-neutral copy: the SDK is embedded in other products (see `title`), so the
|
||||
// empty state must not name a specific photo app or reference its settings.
|
||||
const hints: Array<{ icon: IconName; text: string }> = [
|
||||
{ icon: 'camera', text: 'Connect a camera or memory card.' },
|
||||
{ icon: 'duplicates', text: 'Drag pictures directly into Photos.' },
|
||||
{ icon: 'download', text: 'Click the + button to import.' },
|
||||
{ icon: 'image', text: 'Turn on iCloud Photos in Settings.' },
|
||||
{ icon: 'duplicates', text: 'Drag photos and videos straight in.' },
|
||||
{ icon: 'camera', text: 'Capture a shot with your camera.' },
|
||||
{ icon: 'image', text: 'Everything you add is searchable.' },
|
||||
];
|
||||
const title = useGallery((s) => s.config.title);
|
||||
return (
|
||||
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
|
||||
<div className="apg-empty__title">Welcome to Photos</div>
|
||||
<div className="apg-empty__subtitle">To get started with Photos, do any of the following:</div>
|
||||
<div className="apg-empty__title">Welcome to {title}</div>
|
||||
<div className="apg-empty__subtitle">To get started, do any of the following:</div>
|
||||
<div className="apg-empty__hints">
|
||||
{hints.map((h, i) => (
|
||||
<div className="apg-empty__hint" key={i}>
|
||||
|
||||
@@ -1,15 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MAP_TILES } from '../../constants';
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { Icon } from '../../icons';
|
||||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||||
import { clusterByLocation, type LocationCluster, liveMedia, locatedMedia } from '../../store/selectors';
|
||||
import {
|
||||
clusterByLocation,
|
||||
type LocationCluster,
|
||||
liveMedia,
|
||||
locatedMedia,
|
||||
searchMedia,
|
||||
} from '../../store/selectors';
|
||||
import type { MediaItem } from '../../types';
|
||||
import { MediaGrid } from '../MediaGrid';
|
||||
import { MosaicGrid } from './MosaicGrid';
|
||||
|
||||
/** Thumbnails shown in a multi-photo pin's hover strip before the "+N" chip. */
|
||||
const TIP_STRIP_MAX = 5;
|
||||
/** How long each frame of the hover mini-slider stays up. */
|
||||
const TIP_FRAME_MS = 900;
|
||||
|
||||
/** Parse a `<input type="date">` value into a local-midnight epoch, or null. */
|
||||
function parseDateInput(value: string, endOfDay: boolean): number | null {
|
||||
if (!value) return null;
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) return null;
|
||||
return endOfDay
|
||||
? new Date(y, m - 1, d, 23, 59, 59, 999).getTime()
|
||||
: new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
/** Format a Date to a `<input type="date">` value (local `YYYY-MM-DD`). */
|
||||
function toInputDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Short human label for one `YYYY-MM-DD` value. */
|
||||
function labelDate(value: string): string {
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) return '';
|
||||
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Button label for the current range ("All dates" when unset). */
|
||||
function rangeLabel(from: string, to: string): string {
|
||||
if (!from && !to) return 'All dates';
|
||||
if (from && to) return `${labelDate(from)} – ${labelDate(to)}`;
|
||||
if (from) return `From ${labelDate(from)}`;
|
||||
return `Until ${labelDate(to)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single, polished "date range" control: a button showing the current range
|
||||
* that opens a popover of quick presets + a custom From/To pair. It drives the
|
||||
* SAME from/to strings the MapView already filters by — so search/object chips,
|
||||
* the live count, and Clear all keep working unchanged.
|
||||
*/
|
||||
function DateRangeControl({
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onDown, true);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const today = new Date();
|
||||
const daysAgo = (n: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(today.getDate() - n);
|
||||
return d;
|
||||
};
|
||||
const presets: Array<{ label: string; from: string; to: string }> = [
|
||||
{ label: 'All dates', from: '', to: '' },
|
||||
{ label: 'Last 7 days', from: toInputDate(daysAgo(6)), to: toInputDate(today) },
|
||||
{ label: 'Last 30 days', from: toInputDate(daysAgo(29)), to: toInputDate(today) },
|
||||
{
|
||||
label: 'This year',
|
||||
from: toInputDate(new Date(today.getFullYear(), 0, 1)),
|
||||
to: toInputDate(today),
|
||||
},
|
||||
];
|
||||
|
||||
const active = Boolean(from || to);
|
||||
|
||||
return (
|
||||
<div className="apg-daterange" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={['apg-daterange__button', active ? 'apg-daterange__button--active' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="clock" size={14} />
|
||||
<span className="apg-daterange__label">{rangeLabel(from, to)}</span>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="apg-daterange__pop" role="dialog" aria-label="Choose a date range">
|
||||
<div className="apg-daterange__presets">
|
||||
{presets.map((p) => {
|
||||
const on = p.from === from && p.to === to;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
className={['apg-daterange__preset', on ? 'apg-daterange__preset--on' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
onChange(p.from, p.to);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
{on ? <Icon name="check" size={14} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="apg-daterange__custom">
|
||||
<div className="apg-daterange__custom-label">Custom range</div>
|
||||
<div className="apg-daterange__fields">
|
||||
<label className="apg-daterange__field">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => onChange(e.target.value, to)}
|
||||
/>
|
||||
</label>
|
||||
<label className="apg-daterange__field">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => onChange(from, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const mapMode = useGallery((s) => s.mapMode);
|
||||
@@ -23,6 +193,19 @@ export function MapView() {
|
||||
const [sheetH, setSheetH] = useState(0.5);
|
||||
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||
|
||||
// ---- sheet filters (search + date range + object chips), AND-combined ----
|
||||
const [query, setQuery] = useState('');
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState('');
|
||||
const [objectFilter, setObjectFilter] = useState<string | null>(null);
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery('');
|
||||
setFromDate('');
|
||||
setToDate('');
|
||||
setObjectFilter(null);
|
||||
};
|
||||
|
||||
const onHandleDown = (e: React.PointerEvent) => {
|
||||
dragRef.current = { startY: e.clientY, startH: sheetH };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
@@ -44,16 +227,54 @@ export function MapView() {
|
||||
const tileRef = useRef<any>(null);
|
||||
const markersRef = useRef<any>(null);
|
||||
const leafletRef = useRef<any>(null);
|
||||
// Every hover-slider interval currently running, so teardown can kill them all
|
||||
// (a leaked interval keeps mutating detached DOM forever).
|
||||
const tipTimersRef = useRef(new Set<ReturnType<typeof setInterval>>());
|
||||
const clearTipTimers = () => {
|
||||
for (const t of tipTimersRef.current) clearInterval(t);
|
||||
tipTimersRef.current.clear();
|
||||
};
|
||||
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
|
||||
// are created once, so closing over state directly would go stale).
|
||||
const locatedRef = useRef(located);
|
||||
locatedRef.current = located;
|
||||
// Current sheet height in px, read by the fly-to offset (the sheet covers the
|
||||
// bottom of the map, so the pin must end up above it).
|
||||
const sheetHRef = useRef(sheetH);
|
||||
sheetHRef.current = sheetH;
|
||||
|
||||
/** Zoom to the pin, keeping it clear of the sheet, then open the sheet. */
|
||||
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
|
||||
openClusterRef.current = (c) => {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
const current = map.getZoom?.() ?? 2;
|
||||
// Never zoom OUT: a single photo warrants street level, a cluster stays wide
|
||||
// enough that its members remain distinguishable.
|
||||
const target = Math.max(current, c.items.length > 1 ? 12 : 14);
|
||||
const size = map.getSize?.();
|
||||
const mapH: number = size?.y ?? 0;
|
||||
// The sheet is about to occupy the bottom `sheetH` of the map, so shift the
|
||||
// centre up by half of that — the pin then sits in the visible upper band.
|
||||
const sheetPx = mapH * 0.5; // the sheet always (re)opens at half height
|
||||
map.flyTo([c.lat, c.lng], target, { animate: true, duration: 0.6 });
|
||||
if (sheetPx > 0) {
|
||||
// panBy after the fly settles, else Leaflet cancels the in-flight animation.
|
||||
// Leaflet's panBy moves the map pane BY the offset, so content shifts the
|
||||
// opposite way: a POSITIVE y lifts the pin toward the top of the map,
|
||||
// which is the direction that gets it clear of the sheet.
|
||||
map.once('moveend', () => map.panBy([0, sheetPx / 2], { animate: true }));
|
||||
}
|
||||
}
|
||||
setCluster(c);
|
||||
setSheetH(0.5); // reset to half height each time a pin is opened
|
||||
resetFilters(); // a new pin starts with a clean filter
|
||||
};
|
||||
|
||||
// Opening one photo from a pin's hover strip.
|
||||
const openLightboxRef = useRef<(id: string) => void>(() => {});
|
||||
openLightboxRef.current = (id) => api.getState().openLightbox(id);
|
||||
|
||||
// Create the map once when entering a map tile mode.
|
||||
useEffect(() => {
|
||||
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
|
||||
@@ -91,16 +312,19 @@ export function MapView() {
|
||||
// Tear the map down when leaving map tile modes.
|
||||
useEffect(() => {
|
||||
if (mapMode === 'grid' && mapRef.current) {
|
||||
clearTipTimers();
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
tileRef.current = null;
|
||||
markersRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapMode]);
|
||||
|
||||
// Destroy on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTipTimers();
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
@@ -108,6 +332,7 @@ export function MapView() {
|
||||
markersRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addTiles = () => {
|
||||
@@ -131,6 +356,8 @@ export function MapView() {
|
||||
if (!L || !map) return;
|
||||
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
|
||||
if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map);
|
||||
// Every marker is about to be destroyed — kill their hover timers first.
|
||||
clearTipTimers();
|
||||
markersRef.current.clearLayers();
|
||||
|
||||
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
|
||||
@@ -140,16 +367,19 @@ export function MapView() {
|
||||
|
||||
for (const c of clusters) {
|
||||
const cover = c.items[0]!;
|
||||
const multi = c.items.length > 1;
|
||||
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
|
||||
// injected; the thumbnail src + count are set via DOM after the marker mounts.
|
||||
const icon = L.divIcon({
|
||||
className: 'apg-pin-wrap',
|
||||
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
|
||||
// hover tooltip with an image preview + caption.
|
||||
// hover tooltip: one preview for a single photo, an auto-advancing strip
|
||||
// of thumbnails for a cluster.
|
||||
html:
|
||||
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
|
||||
'<span class="apg-pin__count"></span>' +
|
||||
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
|
||||
'<span class="apg-pin__strip"></span>' +
|
||||
'<span class="apg-pin__tip-cap"></span></span></span>',
|
||||
iconSize: [56, 64],
|
||||
iconAnchor: [28, 64],
|
||||
@@ -162,8 +392,30 @@ export function MapView() {
|
||||
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
|
||||
if (img) img.src = thumb;
|
||||
const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null;
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
const strip = el.querySelector('.apg-pin__strip') as HTMLElement | null;
|
||||
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
|
||||
|
||||
// Keep the hover tooltip inside the map: on enter, measure it against the map
|
||||
// container and slide it horizontally (--tip-dx) / flip it below (--below) so it
|
||||
// never overflows and gets clipped by the gallery's rounded, overflow-hidden shell.
|
||||
const pinEl = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||
const tipEl = el.querySelector('.apg-pin__tip') as HTMLElement | null;
|
||||
const clampTip = () => {
|
||||
if (!tipEl) return;
|
||||
const mapEl = el.closest('.leaflet-container') as HTMLElement | null;
|
||||
if (!mapEl) return;
|
||||
tipEl.style.setProperty('--tip-dx', '0px');
|
||||
tipEl.classList.remove('apg-pin__tip--below');
|
||||
const m = mapEl.getBoundingClientRect();
|
||||
const t = tipEl.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
let dx = 0;
|
||||
if (t.left < m.left + pad) dx = m.left + pad - t.left;
|
||||
else if (t.right > m.right - pad) dx = m.right - pad - t.right;
|
||||
if (dx) tipEl.style.setProperty('--tip-dx', `${Math.round(dx)}px`);
|
||||
if (t.top < m.top + pad) tipEl.classList.add('apg-pin__tip--below');
|
||||
};
|
||||
pinEl?.addEventListener('mouseenter', clampTip);
|
||||
if (cap) {
|
||||
// textContent (never innerHTML) — place names are attacker-influencable.
|
||||
const place = cover.location?.place ?? '';
|
||||
@@ -172,9 +424,81 @@ export function MapView() {
|
||||
}
|
||||
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
|
||||
if (badge) {
|
||||
// The badge always carries the cluster's TOTAL, and only appears when
|
||||
// there is genuinely more than one photo here.
|
||||
badge.textContent = String(c.items.length);
|
||||
if (c.items.length < 2) badge.style.display = 'none';
|
||||
badge.style.display = multi ? '' : 'none';
|
||||
}
|
||||
|
||||
if (!multi || !strip) {
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- multi-photo pin: horizontal strip + auto-advancing preview ----
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
const shown = c.items.slice(0, TIP_STRIP_MAX);
|
||||
const cells: HTMLElement[] = [];
|
||||
for (const item of shown) {
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'apg-pin__strip-cell';
|
||||
cell.title = item.name;
|
||||
const thumbEl = document.createElement('img');
|
||||
thumbEl.alt = '';
|
||||
thumbEl.src = item.thumbnail ?? item.src;
|
||||
cell.appendChild(thumbEl);
|
||||
// Opening a specific photo must not also open the cluster sheet.
|
||||
cell.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
openLightboxRef.current(item.id);
|
||||
});
|
||||
strip.appendChild(cell);
|
||||
cells.push(cell);
|
||||
}
|
||||
if (c.items.length > TIP_STRIP_MAX) {
|
||||
const more = document.createElement('span');
|
||||
more.className = 'apg-pin__strip-more';
|
||||
more.textContent = `+${c.items.length - TIP_STRIP_MAX}`;
|
||||
strip.appendChild(more);
|
||||
}
|
||||
|
||||
// Auto-advance the big preview (and the highlighted cell) while hovered.
|
||||
const pin = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||
if (!pin) return;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let frame = 0;
|
||||
const paint = () => {
|
||||
const item = c.items[frame % c.items.length]!;
|
||||
if (tipImg) tipImg.src = item.thumbnail ?? item.src;
|
||||
cells.forEach((cell, i) =>
|
||||
cell.classList.toggle('apg-pin__strip-cell--on', i === frame % c.items.length),
|
||||
);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer === null) return;
|
||||
clearInterval(timer);
|
||||
tipTimersRef.current.delete(timer);
|
||||
timer = null;
|
||||
frame = 0;
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
cells.forEach((cell) => cell.classList.remove('apg-pin__strip-cell--on'));
|
||||
};
|
||||
const start = () => {
|
||||
if (timer !== null) return;
|
||||
frame = 0;
|
||||
paint();
|
||||
timer = setInterval(() => {
|
||||
frame += 1;
|
||||
paint();
|
||||
}, TIP_FRAME_MS);
|
||||
tipTimersRef.current.add(timer);
|
||||
};
|
||||
pin.addEventListener('mouseenter', start);
|
||||
pin.addEventListener('mouseleave', stop);
|
||||
// Leaflet destroys the element on clearLayers(); `remove` fires first.
|
||||
marker.on('remove', stop);
|
||||
});
|
||||
marker.on('click', () => openClusterRef.current(c));
|
||||
markersRef.current.addLayer(marker);
|
||||
@@ -200,6 +524,36 @@ export function MapView() {
|
||||
}
|
||||
}, [mapFocus]);
|
||||
|
||||
// Distinct object labels present in the open cluster, most common first.
|
||||
const clusterObjects = useMemo(() => {
|
||||
if (!cluster) return [] as Array<{ label: string; count: number }>;
|
||||
const counts = new Map<string, number>();
|
||||
for (const m of cluster.items) {
|
||||
for (const label of new Set(m.objectLabels)) {
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
}, [cluster]);
|
||||
|
||||
// Search + date range + object chip, combined with AND.
|
||||
const filtered = useMemo<MediaItem[]>(() => {
|
||||
if (!cluster) return [];
|
||||
let items = cluster.items;
|
||||
if (objectFilter) items = items.filter((m) => m.objectLabels.includes(objectFilter));
|
||||
const from = parseDateInput(fromDate, false);
|
||||
const to = parseDateInput(toDate, true);
|
||||
if (from !== null) items = items.filter((m) => m.takenAt >= from);
|
||||
if (to !== null) items = items.filter((m) => m.takenAt <= to);
|
||||
// Same matcher as the global search bar, so "car" means the same thing here.
|
||||
if (query.trim()) items = searchMedia(items, query);
|
||||
return items;
|
||||
}, [cluster, query, fromDate, toDate, objectFilter]);
|
||||
|
||||
const filterActive = Boolean(query.trim() || fromDate || toDate || objectFilter);
|
||||
|
||||
if (mapMode === 'grid') {
|
||||
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
|
||||
// located ones) so it's a rich date-grouped collage, per the design.
|
||||
@@ -275,8 +629,12 @@ export function MapView() {
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>
|
||||
{cluster.items[0]?.location?.place ?? 'This location'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
|
||||
{cluster.items.length} photo{cluster.items.length === 1 ? '' : 's'}
|
||||
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }} aria-live="polite">
|
||||
{filterActive
|
||||
? `${filtered.length} of ${cluster.items.length} photo${
|
||||
cluster.items.length === 1 ? '' : 's'
|
||||
}`
|
||||
: `${cluster.items.length} photo${cluster.items.length === 1 ? '' : 's'}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -288,10 +646,85 @@ export function MapView() {
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + date range + object chips — all AND-combined. */}
|
||||
<div className="apg-map__sheet-filters">
|
||||
<div className="apg-mapfilter__row">
|
||||
<div className="apg-mapfilter__search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search photos at this location"
|
||||
placeholder="Search photos, objects, text…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{filterActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-mapfilter__clear"
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="apg-mapfilter__row apg-mapfilter__dates">
|
||||
<DateRangeControl
|
||||
from={fromDate}
|
||||
to={toDate}
|
||||
onChange={(f, t) => {
|
||||
setFromDate(f);
|
||||
setToDate(t);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{clusterObjects.length ? (
|
||||
<div className="apg-mapfilter__chips" role="group" aria-label="Filter by object">
|
||||
{clusterObjects.map(({ label, count }) => {
|
||||
const on = objectFilter === label;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={[
|
||||
'apg-mapfilter__chip',
|
||||
on ? 'apg-mapfilter__chip--on' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={on}
|
||||
onClick={() => setObjectFilter(on ? null : label)}
|
||||
>
|
||||
{label}
|
||||
<span className="apg-mapfilter__chip-n">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="apg-map__sheet-body apg-scroll">
|
||||
{groupByTime(cluster.items, 'day').map((s) => (
|
||||
<MediaGrid key={s.key} items={s.items} title={s.title} />
|
||||
))}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="apg-mapfilter__empty">
|
||||
<Icon name="search" size={26} />
|
||||
<div className="apg-mapfilter__empty-title">No matching photos</div>
|
||||
<div className="apg-mapfilter__empty-sub">
|
||||
Try a different search, date range, or object.
|
||||
</div>
|
||||
<button type="button" className="apg-btn" onClick={resetFilters}>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
groupByTime(filtered, 'day').map((s) => (
|
||||
<MediaGrid key={s.key} items={s.items} title={s.title} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -20,8 +20,8 @@ export function PeopleView() {
|
||||
Finding People…
|
||||
</div>
|
||||
<div className="apg-empty__subtitle">
|
||||
Photos creates albums and groups of people and pets found in your library when you are
|
||||
not using the app.
|
||||
People and pets are grouped automatically as photos are analyzed. Add more photos and
|
||||
they will appear here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,16 +13,18 @@ import { openSecuritySettings } from '../modals';
|
||||
export function RecentlyDeletedView() {
|
||||
const api = useGalleryStoreApi();
|
||||
const items = useViewMedia();
|
||||
const lockHash = useGallery((s) => s.lock.hash);
|
||||
const lockConfigured = useGallery((s) => s.lockConfigured);
|
||||
const lockUnlocked = useGallery((s) => s.lockUnlocked);
|
||||
const lockError = useGallery((s) => s.lockError);
|
||||
const [pw, setPw] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Locked: a password is set and the user hasn't unlocked this session.
|
||||
if (lockHash && !lockUnlocked) {
|
||||
if (lockConfigured && !lockUnlocked) {
|
||||
const submit = async () => {
|
||||
const ok = await api.getState().unlockLock(pw);
|
||||
if (!ok) setError(true);
|
||||
setBusy(true);
|
||||
await api.getState().unlockLock(pw);
|
||||
setBusy(false);
|
||||
setPw('');
|
||||
};
|
||||
return (
|
||||
@@ -42,17 +44,26 @@ export function RecentlyDeletedView() {
|
||||
style={{ maxWidth: 280 }}
|
||||
onChange={(e) => {
|
||||
setPw(e.target.value);
|
||||
setError(false);
|
||||
api.getState().clearLockError();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void submit();
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>Incorrect password.</div>
|
||||
{lockError ? (
|
||||
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }} role="alert">
|
||||
{lockError === 'wrong-password'
|
||||
? 'Incorrect password.'
|
||||
: "Couldn't check the password. Please try again."}
|
||||
</div>
|
||||
) : null}
|
||||
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void submit()}>
|
||||
Unlock
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary"
|
||||
disabled={busy}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{busy ? 'Checking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -107,8 +118,8 @@ export function RecentlyDeletedView() {
|
||||
style={{ flexShrink: 0, padding: '4px 10px' }}
|
||||
onClick={openSecuritySettings}
|
||||
>
|
||||
<Icon name={lockHash ? 'lock' : 'unlock'} size={13} />
|
||||
{lockHash ? 'Locked' : 'Lock…'}
|
||||
<Icon name={lockConfigured ? 'lock' : 'unlock'} size={13} />
|
||||
{lockConfigured ? 'Locked' : 'Lock…'}
|
||||
</button>
|
||||
</div>
|
||||
<MediaGrid items={items} />
|
||||
|
||||
@@ -75,6 +75,7 @@ export function VersionsView() {
|
||||
<span className="apg-audit-card__latest">
|
||||
Latest: {latest.changes.join(', ')} · {formatDate(latest.createdAt)}{' '}
|
||||
{formatTime(latest.createdAt)}
|
||||
{latest.author ? ` · ${latest.author}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { isContextMenuOpen } from '../components/ContextMenu';
|
||||
import { isModalOpen } from '../components/Modal';
|
||||
import { useGalleryStoreApi } from '../store/context';
|
||||
import { mediaForView } from '../store/selectors';
|
||||
|
||||
/** Wire Apple-Photos-style global keyboard shortcuts. */
|
||||
export function useKeyboardShortcuts() {
|
||||
/**
|
||||
* Wire Apple-Photos-style global keyboard shortcuts.
|
||||
*
|
||||
* `enabled` is a parameter (not a call-site condition) so hook order stays
|
||||
* stable: an embedding host that owns its own key handling passes `false`.
|
||||
*/
|
||||
export function useKeyboardShortcuts(enabled: boolean = true) {
|
||||
const api = useGalleryStoreApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
// Never hijack typing in inputs / editable fields.
|
||||
@@ -50,10 +58,20 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — clear selection / focus.
|
||||
// Escape — clear selection / focus, then leave full screen.
|
||||
if (e.key === 'Escape') {
|
||||
// Anything layered above the shell owns Escape first: the lightbox,
|
||||
// editors, the camera, modals and context menus each close themselves.
|
||||
const overlayOpen =
|
||||
Boolean(state.lightboxId) ||
|
||||
Boolean(state.editorId) ||
|
||||
state.cameraOpen ||
|
||||
isModalOpen() ||
|
||||
isContextMenuOpen();
|
||||
if (overlayOpen) return;
|
||||
if (state.objectFocus) state.setObjectFocus(null);
|
||||
else if (state.selection.size) state.clearSelection();
|
||||
else if (state.fullscreen) state.setFullscreen(false);
|
||||
}
|
||||
|
||||
// Enter / Space — open the (single) selection in the lightbox.
|
||||
@@ -65,5 +83,5 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [api]);
|
||||
}, [api, enabled]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { SVGProps } from 'react';
|
||||
import type { ReactElement, SVGProps } from 'react';
|
||||
|
||||
/**
|
||||
* Original SF-symbol-inspired icon set drawn from scratch (no Apple assets).
|
||||
@@ -52,10 +52,13 @@ export type IconName =
|
||||
| 'volume'
|
||||
| 'mute'
|
||||
| 'expand'
|
||||
| 'maximize'
|
||||
| 'minimize'
|
||||
| 'pip'
|
||||
| 'tag'
|
||||
| 'document'
|
||||
| 'pin'
|
||||
| 'pencil'
|
||||
| 'mic';
|
||||
|
||||
export interface IconProps extends SVGProps<SVGSVGElement> {
|
||||
@@ -83,7 +86,9 @@ export function Icon({ name, size = 20, ...rest }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const paths: Record<IconName, JSX.Element> = {
|
||||
// `ReactElement`, not the global `JSX.Element`: React 19 removed the global JSX
|
||||
// namespace, so a React-19 host compiling this source would fail to resolve it.
|
||||
const paths: Record<IconName, ReactElement> = {
|
||||
mic: (
|
||||
<>
|
||||
<rect x="9" y="3" width="6" height="11" rx="3" />
|
||||
@@ -91,6 +96,12 @@ const paths: Record<IconName, JSX.Element> = {
|
||||
<path d="M12 17v3M9 20.5h6" />
|
||||
</>
|
||||
),
|
||||
pencil: (
|
||||
<>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</>
|
||||
),
|
||||
library: (
|
||||
<>
|
||||
<rect x="3" y="6" width="18" height="13" rx="2.5" />
|
||||
@@ -321,6 +332,20 @@ const paths: Record<IconName, JSX.Element> = {
|
||||
expand: (
|
||||
<path d="M9 4H5a1 1 0 0 0-1 1v4m11-5h4a1 1 0 0 1 1 1v4M9 20H5a1 1 0 0 1-1-1v-4m11 5h4a1 1 0 0 0 1-1v-4" />
|
||||
),
|
||||
/* Four corner brackets pushing outward — "enter full screen". */
|
||||
maximize: (
|
||||
<>
|
||||
<path d="M4 9.5V5.5A1.5 1.5 0 0 1 5.5 4h4M14.5 4h4A1.5 1.5 0 0 1 20 5.5v4M20 14.5v4a1.5 1.5 0 0 1-1.5 1.5h-4M9.5 20h-4A1.5 1.5 0 0 1 4 18.5v-4" />
|
||||
<path d="m4.8 4.8 4 4M19.2 4.8l-4 4M19.2 19.2l-4-4M4.8 19.2l4-4" opacity="0.5" />
|
||||
</>
|
||||
),
|
||||
/* Same brackets pulled inward — "exit full screen". */
|
||||
minimize: (
|
||||
<>
|
||||
<path d="M9.5 4v4a1.5 1.5 0 0 1-1.5 1.5H4M20 9.5h-4A1.5 1.5 0 0 1 14.5 8V4M14.5 20v-4a1.5 1.5 0 0 1 1.5-1.5h4M4 14.5h4a1.5 1.5 0 0 1 1.5 1.5v4" />
|
||||
<path d="m4.8 4.8 3.4 3.4M19.2 4.8l-3.4 3.4M19.2 19.2l-3.4-3.4M4.8 19.2l3.4-3.4" opacity="0.5" />
|
||||
</>
|
||||
),
|
||||
pip: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
|
||||
|
||||
@@ -9,14 +9,16 @@ export type { PhotoGalleryProps } from './components/PhotoGallery';
|
||||
|
||||
// Headless store access (for advanced composition / external control)
|
||||
export { useGallery, useGalleryStoreApi, GalleryStoreContext } from './store/context';
|
||||
export { createGalleryStore, DEFAULT_FEATURES } from './store/store';
|
||||
export { createGalleryStore, DEFAULT_FEATURES, DEFAULT_CHROME } from './store/store';
|
||||
export type {
|
||||
GalleryState,
|
||||
GalleryStore,
|
||||
GalleryConfig,
|
||||
GalleryFeatures,
|
||||
GalleryChrome,
|
||||
ThemeTokens,
|
||||
GridFilter,
|
||||
LockProvider,
|
||||
} from './store/store';
|
||||
export * as selectors from './store/selectors';
|
||||
|
||||
@@ -26,6 +28,10 @@ export type {
|
||||
MediaId,
|
||||
MediaKind,
|
||||
MediaSource,
|
||||
MediaComment,
|
||||
MediaVersion,
|
||||
ShareRecord,
|
||||
GalleryUser,
|
||||
Album,
|
||||
AlbumId,
|
||||
AlbumKind,
|
||||
@@ -49,10 +55,15 @@ export type {
|
||||
|
||||
// Storage adapters
|
||||
export { createLocalStorageAdapter } from './adapters/localStorage';
|
||||
export type { StorageAdapter, PersistedState } from './adapters/types';
|
||||
export type {
|
||||
StorageAdapter,
|
||||
PersistedState,
|
||||
StateChanges,
|
||||
StoredBlob,
|
||||
} from './adapters/types';
|
||||
|
||||
// AI provider interface (pluggable; free in-browser or cloud)
|
||||
export type { AIProvider, GenerativeEditOp } from './ai/types';
|
||||
export type { AIProvider, GenerativeEditOp, TiltEstimate } from './ai/types';
|
||||
export { cosineSimilarity } from './ai/types';
|
||||
|
||||
// Helpers
|
||||
|
||||
@@ -49,6 +49,10 @@ export function createMediaItem(input: MediaInput): MediaItem {
|
||||
personIds: input.personIds ?? [],
|
||||
location: input.location,
|
||||
exif: input.exif,
|
||||
// Who captured/imported this (display identity) + user note — preserved through
|
||||
// the single normalization path so they survive a reload.
|
||||
uploadedBy: input.uploadedBy,
|
||||
note: input.note,
|
||||
caption: input.caption,
|
||||
objects: input.objects,
|
||||
faces: input.faces,
|
||||
@@ -109,7 +113,23 @@ export function normalizeMediaItem(raw: unknown): MediaItem | null {
|
||||
const lat = asFiniteNumber(loc.lat);
|
||||
const lng = asFiniteNumber(loc.lng);
|
||||
if (lat !== undefined && lng !== undefined) {
|
||||
location = { lat, lng, place: asString(loc.place), city: asString(loc.city), country: asString(loc.country) };
|
||||
// Preserve every reverse-geocoded address field so a persisted full address
|
||||
// survives a reload (the InfoPanel then won't re-fetch it).
|
||||
location = {
|
||||
lat,
|
||||
lng,
|
||||
place: asString(loc.place),
|
||||
road: asString(loc.road),
|
||||
neighbourhood: asString(loc.neighbourhood),
|
||||
suburb: asString(loc.suburb),
|
||||
city: asString(loc.city),
|
||||
county: asString(loc.county),
|
||||
state: asString(loc.state),
|
||||
postcode: asString(loc.postcode),
|
||||
country: asString(loc.country),
|
||||
countryCode: asString(loc.countryCode),
|
||||
formatted: asString(loc.formatted),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +140,7 @@ export function normalizeMediaItem(raw: unknown): MediaItem | null {
|
||||
poster: safeMediaUrl(r.poster),
|
||||
name: asString(r.name),
|
||||
caption: asString(r.caption),
|
||||
note: asString(r.note),
|
||||
tags: asStringArray(r.tags),
|
||||
objectLabels: asStringArray(r.objectLabels),
|
||||
albumIds: asStringArray(r.albumIds),
|
||||
|
||||
@@ -55,9 +55,13 @@ export function searchMedia(items: MediaItem[], query: string): MediaItem[] {
|
||||
m.source,
|
||||
m.kind,
|
||||
m.caption ?? '',
|
||||
m.note ?? '',
|
||||
m.ocrText ?? '',
|
||||
m.location?.place ?? '',
|
||||
m.location?.formatted ?? '',
|
||||
m.location?.city ?? '',
|
||||
m.location?.state ?? '',
|
||||
m.location?.postcode ?? '',
|
||||
m.location?.country ?? '',
|
||||
...m.tags,
|
||||
...m.objectLabels,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
|
||||
import type { StorageAdapter } from '../adapters/types';
|
||||
import type { StateChanges, StorageAdapter } from '../adapters/types';
|
||||
import type { AIProvider } from '../ai/types';
|
||||
import { DEFAULT_ZOOM_INDEX, GRID_ZOOM_STEPS, PET_LABELS, TRASH_RETENTION_MS } from '../constants';
|
||||
import { clusterFaces } from '../lib/cluster';
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Album,
|
||||
AlbumId,
|
||||
EditState,
|
||||
GalleryUser,
|
||||
LibraryScale,
|
||||
MapMode,
|
||||
MediaComment,
|
||||
@@ -61,24 +62,131 @@ export const DEFAULT_FEATURES: GalleryFeatures = {
|
||||
* (content), dark values in dark mode (and the semi-dark sidebar).
|
||||
*/
|
||||
export interface ThemeTokens {
|
||||
/** Main app / content background. */
|
||||
/** Main app / content background. → `--apg-bg` + `--apg-bg-content` */
|
||||
bgLight?: string;
|
||||
bgDark?: string;
|
||||
/** Raised surfaces (cards, elevated panels). */
|
||||
/** Raised surfaces (cards, elevated panels). → `--apg-bg-elevated` */
|
||||
elevatedLight?: string;
|
||||
elevatedDark?: string;
|
||||
/** Sidebar glass backdrop. */
|
||||
/** Sidebar glass backdrop. → `--apg-sidebar-bg` */
|
||||
sidebarBgLight?: string;
|
||||
sidebarBgDark?: string;
|
||||
/** Primary text color. */
|
||||
/** Primary text color. → `--apg-text` */
|
||||
textLight?: string;
|
||||
textDark?: string;
|
||||
/** Sidebar corner radius in px. */
|
||||
/** Sidebar corner radius in px. → `--apg-sidebar-radius` */
|
||||
sidebarRadius?: number;
|
||||
/** Accent color (overrides `accentColor`). */
|
||||
/** Accent color (overrides `accentColor`). → `--apg-accent` */
|
||||
accent?: string;
|
||||
|
||||
// ---- extended token map (all optional; defaults keep today's look) ----
|
||||
|
||||
/** Accent used behind white text (buttons, menu hover) — needs AA contrast. → `--apg-accent-strong` */
|
||||
accentStrongLight?: string;
|
||||
accentStrongDark?: string;
|
||||
/** Foreground color placed on top of the accent. → `--apg-accent-contrast` */
|
||||
accentContrast?: string;
|
||||
/** Destructive-action color. → `--apg-danger` */
|
||||
dangerLight?: string;
|
||||
dangerDark?: string;
|
||||
/** Card surface (collections / pinned cards). → `--apg-card` */
|
||||
cardLight?: string;
|
||||
cardDark?: string;
|
||||
/** Card surface on hover. → `--apg-card-hover` */
|
||||
cardHoverLight?: string;
|
||||
cardHoverDark?: string;
|
||||
/** Top toolbar glass backdrop. → `--apg-toolbar-bg` */
|
||||
toolbarBgLight?: string;
|
||||
toolbarBgDark?: string;
|
||||
/** Context-menu / popover glass fill. → `--apg-menu-bg` */
|
||||
menuBgLight?: string;
|
||||
menuBgDark?: string;
|
||||
/** Hairline separators. → `--apg-separator` */
|
||||
separatorLight?: string;
|
||||
separatorDark?: string;
|
||||
/** Stronger separators (input borders, scrollbars). → `--apg-separator-strong` */
|
||||
separatorStrongLight?: string;
|
||||
separatorStrongDark?: string;
|
||||
/** Hover wash on rows / icon buttons. → `--apg-hover` */
|
||||
hoverLight?: string;
|
||||
hoverDark?: string;
|
||||
/** Pressed/active wash. → `--apg-active` */
|
||||
activeLight?: string;
|
||||
activeDark?: string;
|
||||
/** Selected sidebar row background. → `--apg-sidebar-selected` */
|
||||
sidebarSelectedLight?: string;
|
||||
sidebarSelectedDark?: string;
|
||||
/** Secondary text (labels, captions). → `--apg-text-secondary` */
|
||||
textSecondaryLight?: string;
|
||||
textSecondaryDark?: string;
|
||||
/** Tertiary text (hints, timestamps). → `--apg-text-tertiary` */
|
||||
textTertiaryLight?: string;
|
||||
textTertiaryDark?: string;
|
||||
/** Border on glass surfaces (menus, sidebar, info panel). → `--apg-glass-border` */
|
||||
glassBorderLight?: string;
|
||||
glassBorderDark?: string;
|
||||
/** Font stack for the whole gallery. → `--apg-font` */
|
||||
fontFamily?: string;
|
||||
/** Menu / popover corner radius in px. → `--apg-radius-menu` */
|
||||
radiusMenu?: number;
|
||||
/** Small elevation shadow. → `--apg-shadow-sm` */
|
||||
shadowSm?: string;
|
||||
/** Medium elevation shadow (menus, action bar). → `--apg-shadow-md` */
|
||||
shadowMdLight?: string;
|
||||
shadowMdDark?: string;
|
||||
/** Large elevation shadow (modals). → `--apg-shadow-lg` */
|
||||
shadowLgLight?: string;
|
||||
shadowLgDark?: string;
|
||||
/** Favourite heart on a grid tile. → `--apg-tile-fav` */
|
||||
tileFav?: string;
|
||||
/** Full-screen lightbox backdrop. → `--apg-overlay-bg` */
|
||||
overlayBg?: string;
|
||||
/** Photo/video editor chrome background. → `--apg-editor-bg` */
|
||||
editorBg?: string;
|
||||
/** Active pill inside a segmented control. → `--apg-segmented-active` */
|
||||
segmentedActive?: string;
|
||||
/** Sidebar width in px. → `--apg-sidebar-w` */
|
||||
sidebarWidth?: number;
|
||||
/** Toolbar height in px. → `--apg-toolbar-h` */
|
||||
toolbarHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server-backed, per-user lock for the Recently Deleted view.
|
||||
*
|
||||
* When a host supplies one the gallery delegates every lock operation to it and
|
||||
* NEVER touches its device-local `localStorage` hash — so the lock follows the
|
||||
* user across devices and is enforced by the host's backend.
|
||||
*/
|
||||
export interface LockProvider {
|
||||
/** Whether this user currently has a lock password configured. */
|
||||
status(): Promise<{ hasPassword: boolean }>;
|
||||
/** Set (or, with `null`, clear) the password. */
|
||||
set(password: string | null): Promise<void>;
|
||||
/** Check a password. Resolve `false` for a wrong password; reject on failure. */
|
||||
verify(password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Which pieces of the gallery's own window chrome are rendered. */
|
||||
export interface GalleryChrome {
|
||||
/** macOS-style traffic-light title bar. */
|
||||
titlebar: boolean;
|
||||
/** Left navigation sidebar. */
|
||||
sidebar: boolean;
|
||||
/** Top toolbar (zoom / filter / search / more). */
|
||||
toolbar: boolean;
|
||||
/** Light/Dark/Semi-Dark items in the "More" menu. Turn off when the host owns the theme. */
|
||||
themeSwitcher: boolean;
|
||||
}
|
||||
|
||||
/** Default chrome: everything except the macOS titlebar (which stays opt-in). */
|
||||
export const DEFAULT_CHROME: GalleryChrome = {
|
||||
titlebar: false,
|
||||
sidebar: true,
|
||||
toolbar: true,
|
||||
themeSwitcher: true,
|
||||
};
|
||||
|
||||
export interface GalleryConfig {
|
||||
features: GalleryFeatures;
|
||||
accentColor: string;
|
||||
@@ -88,6 +196,28 @@ export interface GalleryConfig {
|
||||
title: string;
|
||||
/** Optional theme-token overrides mapped to CSS variables at runtime. */
|
||||
themeTokens?: ThemeTokens;
|
||||
/** The host app's signed-in user. Enables identity-stamped comments. */
|
||||
currentUser?: GalleryUser;
|
||||
/** Base URL used to build share links (default: `${location.origin}/gallery`). */
|
||||
shareBaseUrl?: string;
|
||||
/** Which pieces of the gallery's own chrome to render. */
|
||||
chrome: GalleryChrome;
|
||||
/**
|
||||
* Sidebar rows + Collections sections to hide (by ViewId), e.g.
|
||||
* `['screenshots', 'sys:documents']`. Absent/`[]` = nothing hidden.
|
||||
*/
|
||||
hiddenViews?: ViewId[];
|
||||
/** Whether global keyboard shortcuts are bound to `window`. */
|
||||
keyboardShortcuts: boolean;
|
||||
/** Embedded mode: lay out inside a host container instead of a full viewport. */
|
||||
embedded: boolean;
|
||||
/**
|
||||
* Server-backed per-user lock for Recently Deleted. When present it REPLACES
|
||||
* the device-local localStorage hash entirely.
|
||||
*/
|
||||
lockProvider?: LockProvider;
|
||||
/** Start the gallery maximised over the host's chrome (default false). */
|
||||
defaultFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export interface GalleryState {
|
||||
@@ -149,15 +279,39 @@ export interface GalleryState {
|
||||
/** Whether the custom camera is open. */
|
||||
cameraOpen: boolean;
|
||||
|
||||
/** Client-side lock for the Recently Deleted view (hash persisted in localStorage). */
|
||||
/** Whether the gallery is maximised over the host page (adds `apg--fullscreen`). */
|
||||
fullscreen: boolean;
|
||||
|
||||
/**
|
||||
* Client-side lock for the Recently Deleted view (hash persisted in localStorage).
|
||||
* IGNORED when `config.lockProvider` is set — read `lockConfigured` instead.
|
||||
*/
|
||||
lock: { hash: string | null };
|
||||
/**
|
||||
* Whether a lock password exists. Single source of truth for the UI: mirrors
|
||||
* `lock.hash !== null` on the localStorage path and `lockProvider.status()`
|
||||
* when a provider is configured.
|
||||
*/
|
||||
lockConfigured: boolean;
|
||||
/** Whether the lock has been opened this session (in-memory, never persisted). */
|
||||
lockUnlocked: boolean;
|
||||
/**
|
||||
* Last lock failure. `'wrong-password'` = verify() answered "no";
|
||||
* `'unavailable'` = the provider threw / the network failed (a very different
|
||||
* message for the user — retry, don't re-type).
|
||||
*/
|
||||
lockError: 'wrong-password' | 'unavailable' | null;
|
||||
/** Share records created on this device (persisted in localStorage). */
|
||||
shares: ShareRecord[];
|
||||
|
||||
// ---- actions ----
|
||||
init: (adapter: StorageAdapter, ai: AIProvider | null) => Promise<void>;
|
||||
/**
|
||||
* Replace the live config. `<PhotoGallery>` calls this whenever its props
|
||||
* recompute, so a host changing theme tokens / user / chrome takes effect
|
||||
* without remounting the gallery.
|
||||
*/
|
||||
setConfig: (config: GalleryConfig) => void;
|
||||
addMedia: (items: MediaItem[]) => void;
|
||||
/** Import File objects: builds MediaItems, uploads blobs to the adapter if it supports it. */
|
||||
importFiles: (files: FileList | File[], albumId?: AlbumId) => Promise<MediaId[]>;
|
||||
@@ -219,9 +373,16 @@ export interface GalleryState {
|
||||
|
||||
// Recently Deleted lock
|
||||
setLockPassword: (password: string) => Promise<void>;
|
||||
removeLockPassword: () => void;
|
||||
removeLockPassword: () => Promise<void>;
|
||||
unlockLock: (password: string) => Promise<boolean>;
|
||||
relock: () => void;
|
||||
/** Re-read `lockProvider.status()` (no-op without a provider). */
|
||||
refreshLockStatus: () => Promise<void>;
|
||||
clearLockError: () => void;
|
||||
|
||||
// Fullscreen / maximise
|
||||
setFullscreen: (next: boolean) => void;
|
||||
toggleFullscreen: () => void;
|
||||
|
||||
// Sharing
|
||||
createShare: (scope: ShareRecord['scope'], mediaIds: MediaId[], albumId?: AlbumId) => ShareRecord;
|
||||
@@ -293,20 +454,153 @@ function readShares(): ShareRecord[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** Index entities by id, holding the object REFERENCE (identity = "unchanged"). */
|
||||
function snapshotById<T extends { id: string }>(items: readonly T[]): Map<string, object> {
|
||||
const map = new Map<string, object>();
|
||||
for (const item of items) map.set(item.id, item);
|
||||
return map;
|
||||
}
|
||||
|
||||
function sameRecord(a: Record<string, string>, b: Record<string, string>): boolean {
|
||||
const ak = Object.keys(a);
|
||||
const bk = Object.keys(b);
|
||||
if (ak.length !== bk.length) return false;
|
||||
return ak.every((k) => a[k] === b[k]);
|
||||
}
|
||||
|
||||
function sameList(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
export function createGalleryStore(options: CreateStoreOptions) {
|
||||
// Non-reactive references kept out of state.
|
||||
let adapter: StorageAdapter | null = null;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ---- Incremental-persistence bookkeeping (per store instance) ----
|
||||
// Snapshots of the LAST SUCCESSFULLY PERSISTED entities, by id, holding the
|
||||
// object reference so a `!==` check is a complete change detector (the store
|
||||
// never mutates entities in place — every update produces a new object).
|
||||
let lastMedia = new Map<string, object>();
|
||||
let lastAlbums = new Map<string, object>();
|
||||
let lastPeople = new Map<string, object>();
|
||||
let lastAliases: Record<string, string> = {};
|
||||
let lastDeletedLabels: string[] = [];
|
||||
/** Guards against overlapping in-flight writes reordering on a slow backend. */
|
||||
let persisting = false;
|
||||
let persistDirty = false;
|
||||
/** storageRefs resolved by `uploadBlob` before the media item existed. */
|
||||
const pendingStorageRefs = new Map<MediaId, string>();
|
||||
|
||||
const store = createStore<GalleryState>((set, get) => {
|
||||
/** Seed the snapshots from a just-loaded backend state so a fresh load
|
||||
* does not re-upload the entire library on the first persist. */
|
||||
const seedSnapshots = (
|
||||
media: readonly MediaItem[],
|
||||
albums: readonly Album[],
|
||||
people: readonly Person[],
|
||||
labelAliases: Record<string, string>,
|
||||
deletedLabels: string[],
|
||||
) => {
|
||||
lastMedia = snapshotById(media);
|
||||
lastAlbums = snapshotById(albums.filter((a) => !a.system));
|
||||
lastPeople = snapshotById(people);
|
||||
lastAliases = { ...labelAliases };
|
||||
lastDeletedLabels = [...deletedLabels];
|
||||
};
|
||||
|
||||
const runPersist = async (): Promise<void> => {
|
||||
if (!adapter) return;
|
||||
// A slow backend must not be able to reorder writes: queue instead.
|
||||
if (persisting) {
|
||||
persistDirty = true;
|
||||
return;
|
||||
}
|
||||
persisting = true;
|
||||
try {
|
||||
const { media, albums, people, labelAliases, deletedLabels } = get();
|
||||
// Only persist user-owned albums; smart/system albums are regenerated.
|
||||
const userAlbums = albums.filter((a) => !a.system);
|
||||
|
||||
if (!adapter.applyChanges) {
|
||||
await adapter.save({
|
||||
media,
|
||||
albums: userAlbums,
|
||||
people,
|
||||
labelAliases,
|
||||
deletedLabels,
|
||||
version: 1,
|
||||
});
|
||||
// Keep the snapshots current so an adapter that gains applyChanges
|
||||
// later (or a diff-based retry) starts from the persisted truth.
|
||||
seedSnapshots(media, userAlbums, people, labelAliases, deletedLabels);
|
||||
return;
|
||||
}
|
||||
|
||||
const changes: StateChanges = {};
|
||||
const nextMedia = snapshotById(media);
|
||||
const nextAlbums = snapshotById(userAlbums);
|
||||
const nextPeople = snapshotById(people);
|
||||
|
||||
const upsertMedia = media.filter((m) => lastMedia.get(m.id) !== m);
|
||||
const removeMedia = [...lastMedia.keys()].filter((id) => !nextMedia.has(id));
|
||||
const upsertAlbums = userAlbums.filter((a) => lastAlbums.get(a.id) !== a);
|
||||
const removeAlbums = [...lastAlbums.keys()].filter((id) => !nextAlbums.has(id));
|
||||
const upsertPeople = people.filter((p) => lastPeople.get(p.id) !== p);
|
||||
const removePeople = [...lastPeople.keys()].filter((id) => !nextPeople.has(id));
|
||||
|
||||
if (upsertMedia.length) changes.upsertMedia = upsertMedia;
|
||||
if (removeMedia.length) changes.removeMedia = removeMedia;
|
||||
if (upsertAlbums.length) changes.upsertAlbums = upsertAlbums;
|
||||
if (removeAlbums.length) changes.removeAlbums = removeAlbums;
|
||||
if (upsertPeople.length) changes.upsertPeople = upsertPeople;
|
||||
if (removePeople.length) changes.removePeople = removePeople;
|
||||
if (!sameRecord(lastAliases, labelAliases)) changes.labelAliases = labelAliases;
|
||||
if (!sameList(lastDeletedLabels, deletedLabels)) changes.deletedLabels = deletedLabels;
|
||||
|
||||
if (Object.keys(changes).length === 0) return;
|
||||
|
||||
// Optimistically advance the snapshots, then roll back on rejection so
|
||||
// the very next persist retries exactly the same diff.
|
||||
const prev = {
|
||||
media: lastMedia,
|
||||
albums: lastAlbums,
|
||||
people: lastPeople,
|
||||
aliases: lastAliases,
|
||||
deletedLabels: lastDeletedLabels,
|
||||
};
|
||||
lastMedia = nextMedia;
|
||||
lastAlbums = nextAlbums;
|
||||
lastPeople = nextPeople;
|
||||
lastAliases = { ...labelAliases };
|
||||
lastDeletedLabels = [...deletedLabels];
|
||||
try {
|
||||
await adapter.applyChanges(changes);
|
||||
} catch (err) {
|
||||
lastMedia = prev.media;
|
||||
lastAlbums = prev.albums;
|
||||
lastPeople = prev.people;
|
||||
lastAliases = prev.aliases;
|
||||
lastDeletedLabels = prev.deletedLabels;
|
||||
throw err;
|
||||
}
|
||||
} catch {
|
||||
/* Persistence is best-effort — the app keeps working in memory. */
|
||||
} finally {
|
||||
persisting = false;
|
||||
if (persistDirty) {
|
||||
persistDirty = false;
|
||||
persist();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const persist = () => {
|
||||
if (!adapter) return;
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(() => {
|
||||
const { media, albums, people, labelAliases, deletedLabels } = get();
|
||||
// Only persist user-owned albums; smart/system albums are regenerated.
|
||||
const userAlbums = albums.filter((a) => !a.system);
|
||||
void adapter!.save({ media, albums: userAlbums, people, labelAliases, deletedLabels, version: 1 });
|
||||
persistTimer = null;
|
||||
void runPersist();
|
||||
}, 400);
|
||||
};
|
||||
|
||||
@@ -348,8 +642,13 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
aiStatus: { running: false, done: 0, total: 0 },
|
||||
infoOpen: false,
|
||||
cameraOpen: false,
|
||||
lock: { hash: lsGet(LOCK_KEY) },
|
||||
fullscreen: options.config.defaultFullscreen ?? false,
|
||||
// With a provider the localStorage hash is never read or written; the real
|
||||
// answer arrives from `refreshLockStatus()` during init().
|
||||
lock: { hash: options.config.lockProvider ? null : lsGet(LOCK_KEY) },
|
||||
lockConfigured: options.config.lockProvider ? false : lsGet(LOCK_KEY) !== null,
|
||||
lockUnlocked: false,
|
||||
lockError: null,
|
||||
shares: readShares(),
|
||||
|
||||
async init(a, ai) {
|
||||
@@ -363,6 +662,15 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
// demo `initialMedia` so a fresh install isn't blank.
|
||||
const seed = get().media;
|
||||
const seedEmptyBackend = loaded.media.length === 0 && seed.length > 0;
|
||||
// Seed the diff snapshots from what the backend just returned, so the
|
||||
// first persist only sends what actually changed afterwards.
|
||||
seedSnapshots(
|
||||
loaded.media,
|
||||
loaded.albums,
|
||||
loaded.people ?? [],
|
||||
loaded.labelAliases ?? {},
|
||||
loaded.deletedLabels ?? [],
|
||||
);
|
||||
set({
|
||||
media: seedEmptyBackend ? seed : loaded.media,
|
||||
albums: [...defaultSystemAlbums(ts), ...loaded.albums.filter((al) => !al.system)],
|
||||
@@ -386,10 +694,35 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
// Build object smart albums for an already-analyzed library (works even when
|
||||
// AI is disabled — labels persisted from a prior session still group).
|
||||
get().syncObjectAlbums();
|
||||
// A server-backed lock owns the truth about whether a password exists.
|
||||
await get().refreshLockStatus();
|
||||
},
|
||||
|
||||
setConfig(config) {
|
||||
// A host that only NOW supplies a lock provider (async auth, late mount)
|
||||
// must get its real lock status. Keyed on presence, not identity, so an
|
||||
// inline `lockProvider={{…}}` literal doesn't refetch on every render.
|
||||
const had = Boolean(get().config.lockProvider);
|
||||
const has = Boolean(config.lockProvider);
|
||||
set({ config });
|
||||
if (has && !had) void get().refreshLockStatus();
|
||||
else if (!has && had) {
|
||||
// Provider removed → fall back to the device-local hash.
|
||||
const hash = lsGet(LOCK_KEY);
|
||||
set({ lock: { hash }, lockConfigured: hash !== null, lockUnlocked: false });
|
||||
}
|
||||
},
|
||||
|
||||
addMedia(items) {
|
||||
if (!items.length) return;
|
||||
// Apply any storageRef resolved by uploadBlob() before the item existed.
|
||||
const withRefs = items.map((item) => {
|
||||
const ref = pendingStorageRefs.get(item.id);
|
||||
if (!ref) return item;
|
||||
pendingStorageRefs.delete(item.id);
|
||||
return item.storageRef ? item : { ...item, storageRef: ref };
|
||||
});
|
||||
items = withRefs;
|
||||
set((s) => ({ media: [...items, ...s.media] }));
|
||||
// Auto-file captures/imports into source albums (created on first use).
|
||||
const categories: Array<[string, string]> = [
|
||||
@@ -409,14 +742,35 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
async importFiles(files, albumId) {
|
||||
const arr = Array.from(files as ArrayLike<File>);
|
||||
const items: MediaItem[] = [];
|
||||
// Stamp the importing user (display identity) when the host supplies one.
|
||||
const currentUser = get().config.currentUser;
|
||||
for (const f of arr) {
|
||||
// mediaFromFile validates the type (image/video allow-list) and returns
|
||||
// null for anything else — the backend validation for uploads.
|
||||
const item = await mediaFromFile(f);
|
||||
if (!item) continue;
|
||||
// Record who imported it — but never overwrite an existing value on re-import.
|
||||
if (currentUser && !item.uploadedBy) item.uploadedBy = currentUser;
|
||||
const objectUrl = item.src; // blob: URL from mediaFromFile (dies on reload)
|
||||
// Preferred path: a durable byte store that hands back BOTH a renderable
|
||||
// URL and a permanent ref we can re-sign after a reload.
|
||||
if (adapter?.putMedia) {
|
||||
try {
|
||||
const stored = await adapter.putMedia(item.id, f, {
|
||||
name: item.name,
|
||||
mime: item.mime,
|
||||
});
|
||||
if (stored?.url) {
|
||||
item.src = stored.url;
|
||||
item.storageRef = stored.ref;
|
||||
item.thumbnail = undefined;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to putBlob / the data-URL fallback below */
|
||||
}
|
||||
}
|
||||
// Upload to the backend (if the adapter supports blobs) for a durable URL.
|
||||
if (adapter?.putBlob) {
|
||||
if (!item.storageRef && adapter?.putBlob) {
|
||||
try {
|
||||
const url = await adapter.putBlob(item.id, f);
|
||||
if (url) {
|
||||
@@ -455,6 +809,30 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
},
|
||||
|
||||
async uploadBlob(id, blob) {
|
||||
if (adapter?.putMedia) {
|
||||
try {
|
||||
const existing = get().media.find((m) => m.id === id);
|
||||
const stored = await adapter.putMedia(id, blob, {
|
||||
name: existing?.name ?? id,
|
||||
mime: existing?.mime ?? blob.type ?? 'application/octet-stream',
|
||||
});
|
||||
if (stored?.url) {
|
||||
// Record the durable ref on the item. When the item doesn't exist
|
||||
// yet (camera capture) it is stashed and applied by addMedia().
|
||||
if (existing) {
|
||||
set((s) => ({
|
||||
media: s.media.map((m) => (m.id === id ? { ...m, storageRef: stored.ref } : m)),
|
||||
}));
|
||||
persist();
|
||||
} else {
|
||||
pendingStorageRefs.set(id, stored.ref);
|
||||
}
|
||||
return stored.url;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to putBlob below */
|
||||
}
|
||||
}
|
||||
if (adapter?.putBlob) {
|
||||
try {
|
||||
return await adapter.putBlob(id, blob);
|
||||
@@ -542,6 +920,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
|
||||
addVersion(id, patch, changes) {
|
||||
const now = Date.now();
|
||||
const user = get().config.currentUser;
|
||||
set((s) => ({
|
||||
media: s.media.map((m) => {
|
||||
if (m.id !== id) return m;
|
||||
@@ -573,6 +952,10 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
height: patch.height ?? m.height,
|
||||
edits: patch.edits,
|
||||
changes: changes.length ? changes : ['Edited'],
|
||||
// Who made this edit (display identity), when the host supplies a user.
|
||||
...(user
|
||||
? { authorId: user.id, author: user.name, authorAvatar: user.avatarUrl }
|
||||
: {}),
|
||||
};
|
||||
return { ...m, ...patch, versions: [...base, newVersion], editedAt: now };
|
||||
}),
|
||||
@@ -582,6 +965,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
|
||||
restoreVersion(id, versionId) {
|
||||
const now = Date.now();
|
||||
const user = get().config.currentUser;
|
||||
set((s) => ({
|
||||
media: s.media.map((m) => {
|
||||
if (m.id !== id || !m.versions) return m;
|
||||
@@ -598,6 +982,10 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
height: target.height,
|
||||
edits: target.edits,
|
||||
changes: [`Restored version ${target.version}`],
|
||||
// Who performed the restore (display identity), when a user is set.
|
||||
...(user
|
||||
? { authorId: user.id, author: user.name, authorAvatar: user.avatarUrl }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
...m,
|
||||
@@ -617,7 +1005,19 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
addComment(id, text, author) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const comment: MediaComment = { id: nanoid(8), author, text: trimmed, createdAt: Date.now() };
|
||||
// With a host-provided user, comments carry a real identity instead of a
|
||||
// free-text name (the backend is still the authority on `authorId`).
|
||||
const user = get().config.currentUser;
|
||||
const comment: MediaComment = user
|
||||
? {
|
||||
id: nanoid(8),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
authorId: user.id,
|
||||
author: author ?? user.name,
|
||||
authorAvatar: user.avatarUrl,
|
||||
}
|
||||
: { id: nanoid(8), author, text: trimmed, createdAt: Date.now() };
|
||||
set((s) => ({
|
||||
media: s.media.map((m) =>
|
||||
m.id === id ? { ...m, comments: [...(m.comments ?? []), comment] } : m,
|
||||
@@ -626,6 +1026,16 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
persist();
|
||||
},
|
||||
deleteComment(id, commentId) {
|
||||
// Client-side mirror of the server rule: with a configured user you may
|
||||
// only delete your own comments (legacy comments with no authorId stay
|
||||
// deletable so pre-identity libraries keep working).
|
||||
const user = get().config.currentUser;
|
||||
if (user) {
|
||||
const target = get()
|
||||
.media.find((m) => m.id === id)
|
||||
?.comments?.find((c) => c.id === commentId);
|
||||
if (target && target.authorId !== undefined && target.authorId !== user.id) return;
|
||||
}
|
||||
set((s) => ({
|
||||
media: s.media.map((m) =>
|
||||
m.id === id ? { ...m, comments: (m.comments ?? []).filter((c) => c.id !== commentId) } : m,
|
||||
@@ -984,22 +1394,81 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
},
|
||||
|
||||
async setLockPassword(password) {
|
||||
const provider = get().config.lockProvider;
|
||||
if (provider) {
|
||||
try {
|
||||
await provider.set(password);
|
||||
} catch {
|
||||
set({ lockError: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
set({ lockConfigured: true, lockUnlocked: true, lockError: null });
|
||||
return;
|
||||
}
|
||||
const hash = await hashPassword(password);
|
||||
if (!hash) return;
|
||||
lsSet(LOCK_KEY, hash);
|
||||
set({ lock: { hash }, lockUnlocked: true });
|
||||
set({ lock: { hash }, lockConfigured: true, lockUnlocked: true, lockError: null });
|
||||
},
|
||||
removeLockPassword() {
|
||||
async removeLockPassword() {
|
||||
const provider = get().config.lockProvider;
|
||||
if (provider) {
|
||||
try {
|
||||
await provider.set(null);
|
||||
} catch {
|
||||
set({ lockError: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
set({ lockConfigured: false, lockUnlocked: false, lockError: null });
|
||||
return;
|
||||
}
|
||||
lsSet(LOCK_KEY, null);
|
||||
set({ lock: { hash: null }, lockUnlocked: false });
|
||||
set({ lock: { hash: null }, lockConfigured: false, lockUnlocked: false, lockError: null });
|
||||
},
|
||||
async unlockLock(password) {
|
||||
const provider = get().config.lockProvider;
|
||||
if (provider) {
|
||||
let ok: boolean;
|
||||
try {
|
||||
ok = await provider.verify(password);
|
||||
} catch {
|
||||
// A rejected verify is NOT a wrong password — say so.
|
||||
set({ lockError: 'unavailable' });
|
||||
return false;
|
||||
}
|
||||
set(ok ? { lockUnlocked: true, lockError: null } : { lockError: 'wrong-password' });
|
||||
return ok;
|
||||
}
|
||||
const ok = await verifyPassword(password, get().lock.hash);
|
||||
if (ok) set({ lockUnlocked: true });
|
||||
set(ok ? { lockUnlocked: true, lockError: null } : { lockError: 'wrong-password' });
|
||||
return ok;
|
||||
},
|
||||
relock() {
|
||||
set({ lockUnlocked: false });
|
||||
set({ lockUnlocked: false, lockError: null });
|
||||
},
|
||||
async refreshLockStatus() {
|
||||
const provider = get().config.lockProvider;
|
||||
if (!provider) return;
|
||||
try {
|
||||
const { hasPassword } = await provider.status();
|
||||
set((s) => ({
|
||||
lockConfigured: hasPassword,
|
||||
// Losing the password server-side must not leave a stale unlock.
|
||||
lockUnlocked: hasPassword ? s.lockUnlocked : false,
|
||||
}));
|
||||
} catch {
|
||||
set({ lockError: 'unavailable' });
|
||||
}
|
||||
},
|
||||
clearLockError() {
|
||||
set({ lockError: null });
|
||||
},
|
||||
|
||||
setFullscreen(next) {
|
||||
set({ fullscreen: next });
|
||||
},
|
||||
toggleFullscreen() {
|
||||
set((s) => ({ fullscreen: !s.fullscreen }));
|
||||
},
|
||||
|
||||
createShare(scope, mediaIds, albumId) {
|
||||
@@ -1013,6 +1482,8 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
: `${mediaIds.length} Photos`;
|
||||
const origin =
|
||||
typeof location !== 'undefined' && location.origin ? location.origin : 'https://photos.app';
|
||||
// The host owns where a share link points (its own route, not ours).
|
||||
const base = get().config.shareBaseUrl ?? `${origin}/gallery`;
|
||||
const share: ShareRecord = {
|
||||
id: nanoid(12),
|
||||
token,
|
||||
@@ -1020,7 +1491,7 @@ export function createGalleryStore(options: CreateStoreOptions) {
|
||||
mediaIds,
|
||||
albumId,
|
||||
title,
|
||||
url: `${origin}/gallery?shared=${token}`,
|
||||
url: `${base}?shared=${token}`,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const next = [share, ...get().shares];
|
||||
|
||||
@@ -292,29 +292,62 @@
|
||||
background: var(--apg-bg-content);
|
||||
}
|
||||
|
||||
/* ---- Toolbar ---- */
|
||||
/* ---- Toolbar (floating rounded glass bar — matches the sidebar) ----
|
||||
The toolbar stays IN FLOW as a flex item of `.apg-main` and floats via margins
|
||||
on all sides. Because it remains in flow, `.apg-viewport` naturally begins below
|
||||
it — the grid is never hidden behind the bar and scrolling stays inside the
|
||||
viewport (no manual top-padding / overlap bookkeeping needed). `position:
|
||||
relative` scopes the absolutely-centred control to the toolbar itself. */
|
||||
.apg-toolbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: var(--apg-toolbar-h);
|
||||
padding: 0 14px;
|
||||
padding: 0 12px;
|
||||
margin: 8px;
|
||||
background: var(--apg-toolbar-bg);
|
||||
backdrop-filter: saturate(180%) blur(24px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(24px);
|
||||
border-bottom: var(--apg-hairline) solid var(--apg-glass-border);
|
||||
border: var(--apg-hairline) solid var(--apg-glass-border);
|
||||
border-radius: var(--apg-radius-lg);
|
||||
box-shadow: var(--apg-shadow-md);
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
.apg-toolbar__spacer { flex: 1; }
|
||||
.apg-toolbar__spacer { flex: 1 1 0; min-width: 0; }
|
||||
/* In normal flow between two spacers: centred when there's room, squeezed (never
|
||||
overlapping the right-hand zoom/filter group) when the toolbar is narrow. The old
|
||||
`position:absolute; left:50%` overlapped the right group on any narrow toolbar —
|
||||
and its 720px media query keyed on VIEWPORT width, so it never fired when the
|
||||
toolbar was narrowed by an embedding host's sidebars. */
|
||||
.apg-toolbar__center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.apg-toolbar__center { position: static; transform: none; margin: 0 auto; }
|
||||
|
||||
/* Compact library-scale dropdown (replaces the wide 3-item segmented). */
|
||||
.apg-scalemenu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px 5px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--apg-bg-elevated);
|
||||
color: var(--apg-text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.apg-scalemenu:hover { background: var(--apg-hover); }
|
||||
.apg-scalemenu svg { color: var(--apg-text-secondary); }
|
||||
.apg-scalemenu__label { line-height: 1; }
|
||||
|
||||
.apg-iconbtn {
|
||||
width: 30px;
|
||||
@@ -359,12 +392,12 @@
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.apg-segmented__item--active {
|
||||
background: var(--apg-bg);
|
||||
background: var(--apg-segmented-active, var(--apg-bg));
|
||||
box-shadow: var(--apg-shadow-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.apg[data-theme='dark'] .apg-segmented__item--active {
|
||||
background: #636366;
|
||||
background: var(--apg-segmented-active, #636366);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
@@ -395,9 +428,18 @@
|
||||
.apg-search:focus-within {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--apg-accent) 32%, transparent);
|
||||
}
|
||||
/* ONE ring, not two: the global `.apg :focus-visible` outline would otherwise
|
||||
draw a second line INSIDE the wrapper's ring. The wrapper ring is the
|
||||
focus indicator for this control. */
|
||||
.apg-search input:focus-visible,
|
||||
.apg-search input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.apg-search__recents {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
/* Clears the 3px focus ring so the popover's own hairline never reads as a
|
||||
second ring stacked under the field. */
|
||||
top: calc(100% + 9px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
z-index: 60;
|
||||
@@ -515,7 +557,7 @@
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 7px;
|
||||
color: #ff3b30;
|
||||
color: var(--apg-tile-fav, #ff3b30);
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
/* ---- Custom video player (macOS-style) ---- */
|
||||
@@ -864,6 +906,20 @@
|
||||
|
||||
/* ---- Map thumbnail pins (macOS-style) ---- */
|
||||
.apg-pin-wrap { background: none !important; border: none !important; }
|
||||
/* Pins + their hover tooltip are pointer targets, never text: suppress selection so
|
||||
dragging across the map never leaves a grey selection rectangle over the pin/preview. */
|
||||
.apg-pin,
|
||||
.apg-pin__tip {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.apg-pin__img,
|
||||
.apg-pin__tip-img,
|
||||
.apg-pin__strip-cell img {
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.apg-pin {
|
||||
position: relative;
|
||||
display: block;
|
||||
@@ -897,9 +953,12 @@
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 10px);
|
||||
transform: translateX(-50%) scale(0.9);
|
||||
/* --tip-dx is set per-hover by MapView so the tooltip slides back inside the map
|
||||
when a pin sits near the left/right edge (instead of overflowing + being clipped). */
|
||||
transform: translateX(calc(-50% + var(--tip-dx, 0px))) scale(0.9);
|
||||
transform-origin: bottom center;
|
||||
width: 168px;
|
||||
max-width: 70vw;
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
background: var(--apg-menu-bg, #fff);
|
||||
@@ -913,7 +972,24 @@
|
||||
}
|
||||
.apg-pin:hover .apg-pin__tip {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
transform: translateX(calc(-50% + var(--tip-dx, 0px))) scale(1);
|
||||
}
|
||||
/* Pin near the TOP of the map: flip the tooltip below so it isn't clipped off-screen. */
|
||||
.apg-pin__tip--below {
|
||||
bottom: auto;
|
||||
top: calc(100% + 10px);
|
||||
transform-origin: top center;
|
||||
}
|
||||
.apg-pin__tip--below::after { top: auto; bottom: 100%; }
|
||||
/* Invisible bridge across the 10px gap so the pointer can travel from the pin
|
||||
into the tooltip (to click a thumbnail) without dropping :hover. */
|
||||
.apg-pin__tip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
height: 12px;
|
||||
}
|
||||
.apg-pin__tip-img {
|
||||
width: 100%;
|
||||
@@ -950,19 +1026,71 @@
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 9px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
min-width: 19px;
|
||||
height: 19px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
/* Opaque enough to stay legible over a bright photo, with a hairline ring so
|
||||
the digits never blend into a dark one. */
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.9);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 19px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hover mini-slider: a strip of the cluster's thumbnails under the preview. */
|
||||
.apg-pin__strip {
|
||||
display: none;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.apg-pin__strip:not(:empty) { display: flex; }
|
||||
.apg-pin__strip-cell {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.62;
|
||||
transition: opacity 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.apg-pin__strip-cell img {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.apg-pin__strip-cell:hover { opacity: 1; }
|
||||
.apg-pin__strip-cell--on {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px var(--apg-accent);
|
||||
}
|
||||
.apg-pin__strip-more {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 5px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
border-radius: 5px;
|
||||
background: var(--apg-hover, rgba(0, 0, 0, 0.08));
|
||||
color: var(--apg-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* The tooltip is only interactive while hovered (it is pointer-events:none
|
||||
otherwise), so the strip's buttons stay clickable without stealing hits. */
|
||||
.apg-pin:hover .apg-pin__tip { pointer-events: auto; }
|
||||
|
||||
/* ---- Map location sheet (tap a pin → its photos, date-grouped) ---- */
|
||||
.apg-map__sheet {
|
||||
position: absolute;
|
||||
@@ -1016,6 +1144,227 @@
|
||||
}
|
||||
.apg-map__sheet-body { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
|
||||
/* ---- Map sheet filter bar (search + date range + object chips) ---- */
|
||||
.apg-map__sheet-filters {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--apg-bg-elevated);
|
||||
border-bottom: 1px solid var(--apg-separator);
|
||||
}
|
||||
.apg-mapfilter__row { display: flex; align-items: center; gap: 8px; }
|
||||
.apg-mapfilter__search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 9px;
|
||||
border-radius: var(--apg-radius);
|
||||
background: var(--apg-bg);
|
||||
border: 1px solid var(--apg-separator);
|
||||
color: var(--apg-text-secondary);
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
.apg-mapfilter__search:focus-within {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--apg-accent) 32%, transparent);
|
||||
}
|
||||
.apg-mapfilter__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--apg-text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* One ring only — the wrapper owns the focus indicator. */
|
||||
.apg-mapfilter__search input:focus,
|
||||
.apg-mapfilter__search input:focus-visible { outline: none; }
|
||||
.apg-mapfilter__search input::placeholder { color: var(--apg-text-secondary); }
|
||||
/* Hide the UA's own clear affordance; ours resets the dates too. */
|
||||
.apg-mapfilter__search input::-webkit-search-cancel-button { display: none; }
|
||||
.apg-mapfilter__clear {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--apg-separator-strong);
|
||||
color: var(--apg-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.apg-mapfilter__dates { flex-wrap: wrap; }
|
||||
.apg-mapfilter__date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--apg-text-secondary);
|
||||
}
|
||||
.apg-mapfilter__date input {
|
||||
padding: 4px 7px;
|
||||
border-radius: var(--apg-radius-sm);
|
||||
border: 1px solid var(--apg-separator);
|
||||
background: var(--apg-bg);
|
||||
color: var(--apg-text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
/* Safari sizes date inputs intrinsically; keep them from blowing out the row. */
|
||||
max-width: 148px;
|
||||
min-width: 0;
|
||||
}
|
||||
.apg-mapfilter__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 66px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.apg-mapfilter__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--apg-separator);
|
||||
background: var(--apg-bg);
|
||||
color: var(--apg-text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.apg-mapfilter__chip:hover { background: var(--apg-hover); }
|
||||
.apg-mapfilter__chip--on {
|
||||
background: var(--apg-accent);
|
||||
border-color: transparent;
|
||||
color: var(--apg-accent-contrast, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
.apg-mapfilter__chip-n {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.apg-mapfilter__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 34px 20px;
|
||||
color: var(--apg-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.apg-mapfilter__empty-title { font-size: 15px; font-weight: 600; color: var(--apg-text); }
|
||||
.apg-mapfilter__empty-sub { font-size: 12.5px; }
|
||||
|
||||
/* ---- Map filter: single date-range control (button + presets/custom popover) ---- */
|
||||
.apg-daterange { position: relative; }
|
||||
.apg-daterange__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--apg-radius);
|
||||
border: 1px solid var(--apg-separator);
|
||||
background: var(--apg-bg);
|
||||
color: var(--apg-text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.apg-daterange__button:hover { background: var(--apg-hover); }
|
||||
.apg-daterange__button svg:last-child { color: var(--apg-text-secondary); }
|
||||
.apg-daterange__button--active {
|
||||
border-color: var(--apg-accent);
|
||||
color: var(--apg-accent);
|
||||
}
|
||||
.apg-daterange__button--active svg { color: var(--apg-accent); }
|
||||
.apg-daterange__label { line-height: 1; white-space: nowrap; }
|
||||
.apg-daterange__pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
min-width: 232px;
|
||||
padding: 8px;
|
||||
background: var(--apg-menu-bg);
|
||||
border: var(--apg-hairline) solid var(--apg-glass-border);
|
||||
border-radius: var(--apg-radius-menu);
|
||||
box-shadow: var(--apg-shadow-md);
|
||||
backdrop-filter: blur(34px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(34px) saturate(180%);
|
||||
}
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.apg-daterange__pop { background: var(--apg-bg-elevated); }
|
||||
}
|
||||
.apg-daterange__presets {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.apg-daterange__preset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--apg-separator);
|
||||
border-radius: var(--apg-radius-sm);
|
||||
background: var(--apg-bg);
|
||||
color: var(--apg-text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.apg-daterange__preset:hover { background: var(--apg-hover); }
|
||||
.apg-daterange__preset--on {
|
||||
border-color: transparent;
|
||||
background: var(--apg-accent);
|
||||
color: var(--apg-accent-contrast, #fff);
|
||||
font-weight: 600;
|
||||
}
|
||||
.apg-daterange__preset--on svg { color: var(--apg-accent-contrast, #fff); }
|
||||
.apg-daterange__custom { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--apg-separator); }
|
||||
.apg-daterange__custom-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--apg-text-secondary);
|
||||
padding: 0 2px 6px;
|
||||
}
|
||||
.apg-daterange__fields { display: flex; gap: 8px; }
|
||||
.apg-daterange__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--apg-text-secondary);
|
||||
}
|
||||
.apg-daterange__field input {
|
||||
padding: 5px 7px;
|
||||
border-radius: var(--apg-radius-sm);
|
||||
border: 1px solid var(--apg-separator);
|
||||
background: var(--apg-bg);
|
||||
color: var(--apg-text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.apg-daterange__field input:focus { outline: none; border-color: var(--apg-accent); }
|
||||
|
||||
/* ---- Mosaic / Memories grid (Map → Grid tab) ---- */
|
||||
.apg-mosaic-wrap { padding: 4px 0 28px; }
|
||||
.apg-mosaic-section { padding: 6px 16px 10px; }
|
||||
@@ -1094,7 +1443,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.97);
|
||||
background: var(--apg-overlay-bg, rgba(0, 0, 0, 0.97));
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
@@ -1254,13 +1603,30 @@
|
||||
.apg-modal__list-item:hover { background: var(--apg-hover); }
|
||||
|
||||
/* ---- Editor ---- */
|
||||
.apg-editor { position: fixed; inset: 0; z-index: 1050; background: #161617; display: flex; flex-direction: column; color: #fff; }
|
||||
.apg-editor { position: fixed; inset: 0; z-index: 1050; background: var(--apg-editor-bg, #161617); display: flex; flex-direction: column; color: #fff; font-family: var(--apg-font); }
|
||||
.apg-editor__bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.apg-editor__bar .apg-iconbtn { color: #fff; }
|
||||
.apg-editor__bar .apg-iconbtn:hover { background: rgba(255,255,255,0.12); }
|
||||
.apg-editor__body { flex: 1; display: flex; min-height: 0; }
|
||||
.apg-editor__canvaswrap { flex: 1; display: grid; place-items: center; padding: 18px; min-width: 0; }
|
||||
.apg-editor__canvaswrap { flex: 1; display: grid; place-items: center; gap: 12px; padding: 18px; min-width: 0; }
|
||||
.apg-editor__canvaswrap canvas { max-width: 100%; max-height: 100%; border-radius: 4px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); }
|
||||
.apg-editor__canvaswrap video { cursor: pointer; }
|
||||
/* Custom video transport — sibling of the (transformable) preview, so it never
|
||||
rotates/flips with the frame. */
|
||||
.apg-vedit__transport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(560px, 100%);
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(30, 30, 32, 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.apg-vedit__transport .apg-iconbtn { color: #fff; flex: none; }
|
||||
.apg-vedit__scrub { flex: 1; min-width: 0; accent-color: var(--apg-accent); cursor: pointer; }
|
||||
.apg-vedit__time { flex: none; font-size: 11.5px; color: rgba(255, 255, 255, 0.8); font-variant-numeric: tabular-nums; }
|
||||
.apg-editor__panel { width: 300px; flex-shrink: 0; border-left: 1px solid rgba(255,255,255,0.1); padding: 16px; overflow-y: auto; }
|
||||
.apg-editor__tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 14px; }
|
||||
.apg-editor__tab { flex: 1 1 auto; padding: 6px 8px; border-radius: 7px; background: rgba(255,255,255,0.08); border: none; color: #fff; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||
@@ -1462,7 +1828,8 @@
|
||||
/* ---- Info panel ---- */
|
||||
.apg-info {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
/* A host with its own header can push the panel below it via --apg-overlay-top. */
|
||||
top: var(--apg-overlay-top, 64px);
|
||||
right: 14px;
|
||||
z-index: 1300;
|
||||
width: 300px;
|
||||
@@ -1496,6 +1863,14 @@
|
||||
border-radius: var(--apg-radius);
|
||||
background: var(--apg-bg);
|
||||
}
|
||||
/* Video preview: show the whole frame (never crop) over a dark backing, and let the
|
||||
native control bar sit below without forcing a fixed square. */
|
||||
.apg-info__thumb--video {
|
||||
height: auto;
|
||||
max-height: 220px;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.apg-info__name { font-weight: 600; font-size: 14px; margin-top: 10px; word-break: break-all; }
|
||||
.apg-info__sub { color: var(--apg-text-secondary); font-size: 12px; margin-bottom: 10px; }
|
||||
.apg-info__row {
|
||||
@@ -1608,9 +1983,174 @@
|
||||
}
|
||||
.apg-version__changes { list-style: disc; margin: 0 0 8px; padding-left: 16px; font-size: 12px; color: var(--apg-text); }
|
||||
.apg-version__changes li { padding: 1px 0; }
|
||||
/* Version author (who created this version) — avatar + name under the timestamp. */
|
||||
.apg-version__author { display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; }
|
||||
.apg-version__author-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--apg-bg-elevated);
|
||||
}
|
||||
.apg-version__author-avatar--initial {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--apg-accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.apg-version__author-name { font-size: 11px; color: var(--apg-text-secondary); }
|
||||
|
||||
.apg-comments { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.apg-comment { display: flex; gap: 8px; align-items: flex-start; }
|
||||
/* ---- Info panel: "Uploaded by" identity block ---- */
|
||||
.apg-info__uploader {
|
||||
/* Breathing room above (from the caption/note) AND below, so the card is a
|
||||
distinct block, not glued to the "Analyzing…" line or the detail rows. */
|
||||
margin: 12px 0 14px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--apg-radius);
|
||||
background: var(--apg-bg-elevated);
|
||||
border: 1px solid var(--apg-separator);
|
||||
}
|
||||
.apg-info__uploader-label {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--apg-text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.apg-info__uploader-row { display: flex; align-items: center; gap: 9px; }
|
||||
.apg-info__uploader-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--apg-bg);
|
||||
}
|
||||
.apg-info__uploader-avatar--initial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--apg-accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.apg-info__uploader-meta { display: flex; flex-direction: column; min-width: 0; }
|
||||
.apg-info__uploader-name { font-size: 13px; font-weight: 600; color: var(--apg-text); }
|
||||
.apg-info__uploader-email {
|
||||
font-size: 11px;
|
||||
color: var(--apg-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Info panel: editable caption / note ---- */
|
||||
.apg-editable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--apg-radius);
|
||||
background: var(--apg-bg-elevated);
|
||||
color: var(--apg-text);
|
||||
cursor: text;
|
||||
font-family: inherit;
|
||||
}
|
||||
.apg-editable:hover { border-color: var(--apg-separator-strong); }
|
||||
.apg-editable__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--apg-text-tertiary);
|
||||
}
|
||||
.apg-editable__label svg { margin-left: auto; opacity: 0.55; }
|
||||
.apg-editable__edited {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
color: var(--apg-text-tertiary);
|
||||
}
|
||||
.apg-editable__value { font-size: 12.5px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
|
||||
.apg-editable__value--empty { color: var(--apg-text-tertiary); font-style: italic; }
|
||||
.apg-editable--editing { cursor: default; }
|
||||
.apg-editable__input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--apg-accent);
|
||||
background: var(--apg-bg);
|
||||
color: var(--apg-text);
|
||||
border-radius: var(--apg-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ---- Info panel: full reverse-geocoded address ---- */
|
||||
.apg-address { margin-top: 6px; }
|
||||
.apg-address__line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
border-radius: var(--apg-radius);
|
||||
border: 1px solid var(--apg-glass-border);
|
||||
background: var(--apg-bg-elevated);
|
||||
color: var(--apg-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
.apg-address__line svg { flex-shrink: 0; margin-top: 1px; color: var(--apg-accent); }
|
||||
.apg-address__line:hover { border-color: var(--apg-accent); }
|
||||
.apg-address__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.apg-address__cell { display: flex; flex-direction: column; gap: 1px; }
|
||||
.apg-address__cell dt {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--apg-text-tertiary);
|
||||
}
|
||||
.apg-address__cell dd { margin: 0; font-size: 12px; color: var(--apg-text); word-break: break-word; }
|
||||
|
||||
.apg-comments { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
/* Each comment is a distinct card so consecutive comments (and the compose box
|
||||
below) read as separate, not one run-on block. */
|
||||
.apg-comment {
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
padding: 8px 9px;
|
||||
border-radius: 10px;
|
||||
background: var(--apg-bg-elevated);
|
||||
border: 1px solid var(--apg-separator);
|
||||
}
|
||||
/* Separate the compose form from the comment list above it. */
|
||||
.apg-comment-form { margin-top: 4px; padding-top: 12px; border-top: 1px solid var(--apg-separator); }
|
||||
.apg-comment__avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@@ -1624,6 +2164,22 @@
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Real avatar image (host-provided identity) — same footprint as the initial. */
|
||||
.apg-comment__avatar-img {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--apg-bg-elevated);
|
||||
}
|
||||
.apg-comment-form__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0 2px;
|
||||
}
|
||||
.apg-comment__body { flex: 1; min-width: 0; }
|
||||
.apg-comment__meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.apg-comment__author { font-size: 12.5px; font-weight: 600; }
|
||||
@@ -1675,7 +2231,7 @@
|
||||
.apg-info__analyzing {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 13px; font-weight: 500; color: var(--apg-accent);
|
||||
padding: 8px 10px; margin: 2px 0 4px;
|
||||
padding: 8px 10px; margin: 6px 0 10px;
|
||||
background: color-mix(in srgb, var(--apg-accent) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -1852,3 +2408,76 @@
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Embedded mode — the gallery lives inside a host app's layout.
|
||||
The full-screen overlays (lightbox, editor, camera, modals, context menu)
|
||||
intentionally stay `position: fixed`: that is correct for a modal rendered
|
||||
over a host application. Only the in-flow layout changes here.
|
||||
========================================================================= */
|
||||
.apg--embedded {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.apg--embedded .apg__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* macOS uses `cursor: default` on controls; inside a web app that reads as
|
||||
broken, so embedded mode restores the expected pointer affordance. */
|
||||
.apg--embedded .apg-btn,
|
||||
.apg--embedded .apg-iconbtn,
|
||||
.apg--embedded .apg-tile,
|
||||
.apg--embedded .apg-chip,
|
||||
.apg--embedded .apg-menu__item,
|
||||
.apg--embedded .apg-modal__list-item,
|
||||
.apg--embedded .apg-sidebar__item,
|
||||
.apg--embedded .apg-segmented__item,
|
||||
.apg--embedded .apg-lightbox__nav,
|
||||
.apg--embedded .apg-collections__action,
|
||||
.apg--embedded .apg-pinned-card,
|
||||
.apg--embedded .apg-editor__filter,
|
||||
.apg--embedded .apg-camera__tool,
|
||||
.apg--embedded .apg-scalemenu,
|
||||
.apg--embedded .apg-daterange__button,
|
||||
.apg--embedded .apg-daterange__preset,
|
||||
.apg--embedded .apg-editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.apg--embedded .apg-btn:disabled,
|
||||
.apg--embedded .apg-iconbtn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Full screen / maximise — the gallery covers the host page.
|
||||
|
||||
Why the SDK's own overlays need no change: the lightbox / editor / camera /
|
||||
modals are `position: fixed; inset: 0`, so they resolve against the VIEWPORT
|
||||
(a plain `position: fixed` ancestor is not a containing block for them — only
|
||||
transform/filter/contain would be). Full screen makes `.apg` cover exactly the
|
||||
viewport too, so the two coincide and the overlays land in the right place.
|
||||
And because `z-index: 1400` opens a stacking context, their 1000-1300 z-indexes
|
||||
now stack INSIDE it — above the gallery's content, and (via 1400) above the
|
||||
host's chrome. Nothing else needs raising. `overflow: hidden` on `.apg` does
|
||||
not clip them, for the same containing-block reason.
|
||||
========================================================================= */
|
||||
.apg--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1400;
|
||||
width: 100%;
|
||||
/* Fill the viewport regardless of what height the host container had.
|
||||
`dvh` (where supported) keeps mobile browser chrome from cropping it. */
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.apg--fullscreen .apg__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,18 @@ export interface GeoLocation {
|
||||
lng: number;
|
||||
/** Reverse-geocoded human label, e.g. "Mumbai, India". */
|
||||
place?: string;
|
||||
// ---- full-address fields (filled by reverse geocoding, all optional) ----
|
||||
road?: string;
|
||||
neighbourhood?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
county?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
/** Full one-line address from reverse geocoding (Nominatim display_name). */
|
||||
formatted?: string;
|
||||
}
|
||||
|
||||
/** Non-destructive edit adjustments (all normalized -1..1 unless noted). */
|
||||
@@ -194,6 +204,11 @@ export interface MediaItem {
|
||||
kind: MediaKind;
|
||||
/** Full-resolution source (URL, blob URL, or data URL). */
|
||||
src: string;
|
||||
/**
|
||||
* Durable backend reference for the bytes (adapter-owned). `src` may be a
|
||||
* short-lived signed URL derived from this; `storageRef` is what survives a reload.
|
||||
*/
|
||||
storageRef?: string;
|
||||
/** Optional smaller thumbnail; falls back to `src`. */
|
||||
thumbnail?: string;
|
||||
/** Optional poster frame for videos. */
|
||||
@@ -230,6 +245,16 @@ export interface MediaItem {
|
||||
location?: GeoLocation;
|
||||
exif?: Record<string, string | number>;
|
||||
|
||||
/** Who imported/captured this item (display identity; be-crm still owns owner_principal_id). */
|
||||
uploadedBy?: GalleryUser;
|
||||
|
||||
/**
|
||||
* User-authored free-form note (multi-line). Editable from the Info panel and
|
||||
* included in the search haystack.
|
||||
* @security Rendered ONLY via React JSX / textContent — never innerHTML.
|
||||
*/
|
||||
note?: string;
|
||||
|
||||
// AI-derived metadata (optional, filled lazily by providers).
|
||||
caption?: string;
|
||||
/**
|
||||
@@ -280,14 +305,39 @@ export interface MediaVersion {
|
||||
changes: string[];
|
||||
/** Optional user label / note. */
|
||||
note?: string;
|
||||
// ---- who created this version (display identity; all optional) ----
|
||||
authorId?: string;
|
||||
author?: string;
|
||||
authorAvatar?: string;
|
||||
}
|
||||
|
||||
/** A comment left on a photo/video. */
|
||||
export interface MediaComment {
|
||||
id: string;
|
||||
author?: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
/** Display name shown next to the comment. */
|
||||
author?: string;
|
||||
/**
|
||||
* The host app's stable user id for the comment's author. Set/enforced by the
|
||||
* backend, not the client — the UI only uses it to decide whether the current
|
||||
* user may delete this comment.
|
||||
*/
|
||||
authorId?: string;
|
||||
/** Avatar image URL for the author, when the host app knows one. */
|
||||
authorAvatar?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user of the HOST app. When provided the gallery stamps comments
|
||||
* with a real identity instead of a free-text name, and shows delete affordances
|
||||
* only on the current user's own comments.
|
||||
*/
|
||||
export interface GalleryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export type AlbumKind = 'user' | 'smart' | 'folder' | 'shared';
|
||||
|
||||
Reference in New Issue
Block a user