396 lines
92 KiB
JSON
396 lines
92 KiB
JSON
{
|
||
"summary": "Produce file-level implementation plans (in parallel) for the larger photo-SDK features: sharing/links, password lock, map pins, map grid mosaic, glass design, semantic search, video annotations",
|
||
"agentCount": 7,
|
||
"logs": [],
|
||
"result": {
|
||
"plans": [
|
||
{
|
||
"feature": "Share Action + Shared Albums + Activity Feed",
|
||
"approach": "\n## Architecture Overview\n\n### 1. **Data Model Additions** (TypeScript types in `packages/photo-sdk/src/types.ts`)\n - Extend `Album` with sharing fields:\n - `shareId?: string` – unique share token (generated on share creation)\n - `sharedAt?: number` – timestamp when shared (ms epoch)\n - `shareExpiry?: number` – optional expiry timestamp (ms epoch, for time-limited links)\n - `sharedWith?: Array<{ email?: string; name?: string; role?: 'view' | 'edit' }>` – recipients (future)\n \n - New `ShareRecord` type (stored in Supabase `gallery_shares` table):\n ```\n interface ShareRecord {\n id: string; // unique share record ID\n shareId: string; // short token for public links\n albumId?: AlbumId; // if sharing an album (null = single photo)\n mediaId?: MediaId; // if sharing a single photo\n mediaIds?: MediaId[]; // if sharing multiple photos\n createdBy: string; // user email or anon ID (for activity)\n createdAt: number; // ms epoch\n expiresAt?: number; // optional expiry (7 days = 604800000ms)\n downloadUrl: string; // pre-signed Supabase URL\n activityEvents?: ActivityEvent[]; // metadata for activity feed\n }\n ```\n \n - New `ActivityEvent` type:\n ```\n interface ActivityEvent {\n id: string;\n type: 'share' | 'comment' | 'download' | 'add';\n timestamp: number;\n actor?: string; // email or anon ID\n message: string; // e.g., \"Shared 3 photos\"\n shareId?: string; // link back to share record\n }\n ```\n\n### 2. **StorageAdapter Extension** (`packages/photo-sdk/src/adapters/types.ts`)\n - Add method to adapter interface:\n ```typescript\n generateShareLink(\n shareId: string,\n mediaIds: MediaId[],\n albumId?: AlbumId,\n expiresInMs?: number\n ): Promise<{ url: string; expiresAt?: number }>;\n ```\n - Optional activity logging method:\n ```typescript\n logActivity?(\n shareId: string,\n event: ActivityEvent\n ): Promise<void>;\n ```\n\n### 3. **Supabase Adapter Implementation** (`apps/web/src/lib/adapters/supabaseAdapter.ts`)\n - Implement signed URL generation:\n - For single photos: direct Supabase Storage signed URL (7-day default)\n - For albums/multiple: ZIP download via Supabase Edge Function (if available) or direct multi-file signed URLs\n - Alternative (simpler): store share metadata in `gallery_shares` table with time-limited tokens\n - Use Supabase RLS policies to secure access (read-only for public share links, write-restricted to creator)\n - SQL schema (run in `docs/supabase-setup.sql`):\n ```sql\n CREATE TABLE gallery_shares (\n id TEXT PRIMARY KEY,\n share_id TEXT UNIQUE NOT NULL,\n album_id TEXT,\n media_ids TEXT[],\n created_by TEXT NOT NULL,\n created_at BIGINT NOT NULL,\n expires_at BIGINT,\n download_url TEXT NOT NULL,\n updated_at BIGINT,\n FOREIGN KEY (album_id) REFERENCES gallery_albums(id) ON DELETE CASCADE\n );\n CREATE INDEX idx_shares_share_id ON gallery_shares(share_id);\n CREATE INDEX idx_shares_album_id ON gallery_shares(album_id);\n CREATE INDEX idx_shares_expires_at ON gallery_shares(expires_at);\n \n CREATE TABLE gallery_activity (\n id TEXT PRIMARY KEY,\n share_id TEXT NOT NULL,\n event_type TEXT NOT NULL,\n timestamp BIGINT NOT NULL,\n actor TEXT,\n message TEXT,\n FOREIGN KEY (share_id) REFERENCES gallery_shares(id) ON DELETE CASCADE\n );\n CREATE INDEX idx_activity_share_id ON gallery_activity(share_id);\n CREATE INDEX idx_activity_timestamp ON gallery_activity(timestamp);\n ```\n\n### 4. **Store Updates** (`packages/photo-sdk/src/store/store.ts`)\n - New state fields in `GalleryState`:\n ```typescript\n // Sharing\n shareModalOpen: boolean;\n shareModalTarget: {\n type: 'single' | 'album' | 'multiple';\n mediaIds?: MediaId[];\n albumId?: AlbumId;\n } | null;\n recentShares: ShareRecord[]; // in-memory cache, last 10 shares\n ```\n - New actions:\n ```typescript\n openShareModal(mediaIds?: MediaId[], albumId?: AlbumId): void;\n closeShareModal(): void;\n createShare(mediaIds: MediaId[], albumId?: AlbumId, expiryDays?: number): Promise<ShareRecord>;\n fetchActivityFeed(albumId?: AlbumId): Promise<ActivityEvent[]>;\n ```\n\n### 5. **ShareModal Component** (New: `packages/photo-sdk/src/components/ShareModal.tsx`)\n - Modal asking \"What would you like to share?\"\n - Three options:\n 1. **Single Selected Photo**: if 1 item selected, show preview + \"Share this photo\"\n 2. **Multiple Photos**: if 2+ selected, show grid preview + \"Share [N] photos\"\n 3. **Entire Album**: if viewing album, show \"Share this album\"\n - On selection, generate share link:\n - Call adapter's `generateShareLink()`\n - Copy URL to clipboard (native `navigator.clipboard.writeText()`)\n - Show confirmation toast + share buttons (Copy Link / Message / Email placeholders)\n - Design: glass backdrop modal with rounded options, matching the reference design (backdrop-blur ~20px, semi-transparent dark fill, rounded containers with icons)\n\n### 6. **UI Integration Points**\n - **TopToolbar**: Add Share icon button (when selection > 0 or in album view)\n - **SelectionBar**: Add Share button alongside Favorite/Add to Album/Export (only when `features.sharing === true`)\n - **PhotoTile ContextMenu**: Add \"Share\" option (single photo)\n - **Album/GridScreens**: Add Share button in album header (for entire album)\n - All Share actions open `ShareModal`\n\n### 7. **SharedAlbumsView & ActivityView** (`packages/photo-sdk/src/components/views/GridScreens.tsx`)\n - **SharedAlbumsView**: List of albums shared by this user (fetch from Supabase `gallery_shares` where `album_id IS NOT NULL`)\n - Show album cover + name + \"Shared X days ago\"\n - Click to view share details (copy link, see who has access, expiry, revoke)\n - **ActivityView**: Timeline of recent share/download/comment events\n - Group by share (e.g., \"You shared 3 photos 2 hours ago\")\n - Show actor + timestamp + action\n - Load from Supabase `gallery_activity` table, sorted by timestamp DESC\n\n### 8. **Styling** (CSS in `packages/photo-sdk/src/styles/sdk.css`)\n - Share button: reuse existing `.apg-iconbtn` + `.apg-btn` classes\n - Modal backdrop: reuse `.apg-modal__backdrop`\n - Activity list: `.apg-activity__item`, `.apg-activity__group`, with timestamps and actor names\n - No new external deps (uses native CSS, existing design tokens)\n\n### 9. **Security & Privacy**\n - Supabase RLS policies ensure only the creator can view/revoke shares\n - Signed URLs are time-limited by default (7 days for storage, customizable)\n - Public links do NOT require auth (open in browser, no user session needed)\n - Optional email-based recipient list (future: send share invitation emails)\n - Activity feed is private per user/share (RLS gated)\n\n### 10. **Free/Lean Approach**\n - No ML deps in SDK (providers are injected, not bundled)\n - No third-party sharing services (Dropbox, Google Drive, OneDrive) — direct Supabase Storage\n - Activity metadata is lightweight (text + timestamp, no full-resolution previews)\n - Default 7-day expiry keeps storage footprint bounded\n - Optional ZIP download uses Supabase Functions (free tier available) or falls back to multi-file signed URLs\n\n### 11. **Configuration**\n - Add to `GalleryFeatures`:\n ```typescript\n sharing: boolean; // already present\n ```\n - Add to `GalleryConfig`:\n ```typescript\n shareExpiryDays?: number; // default 7\n maxShareSize?: number; // max bytes per share (default 1GB)\n ```\n\n### 12. **Supabase Adapter Changes** (concrete)\n - In `createSupabaseAdapter()`, add:\n ```typescript\n async generateShareLink(shareId, mediaIds, albumId, expiresInMs) {\n // 1. Validate media exists in Supabase Storage\n // 2. Generate Supabase Storage signed URLs (expiresIn = expiresInMs / 1000)\n // 3. For single file: return signed URL directly\n // 4. For multiple: create gallery_shares record + return share token URL\n // 5. Log activity event\n }\n async logActivity(shareId, event) {\n // Insert into gallery_activity table\n }\n ```\n - Ensure RLS policies protect shares from unauthorized access\n\n## File Structure (Summary)\n```\npackages/photo-sdk/src/\n components/\n ShareModal.tsx [NEW]\n views/GridScreens.tsx [UPDATED: SharedAlbumsView, ActivityView]\n SelectionBar.tsx [UPDATED: Add Share button]\n TopToolbar.tsx [UPDATED: Add Share icon + conditional logic]\n PhotoTile.tsx [UPDATED: Add Share to context menu]\n modals.tsx [UPDATED: Add shareModalOpener helper]\n store/\n store.ts [UPDATED: Add share state + actions]\n types.ts [UPDATED: Add ShareRecord, ActivityEvent, Album.shareId, etc.]\n adapters/\n types.ts [UPDATED: Add generateShareLink, logActivity methods]\n styles/sdk.css [UPDATED: Add .apg-activity-* and share-modal styles]\n\napps/web/src/lib/adapters/\n supabaseAdapter.ts [UPDATED: Implement generateShareLink, logActivity]\n \ndocs/\n supabase-setup.sql [UPDATED: Add gallery_shares, gallery_activity tables + RLS]\n```\n\n## Key Design Decisions\n\n1. **Supabase Signed URLs vs. Public Shares Table**: Chose signed URLs with optional metadata table because:\n - Signed URLs are ephemeral (no DB bloat)\n - Expiry is enforced by Supabase, not app logic\n - Falls back gracefully if backend fails\n - Shares table is optional (just metadata + activity logging)\n\n2. **Time-Limited by Default**: 7 days is conservative but safe for free tier (storage quota)\n\n3. **No Email Service Integration**: Share links are copied to clipboard or shared manually (via Messages, email, etc.)\n\n4. **Activity Feed is Optional**: Can be feature-gated or disabled if storage is a concern\n\n5. **Glass UI Styling**: Follows user's macOS reference (backdrop-blur, semi-transparent, rounded corners) using only CSS variables + no new dependencies\n",
|
||
"risks": "\n1. **Supabase RLS Complexity**: Multi-user sharing requires careful RLS policy design; test thoroughly with different user/anon scenarios. Risk: security hole if policies misconfigured.\n\n2. **Storage Quota**: Free tier Supabase has 1GB default; shared files count toward quota. Mitigation: enforce maxShareSize config + warn users on quota near-full.\n\n3. **ZIP Download Scalability**: If albums are large, multi-file ZIP via Edge Function may timeout. Mitigation: fall back to individual signed URLs, or async ZIP delivery via email.\n\n4. **Activity Log Growth**: Unbounded activity table could bloat; no archiving planned. Mitigation: optional TTL policy (auto-delete activity >90 days old) or disable logging for large-scale deployments.\n\n5. **Cross-Origin Issues**: If shared URL is accessed from different domain, CORS headers must allow. Mitigation: Supabase Storage handles CORS by default; test in isolation.\n\n6. **Token Collision**: Very low risk, but shareId generation (using nanoid) must not collide. Using nanoid(12) gives ~1e14 combinations; acceptable.\n\n7. **No Real-Time Notifications**: Activity feed is polled, not pushed; users won't see new comments immediately. Mitigation: add optional Supabase Realtime subscription in ActivityView (future work).\n\n8. **Revocation Not Implemented**: Once a share link is generated, it's valid until expiry; no manual revocation UI yet. Mitigation: mark shares as revoked in DB, check on access (post-MVP).\n",
|
||
"openQuestions": "\n1. **Email Integration**: Should share invitations send emails automatically, or just copy link? (Current plan: manual copy/paste; email is future feature)\n\n2. **Recipient Tracking**: Should we track who accessed a share (view analytics)? Adds complexity; suggest deferring to post-MVP.\n\n3. **Expiry UI**: Should users customize expiry (7/14/30 days) or is 7-day default sufficient for MVP?\n\n4. **ZIP Download**: Is Edge Function available on target Supabase plan, or should we assume multi-file signed URLs only?\n\n5. **Album vs. Single Photo**: When sharing from album view (entire album), should we auto-add all current photos, or create a \"smart\" share that includes future additions? (Current plan: snapshot at creation time)\n\n6. **Comments & Reactions**: ActivityView shows share events; should recipients be able to comment/like photos in shared album? (Current plan: activity is read-only for MVP)\n\n7. **Anon vs. Auth**: Should app require auth to create shares, or allow anon users? (Current plan: adapter supports both; RLS gates by creator ID)\n",
|
||
"files": [
|
||
{
|
||
"path": "packages/photo-sdk/src/types.ts",
|
||
"change": "Add ShareRecord, ActivityEvent types; extend Album with shareId, sharedAt, shareExpiry, sharedWith fields"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/adapters/types.ts",
|
||
"change": "Add generateShareLink(shareId, mediaIds, albumId?, expiresInMs?) and logActivity?(shareId, event) methods to StorageAdapter interface"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/store/store.ts",
|
||
"change": "Add shareModalOpen, shareModalTarget, recentShares to GalleryState; add openShareModal, closeShareModal, createShare, fetchActivityFeed actions"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/ShareModal.tsx",
|
||
"change": "Create new modal component with three share options (single photo, multiple, album); integrates with store.createShare(); shows confirmation + copy-to-clipboard"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/SelectionBar.tsx",
|
||
"change": "Add Share button (when selection.size > 0 and features.sharing); opens ShareModal with selected IDs"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/TopToolbar.tsx",
|
||
"change": "Add Share icon button (visible when selection.size > 0 or in album view); opens ShareModal"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/PhotoTile.tsx",
|
||
"change": "Add 'Share' option to context menu (for single photo); calls openShareModal(photoId)"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/modals.tsx",
|
||
"change": "Add shareModalOpener(mediaIds?, albumId?) helper function (mirrors promptAlbumName, addToAlbumPicker pattern)"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/views/GridScreens.tsx",
|
||
"change": "Implement SharedAlbumsView (list shared albums with cover + 'Shared X days ago') and ActivityView (timeline of share/download events); replace empty EmptyState placeholders"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/styles/sdk.css",
|
||
"change": "Add .apg-activity-* classes (list, item, timestamp, actor); style share modal + glass backdrop (backdrop-blur, semi-transparent dark, rounded); reuse existing button/modal classes"
|
||
},
|
||
{
|
||
"path": "apps/web/src/lib/adapters/supabaseAdapter.ts",
|
||
"change": "Implement generateShareLink() using Supabase Storage signed URLs; implement logActivity() by inserting into gallery_activity; handle expiry + album/single-photo logic"
|
||
},
|
||
{
|
||
"path": "docs/supabase-setup.sql",
|
||
"change": "Add gallery_shares table (id, share_id, album_id, media_ids, created_by, created_at, expires_at, download_url, updated_at); add gallery_activity table (id, share_id, event_type, timestamp, actor, message); add RLS policies for secure access"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"feature": "Password Lock for Recently Deleted View",
|
||
"approach": "Implement a client-side password protection system for the Recently Deleted view using bcrypt-hashing (via a lightweight library like tweetnacl or native crypto.subtle) to store password hashes in localStorage alongside the gallery state. The feature includes:\n\n1. **State Management (store.ts)**: Add to GalleryState a new `recentlyDeletedLock` object with `passwordHash: string | null` (persisted via StorageAdapter alongside media/albums) and `unlockedSessionTokens: Set<string>` (in-memory, cleared on mount). Add store actions: `setRecentlyDeletedPassword(hash)` and `unlockRecentlyDeleted(sessionToken)`.\n\n2. **Session Unlock State**: On successful password entry, generate a random sessionToken (via crypto.getRandomValues), store in in-memory Set, and clear on unmount/app reload. No cookie/localStorage tokens for security.\n\n3. **Settings UI (new file: RecentlyDeletedSettingsModal.tsx)**: A modal accessed from a new \"Security\" settings section, allowing users to:\n - View: \"Recently Deleted is locked\" or \"Recently Deleted is unlocked\"\n - Set new password (if none set): password input + confirm + set button\n - Change password (if already set): current password + new password + new confirm + save button\n - Remove password: current password + \"Remove Lock\" button\n\n Uses simple password validation (8+ chars, non-empty after trim).\n\n4. **Unlock Modal (new file: UnlockRecentlyDeletedModal.tsx)**: Simple password prompt shown when clicking Recently Deleted while locked:\n - Single password input field\n - On correct entry: dispatch unlock action with sessionToken, close modal, navigate to recently-deleted\n - On 3 failed attempts: show \"Too many attempts. Try again in 60 seconds\" and briefly disable input\n - Settings button to create/change password if none is set\n\n5. **RecentlyDeletedView.tsx Updates**: Replace the hardcoded unlock button with:\n - Check if `recentlyDeletedLock.passwordHash` exists AND session NOT unlocked\n - If locked: show lock screen with \"Enter Password\" button (opens UnlockRecentlyDeletedModal)\n - If unlocked or no password set: show media grid as normal\n\n6. **Sidebar.tsx Updates**: \n - Recently Deleted row: trailing lock/unlock icon reflects session state (locked = \"lock\", unlocked = \"unlock\")\n - Icon state wired to store subscription\n - Clicking trailing icon opens RecentlyDeletedSettingsModal instead of navigating\n\n7. **Password Hashing**: Use native `crypto.subtle.digest('SHA-256')` (no dependency) with a fixed salt embedded in code (not cryptographic - just for obfuscation since this is client-side). Hash format: `sha256:${base64(hash)}`. Alternatively, for better UX, use `argon2id` via a lightweight wasm library if available, or fall back to bcryptjs npm package.\n\n8. **StorageAdapter Integration**: Extend `PersistedState` type to include optional `recentlyDeletedLock?: { passwordHash: string | null }`. Persist via existing `save()` mechanism; no changes to load contract.\n\n9. **CSS (sdk.css)**: Add `.apg-password-input`, `.apg-password-feedback`, `.apg-unlock-modal` classes matching modal/input styles. Reuse existing `.apg-modal*` classes for consistency.",
|
||
"files": [
|
||
{
|
||
"path": "packages/photo-sdk/src/store/store.ts",
|
||
"change": "Add to GalleryState interface: `recentlyDeletedLock: { passwordHash: string | null; unlockedSessionTokens: Set<string> }`. Add actions: `setRecentlyDeletedPassword(hash: string | null)` and `unlockRecentlyDeleted(sessionToken: string)`. Initialize in createGalleryStore with empty state. Extend persist() to save recentlyDeletedLock.passwordHash via adapter."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/adapters/types.ts",
|
||
"change": "Update PersistedState interface to include optional `recentlyDeletedLock?: { passwordHash: string | null }`. This allows secure password hash storage alongside media/albums."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/views/RecentlyDeletedView.tsx",
|
||
"change": "Replace the hardcoded unlock button logic with store subscription to `recentlyDeletedLock.passwordHash` and session token check. If locked and no session token, render lock screen with 'Enter Password' button that opens UnlockRecentlyDeletedModal. If unlocked or no password, render MediaGrid as normal. Import hooks for store access."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/Sidebar.tsx",
|
||
"change": "Update 'Recently Deleted' row in Utilities section (line 190): change `trailing='lock'` to dynamic `trailing={sessionTokenPresent ? 'unlock' : 'lock'}` wired to store subscription. Add `onTrailingClick` handler to open RecentlyDeletedSettingsModal instead of setting view. Subscribe to recentlyDeletedLock state to update icon in real time."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/modals.tsx",
|
||
"change": "Add new export functions: `promptRecentlyDeletedPassword(mode: 'set' | 'unlock' | 'change', onConfirm: (password: string) => void)` and `openRecentlyDeletedSettings()`. These dispatch to modal components defined in separate files."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/UnlockRecentlyDeletedModal.tsx",
|
||
"change": "New file. React component displaying password input, submit button, and optional 'Settings' link. On submission: hash input, compare with stored hash (via crypto.subtle), on match generate sessionToken, dispatch unlock action, close modal. On 3 failures, show rate-limit message and disable input for 60s. Handles Escape to close and Enter to submit."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/RecentlyDeletedSettingsModal.tsx",
|
||
"change": "New file. React component with tabs or sections: 'Set Password' (if none), 'Change Password' (if exists), 'Remove Lock' (if exists). Each section uses password inputs. On submit: hash new password, store via action, close modal. Validates 8+ char minimum. Shows success/error feedback."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/lib/crypto.ts",
|
||
"change": "New utility file with functions: `hashPassword(password: string): Promise<string>` using crypto.subtle.digest('SHA-256') with embedded salt, returns 'sha256:${base64}' format. `verifyPassword(password: string, hash: string): Promise<boolean>` for comparison. Use TextEncoder/btoa for encoding. Add JSDoc note: 'Client-side obfuscation only; not cryptographically secure backend auth.'"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/styles/sdk.css",
|
||
"change": "Add CSS classes: `.apg-password-input` (inherits from `.apg-modal__input` with optional icon styling), `.apg-password-feedback` (font-size 13px, color var(--apg-text-secondary), margin-top 4px), `.apg-unlock-modal` (extends `.apg-modal`), `.apg-unlock-modal__attempt-count` (smaller text, gray). Optionally add `.apg-lock-screen` for the locked state overlay."
|
||
}
|
||
],
|
||
"risks": "1. **Plaintext in Memory**: Session tokens and unlocked state exist in-memory. XSS could steal tokens. Mitigation: contentSecurityPolicy headers, code review for innerHTML usage (only JSX/textContent in SDK).\n\n2. **Client-Side Hash Security**: SHA-256 with embedded salt is obfuscation only, not production-grade auth. A determined user can inspect source, extract salt, and brute-force. Mitigation: This is intentional design (client-side UX feature, not backend security). Document as 'privacy, not security'. For actual sensitive data, hash server-side or use WebAuthn (biometric).\n\n3. **No Rate Limiting Persistence**: Rate-limit (3 attempts, 60s block) is in-memory. Reloading the app resets it. Mitigation: Accept as design constraint; if stronger protection needed, move to backend validation or localStorage with timestamp.\n\n4. **Accidental Data Loss**: User sets password, forgets it, cannot unlock. No recovery mechanism. Mitigation: Add in settings a 'Forgot Password?' link that (a) shows recovery code on first setup, or (b) requires email verification (out of scope for SDK, requires host integration).\n\n5. **Cross-Tab Sync**: Unlock in tab A doesn't unlock tab B (session tokens are per-document). Mitigation: Accept; users expect per-tab privacy. If needed, use BroadcastChannel API (advanced).\n\n6. **Dependency on crypto.subtle**: Browser crypto API has good support (>95% globally) but older mobile browsers may lack it. Fallback: gracefully disable lock feature with console warning, show unlock screen always-open. Or add optional npm dependency on tweetnacl/libsodium.wasm.\n\n7. **Hash Format Mismatch**: If password hash is corrupted in storage, unlock always fails silently. Mitigation: Add validation in load() that sanitizes hash format; log to console if invalid.",
|
||
"openQuestions": "1. **Crypto Library Choice**: Should we use native crypto.subtle (no dependencies, good browser support) or add a dependency like argon2id/bcryptjs for stronger KDF? Native is simpler but SHA-256 + fixed salt is weaker. Recommend: Start with native, document as 'client-side UX feature', mention in settings UI.\n\n2. **Password Complexity**: Enforce 8+ chars? Add uppercase/special requirements? Current design: 8+ chars minimum (simple, user-friendly). Confirm acceptable or add stricter validation.\n\n3. **Settings Access**: Should settings be a separate modal (current plan) or a settings gear icon in the Recently Deleted view header? Current plan is simpler and reusable for future 'Security' settings section.\n\n4. **Recovery Mechanism**: If user forgets password, we cannot decrypt/reset. Should we:\n - Show a recoveryCode on first setup (can be written down, used to reset)?\n - Require email verification to unlock (requires host app integration)?\n - Accept 'no recovery' and document clearly?\n Recommend: No recovery for MVP; document as 'if forgotten, cannot unlock this session'. Can add in v2.\n\n5. **Sidebar Icon Behavior**: Clicking the lock/unlock icon should:\n - Open settings modal (current plan)?\n - Manually lock (if unlocked)?\n - Prompt to unlock (if locked)?\n Recommend: Current (open settings) is most flexible; advanced users can set password from there.\n\n6. **Mobile UX**: On mobile, should password use `type='password'` (masked) or show a PIN-style numeric pad? Current: password input with standard masking. Acceptable?\n\n7. **Multiple Passwords/Users**: Should each user profile have a separate lock, or is global lock sufficient? Current design: global (single password for the entire Recently Deleted). Acceptable for personal galleries."
|
||
},
|
||
{
|
||
"feature": "Map Thumbnail Pins - Replace plain circle markers with macOS-style rounded photo-thumbnail pins featuring COUNT badges, proximity clustering, and location-grouped photo browsing",
|
||
"approach": "\n## Design & Architecture\n\nReplace the current Leaflet `circleMarker` implementation with macOS Photos-style DivIcon pins. The feature clusters photos by location proximity and provides a location-focused view for date-grouped browsing.\n\n### 1. **Leaflet DivIcon Pins** (components/views/MapView.tsx)\n\n**Current State:**\n- Uses plain `L.circleMarker` (blue circles with white border)\n- Single marker per media item (no clustering)\n- Basic tooltip with place name\n- Popup with single photo thumbnail + date/place\n\n**New Implementation:**\n- Replace `circleMarker` with `L.DivIcon` for custom DOM markup\n- Each pin shows a rounded-square thumbnail (46-52px) with 10px radius\n- White border + drop shadow\n- Small downward triangular tail\n- COUNT badge in lower-left corner (white number on semi-transparent dark background)\n- Cluster nearby markers by location (within ~500m radius or configurable threshold)\n- When location has multiple photos, show count; single photo shows \"1\" minimized\n\n### 2. **CSS for Pins** (packages/photo-sdk/src/styles/sdk.css)\n\nAdd `.apg-map-pin*` classes:\n```\n.apg-map-pin\n ├── .apg-map-pin__thumb (img: 46-52px, 10px radius, white border)\n ├── .apg-map-pin__tail (small triangle pointing down)\n └── .apg-map-pin__badge (position: absolute, bottom-left, white text, dark semi-transparent bg)\n```\n\nEnsure XSS-safe via `textContent` only (no innerHTML for count numbers or tooltips).\n\n### 3. **Location Clustering & Aggregation** (lib/clustering or addMarkers logic)\n\nImplement K-means or grid-based spatial clustering:\n- Group `located` media by proximity (e.g., within 500m)\n- Aggregate into location clusters (lat/lng centroid of cluster)\n- Store cluster metadata: { location, items: MediaItem[], count, centroid, bounds }\n- Render one pin per cluster\n- On pin click, set a new \"location focus\" state\n\n### 4. **Location Focus State** (store/store.ts + selectors.ts)\n\nAdd `locationFocus` (analogous to `objectFocus`, `tagFocus`, `personFocus`):\n```typescript\nlocationFocus: { lat: number; lng: number; count: number } | null;\nsetLocationFocus: (loc: { lat: number; lng: number; count: number } | null) => void;\n```\n\nWhen `locationFocus` is set, the grid view filters and shows photos at that location, grouped by date (leveraging existing `mediaForView` logic).\n\n**OR**: Use `mapFocus` (already exists) + add a new `locationClusterFocus` that triggers a sidebar/modal overlay showing location photos.\n\n### 5. **Click-to-Photos Flow**\n\nOn pin click:\n- Set `locationFocus` or emit a location-selected event\n- If sidebar is visible, switch to a filtered \"Location\" view showing photos from that cluster\n- Display photos grouped by `takenAt` date (reuse `.apg-grid__section-title` date grouping)\n- Show a breadcrumb or title: \"Location · {place} · {count} Photos\"\n- Clicking a photo thumbnail opens the lightbox\n\n**Alternative (lightweight):** Open a bottom sheet / modal overlay with location photos without changing the main view.\n\n### 6. **GPS/Screenshot Inclusion**\n\nEnsure `locatedMedia` selector includes:\n- Any media with `m.location` (existing check: `m.location?.lat && m.location?.lng`)\n- Screenshots with GPS data (if captured on modern phones with location)\n- Ensure MediaItem type check (`m.location` not undefined)\n\nCurrent selector already correct: `liveMedia(media).filter((m) => m.location)`\n\n### 7. **Implementation Files**\n\n**New/Modified:**\n- `packages/photo-sdk/src/components/views/MapView.tsx` — rewrite `addMarkers()`, add clustering logic or import utility\n- `packages/photo-sdk/src/styles/sdk.css` — add `.apg-map-pin*` classes\n- `packages/photo-sdk/src/lib/clustering.ts` (NEW) — spatial clustering helper\n- `packages/photo-sdk/src/store/store.ts` — add `locationFocus` state + action\n- Optionally: `packages/photo-sdk/src/store/selectors.ts` — add `locationMediaForFocus()` selector\n\n**Key Patterns:**\n- Reuse `.apg-grid__section-title` for date grouping in location view\n- Reuse `.apg-map-popup` styling for any location overlay\n- Follow `tagFocus` / `objectFocus` pattern for state management\n- Use `formatDate()` utility for date labels\n\n### 8. **XSS & Security**\n\n- Never set innerHTML on marker DOM\n- Use `textContent` for count badge numbers\n- Use `textContent` for place name in tooltip (already done in current code)\n- Build DivIcon HTML safely: `document.createElement()` + `textContent`, no template literals with user data\n\n### 9. **Responsive & Theme**\n\n- Light/dark theme support via CSS variables (already in `.apg-map-pin*`)\n- Touch-friendly badge + pin size (~48px) meets WCAG 2.5.5 coarse-pointer requirement\n- Pins remain visible on both satellite and map tile layers\n",
|
||
"files": [
|
||
{
|
||
"path": "packages/photo-sdk/src/components/views/MapView.tsx",
|
||
"change": "1. Refactor `addMarkers()` to group `located` items by spatial clusters (e.g., within ~500m proximity threshold)\n2. For each cluster, create a single Leaflet DivIcon pin showing a rounded thumbnail + COUNT badge + tail\n3. Replace `L.circleMarker` with `L.marker(clusterCentroid, { icon: createPinIcon(...) })`\n4. DivIcon markup: div.apg-map-pin > img.apg-map-pin__thumb + div.apg-map-pin__tail + div.apg-map-pin__badge (count text via textContent)\n5. On pin click, set `locationFocus` state (new store action) or dispatch location-selected event\n6. Bind popup to show cluster info or omit; instead rely on click handler to populate location view\n7. Add tooltip showing cluster count and primary place name (textContent-safe)\n8. Maintain existing mapFocus behavior for Info mini-map"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/styles/sdk.css",
|
||
"change": "Add section after .apg-map-popup rules (~line 686):\n.apg-map-pin { position: relative; }\n.apg-map-pin__thumb { width: 48px; height: 48px; border-radius: 10px; border: 2.5px solid #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.24); object-fit: cover; display: block; }\n.apg-map-pin__tail { position: absolute; bottom: -6px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid #fff; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2)); }\n.apg-map-pin__badge { position: absolute; bottom: 2px; left: 2px; background: rgba(0,0,0,0.6); color: #fff; font-size: 11px; font-weight: 600; padding: 2px 5px; border-radius: 4px; min-width: 18px; text-align: center; }\n.apg[data-theme='light'] .apg-map-pin__thumb { border-color: #f5f5f7; }\n.apg[data-theme='dark'] .apg-map-pin__thumb { border-color: #1e1e1e; }"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/lib/clustering.ts",
|
||
"change": "(NEW FILE) Spatial clustering utility:\nexport interface LocationCluster { lat: number; lng: number; items: MediaItem[]; centroidLat: number; centroidLng: number; count: number; primaryPlace?: string; }\nexport function clusterMediaByLocation(media: MediaItem[], radiusKm: number = 0.5): LocationCluster[]\n- Group items within `radiusKm` radius using haversine distance\n- Compute cluster centroid (average lat/lng of all items in cluster)\n- Return array of clusters sorted by count (descending)\n- Use for loop + distance checks or K-means variant if perf needed\nAlternatively: integrate clustering directly into MapView.tsx addMarkers() if simpler"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/store/store.ts",
|
||
"change": "1. Add to GalleryState interface (after mapFocus, ~line 100):\n locationFocus: { lat: number; lng: number; place?: string } | null;\n2. Initialize in store creation (line ~232):\n locationFocus: null,\n3. Add action in GalleryState interface (after focusMap, ~line 151):\n setLocationFocus: (loc: { lat: number; lng: number; place?: string } | null) => void;\n4. Implement action in store reducer (after focusMap implementation):\n set({ locationFocus: loc })"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/store/selectors.ts",
|
||
"change": "Add selector for location-focused media (after mediaForView, ~line 155):\nexport function mediaAtLocation(media: MediaItem[], loc: { lat: number; lng: number }): MediaItem[] {\n const threshold = 0.5; // km tolerance\n const liveItems = liveMedia(media);\n return liveItems.filter(m => {\n if (!m.location) return false;\n const dist = haversineDistance(m.location.lat, m.location.lng, loc.lat, loc.lng);\n return dist <= threshold;\n });\n}\nOr simpler: return liveItems.filter(m => m.location && Math.abs(m.location.lat - loc.lat) < 0.01 && Math.abs(m.location.lng - loc.lng) < 0.01);"
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/lib/distance.ts",
|
||
"change": "(NEW FILE, optional) Haversine distance utility:\nexport function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {\n const R = 6371; // Earth radius in km\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLng = (lng2 - lng1) * Math.PI / 180;\n const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2;\n return R * 2 * Math.asin(Math.sqrt(a));\n}"
|
||
}
|
||
],
|
||
"risks": "\n1. **Clustering Performance**: If library has 10k+ geotagged photos, clustering on every render may lag. Mitigate: memoize cluster computation, run in web worker if needed, or cache clusters at store level.\n\n2. **Leaflet DivIcon Limitations**: DivIcons don't have built-in click handlers; must attach listeners manually to the icon DOM. Ensure events bubble correctly and clean up listeners on re-cluster.\n\n3. **Proximity Threshold Tuning**: 500m default may be too loose (clusters unrelated photos) or too tight (misses related ones). Make configurable; consider zoom level (cluster more at low zoom, less at high zoom).\n\n4. **Mobile UX**: Pins (48px) are touch-friendly but may overlap on crowded locations. Consider dynamic clustering based on zoom level (cluster aggressively when zoomed out, expand when zoomed in).\n\n5. **State Proliferation**: Adding `locationFocus` mirrors `objectFocus`/`tagFocus` logic. If not integrated cleanly into `mediaForView`, could lead to confusion. Ensure `locationFocus` clears when user navigates away from Map view.\n\n6. **Screenshot GPS Data**: Not all screenshots have GPS. Ensure `MediaItem.location` is only set if valid lat/lng exist. Already handled by `locatedMedia` selector.\n\n7. **First/Subsequent Renders**: On first pin click, grid may flash while filtering. Use React transitions or optimistic updates if needed.\n\n8. **Thumbnail Quality**: Using `m.thumbnail ?? m.src` for pin images. Ensure thumbnails are cached and fast-loading (likely already optimized in SDK).\n\n9. **Color Contrast on Satellite Tiles**: White pin border + dark badge may have poor contrast on light areas of satellite imagery. Consider adaptive badge styling (light badge on dark areas, dark on light) or always use semi-opaque dark background.\n\n10. **Backward Compatibility**: If existing apps rely on `mapFocus` for centering, ensure `locationFocus` doesn't interfere. Keep `mapFocus` as a separate \"zoom to coordinate\" action.\n",
|
||
"openQuestions": "\n1. **Clustering Granularity**: Should clustering be based on a fixed radius (e.g., 500m), or adaptive based on zoom level? (Recommend: zoom-adaptive for better UX at all scales.)\n\n2. **Location View UI**: When a pin is clicked, should the view:\n - Switch to a filtered \"Location\" tab in the sidebar (new view enum value)?\n - Open a bottom-sheet modal overlay?\n - Update the main grid in-place with location photos?\n Recommend: bottom-sheet modal or overlay for light modal feel, reusing existing MediaGrid component inside.\n\n3. **Cluster Expansion**: Should clicking on a high-count cluster (e.g., 100 photos at one place) also offer a \"zoom to cluster bounds\" map action, or only show the filtered list?\n\n4. **Place Name Precision**: Should cluster use the `place` field from the most common location in the cluster, or compute a new reverse-geocode? Recommend: use most common place for speed.\n\n5. **State Cleanup**: Should `locationFocus` auto-clear when user switches map modes (map → grid, grid → map), or persist as an explicit filter? Recommend: clear on mode change to avoid confusion.\n\n6. **Integration with `mapFocus`**: The Info panel's mini-map uses `mapFocus` to center on a single photo's location. Should location pins also honor this (e.g., highlight the pin if it matches `mapFocus`)? Or keep separate?\n\n7. **Empty Cluster Handling**: If a cluster has only 1 photo, should the badge still show \"1\"? Or hide it for single-item clusters? Recommend: show \"1\" for consistency; alternatively suppress for visual cleanliness.\n\n8. **Performance Monitoring**: Should SDK expose clustering stats (num clusters, cluster sizes) for debugging? Or keep internal?\n\n9. **Accessibility**: Should each pin have an `aria-label` (e.g., \"Location: Mumbai, India. 5 photos\")? Currently Leaflet tooltips are accessible; DivIcons need explicit ARIA.\n\n10. **Dark Mode Tail**: The triangular tail in dark mode should invert color. CSS `.apg[data-theme='dark'] .apg-map-pin__tail { border-top-color: ... }` to match dark pin frame. Confirm desired color.\n"
|
||
},
|
||
{
|
||
"feature": "MAP \"GRID\" TAB MOSAIC LAYOUT - Grid mode shows date-grouped photos in an irregular collage with hero, portrait tiles, and auto-sliding carousel",
|
||
"approach": "\n## Design Approach: MosaicGrid SDK Component\n\nThe Grid mode in MapView (mapMode='grid') currently delegates to MediaGrid, which renders a uniform responsive square grid. We'll introduce a new **MosaicGrid** component that creates an irregular, Apple Photos-style layout with:\n\n1. **Date-Grouped Sections** (via existing `groupByTime('day')`)\n - Each date becomes a collage section with variable-sized tiles\n - Sections stack vertically with clear visual separation\n\n2. **Mosaic Layout Strategy (CSS Grid with spanning)**\n - First item (hero): spans full width, landscape ratio (~16:9 or matching image AR)\n - Following items: portrait/varied-height tiles with `grid-row: span 2` and `grid-column: span 1` for visual rhythm\n - Every 5-7 tiles, inject a carousel/slideshow tile (spans 2 rows, 1 column) with auto-advance timer\n - Grid template: responsive base (e.g., 4-6 columns on desktop, 2-3 on tablet, 1 on mobile)\n\n3. **Carousel Tile Component (AutoCarousel)**\n - Shows rotating images from the current section or a \"featured moment\"\n - Timer: 4s per image, smooth fade/slide transition (CSS transition + React state)\n - On hover: pause and show prev/next arrows\n - Responsive: maintains aspect ratio, lazy-loads images\n\n4. **CSS Classes (apg-*-prefixed)**\n - `.apg-mosaic`: Container for date sections\n - `.apg-mosaic__section`: Wrapper for each date group (optional visual divider)\n - `.apg-mosaic__grid`: CSS Grid with auto-fill + row-span logic\n - `.apg-mosaic__hero`: First tile, full-width (grid-column: 1 / -1)\n - `.apg-mosaic__tile`: Standard tile with aspect-ratio: auto (let image control proportions)\n - `.apg-mosaic__carousel`: Carousel tile (grid-row: span 2)\n - `.apg-carousel__slide`: Each image inside carousel\n - `.apg-carousel__controls`: Prev/next buttons (hidden by default, shown on hover)\n\n5. **Responsive Behavior**\n - Desktop (>1200px): 5-6 columns, hero full-width, standard 1x1 tiles, carousel 2x1\n - Tablet (768-1200px): 3-4 columns, proportional scaling\n - Mobile (<768px): 2 columns, hero spans both, carousel spans 2 rows\n\n6. **PhotoTile Integration**\n - Reuse existing PhotoTile component (selection, context menu, favorites badge, etc.)\n - Wrap in mosaic-specific CSS that applies grid-row/column spans\n\n7. **File Structure**\n - New: `packages/photo-sdk/src/components/MosaicGrid.tsx` (main component)\n - New: `packages/photo-sdk/src/components/AutoCarousel.tsx` (carousel tile sub-component)\n - Modify: `packages/photo-sdk/src/components/views/MapView.tsx` (dispatch to MosaicGrid for grid mode)\n - Append: `.apg-mosaic*` styles to `packages/photo-sdk/src/styles/sdk.css`\n\n## Technical Details\n\n### MosaicGrid.tsx Logic\n- Input: `items: MediaItem[]` (from `locatedMedia()` in MapView)\n- Call `groupByTime(items, 'day')` to get date sections\n- For each section, generate tiles with layout hints:\n - First tile: `{ span: 'full' }`\n - Subsequent: `{ span: 'normal' }` (default 1x1)\n - Every 5th-7th tile: `{ span: 'carousel' }` (2x1 or 2x2 based on screen)\n- Pass `layoutHint` prop to PhotoTile wrapper so it can apply the right CSS class\n\n### AutoCarousel.tsx Logic\n- Props: `images: MediaItem[]` (3-5 images from the current section or random subset)\n- State: `currentIndex: number` (starts at 0)\n- Effect: `setInterval` that increments index every 4000ms (pauses on hover)\n- Render: Current image + prev/next arrows (on hover only)\n- Transitions: CSS `opacity + transform` for fade-slide effect\n\n### Store Integration\n- No changes needed to the store; MapView already filters `locatedMedia()`\n- MosaicGrid receives pre-filtered items from MapView\n\n### Styling Highlights\n- Hero tile: `aspect-ratio: 16 / 9` (or `object-fit: cover` to auto-scale)\n- Standard tile: `aspect-ratio: auto` (let image dictate shape)\n- Carousel tile: `grid-row: span 2; grid-column: span 1` on desktop, adjusts on mobile\n- Section title: sticky position, matches LibraryView title styling (~20px bold, 22px padding)\n- Glass/blur: Carousel controls use semi-transparent bg + backdrop-blur (matches design tokens)\n",
|
||
"files": [
|
||
{
|
||
"path": "d:/advance-photo-gallery-web-sdk/packages/photo-sdk/src/components/MosaicGrid.tsx",
|
||
"change": "NEW FILE - Main mosaic grid component that groups items by date and renders irregular tile layout with hero, portrait tiles, and carousel tiles. Imports AutoCarousel and PhotoTile, uses CSS grid with row/column spanning. Receives `items: MediaItem[]` and optional `title` prop. Calls `groupByTime(items, 'day')` and yields layout hints for each tile based on position."
|
||
},
|
||
{
|
||
"path": "d:/advance-photo-gallery-web-sdk/packages/photo-sdk/src/components/AutoCarousel.tsx",
|
||
"change": "NEW FILE - Carousel tile sub-component that auto-advances through 3-5 featured images every 4 seconds. Uses React state for currentIndex and useEffect with setInterval. Shows prev/next nav arrows on hover. Pauses carousel on hover, resumes on leave. Applies fade+slide CSS transition for image changes."
|
||
},
|
||
{
|
||
"path": "d:/advance-photo-gallery-web-sdk/packages/photo-sdk/src/components/views/MapView.tsx",
|
||
"change": "MODIFY - Import MosaicGrid component. When mapMode === 'grid', dispatch to MosaicGrid instead of MediaGrid. Pass located items and title to MosaicGrid. Keep empty state logic unchanged."
|
||
},
|
||
{
|
||
"path": "d:/advance-photo-gallery-web-sdk/packages/photo-sdk/src/styles/sdk.css",
|
||
"change": "APPEND - Add CSS rules for .apg-mosaic, .apg-mosaic__section, .apg-mosaic__grid, .apg-mosaic__hero, .apg-mosaic__tile, .apg-mosaic__carousel, .apg-carousel__slide, .apg-carousel__controls, .apg-carousel__btn (with responsive grid column/row counts for desktop/tablet/mobile). Use CSS Grid auto-fill + minmax, glass backdrop effects for carousel controls."
|
||
}
|
||
],
|
||
"risks": "\n1. **CSS Grid Complexity**: Row/column spanning on `auto-fill` grids can be unpredictable if items don't align cleanly. Mitigation: Test extensively on multiple screen sizes; consider explicit grid-template-rows for sections if needed.\n2. **Performance with Large Collections**: groupByTime() + rendering 100+ tiles per day could slow rendering. Mitigation: Use React.memo on PhotoTile and AutoCarousel; consider virtualization for very large days (100+ photos).\n3. **Auto-Carousel State Complexity**: Carousel timer state can create memory leaks if not cleaned up. Mitigation: Ensure useEffect dependencies and cleanup function cancel active setInterval.\n4. **Image AR Variability**: If images have wildly different aspect ratios (portrait vs. landscape), the mosaic can look chaotic. Mitigation: Hero image selection algo should prefer landscape; carousel tiles use fixed aspect ratio container.\n5. **Mobile Responsiveness**: Hero full-width on mobile (2 columns) may crop landscape images heavily. Mitigation: Test on real devices; consider different AR targets per breakpoint.\n6. **Lighthouse: Layout Shift**: Carousel auto-advance without explicit height can cause CLS. Mitigation: Set fixed aspect ratio or height on carousel tile container.\n",
|
||
"openQuestions": "\n1. **Hero Image Selection**: Should the first image always be the most recent? Or should we prefer landscape-oriented images for the hero slot to maximize visual impact?\n2. **Carousel Subset**: Should the carousel tile display images from only the current day section, or pull a \"featured moment\" mix across all dates?\n3. **Tile Aspect Ratio Behavior**: Should portrait tiles use `aspect-ratio: 1 / 1` (square, like current grid) or `aspect-ratio: auto` (preserve source ratio, irregular heights)? The design calls for \"varying heights\" which suggests auto, but may look chaotic.\n4. **Carousel Transition Style**: Fade (opacity), slide (translateX), or cross-dissolve? The DESIGN reference doesn't specify; smooth fade is safest.\n5. **Carousel Pause Behavior**: Should pausing on hover auto-resume after N seconds of inactivity, or only resume when cursor leaves?\n6. **Section Visual Divider**: Should each date section have a sticky header (like LibraryView) or subtle border/spacing? DESIGN doesn't show this for grid mode.\n"
|
||
},
|
||
{
|
||
"feature": "GLASS/DARK-MODE/CORNER-RADIUS Design Polish for macOS Photos-style UI",
|
||
"approach": "The SDK already has a strong foundation with CSS variables, backdrop-filter implementations, and dark/light/semi-dark themes. The polish refines:\n\n1. **MENU/DROPDOWN GLASS** (`.apg-menu`): Increase backdrop blur from 30px to 35px, add a subtle inset hairline border for depth, reduce background opacity slightly for more translucency, add drop shadow refinement, and ensure rounded corners match sidebar (10-12px).\n\n2. **SIDEBAR GLASS** (`.apg-sidebar`): Already has strong glass (blur 24px), but refine: increase blur to 28px for deeper vibrancy, adjust background opacity rgba(28,28,30,0.72) to 0.76 for slightly more substance, ensure 12-14px radius corners are applied, add hairline bottom border as visual separator on mobile.\n\n3. **TOOLBAR GLASS** (`.apg-toolbar`): Increase blur from 20px to 24px, refine background to rgba(28,28,30,0.62) for consistency with macOS toolbar, keep hairline separator.\n\n4. **CORNER-RADIUS SYSTEM**: Introduce new CSS variable `--apg-radius-menu: 11px` for menu/dropdown consistency, standardize `.apg-menu` and `.apg-search__recents` to use this, ensure all rounded elements (buttons, cards, pills) have cohesive radius hierarchy (6px sm < 8px buttons < 10px default < 11px menus < 14px lg panels).\n\n5. **DROPDOWN CARET/NOTCH**: Add optional `.apg-menu--positioned` modifier that injects a small triangular SVG pointer (8-10px) toward the trigger button, created via ::before pseudo-element with clip-path or SVG background.\n\n6. **DARK-MODE COLOR ACCURACY**: Refine dark-mode sidebar and menu backgrounds to match true macOS vibrancy: change `--apg-sidebar-bg: rgba(28,28,30,0.78)` to use `rgba(20,20,22,0.8)` for darker glass on pure dark scenes, add optional `--apg-dark-vibrancy: rgba(15,15,17,0.85)` for ultra-dark variant.\n\n7. **HAIRLINE BORDERS**: Add `--apg-hairline: 0.5px` variable; apply to `.apg-menu`, `.apg-sidebar` (right edge), `.apg-info`, `.apg-actionbar`, and modals for refined separation from background.\n\n8. **MENU ITEM HOVER**: Keep solid accent (no glass), refine transition timing to 0.15s for snappier feel.\n\n9. **FALLBACKS FOR BACKDROP-FILTER**: Ensure `@supports (backdrop-filter: blur(1px))` or feature detection; fallback to solid `rgba(28,28,30,0.9)` for browsers without support (no blur).\n\n10. **MAP PIN STYLING**: Pin thumbnails already have ~10px radius and white border; ensure clip-path or border-radius applies consistently. Count badge (white text) remains in lower-left corner with shadow for legibility.\n\n11. **MODAL/CARD CONSISTENCY**: Info panel, modals, and cards all use `--apg-radius-lg: 14px` consistently.",
|
||
"files": [
|
||
{
|
||
"path": "d:/advance-photo-gallery-web-sdk/packages/photo-sdk/src/styles/sdk.css",
|
||
"change": "Lines 14-51: Add new CSS variables after existing radius set:\n\n --apg-hairline: 0.5px;\n --apg-radius-menu: 11px;\n \nLines 183-185 (sidebar backdrop): Change from:\n backdrop-filter: saturate(180%) blur(24px);\n -webkit-backdrop-filter: saturate(180%) blur(24px);\nTo:\n backdrop-filter: saturate(180%) blur(28px);\n -webkit-backdrop-filter: saturate(180%) blur(28px);\n\nLines 72-73 (dark sidebar bg): Change from:\n --apg-sidebar-bg: rgba(28, 28, 30, 0.66);\nTo:\n --apg-sidebar-bg: rgba(20, 20, 22, 0.80);\n\nLine 185 (sidebar border): Change from:\n border-right: 1px solid var(--apg-separator);\nTo:\n border-right: var(--apg-hairline) solid var(--apg-separator);\n\nLines 292-293 (toolbar backdrop): Change from:\n backdrop-filter: saturate(180%) blur(20px);\n -webkit-backdrop-filter: saturate(180%) blur(20px);\nTo:\n backdrop-filter: saturate(180%) blur(24px);\n -webkit-backdrop-filter: saturate(180%) blur(24px);\n\nLine 73 (toolbar bg dark mode): Add after existing rule:\n --apg-toolbar-bg: rgba(20, 20, 22, 0.64);\n\nLines 743-755 (.apg-menu): Update entire block to:\n .apg-menu {\n position: fixed;\n z-index: 1200;\n min-width: 210px;\n background: var(--apg-bg-elevated);\n backdrop-filter: blur(35px) saturate(180%);\n -webkit-backdrop-filter: blur(35px) saturate(180%);\n border: var(--apg-hairline) solid var(--apg-separator);\n border-radius: var(--apg-radius-menu);\n box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24);\n padding: 5px;\n font-size: 13px;\n }\n @supports not (backdrop-filter: blur(1px)) {\n .apg-menu {\n background: rgba(28, 28, 30, 0.95);\n backdrop-filter: none;\n }\n }\n\nLines 382-395 (.apg-search__recents): Update to:\n .apg-search__recents {\n position: absolute;\n top: calc(100% + 6px);\n right: 0;\n min-width: 220px;\n z-index: 60;\n background: var(--apg-bg-elevated);\n border: var(--apg-hairline) solid var(--apg-separator);\n border-radius: var(--apg-radius-menu);\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);\n padding: 5px;\n backdrop-filter: blur(35px) saturate(180%);\n -webkit-backdrop-filter: blur(35px) saturate(180%);\n }\n\nLines 1011-1017 (.apg-info): Update to:\n .apg-info {\n position: fixed;\n top: 64px;\n right: 14px;\n z-index: 1300;\n width: 300px;\n max-width: calc(100vw - 28px);\n max-height: calc(100vh - 90px);\n overflow-y: auto;\n background: var(--apg-bg-elevated);\n border: var(--apg-hairline) solid var(--apg-separator);\n border-radius: var(--apg-radius-lg);\n box-shadow: var(--apg-shadow-md);\n backdrop-filter: blur(35px) saturate(180%);\n -webkit-backdrop-filter: blur(35px) saturate(180%);\n color: var(--apg-text);\n }\n\nLines 791-799 (.apg-actionbar): Update to:\n .apg-actionbar {\n position: absolute;\n bottom: 18px;\n left: 50%;\n transform: translateX(-50%);\n z-index: 40;\n display: flex;\n align-items: center;\n gap: 6px;\n background: var(--apg-bg-elevated);\n border: var(--apg-hairline) solid var(--apg-separator);\n border-radius: 14px;\n padding: 7px 10px;\n box-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);\n backdrop-filter: blur(28px) saturate(180%);\n -webkit-backdrop-filter: blur(28px) saturate(180%);\n }\n\nLines 812-822 (.apg-modal): Update to:\n .apg-modal {\n background: var(--apg-bg);\n border-radius: var(--apg-radius-lg);\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n width: 100%;\n max-width: 380px;\n padding: 20px;\n display: flex;\n flex-direction: column;\n gap: 14px;\n }\n\nLines 1059-1067 (.apg-chip): Update to:\n .apg-chip {\n border: var(--apg-hairline) solid var(--apg-separator-strong);\n background: var(--apg-bg);\n color: var(--apg-text);\n border-radius: 999px;\n padding: 3px 10px;\n font-size: 12px;\n transition: border-color 0.15s ease, color 0.15s ease;\n }"
|
||
}
|
||
],
|
||
"risks": "1. **Backdrop-filter browser support**: Older Safari, Edge, and Firefox versions may not support backdrop-filter. The fallback to solid rgba() is included but visually less refined. Consider adding feature detection via CSS @supports or JavaScript for progressive enhancement.\n\n2. **Opacity/color accuracy in dark mode**: Changing sidebar bg to rgba(20,20,22,0.8) is darker than current (28,28,30,0.66); test on actual macOS to ensure it matches expected vibrancy. May need fine-tuning by ~2-5% opacity.\n\n3. **Hairline borders on small screens**: 0.5px borders may not render crisply on devices < 2x DPI. Consider using `min(0.5px, 1px)` or JavaScript-based pixel-perfect rendering for mobile.\n\n4. **Menu positioning with caret**: The planned caret (::before pseudo-element with clip-path) is not included in this CSS change but noted for future iteration. This requires coordinate-aware JavaScript in ContextMenu.tsx to position the pointer triangle.\n\n5. **Performance**: Increased blur values (28px for sidebar, 35px for menus) vs current may impact rendering on lower-end devices or older GPUs. Monitor frame rate; consider reducing blur on mobile via media query if needed.\n\n6. **Consistency across light mode**: The refinements target dark/semi-dark primarily. Light mode sidebar/menu backgrounds should also be tested to ensure hairline borders and blur effects are legible against light backgrounds. Light toolbar may need `rgba(255,255,255,0.75)` instead of current to match vibrancy expectations.",
|
||
"openQuestions": "1. **Caret/notch on dropdown menus**: The design reference mentions a small triangular caret pointing at the trigger. Should this be implemented as a ::before pseudo-element with clip-path, an SVG overlay, or a skewed div? This requires JavaScript in ContextMenu.tsx to detect the trigger's position and point the triangle accordingly. Confirm if this is a priority for phase 1.\n\n2. **Ultra-dark variant for `semi-dark` theme**: The current `semi-dark` uses rgba(28,28,30,0.78). Should we introduce a new `--apg-dark-vibrancy: rgba(15,15,17,0.85)` variable for ultra-dark scenes, or is the current opacity sufficient?\n\n3. **Map pin thumbnails**: The design reference specifies pins are ~46-52px rounded-square thumbnails with a white border and a small downward triangular tail. Current MapView.tsx uses Leaflet's circleMarker (7px radius circles). Should pins be redesigned as custom rounded-square markers with a tail, or is the current circle design acceptable?\n\n4. **Map grid collage layout**: The design reference mentions a \"varied mosaic\" with date-grouped moments, where the first item spans full width. The current MediaGrid appears to use uniform grid. Should the Map grid view implement a Pinterest-style variable-height layout, or is this out of scope for this CSS polish?\n\n5. **Info panel mini-map styling**: The Info panel includes a mini-map for location. Should this mini-map also receive glass treatment (backdrop blur) at the panel level, or remain with Leaflet's default styling?\n\n6. **Fallback strategy**: For browsers without backdrop-filter support, should we: (a) use solid rgba() as fallback (current plan), (b) use a background image blur via filters, or (c) just accept plain solid backgrounds?"
|
||
},
|
||
{
|
||
"feature": "Free in-browser semantic search (CLIP provider) for natural-language photo queries",
|
||
"approach": "\nImplement a lazy-loaded CLIP provider (transformers.js) that follows the existing AI provider pattern:\n\n1. **CLIP Provider** (apps/web/src/lib/ai/clipProvider.ts):\n - Load Xenova/clip-vit-base-patch16 model lazily from CDN (cdn.jsdelivr.net)\n - Implement embedImage() to compute 512-D image embeddings on first pass\n - Implement embedText() to compute query embeddings for user searches\n - Model cache: store at module level (like faceProvider/tensorflowProvider)\n - Error handling: degrade gracefully, return empty array on load failure\n - Bundle: ~80-100 MB model, loaded once on first use; transformers.js ~4 MB\n\n2. **Modify AIAnalyzer** (packages/photo-sdk/src/components/AIAnalyzer.tsx):\n - Add embedImage backfill gate: only process images without embedding\n - Call provider.embedImage() and store result in patch.embedding\n - Gate: `needsEmbeddings = () => !!provider.embedImage && m.embedding === undefined`\n - Store embeddings for ALL media (images + videos, like caption)\n\n3. **Extend searchMedia selector** (packages/photo-sdk/src/store/selectors.ts):\n - Add optional semantic ranking when both embedText() and embedImage results exist\n - Blend keyword + semantic results: keyword-match items first (text relevance), then semantic (cosine > 0.5 threshold)\n - Keep AND semantics for keywords; semantic adds supplementary results\n - Use cosineSimilarity (already exported in types.ts)\n\n4. **Update MediaItem** (packages/photo-sdk/src/types.ts):\n - embedding field already exists at line 172 ✓\n\n5. **CSP update** (apps/web/src/middleware.ts):\n - cdn.jsdelivr.net already allow-listed for script-src + connect-src ✓\n - transformers.js loads models from cdn.jsdelivr.net ✓\n - No new CSP rules needed\n\n6. **Update createDemoAIProvider** (apps/web/src/lib/ai/createDemoAIProvider.ts):\n - Merge clipProvider into the composite:\n ```\n const clip = createClipProvider();\n return {\n ...existing,\n embedImage: clip.embedImage,\n embedText: clip.embedText,\n };\n ```\n\n7. **Optional: Persistence**:\n - Embeddings (512 floats ~2KB/image) persist in Supabase JSONB on media.embedding\n - No schema change needed; use existing embedding field\n - Load from backend on import, backfill locally if missing\n\n**Model choice**: Xenova/clip-vit-base-patch16 (~80MB quantized) balances:\n - Speed: 100-500ms per image (WebGL GPU on modern devices)\n - Quality: 77% ImageNet accuracy (comparable to OpenAI CLIP)\n - Size: Fits browser cache; ~5% of a typical photo library\n - Free: No API key, fully in-browser, no server inference\n\n**Search UI integration** (no changes; flows through existing searchQuery):\n - User types \"beach photos\" → setState(searchQuery: 'beach photos')\n - mediaForView() calls searchMedia(items, searchQuery)\n - searchMedia now checks for embedText capability, runs semantic if available\n - Results blend keyword hits + semantic hits ranked by similarity\n\n**Performance**:\n - First image embedding: 400-800ms (model load + inference)\n - Subsequent images: 100-200ms each (batched if needed)\n - Query embedding: 50-100ms\n - Search time: O(n) cosine similarity across library (negligible for <10K photos)\n - Backfill runs in AIAnalyzer background queue (like OCR)\n",
|
||
"files": [
|
||
{
|
||
"path": "apps/web/src/lib/ai/clipProvider.ts",
|
||
"change": "Create new CLIP embedding provider. Load Xenova/clip-vit-base-patch16 lazily from cdn.jsdelivr.net. Implement embedImage(item, image) → Promise<number[]> and embedText(query) → Promise<number[]>. Memoize model + processor at module level. Handle errors gracefully (warn + return empty []). Match faceProvider/ocrProvider structure exactly."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/components/AIAnalyzer.tsx",
|
||
"change": "Add embedImage backfill gate similar to needsOcr/needsFaces. Check `provider.embedImage && m.embedding === undefined`. Modify analyzeOne() to call provider.embedImage() and store in patch.embedding alongside objects/faces/ocrText. Include embedding in the shared analyzedAt backfill."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/store/selectors.ts",
|
||
"change": "Extend searchMedia() function to optionally perform semantic search when embedText is available. After keyword filtering, check if provider.embedText exists in closure (or store as provider context). Compute query embedding, rank items by cosine similarity threshold (>0.5), and blend results: keyword matches first, then semantic matches sorted by similarity score. Keep AND semantics for keywords."
|
||
},
|
||
{
|
||
"path": "apps/web/src/lib/ai/createDemoAIProvider.ts",
|
||
"change": "Import and instantiate createClipProvider(). Merge its embedImage and embedText into the returned AIProvider object alongside existing detectObjects/detectFaces/ocr capabilities."
|
||
},
|
||
{
|
||
"path": "apps/web/src/middleware.ts",
|
||
"change": "No changes required. cdn.jsdelivr.net is already allow-listed in script-src, connect-src, and supports wasm-unsafe-eval. transformers.js models load from this CDN."
|
||
},
|
||
{
|
||
"path": "packages/photo-sdk/src/types.ts",
|
||
"change": "No changes required. embedding field already exists on MediaItem at line 172 for semantic search vectors."
|
||
}
|
||
],
|
||
"risks": "\n1. **Model size & load time**: 80-100 MB model loads on first embedImage() call. User waits 3-5 seconds. Mitigation: backload in AIAnalyzer background queue; embed text queries immediately (~100ms); show inference progress.\n\n2. **Browser resource drain**: CLIP inference on every image (especially older devices) can block the UI during backfill. Mitigation: CONCURRENCY=2 in AIAnalyzer already throttles; run on GPU (WebGL); allow user to pause.\n\n3. **Stale embeddings after model updates**: If transformers.js ships new model versions, old embeddings won't match. Mitigation: pin model URL to exact version; consider analyzedAt versioning if models drift.\n\n4. **Semantic relevance variability**: CLIP can hallucinate (e.g., \"sunset\" matches random images with warm tones). Mitigation: threshold at cosine > 0.5; blend with keyword results so exact matches rank first; let users adjust manually.\n\n5. **Storage bloat**: 512 embeddings × 10K photos = 5 MB per library. Mitigation: embeddings are optional (nullable); persist in JSONB only if backend; in-browser compute on-demand (no persistence by default).\n\n6. **Cross-origin CORS issues**: model loading from cdn.jsdelivr.net may fail if headers don't permit. Mitigation: CDN is public + CORS-enabled; fallback to null on fetch errors (graceful degradation).\n\n7. **transformers.js onnxruntime-web**: Uses ONNX Runtime WebAssembly. Requires wasm-unsafe-eval CSP. Already permitted in middleware, but some strict policies may block. Mitigation: documented; users can disable semantic search.\n\n8. **Memory footprint**: Model + intermediate tensors ~150-200 MB in browser. On older devices (< 2GB RAM), may OOM. Mitigation: run in Web Worker if possible (future); garbage collection between batches; lazy unload after inactivity.\n",
|
||
"openQuestions": "\n1. **Should embeddings persist to Supabase?** \n - Current: compute on-demand, cache in-memory (MediaItem.embedding)\n - Option A: Write embeddings to backend on first inference (2KB per image)\n - Option B: Keep in-memory only, recompute on each session\n - Question: Accept the ~5 MB storage cost + backend round-trips, or accept recompute overhead?\n\n2. **How should semantic search be exposed in the UI?**\n - Current: automatic blending in searchMedia if embedText available\n - Option A: Auto-blend (default; user unaware of semantic boost)\n - Option B: Toggle in search toolbar (\"Semantic Search On/Off\")\n - Option C: Separate search mode (Keyword vs. Semantic tabs)\n - Question: Should users control this, or silently enhance keyword search?\n\n3. **Model pinning strategy?**\n - Current: Pin exact transformers.js version in package.json\n - Option A: Pin model URL to exact version (slow security updates)\n - Option B: Allow minor version updates (model behavior may drift)\n - Question: How strictly should the model be versioned vs. transformers.js?\n\n4. **Should video embedding use keyframe extraction?**\n - Current: Video support planned but embedImage only takes first frame (or poster)\n - Option A: Embed poster frame only (fast, may miss content)\n - Option B: Extract 3-5 keyframes and average embeddings (slow but richer)\n - Question: Worth the complexity for video semantic search?\n\n5. **Search UX for semantic results:**\n - Current: blend keyword + semantic seamlessly\n - Option A: Show semantic results separately below keyword results\n - Option B: Mixed ranking (interleave by score)\n - Option C: Keyword-only unless no keyword matches, then semantic fallback\n - Question: How should users understand why a photo appeared in results?\n"
|
||
},
|
||
{
|
||
"feature": "Video Annotations: Time-scoped and Static Overlays in Editor & Lightbox",
|
||
"approach": "\n## Architecture Overview\n\n**Core Principle**: Reuse the existing `Annotations` component (SVG overlay, 0..1 normalized geometry) for video by:\n1. **Scoping annotations in time** (frame range or keyframe) or marking as **static** (all frames)\n2. **Extending `EditState`** to store `videoAnnotations` separately with timing metadata\n3. **Creating a `VideoEditor`** surface that wraps the video element + annotation canvas + timeline UI\n4. **Rendering read-only annotations** in the Lightbox over the `<video>` element\n\n## Design Decisions\n\n### 1. **Type System Extension** (`types.ts`)\n- Introduce `VideoAnnotationFrame`: extends `Annotation` with timing scope:\n ```\n interface VideoAnnotationFrame {\n annotation: Annotation;\n scope: 'static' | { start: number; end: number }; // seconds or frame-based\n }\n ```\n- Extend `EditState` to add:\n ```\n videoAnnotations?: VideoAnnotationFrame[];\n videoDuration?: number; // cached for UI\n ```\n- Keep existing `annotations` for **image-only** markup (backward compatible).\n\n### 2. **Editor UI: VideoEditor Component** (`components/editor/VideoEditor.tsx`)\nParallel to `PhotoEditor`, but:\n- **Left side**: `<video>` element + SVG canvas overlay\n- **Center controls**:\n - Play/pause button\n - Current time display + scrubber (HTML5 `<input type=\"range\">`)\n - Frame-by-frame nav (← / →)\n - Playback speed selector (0.5x, 1x, 1.5x, 2x)\n- **Right panel**: Same as PhotoEditor but video-specific tabs:\n - **\"Markup\"** tab: Annotation tools (rect, arrow, text, etc.) — **annotation mode** (draw only on current frame or set time range)\n - **\"Timing\"** tab: List view of all annotations with start/end times, delete buttons, scope toggle\n - **Filters/Adjustments**: Read-only badges (filters not applied to video playback, only stored non-destructively)\n\n#### Annotation Drawing Workflow\n- **Tool selection** + **color picker** (reuse from PhotoEditor)\n- **Scope selector** in \"Markup\" tab: radio buttons for \"Static (all frames)\" or \"Timed (set range)\"\n - If \"Timed\": Show dual sliders for start/end time relative to playback\n - Snap to keyframes (if available; optional — default to 0.1s granularity)\n- **Draw on canvas**: Click to create annotation on the current frame; the scope auto-applies\n- **Inline editing**: Double-click annotation to edit text (measure label / text shape)\n- **Preview**: Play video → annotations fade in/out or stay visible based on their scope\n\n### 3. **Lightbox Video Playback** (`Lightbox.tsx`)\n- Detect `item.kind === 'video'` and `item.edits?.videoAnnotations?.length`\n- Wrap `<video>` in a container with absolute-positioned SVG overlay:\n ```jsx\n <div style={{ position: 'relative', display: 'inline-block' }}>\n <video src={item.src} ... />\n {item.edits?.videoAnnotations?.length ? (\n <VideoAnnotationsOverlay\n videoRef={videoRef}\n annotations={item.edits.videoAnnotations}\n />\n ) : null}\n </div>\n ```\n- **VideoAnnotationsOverlay** component: Subscribes to video `timeupdate` events and renders only annotations whose `scope` matches the current playback time (or all if `scope === 'static'`).\n\n### 4. **EditState Persistence**\n- **Store action** (`store.ts`): `applyEdit(id, edits)` already handles `editState` patch merge.\n- **No destructive video processing**: Unlike images, video edits (filters, annotations) are **non-destructive** and purely overlaid during playback. No \"bake\" for video.\n- **Serialization**: `videoAnnotations` stored as-is in the media item's `edits` object.\n\n### 5. **CSS Styling** (`sdk.css`)\n- Minimal new rules:\n - `.apg-video-editor`: (similar to `.apg-editor`)\n - `.apg-video-timeline`: Flexbox row with scrubber + time labels\n - `.apg-annotation-overlay`: Absolute-positioned SVG container (same as image editor)\n - `.apg-timing-list`: Scrollable list of annotation cards (time range + delete button)\n\n### 6. **Reusability**\n- `Annotations.tsx` component remains **agnostic** to video (it only cares about normalized x/y and canvas size).\n- `VideoAnnotationsOverlay` handles the **filtering** by time (stateless, re-renders on timeupdate).\n- Same color picker, tool selector, text input from PhotoEditor.\n\n---\n\n## Implementation Roadmap\n\n### Phase 1: Types + Read-Only Lightbox\n1. Extend `types.ts`: Add `VideoAnnotationFrame`, extend `EditState`\n2. Create `VideoAnnotationsOverlay.tsx`: Renders annotations filtered by current playback time\n3. Update `Lightbox.tsx` to conditionally render overlay for videos\n4. CSS: Minimal overlay styling (reuse existing SVG styles)\n\n### Phase 2: VideoEditor (Full Editor UI)\n1. Create `components/editor/VideoEditor.tsx`\n2. Timeline scrubber + play controls\n3. Scope selector (static / timed range)\n4. Reuse `Annotations` component (editable mode)\n5. Timing list panel (manage annotation lifetimes)\n6. Store integration (save to item.edits.videoAnnotations)\n\n### Phase 3: Polish + Optimizations\n- Keyframe snapping (if AI/metadata provides scene cuts)\n- Multi-annotation selection / bulk timing edit\n- Annotation trails (option to show all annotations up to current frame)\n- Keyboard shortcuts (spacebar = play/pause, ← → = frame nav)\n\n---\n\n## File-Level Changes\n\n### Absolute Paths\n1. **D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\types.ts**\n - Add `VideoAnnotationFrame`, extend `EditState`\n\n2. **D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\components\\editor\\VideoAnnotationsOverlay.tsx** (NEW)\n - Stateless render component; filters and renders annotations by time\n\n3. **D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\components\\editor\\VideoEditor.tsx** (NEW)\n - Main editor surface; parallel to PhotoEditor but for video\n\n4. **D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\components\\Lightbox.tsx**\n - Conditional render of VideoAnnotationsOverlay for video + annotations\n\n5. **D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\components\\editor\\Annotations.tsx**\n - No changes (already supports editable=false for read-only mode)\n\n6. **D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\store\\store.ts**\n - Conditional logic: openEditor only for kind==='image' (or extend for video in Phase 2)\n\n7. **D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\styles\\sdk.css**\n - Add `.apg-video-editor*` classes (flex layout, timeline, panel)\n - Add `.apg-annotation-overlay-video` for SVG container\n\n---\n\n## Design System Alignment\n\n**Glass UI Elements** (from macOS reference):\n- Timeline panel: Same glass background as editor panel (rgba(40,40,40,0.72) + backdrop-blur 20px)\n- Scope toggle buttons: Use `.apg-editor__tab` style (rounded pill, hover state)\n- Time slider: Standard HTML5 `<input type=\"range\">` with custom styling matching `.apg-slider` class\n\n**Typography**:\n- Time labels: 13px, secondary gray (var(--apg-text-secondary))\n- Annotation metadata (shape, color): 11px uppercase, tertiary gray\n\n**Layout**:\n- Editor canvas centered (like PhotoEditor)\n- Right panel 300px (same width as PhotoEditor)\n- Timeline panel below canvas (responsive: full-width on mobile)\n- Annotation list scrollable within panel bounds\n\n---\n\n## Security & Performance Notes\n\n1. **Normalized geometry**: Annotations stored as 0..1, so they scale with video viewport (handles responsive layouts).\n2. **No ML in SDK**: Video scene-detection / keyframe extraction **must** come from host app (apps/web), passed via EditState or metadata.\n3. **Read-only in Lightbox**: Time-filtering is client-side (no server cost).\n4. **Lazy rendering**: SVG only re-renders when time changes (via timeupdate listener), not on every frame.\n\n---\n\n## Open Questions for Human Confirmation\n\n1. **Annotation scope precision**: Frame-based (e.g., frame 0–120 @ 30fps) or time-based (e.g., 0.5–4.0 seconds)? Recommendation: **time-based** (simpler, no fps metadata coupling).\n2. **Keyframe snapping**: Should timeline provide snap points for detected scene cuts / keyframes? Recommendation: **defer to Phase 3**; start with freeform time ranges.\n3. **Video filters in Editor**: Should the editor allow non-destructive filters (brightness, saturation) on video like it does for images? Recommendation: **yes, store in editState.adjustments** and apply on playback (or lazy), not on the stored video file.\n4. **Multi-annotation drawing**: Can you draw multiple annotations before committing? Recommendation: **yes, same UX as image editor** (tool mode stays active until switched).\n5. **Annotation trails**: Should older annotations remain visible, or fade out when their time range passes? Recommendation: **configurable per annotation** (toggle in timing list), default to \"fade out at end time\".\n",
|
||
"files": [
|
||
{
|
||
"path": "D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\types.ts",
|
||
"change": "Add VideoAnnotationFrame type and extend EditState. After line 92 (after Annotation interface), insert:\n\nexport interface VideoAnnotationFrame {\n annotation: Annotation;\n scope: 'static' | { start: number; end: number }; // time in seconds\n}\n\nThen in EditState interface (around line 53-67), add after annotations?:\n /** Markup annotations scoped to video frame ranges or static overlays. */\n videoAnnotations?: VideoAnnotationFrame[];"
|
||
},
|
||
{
|
||
"path": "D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\components\\editor\\VideoAnnotationsOverlay.tsx",
|
||
"change": "Create new component (Phase 1). Accepts videoRef and annotations, subscribes to timeupdate, renders Annotations filtered by currentTime. Structure:\n\nexport function VideoAnnotationsOverlay({\n videoRef,\n annotations,\n}: {\n videoRef: React.RefObject<HTMLVideoElement>;\n annotations: VideoAnnotationFrame[];\n}) {\n const [currentTime, setCurrentTime] = useState(0);\n // Subscribe to video.timeupdate\n // Filter annotations by scope + currentTime\n // Render Annotations component with filtered set\n}"
|
||
},
|
||
{
|
||
"path": "D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\components\\Lightbox.tsx",
|
||
"change": "Update video rendering block (around line 147-167). After line 148 where video is rendered, wrap in relative container and add overlay:\n\n{item.kind === 'video' ? (\n <div style={{ position: 'relative', display: 'inline-block', maxWidth: '100%', maxHeight: '100%' }}>\n <video ref={videoRef} src={item.src} poster={item.poster} controls autoPlay playsInline />\n {item.edits?.videoAnnotations?.length ? (\n <VideoAnnotationsOverlay videoRef={videoRef} annotations={item.edits.videoAnnotations} />\n ) : null}\n </div>\n) : ..."
|
||
},
|
||
{
|
||
"path": "D:\\advance-photo-gallery-web-sdk\\packages\\photo-sdk\\src\\styles\\sdk.css",
|
||
"change": "Add video editor styles before the last @media rule. Insert after line 861 (after .apg-editor__filter--active):\n\n/* ---- Video Editor ---- */\n.apg-video-editor { position: fixed; inset: 0; z-index: 1050; background: #161617; display: flex; flex-direction: column; color: #fff; }\n.apg-video-editor__bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.1); }\n.apg-video-editor__canvas { flex: 1; display: grid; place-items: center; padding: 18px; min-width: 0; position: relative; }\n.apg-video-editor__canvas video { max-width: 100%; max-height: 60vh; border-radius: 4px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); }\n.apg-video-timeline { display: flex; align-items: center; gap: 10px; padding: 10px 18px; background: rgba(255,255,255,0.04); border-top: 1px solid rgba(255,255,255,0.1); font-size: 12px; }\n.apg-video-timeline__time { min-width: 50px; color: var(--apg-text-secondary); }\n.apg-video-timeline__scrubber { flex: 1; }\n.apg-video-timeline__scrubber input[type='range'] { width: 100%; }\n.apg-annotation-overlay-video { position: absolute; inset: 0; pointer-events: auto; }"
|
||
}
|
||
],
|
||
"risks": "\n1. **Video performance**: Rendering SVG overlay + timeupdate listeners on every frame could tax lower-end devices if annotations are complex. Mitigation: Debounce timeupdate, use requestAnimationFrame, consider canvas instead of SVG for very dense annotations (Phase 3).\n2. **Time synchronization**: If video playback rate changes (0.5x–2x), annotation timing must scale inversely. Currently not handled; recommend storing times in absolute seconds and dynamically adjusting on playback rate change.\n3. **Browser compatibility**: Older IE/Edge may not support backdrop-filter or SVG filters. Fallback to solid background color already in place (sdk.css uses -webkit prefix).\n4. **Scope ambiguity**: Users may confuse \"frame-based\" and \"time-based\" scoping. Phase 1 uses time; if Phase 2 adds frame snapping, document clearly.\n5. **No baking for video**: Unlike images (which can be flattened), video annotations are purely overlay. If user exports the video, annotations aren't embedded in the file—only stored in editState. Host app must handle export rendering separately (FFmpeg, etc.).\n6. **EditState bloat**: videoAnnotations can grow large if many annotations. Recommend compression/deduplication in Phase 3 (deduplicate identical geometry across frames).\n",
|
||
"openQuestions": "\n1. Should annotations be scoped to **absolute time (seconds)** or **frames** (frame number @ fps)? Time is simpler and independent of encoding. **Recommendation: time-based.**\n2. Should the editor open for **video kind** as well as image, or only image initially? Current code has `item.kind === 'image' ? <Edit button>`. **Recommendation: Phase 2 adds video support.**\n3. Should **Annotations.tsx** accept a time filter parameter, or should **VideoAnnotationsOverlay** do the filtering? **Recommendation: keep Annotations pure; VideoAnnotationsOverlay handles filtering** (single responsibility).\n4. For read-only Lightbox playback, should annotations **persist across scrubbing**, or reset when user seeks? **Recommendation: persist** (user scrubbed to a moment where annotation is active, they should see it).\n5. Should annotation **text labels** (measure labels, text shapes) remain visible at all times or also fade with the annotation? **Recommendation: same lifetime as the annotation shape** (if annotation hidden, label hidden).\n6. How should the editor handle **videos with unknown duration** (streaming, live)? **Recommendation: disable time-scoped annotations, only allow static.**\n"
|
||
}
|
||
]
|
||
},
|
||
"workflowProgress": [
|
||
{
|
||
"type": "workflow_phase",
|
||
"index": 1,
|
||
"title": "Plan"
|
||
},
|
||
{
|
||
"type": "workflow_agent",
|
||
"index": 1,
|
||
"label": "plan:sharing-links",
|
||
"phaseIndex": 1,
|
||
"phaseTitle": "Plan",
|
||
"agentId": "a6c9e57f34276e846",
|
||
"agentType": "Explore",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"state": "done",
|
||
"startedAt": 1782743359805,
|
||
"queuedAt": 1782743359790,
|
||
"attempt": 1,
|
||
"lastToolName": "StructuredOutput",
|
||
"lastToolSummary": "Share Action + Shared Albums + Activity Feed",
|
||
"promptPreview": "Plan SHARING + SHARED ALBUMS + ACTIVITY. Currently SharedAlbumsView/ActivityView are empty placeholders (packages/photo-sdk/src/components/views/GridScreens.tsx). Requirement: a Share action (toolbar + context menu + a Share button) opens a modal asking WHAT to share — a single selected photo, an entire album, or multiple albums — then creates a downloadable link. The demo backend is Supabase Stor…",
|
||
"lastProgressAt": 1782743440914,
|
||
"tokens": 56569,
|
||
"toolCalls": 26,
|
||
"durationMs": 81108,
|
||
"resultPreview": "{\"feature\":\"Share Action + Shared Albums + Activity Feed\",\"approach\":\"\\n## Architecture Overview\\n\\n### 1. **Data Model Additions** (TypeScript types in `packages/photo-sdk/src/types.ts`)\\n - Extend `Album` with sharing fields:\\n - `shareId?: string` – unique share token (generated on share creation)\\n - `sharedAt?: number` – timestamp when shared (ms epoch)\\n - `shareExpiry?: number…"
|
||
},
|
||
{
|
||
"type": "workflow_agent",
|
||
"index": 2,
|
||
"label": "plan:password-lock",
|
||
"phaseIndex": 1,
|
||
"phaseTitle": "Plan",
|
||
"agentId": "ad367f050f310914e",
|
||
"agentType": "Explore",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"state": "done",
|
||
"startedAt": 1782743359807,
|
||
"queuedAt": 1782743359790,
|
||
"attempt": 1,
|
||
"lastToolName": "StructuredOutput",
|
||
"lastToolSummary": "Password Lock for Recently Deleted View",
|
||
"promptPreview": "Plan a PASSWORD LOCK for the Recently Deleted (and lockable) views. Requirement: clicking Recently Deleted prompts for a password; if none is set, send the user to a General Settings panel to create one; the sidebar lock icon shows LOCKED vs UNLOCKED (open) state, and unlocks for the session after correct entry. Read store.ts (state/actions), Sidebar.tsx (the lock 'trailing' icon on Recently Delet…",
|
||
"lastProgressAt": 1782743454219,
|
||
"tokens": 47690,
|
||
"toolCalls": 35,
|
||
"durationMs": 94411,
|
||
"resultPreview": "{\"feature\":\"Password Lock for Recently Deleted View\",\"approach\":\"Implement a client-side password protection system for the Recently Deleted view using bcrypt-hashing (via a lightweight library like tweetnacl or native crypto.subtle) to store password hashes in localStorage alongside the gallery state. The feature includes:\\n\\n1. **State Management (store.ts)**: Add to GalleryState a new `recently…"
|
||
},
|
||
{
|
||
"type": "workflow_agent",
|
||
"index": 3,
|
||
"label": "plan:map-pins",
|
||
"phaseIndex": 1,
|
||
"phaseTitle": "Plan",
|
||
"agentId": "a181d4b996c451225",
|
||
"agentType": "Explore",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"state": "done",
|
||
"startedAt": 1782743359807,
|
||
"queuedAt": 1782743359790,
|
||
"attempt": 1,
|
||
"lastToolName": "StructuredOutput",
|
||
"lastToolSummary": "Map Thumbnail Pins - Replace plain circle markers with macO…",
|
||
"promptPreview": "Plan MAP THUMBNAIL PINS. Read packages/photo-sdk/src/components/views/MapView.tsx (Leaflet integration, current markers/popups) + selectors locatedMedia. Requirement (see DESIGN): replace plain markers with macOS-style rounded photo-thumbnail pins that show a COUNT badge and cluster nearby photos; clicking a pin opens that location's photos grouped date-wise; ensure screenshots/items with GPS also…",
|
||
"lastProgressAt": 1782747522712,
|
||
"tokens": 46069,
|
||
"toolCalls": 20,
|
||
"durationMs": 4162904,
|
||
"resultPreview": "{\"feature\":\"Map Thumbnail Pins - Replace plain circle markers with macOS-style rounded photo-thumbnail pins featuring COUNT badges, proximity clustering, and location-grouped photo browsing\",\"approach\":\"\\n## Design & Architecture\\n\\nReplace the current Leaflet `circleMarker` implementation with macOS Photos-style DivIcon pins. The feature clusters photos by location proximity and provides a locati…"
|
||
},
|
||
{
|
||
"type": "workflow_agent",
|
||
"index": 4,
|
||
"label": "plan:grid-mosaic",
|
||
"phaseIndex": 1,
|
||
"phaseTitle": "Plan",
|
||
"agentId": "a31f88bcac1499f8b",
|
||
"agentType": "Explore",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"state": "done",
|
||
"startedAt": 1782743359808,
|
||
"queuedAt": 1782743359791,
|
||
"attempt": 1,
|
||
"lastToolName": "StructuredOutput",
|
||
"lastToolSummary": "MAP \"GRID\" TAB MOSAIC LAYOUT - Grid mode shows date-grouped…",
|
||
"promptPreview": "Plan the MAP \"GRID\" tab MOSAIC layout. The toolbar already has Map|Satellite|Grid (MapView mapMode/gridFilter). Requirement (see DESIGN): the Grid mode shows date-grouped photos in an irregular collage — full-width hero first, then portrait/vertical tiles of varying heights, plus an auto-advancing slideshow tile — NOT a uniform grid. Read MapView.tsx + how 'grid' mode currently renders + groupByTi…",
|
||
"lastProgressAt": 1782747496246,
|
||
"tokens": 48779,
|
||
"toolCalls": 20,
|
||
"durationMs": 4136438,
|
||
"resultPreview": "{\"feature\":\"MAP \\\"GRID\\\" TAB MOSAIC LAYOUT - Grid mode shows date-grouped photos in an irregular collage with hero, portrait tiles, and auto-sliding carousel\",\"approach\":\"\\n## Design Approach: MosaicGrid SDK Component\\n\\nThe Grid mode in MapView (mapMode='grid') currently delegates to MediaGrid, which renders a uniform responsive square grid. We'll introduce a new **MosaicGrid** component that cre…"
|
||
},
|
||
{
|
||
"type": "workflow_agent",
|
||
"index": 5,
|
||
"label": "plan:glass-design",
|
||
"phaseIndex": 1,
|
||
"phaseTitle": "Plan",
|
||
"agentId": "a1247a00685346f1d",
|
||
"agentType": "Explore",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"state": "done",
|
||
"startedAt": 1782743359808,
|
||
"queuedAt": 1782743359791,
|
||
"attempt": 1,
|
||
"lastToolName": "StructuredOutput",
|
||
"lastToolSummary": "GLASS/DARK-MODE/CORNER-RADIUS Design Polish for macOS Photo…",
|
||
"promptPreview": "Plan the GLASS / DARK-MODE / CORNER-RADIUS design polish to match macOS (see DESIGN). Read packages/photo-sdk/src/styles (the SDK's self-contained CSS, apg- classes; find the dropdown/menu, sidebar, toolbar, and CSS variables for radius/colors/dark theme). Plan exact CSS changes: backdrop-filter blur + translucent fills for menus/sidebar, hairline borders, corner-radius variables, the dropdown car…",
|
||
"lastProgressAt": 1782743418859,
|
||
"tokens": 44388,
|
||
"toolCalls": 13,
|
||
"durationMs": 59051,
|
||
"resultPreview": "{\"feature\":\"GLASS/DARK-MODE/CORNER-RADIUS Design Polish for macOS Photos-style UI\",\"approach\":\"The SDK already has a strong foundation with CSS variables, backdrop-filter implementations, and dark/light/semi-dark themes. The polish refines:\\n\\n1. **MENU/DROPDOWN GLASS** (`.apg-menu`): Increase backdrop blur from 30px to 35px, add a subtle inset hairline border for depth, reduce background opacity …"
|
||
},
|
||
{
|
||
"type": "workflow_agent",
|
||
"index": 6,
|
||
"label": "plan:semantic-search",
|
||
"phaseIndex": 1,
|
||
"phaseTitle": "Plan",
|
||
"agentId": "ae2ff550ec3597e65",
|
||
"agentType": "Explore",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"state": "done",
|
||
"startedAt": 1782743359808,
|
||
"queuedAt": 1782743359791,
|
||
"attempt": 1,
|
||
"lastToolName": "StructuredOutput",
|
||
"lastToolSummary": "Free in-browser semantic search (CLIP provider) for natural…",
|
||
"promptPreview": "Plan free in-browser SEMANTIC SEARCH (\"show me beach photos\"). The AIProvider already declares embedImage(item,image):Promise<number[]> and embedText(query):Promise<number[]> plus cosineSimilarity (packages/photo-sdk/src/ai/types.ts). Plan: a CLIP provider in apps/web/src/lib/ai using transformers.js (Xenova/clip-vit-base-patch16 or similar) loaded lazily from a CSP-allowed CDN (the project pins m…",
|
||
"lastProgressAt": 1782743433376,
|
||
"tokens": 36416,
|
||
"toolCalls": 25,
|
||
"durationMs": 73568,
|
||
"resultPreview": "{\"feature\":\"Free in-browser semantic search (CLIP provider) for natural-language photo queries\",\"approach\":\"\\nImplement a lazy-loaded CLIP provider (transformers.js) that follows the existing AI provider pattern:\\n\\n1. **CLIP Provider** (apps/web/src/lib/ai/clipProvider.ts):\\n - Load Xenova/clip-vit-base-patch16 model lazily from CDN (cdn.jsdelivr.net)\\n - Implement embedImage() to compute 512…"
|
||
},
|
||
{
|
||
"type": "workflow_agent",
|
||
"index": 7,
|
||
"label": "plan:video-annotations",
|
||
"phaseIndex": 1,
|
||
"phaseTitle": "Plan",
|
||
"agentId": "a8e9d6ea2c6d9312b",
|
||
"agentType": "Explore",
|
||
"model": "claude-haiku-4-5-20251001",
|
||
"state": "done",
|
||
"startedAt": 1782743418861,
|
||
"queuedAt": 1782743359791,
|
||
"attempt": 1,
|
||
"lastToolName": "StructuredOutput",
|
||
"lastToolSummary": "Video Annotations: Time-scoped and Static Overlays in Edito…",
|
||
"promptPreview": "Plan VIDEO ANNOTATIONS. The photo editor already has an Annotations SVG layer (packages/photo-sdk/src/components/Annotations.tsx) + PhotoEditor. Requirement: allow drawing the same annotation shapes on a VIDEO (in the editor and/or lightbox), as an overlay. Read PhotoEditor.tsx, Annotations.tsx, Lightbox.tsx (video element), EditState. Plan: a video editor surface that overlays the Annotations lay…",
|
||
"lastProgressAt": 1782747554251,
|
||
"tokens": 46944,
|
||
"toolCalls": 26,
|
||
"durationMs": 4135388,
|
||
"resultPreview": "{\"feature\":\"Video Annotations: Time-scoped and Static Overlays in Editor & Lightbox\",\"approach\":\"\\n## Architecture Overview\\n\\n**Core Principle**: Reuse the existing `Annotations` component (SVG overlay, 0..1 normalized geometry) for video by:\\n1. **Scoping annotations in time** (frame range or keyframe) or marking as **static** (all frames)\\n2. **Extending `EditState`** to store `videoAnnotations…"
|
||
}
|
||
],
|
||
"totalTokens": 326855,
|
||
"totalToolCalls": 165
|
||
} |