diff --git a/docs/plans/2026-04-06-orphan-finder.md b/docs/plans/2026-04-06-orphan-finder.md new file mode 100644 index 0000000..de65210 --- /dev/null +++ b/docs/plans/2026-04-06-orphan-finder.md @@ -0,0 +1,549 @@ +# Orphan Asset Finder Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a page that finds "orphaned" assets — those not in albums, without detected people, missing location, or never favorited — with combinable checkbox filters, asset grid, and bulk actions (add to album, trash, delete). + +**Architecture:** New API route queries Immich's DB via Drizzle with LEFT JOINs to detect orphan conditions. Frontend page follows the empty-videos pattern: filters in Header, AssetGrid with PhotoSelectionContext, FloatingBar with bulk actions. Sidebar entry added. + +**Tech Stack:** Next.js API route, Drizzle ORM (PostgreSQL), React, Tailwind, existing shared components (PageLayout, Header, FloatingBar, AssetGrid, AlbumSelectorDialog, AlertDialog). + +--- + +### Task 1: Add API route for orphan assets + +**Files:** +- Create: `src/pages/api/assets/orphan-finder.ts` + +**Step 1: Create the API route** + +```typescript +import { db } from "@/config/db"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { NextApiRequest, NextApiResponse } from "next"; +import { and, eq, isNull, isNotNull, sql, desc, asc, not } from "drizzle-orm"; +import { assets } from "@/schema/assets.schema"; +import { exif } from "@/schema"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; +import { assetFaces } from "@/schema/assetFaces.schema"; +import { isFlipped } from "@/helpers/asset.helper"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const currentUser = await getCurrentUser(req); + if (!currentUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const { + notInAlbum, + noPeople, + noLocation, + notFavorited, + type, + limit = "100", + page = "1", + } = req.query as Record; + + const limitNum = parseInt(limit, 10); + const pageNum = parseInt(page, 10); + + // Build conditions + const conditions = [ + eq(assets.ownerId, currentUser.id), + eq(assets.visibility, "timeline"), + eq(assets.status, "active"), + isNull(assets.deletedAt), + ]; + + if (type && type !== "ALL") { + conditions.push(eq(assets.type, type)); + } + + if (notFavorited === "true") { + conditions.push(eq(assets.isFavorite, false)); + } + + if (noLocation === "true") { + conditions.push(isNull(exif.latitude)); + } + + // Subquery approach for notInAlbum and noPeople + const notInAlbumCondition = notInAlbum === "true" + ? sql`NOT EXISTS (SELECT 1 FROM "album_asset" aa WHERE aa."assetId" = ${assets.id})` + : undefined; + + const noPeopleCondition = noPeople === "true" + ? sql`NOT EXISTS (SELECT 1 FROM "asset_face" af WHERE af."assetId" = ${assets.id} AND af."personId" IS NOT NULL)` + : undefined; + + if (notInAlbumCondition) conditions.push(notInAlbumCondition); + if (noPeopleCondition) conditions.push(noPeopleCondition); + + // At least one orphan filter must be active + const hasOrphanFilter = notInAlbum === "true" || noPeople === "true" || noLocation === "true" || notFavorited === "true"; + if (!hasOrphanFilter) { + return res.status(400).json({ message: "At least one orphan filter is required" }); + } + + const dbAssets = await db + .selectDistinctOn([assets.id], { + id: assets.id, + deviceId: assets.deviceId, + type: assets.type, + originalPath: assets.originalPath, + isFavorite: assets.isFavorite, + duration: assets.duration, + originalFileName: assets.originalFileName, + deletedAt: assets.deletedAt, + localDateTime: assets.localDateTime, + exifImageWidth: exif.exifImageWidth, + exifImageHeight: exif.exifImageHeight, + ownerId: assets.ownerId, + dateTimeOriginal: exif.dateTimeOriginal, + orientation: exif.orientation, + }) + .from(assets) + .leftJoin(exif, eq(assets.id, exif.assetId)) + .where(and(...conditions)) + .orderBy(assets.id, desc(assets.localDateTime)) + .limit(limitNum) + .offset((pageNum - 1) * limitNum); + + const cleanedAssets = dbAssets.map((asset) => ({ + ...asset, + exifImageHeight: isFlipped(asset?.orientation) ? asset?.exifImageWidth : asset?.exifImageHeight, + exifImageWidth: isFlipped(asset?.orientation) ? asset?.exifImageHeight : asset?.exifImageWidth, + orientation: asset?.orientation, + })); + + return res.status(200).json(cleanedAssets); +} +``` + +**Step 2: Commit** + +```bash +git add src/pages/api/assets/orphan-finder.ts +git commit -m "feat: add orphan finder API route" +``` + +--- + +### Task 2: Add route constant and handler function + +**Files:** +- Modify: `src/config/routes.ts` +- Modify: `src/handlers/api/asset.handler.ts` + +**Step 1: Add route constant** + +In `src/config/routes.ts`, add after `LIST_EMPTY_VIDEOS_PATH`: + +```typescript +export const LIST_ORPHAN_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/orphan-finder"; +``` + +**Step 2: Add handler function** + +In `src/handlers/api/asset.handler.ts`, add import and function: + +Add `LIST_ORPHAN_ASSETS_PATH` to the imports from `@/config/routes`. + +Add this function: + +```typescript +export interface IOrphanFilters { + notInAlbum?: boolean; + noPeople?: boolean; + noLocation?: boolean; + notFavorited?: boolean; + type?: string; + limit?: number; + page?: number; +} + +export const listOrphanAssets = async (filters: IOrphanFilters): Promise => { + const params: Record = {}; + if (filters.notInAlbum) params.notInAlbum = "true"; + if (filters.noPeople) params.noPeople = "true"; + if (filters.noLocation) params.noLocation = "true"; + if (filters.notFavorited) params.notFavorited = "true"; + if (filters.type) params.type = filters.type; + if (filters.limit) params.limit = String(filters.limit); + if (filters.page) params.page = String(filters.page); + return API.get(LIST_ORPHAN_ASSETS_PATH, params).then((assets) => assets.map(cleanUpAsset)); +} +``` + +**Step 3: Commit** + +```bash +git add src/config/routes.ts src/handlers/api/asset.handler.ts +git commit -m "feat: add orphan finder route constant and handler" +``` + +--- + +### Task 3: Create the Orphan Finder page + +**Files:** +- Create: `src/pages/assets/orphan-finder.tsx` + +**Step 1: Create the page** + +Follow the empty-videos pattern: PageLayout, Header with filters, PhotoSelectionContext, AssetGrid, FloatingBar with bulk actions. + +```tsx +import PageLayout from '@/components/layouts/PageLayout' +import Header from '@/components/shared/Header' +import Loader from '@/components/ui/loader' +import { listOrphanAssets, deleteAssets, IOrphanFilters } from '@/handlers/api/asset.handler' +import { addAssetToAlbum, createAlbumWithAssets } from '@/handlers/api/album.handler' +import { IAsset } from '@/types/asset' +import React, { useEffect, useState, useMemo } from 'react' +import { Camera, Trash2, Trash, Search } from 'lucide-react' +import { humanizeNumber } from '@/helpers/string.helper' +import PhotoSelectionContext, { IPhotoSelectionContext } from '@/contexts/PhotoSelectionContext' +import FloatingBar from '@/components/shared/FloatingBar' +import { Button } from '@/components/ui/button' +import { AlertDialog } from '@/components/ui/alert-dialog' +import AssetGrid from '@/components/shared/AssetGrid' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import AlbumSelectorDialog from '@/components/albums/AlbumSelectorDialog' +import { IAlbum } from '@/types/album' +import { toast } from '@/components/ui/use-toast' + +export default function OrphanFinderPage() { + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + const [filters, setFilters] = useState({ + notInAlbum: true, + noPeople: false, + noLocation: false, + notFavorited: false, + type: 'ALL', + limit: 200, + page: 1, + }) + + const [contextState, setContextState] = useState({ + selectedIds: [], + assets: [], + config: { + albumId: '', + sort: "fileOriginalDate", + sortOrder: "asc" + }, + updateContext: (newConfig: Partial) => { + setContextState(prevState => ({ + ...prevState, + ...newConfig, + config: newConfig.config ? { ...prevState.config, ...newConfig.config } : prevState.config + })); + } + }); + + const selectedAssets = useMemo(() => + contextState.assets.filter((a) => contextState.selectedIds.includes(a.id)), + [contextState.assets, contextState.selectedIds] + ); + + const hasActiveFilter = filters.notInAlbum || filters.noPeople || filters.noLocation || filters.notFavorited; + + const fetchOrphans = async () => { + if (!hasActiveFilter) return; + setLoading(true) + setErrorMessage('') + listOrphanAssets(filters) + .then((fetchedAssets) => { + setAssets(fetchedAssets) + contextState.updateContext({ assets: fetchedAssets, selectedIds: [] }) + }) + .catch((error) => { + setErrorMessage(error.message || 'Failed to load orphan assets') + }) + .finally(() => { + setLoading(false) + }) + } + + useEffect(() => { + fetchOrphans() + }, [filters]) + + const handleDelete = () => { + return deleteAssets(contextState.selectedIds, { force: true }).then(() => { + const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id)); + setAssets(newAssets) + contextState.updateContext({ selectedIds: [], assets: newAssets }); + }) + } + + const handleTrash = () => { + return deleteAssets(contextState.selectedIds, { force: false }).then(() => { + const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id)); + setAssets(newAssets) + contextState.updateContext({ selectedIds: [], assets: newAssets }); + }) + } + + const handleAddToAlbum = async (album: IAlbum) => { + await addAssetToAlbum(album.id, contextState.selectedIds); + toast({ title: "Success", description: `Added ${contextState.selectedIds.length} asset(s) to "${album.albumName}"` }); + // Re-fetch if "not in album" filter is active (assets may no longer be orphans) + if (filters.notInAlbum) { + fetchOrphans(); + } + } + + const handleCreateAlbumWithAssets = async (data: { albumName: string }) => { + return createAlbumWithAssets(data.albumName, contextState.selectedIds).then(() => { + if (filters.notInAlbum) { + fetchOrphans(); + } + }); + } + + const handleFilterToggle = (key: keyof IOrphanFilters) => { + setFilters(prev => ({ ...prev, [key]: !prev[key], page: 1 })); + } + + const renderContent = () => { + if (!hasActiveFilter) { + return ( +
+ Select at least one filter to find orphan assets. +
+ ); + } + if (loading) return + if (errorMessage) return
{errorMessage}
+ return ( +
+ {assets.length === 0 ? ( +
+ +

No orphan assets found

+

All your assets are well-organized with the current filters.

+
+ ) : ( + contextState.updateContext({ selectedIds: ids })} + /> + )} +
+ ) + } + + return ( + +
+
+
+ handleFilterToggle('notInAlbum')} + /> + +
+
+ handleFilterToggle('noPeople')} + /> + +
+
+ handleFilterToggle('noLocation')} + /> + +
+
+ handleFilterToggle('notFavorited')} + /> + +
+
+ + {!loading && assets.length > 0 && ( +
+ {humanizeNumber(assets.length)} + +
+ )} + + } + /> + + {renderContent()} + {selectedAssets.length > 0 && ( + +
+

+ {contextState.selectedIds.length} Selected +

+
+ {contextState.selectedIds.length === contextState.assets.length ? ( + + ) : ( + + )} +
+ +
+ + + + + + +
+
+ + )} + + + ) +} +``` + +**Step 2: Commit** + +```bash +git add src/pages/assets/orphan-finder.tsx +git commit -m "feat: add orphan finder page with filters and bulk actions" +``` + +--- + +### Task 4: Add sidebar entry + +**Files:** +- Modify: `src/config/constants/sidebarNavs.tsx` + +**Step 1: Add the sidebar nav entry** + +Add `PackageSearch` to the lucide-react import, then add the entry after "Bulk Duplicate Finder": + +```tsx +{ + title: "Orphan Finder", + link: "/assets/orphan-finder", + icon: , +}, +``` + +**Step 2: Commit** + +```bash +git add src/config/constants/sidebarNavs.tsx +git commit -m "feat: add orphan finder to sidebar navigation" +``` + +--- + +### Task 5: Verify createAlbumWithAssets handler exists + +**Files:** +- Possibly modify: `src/handlers/api/album.handler.ts` + +**Step 1: Check if `createAlbumWithAssets` exists in album handler** + +If it doesn't exist, add it. This function should create an album via Immich API and then add selected assets to it. + +```typescript +export const createAlbumWithAssets = async (albumName: string, assetIds: string[]) => { + const album = await API.post(CREATE_ALBUM_PATH, { albumName, assetIds }); + return album; +} +``` + +**Step 2: Commit if changes were needed** + +```bash +git add src/handlers/api/album.handler.ts +git commit -m "feat: add createAlbumWithAssets handler" +``` + +--- + +### Task 6: Manual testing and fixes + +**Step 1: Run the dev server** + +```bash +bun run dev +``` + +**Step 2: Test each filter combination** + +- Visit `/assets/orphan-finder` +- Toggle each checkbox individually, verify results +- Combine filters, verify AND behavior +- Test asset type dropdown +- Select assets, verify floating bar appears +- Test "Add to Album" flow +- Test Trash and Delete flows +- Verify sidebar link works + +**Step 3: Fix any issues found and commit** + +```bash +git add -A +git commit -m "fix: orphan finder polish and bug fixes" +``` diff --git a/src/components/asset-info/AssetInfoPanel.tsx b/src/components/asset-info/AssetInfoPanel.tsx new file mode 100644 index 0000000..4f5b20a --- /dev/null +++ b/src/components/asset-info/AssetInfoPanel.tsx @@ -0,0 +1,466 @@ +import React, { useEffect, useRef, useState } from 'react' +import { format } from 'date-fns' +import { humanizeBytes } from '@/helpers/string.helper' +import { useConfig } from '@/contexts/ConfigContext' +import { + Calendar, + Image as ImageIcon, + Camera, + Aperture, + MapPin, + EyeOff, + ExternalLink, + Check, +} from 'lucide-react' +import API from '@/lib/api' +import { PERSON_THUBNAIL_PATH } from '@/config/routes' +import { updatePerson } from '@/handlers/api/people.handler' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' +import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css' +import 'leaflet-defaulticon-compatibility' + +export interface IAssetDetail { + id: string + type: string + originalPath: string + originalFileName: string + isFavorite: boolean + duration: string | null + localDateTime: string | Date + fileCreatedAt: string | Date + make: string | null + model: string | null + lensModel: string | null + fNumber: number | null + focalLength: number | null + iso: number | null + exposureTime: string | null + fileSizeInByte: number | null + exifImageWidth: number | null + exifImageHeight: number | null + orientation: string | null + dateTimeOriginal: string | Date | null + timeZone: string | null + latitude: number | null + longitude: number | null + city: string | null + state: string | null + country: string | null + description: string | null + rating: number | null + fps: number | null + projectionType: string | null + people: { personId: string; personName: string; thumbnailPath: string; isHidden: boolean }[] +} + +interface AssetInfoPanelProps { + assetId: string +} + +function getMegapixels(width: number, height: number): string { + const mp = (width * height) / 1_000_000 + return mp >= 1 ? `${mp.toFixed(1)} MP` : `${(mp * 1000).toFixed(0)} KP` +} + +function formatExposureTime(exposure: string): string { + const val = parseFloat(exposure) + if (isNaN(val)) return exposure + if (val >= 1) return `${val} s` + return `1/${Math.round(1 / val)} s` +} + +interface PersonChipProps { + person: { personId: string; personName: string; thumbnailPath: string } + exImmichUrl: string + isHidden: boolean + onUpdate: (updated: { personId: string; personName?: string; hidden?: boolean }) => void +} + +function PersonChip({ person: p, exImmichUrl, isHidden: hidden, onUpdate }: PersonChipProps) { + const [name, setName] = useState(p.personName) + const [saving, setSaving] = useState(false) + const [open, setOpen] = useState(false) + + const handleRename = async () => { + if (name === p.personName) { setOpen(false); return } + setSaving(true) + try { + await updatePerson(p.personId, { name }) + onUpdate({ personId: p.personId, personName: name }) + toast({ title: "Updated", description: `Renamed to "${name}"` }) + setOpen(false) + } catch { + toast({ title: "Error", description: "Failed to rename", variant: "destructive" }) + } finally { + setSaving(false) + } + } + + const handleHide = async () => { + setSaving(true) + try { + await updatePerson(p.personId, { isHidden: true }) + onUpdate({ personId: p.personId, hidden: true }) + toast({ title: "Hidden", description: `"${p.personName || 'Unknown'}" hidden` }) + setOpen(false) + } catch { + toast({ title: "Error", description: "Failed to hide person", variant: "destructive" }) + } finally { + setSaving(false) + } + } + + return ( + + + + + +
+
+ {p.personName} + {p.personName || 'Unknown'} +
+
+ setName(e.target.value)} + placeholder="Name" + className="h-8 text-sm" + onKeyDown={(e) => { if (e.key === 'Enter') handleRename() }} + /> + +
+
+ + + Open in Immich + +
+
+
+
+ ) +} + +function MiniMap({ latitude, longitude }: { latitude: number; longitude: number }) { + const mapContainerRef = useRef(null) + const mapRef = useRef(null) + + useEffect(() => { + if (!mapContainerRef.current) return + + // Check if dark mode + const isDark = document.documentElement.classList.contains('dark') + + const map = L.map(mapContainerRef.current, { + center: [latitude, longitude], + zoom: 14, + zoomControl: true, + scrollWheelZoom: false, + dragging: true, + doubleClickZoom: true, + attributionControl: false, + }) + + mapRef.current = map + + const tileLayer = isDark + ? L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + subdomains: 'abcd', + maxZoom: 20, + }) + : L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 20, + }) + + tileLayer.addTo(map) + L.marker([latitude, longitude]).addTo(map) + + // Force resize after render + setTimeout(() => map.invalidateSize(), 100) + + return () => { + map.remove() + mapRef.current = null + } + }, [latitude, longitude]) + + return ( +
+
+
+ ) +} + +export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) { + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(true) + const [hiddenIds, setHiddenIds] = useState>(new Set()) + const { exImmichUrl } = useConfig() + + useEffect(() => { + setLoading(true) + setHiddenIds(new Set()) + API.get(`/api/assets/${assetId}/detail`) + .then((data) => { + setDetail(data) + if (data?.people) { + setHiddenIds(new Set(data.people.filter((p: any) => p.isHidden).map((p: any) => p.personId))) + } + }) + .catch(() => setDetail(null)) + .finally(() => setLoading(false)) + }, [assetId]) + + if (loading) { + return ( +
+

Info

+
+
+
+
+
+
+ ) + } + + if (!detail) { + return ( +
+

Info

+

Unable to load asset details.

+
+ ) + } + + const dateStr = detail.dateTimeOriginal || detail.localDateTime + const date = dateStr ? new Date(dateStr) : null + + const hasLocation = detail.latitude && detail.longitude + const locationParts = [detail.city, detail.state, detail.country].filter(Boolean) + + const resolution = detail.exifImageWidth && detail.exifImageHeight + ? `${detail.exifImageWidth} x ${detail.exifImageHeight}` + : null + const megapixels = detail.exifImageWidth && detail.exifImageHeight + ? getMegapixels(detail.exifImageWidth, detail.exifImageHeight) + : null + + return ( +
+ {/* Header */} +
+

Info

+
+ +
+ {/* Description */} + {detail.description && ( +

{detail.description}

+ )} + + {/* People */} + {detail.people && detail.people.length > 0 && ( +
+
+

People

+
+ {detail.people.some((p) => !p.personName && !hiddenIds.has(p.personId)) && ( + + )} + {detail.people.some((p) => !hiddenIds.has(p.personId)) && ( + + )} +
+
+
+ {detail.people.map((p) => ( + { + if (updated.hidden) { + setHiddenIds((prev) => new Set(prev).add(updated.personId)) + } + if (updated.personName !== undefined) { + setDetail((prev) => { + if (!prev) return prev + return { ...prev, people: prev.people.map((pp) => pp.personId === updated.personId ? { ...pp, personName: updated.personName! } : pp) } + }) + } + }} /> + ))} +
+
+ )} + +
+ + {/* Details */} +
+

Details

+
+ {/* Date */} + {date && ( +
+ +
+

+ {format(date, 'd MMM yyyy')} +

+

+ {format(date, 'EEE, HH:mm:ss')} + {detail.timeZone ? ` ${detail.timeZone}` : ''} +

+
+
+ )} + + {/* File info */} +
+ +
+

{detail.originalFileName}

+

+ {[ + megapixels, + resolution, + detail.fileSizeInByte ? humanizeBytes(detail.fileSizeInByte) : null, + ].filter(Boolean).join(' ')} +

+
+
+ + {/* Camera */} + {(detail.make || detail.model) && ( +
+ +
+

+ {[detail.make, detail.model].filter(Boolean).join(' ')} +

+

+ {[ + detail.exposureTime ? formatExposureTime(detail.exposureTime) : null, + detail.iso ? `ISO ${detail.iso}` : null, + ].filter(Boolean).join(' ')} +

+
+
+ )} + + {/* Lens */} + {detail.lensModel && ( +
+ +
+

{detail.lensModel}

+

+ {[ + detail.fNumber ? `f/${detail.fNumber}` : null, + detail.focalLength ? `${detail.focalLength} mm` : null, + ].filter(Boolean).join(' ')} +

+
+
+ )} + + {/* Location */} + {(locationParts.length > 0 || hasLocation) && ( +
+ +
+ {locationParts.length > 0 ? ( + locationParts.map((part, i) => ( +

+ {part} +

+ )) + ) : ( +

+ {detail.latitude?.toFixed(6)}, {detail.longitude?.toFixed(6)} +

+ )} +
+
+ )} + + {/* Map */} + {hasLocation && ( + + )} +
+
+
+
+ ) +} diff --git a/src/components/shared/AssetGrid.tsx b/src/components/shared/AssetGrid.tsx index f5c4c8d..9d205c4 100644 --- a/src/components/shared/AssetGrid.tsx +++ b/src/components/shared/AssetGrid.tsx @@ -2,7 +2,7 @@ import "yet-another-react-lightbox/styles.css"; import "react-photo-album/rows.css"; import { IAsset } from '@/types/asset'; -import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useCallback } from 'react' import Lightbox from 'yet-another-react-lightbox'; import { RowsPhotoAlbum } from "react-photo-album"; import type { Photo, RenderImageContext, RenderImageProps } from "react-photo-album"; @@ -11,6 +11,12 @@ import Download from "yet-another-react-lightbox/plugins/download"; import Video from "yet-another-react-lightbox/plugins/video"; import { usePhotoSelectionContext } from '@/contexts/PhotoSelectionContext'; import { useConfig } from '@/contexts/ConfigContext'; +import dynamic from 'next/dynamic'; +import { Heart, Info, Trash2, ExternalLink } from 'lucide-react'; +import { updateAssets } from '@/handlers/api/asset.handler'; +import { toast } from '@/components/ui/use-toast'; + +const AssetInfoPanel = dynamic(() => import('@/components/asset-info/AssetInfoPanel'), { ssr: false }); export interface AssetPhoto extends Photo { id: string; @@ -24,6 +30,8 @@ interface AssetGridProps { isInternal?: boolean; selectable?: boolean; onSelectionChange?: (ids: string[]) => void; + onDeleteAsset?: (id: string) => void; + onFavoriteAsset?: (id: string, isFavorite: boolean) => void; } interface AssetGridRef { @@ -32,13 +40,59 @@ interface AssetGridRef { unselectAll: () => void; } -const AssetGrid = forwardRef(({ assets, isInternal = true, selectable = false, onSelectionChange }, ref) => { +const AssetGrid = forwardRef(({ assets, isInternal = true, selectable = false, onSelectionChange, onDeleteAsset, onFavoriteAsset }, ref) => { const [index, setIndex] = useState(-1); const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); + const [showInfoPanel, setShowInfoPanel] = useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem('assetInfoPanelOpen') === 'true' + } + return false + }); + const [favoriteIds, setFavoriteIds] = useState>(() => new Set(assets.filter(a => a.isFavorite).map(a => a.id))); const { exImmichUrl } = useConfig(); // Use context for selection state const { selectedIds, updateContext } = usePhotoSelectionContext(); + // Sync favoriteIds when assets change + useEffect(() => { + setFavoriteIds(new Set(assets.filter(a => a.isFavorite).map(a => a.id))); + }, [assets]); + + const handleToggleFavorite = useCallback(async (assetId: string) => { + const isFav = favoriteIds.has(assetId); + const newFav = !isFav; + // Optimistic update + setFavoriteIds(prev => { + const next = new Set(prev); + newFav ? next.add(assetId) : next.delete(assetId); + return next; + }); + try { + await updateAssets({ ids: [assetId], isFavorite: newFav }); + toast({ title: newFav ? "Added to favorites" : "Removed from favorites" }); + onFavoriteAsset?.(assetId, newFav); + } catch { + // Revert on failure + setFavoriteIds(prev => { + const next = new Set(prev); + isFav ? next.add(assetId) : next.delete(assetId); + return next; + }); + toast({ title: "Error", description: "Failed to update favorite", variant: "destructive" }); + } + }, [favoriteIds, onFavoriteAsset]); + + const toggleInfoPanel = useCallback(() => { + setShowInfoPanel((v) => { + const next = !v + localStorage.setItem('assetInfoPanelOpen', String(next)) + return next + }) + }, []); + + const currentAsset = index >= 0 && index < assets.length ? assets[index] : null; + useImperativeHandle(ref, () => ({ getSelectedIds: () => selectedIds, selectAll: () => { @@ -159,6 +213,62 @@ const AssetGrid = forwardRef(({ assets, isInternal ); }; + const handleOpenInImmich = useCallback(() => { + if (currentAsset) { + window.open(exImmichUrl + "/photos/" + currentAsset.id, "_blank"); + } + }, [currentAsset, exImmichUrl]); + + const toolbarButtons = useMemo(() => { + if (!currentAsset) return []; + const isFav = favoriteIds.has(currentAsset.id); + return [ + , + , + , + ...(onDeleteAsset ? [ + , + ] : []), + ]; + }, [currentAsset, showInfoPanel, onDeleteAsset, onFavoriteAsset, handleOpenInImmich, favoriteIds, handleToggleFavorite]); + return (
(({ assets, isInternal plugins={[Download, Video]} open={index >= 0} index={index} - close={() => setIndex(-1)} + close={() => { setIndex(-1); }} + on={{ + view: ({ index }) => setIndex(index), + }} + toolbar={{ + buttons: [ + ...toolbarButtons, + "download", + "close", + ], + }} + render={{ + slideContainer: ({ children }) => ( +
+
+ {children} +
+ {showInfoPanel && currentAsset && ( + + )} +
+ ), + }} + styles={{ + container: { + backgroundColor: "rgba(0, 0, 0, 0.95)", + }, + }} /> { - if (generatedLink) { - navigator.clipboard.writeText(generatedLink); + const handleCopy = async () => { + if (!generatedLink) return; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(generatedLink); + } else { + const textArea = document.createElement('textarea'); + textArea.value = generatedLink; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } setCopied(true); setTimeout(() => { setCopied(false); }, 2000); + } catch { + setErrorMessage('Failed to copy link'); } } diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index 78d8655..7d84e7f 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -1,4 +1,4 @@ -import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, Settings, Share2, User, Video } from "lucide-react"; +import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, PackageSearch, Rewind, Search, Settings, Share2, User, Video } from "lucide-react"; export const sidebarNavs = [ // { @@ -50,7 +50,7 @@ export const sidebarNavs = [ // Empty Videos { title: "Empty Videos", - link: "/assets/empty-videos", + link: "/assets/empty-videos", icon:
+ } + /> + + {renderContent()} + {selectedAssets.length > 0 && ( + +
+

+ {contextState.selectedIds.length} Selected +

+
+ {contextState.selectedIds.length === contextState.assets.length ? ( + + ) : ( + + )} +
+ +
+ + + + + + +
+
+ + )} + + + ) +}