From 11e88526c7ba98d77812ecc79c4584a3d70b7bfc Mon Sep 17 00:00:00 2001 From: Varun Date: Mon, 6 Apr 2026 19:16:04 +0530 Subject: [PATCH 1/3] fix: copy button in share assets fix Signed-off-by: Varun --- src/components/shared/ShareAssetsTrigger.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/shared/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx index 31e05db..7d30545 100644 --- a/src/components/shared/ShareAssetsTrigger.tsx +++ b/src/components/shared/ShareAssetsTrigger.tsx @@ -40,13 +40,27 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets }); } - const handleCopy = () => { - 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'); } } From a2916b6ee75aa159dc9e4320e2e3406526c96e0d Mon Sep 17 00:00:00 2001 From: Varun Date: Mon, 6 Apr 2026 19:55:38 +0530 Subject: [PATCH 2/3] feat: added orphan assets and new info panel for the asset dialog Signed-off-by: Varun --- docs/plans/2026-04-06-orphan-finder.md | 549 +++++++++++++++++++ src/components/asset-info/AssetInfoPanel.tsx | 305 +++++++++++ src/components/shared/AssetGrid.tsx | 99 +++- src/config/constants/sidebarNavs.tsx | 9 +- src/config/routes.ts | 1 + src/handlers/api/album.handler.ts | 4 + src/handlers/api/asset.handler.ts | 23 + src/pages/api/assets/[id]/detail.ts | 91 +++ src/pages/api/assets/orphan-finder.ts | 99 ++++ src/pages/assets/orphan-finder.tsx | 262 +++++++++ 10 files changed, 1437 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-04-06-orphan-finder.md create mode 100644 src/components/asset-info/AssetInfoPanel.tsx create mode 100644 src/pages/api/assets/[id]/detail.ts create mode 100644 src/pages/api/assets/orphan-finder.ts create mode 100644 src/pages/assets/orphan-finder.tsx 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..e8dfaff --- /dev/null +++ b/src/components/asset-info/AssetInfoPanel.tsx @@ -0,0 +1,305 @@ +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, +} from 'lucide-react' +import API from '@/lib/api' +import { PERSON_THUBNAIL_PATH } from '@/config/routes' +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 }[] +} + +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` +} + +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 { exImmichUrl } = useConfig() + + useEffect(() => { + setLoading(true) + API.get(`/api/assets/${assetId}/detail`) + .then(setDetail) + .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.map((p) => ( + + {p.personName} + {p.personName || 'Unknown'} + + ))} +
+
+ )} + +
+ + {/* 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 473e5be..5e8dd18 100644 --- a/src/components/shared/AssetGrid.tsx +++ b/src/components/shared/AssetGrid.tsx @@ -1,7 +1,7 @@ import "yet-another-react-lightbox/styles.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 { Gallery } from "react-grid-gallery"; import LazyGridImage from "../ui/lazy-grid-image"; @@ -9,6 +9,10 @@ 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'; + +const AssetInfoPanel = dynamic(() => import('@/components/asset-info/AssetInfoPanel'), { ssr: false }); interface AssetGridProps { @@ -16,6 +20,8 @@ interface AssetGridProps { isInternal?: boolean; selectable?: boolean; onSelectionChange?: (ids: string[]) => void; + onDeleteAsset?: (id: string) => void; + onFavoriteAsset?: (id: string, isFavorite: boolean) => void; } interface AssetGridRef { @@ -24,13 +30,16 @@ 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(false); const { exImmichUrl } = useConfig(); // Use context for selection state const { selectedIds, updateContext } = usePhotoSelectionContext(); + const currentAsset = index >= 0 && index < assets.length ? assets[index] : null; + useImperativeHandle(ref, () => ({ getSelectedIds: () => selectedIds, selectAll: () => { @@ -139,6 +148,61 @@ const AssetGrid = forwardRef(({ assets, isInternal return () => window.removeEventListener("keydown", handleEsc); }, [images]); + const handleOpenInImmich = useCallback(() => { + if (currentAsset) { + window.open(exImmichUrl + "/photos/" + currentAsset.id, "_blank"); + } + }, [currentAsset, exImmichUrl]); + + const toolbarButtons = useMemo(() => { + if (!currentAsset) return []; + return [ + , + , + , + ...(onDeleteAsset ? [ + , + ] : []), + ]; + }, [currentAsset, showInfoPanel, onDeleteAsset, onFavoriteAsset, handleOpenInImmich]); + return (
(({ assets, isInternal plugins={[Download, Video]} open={index >= 0} index={index} - close={() => setIndex(-1)} + close={() => { setIndex(-1); setShowInfoPanel(false); }} + 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)", + }, + }} /> , }, - + { + title: "Orphan Finder", + link: "/assets/orphan-finder", + icon: , + }, + ]; diff --git a/src/config/routes.ts b/src/config/routes.ts index 5011619..34879a7 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -44,6 +44,7 @@ export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/ export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id; export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap"; export const LIST_EMPTY_VIDEOS_PATH = BASE_API_ENDPOINT + "/assets/empty-videos"; +export const LIST_ORPHAN_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/orphan-finder"; diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts index f510df8..2179afb 100644 --- a/src/handlers/api/album.handler.ts +++ b/src/handlers/api/album.handler.ts @@ -57,4 +57,8 @@ export const deleteAlbums = async (albumIds: string[]) => { export const mergeAlbums = async (primaryAlbumId: string, secondaryAlbumIds: string[]) => { return API.post(MERGE_ALBUMS_PATH, { primaryAlbumId, secondaryAlbumIds }); +} + +export const createAlbumWithAssets = async (albumName: string, assetIds: string[]) => { + return API.post(CREATE_ALBUM_PATH, { albumName, assetIds }); } \ No newline at end of file diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 4549f06..f6b6945 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -8,6 +8,7 @@ import { LIST_MISSING_LOCATION_DATES_PATH, UPDATE_ASSETS_PATH, LIST_DUPLICATES_PATH, + LIST_ORPHAN_ASSETS_PATH, } from "@/config/routes"; import { cleanUpAsset } from "@/helpers/asset.helper"; import API from "@/lib/api"; @@ -101,3 +102,25 @@ export interface IAssetAlbumInfo { export const getAlbumsByAssetIds = async (assetIds: string[]): Promise> => { return API.post(ASSET_ALBUMS_BY_ASSETS_PATH, { assetIds }); } + +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)); +} diff --git a/src/pages/api/assets/[id]/detail.ts b/src/pages/api/assets/[id]/detail.ts new file mode 100644 index 0000000..0b4859f --- /dev/null +++ b/src/pages/api/assets/[id]/detail.ts @@ -0,0 +1,91 @@ +import { db } from "@/config/db"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { NextApiRequest, NextApiResponse } from "next"; +import { eq, and } from "drizzle-orm"; +import { assets } from "@/schema/assets.schema"; +import { exif } from "@/schema"; +import { assetFaces } from "@/schema/assetFaces.schema"; +import { person } from "@/schema/person.schema"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const currentUser = await getCurrentUser(req); + if (!currentUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const { id } = req.query as { id: string }; + + if (!id) { + return res.status(400).json({ message: "Asset ID is required" }); + } + + try { + // Fetch asset + exif data + const [assetRow] = await db + .select({ + id: assets.id, + type: assets.type, + originalPath: assets.originalPath, + originalFileName: assets.originalFileName, + isFavorite: assets.isFavorite, + duration: assets.duration, + localDateTime: assets.localDateTime, + fileCreatedAt: assets.fileCreatedAt, + // EXIF fields + make: exif.make, + model: exif.model, + lensModel: exif.lensModel, + fNumber: exif.fNumber, + focalLength: exif.focalLength, + iso: exif.iso, + exposureTime: exif.exposureTime, + fileSizeInByte: exif.fileSizeInByte, + exifImageWidth: exif.exifImageWidth, + exifImageHeight: exif.exifImageHeight, + orientation: exif.orientation, + dateTimeOriginal: exif.dateTimeOriginal, + timeZone: exif.timeZone, + latitude: exif.latitude, + longitude: exif.longitude, + city: exif.city, + state: exif.state, + country: exif.country, + description: exif.description, + rating: exif.rating, + fps: exif.fps, + projectionType: exif.projectionType, + }) + .from(assets) + .leftJoin(exif, eq(assets.id, exif.assetId)) + .where(and( + eq(assets.id, id), + eq(assets.ownerId, currentUser.id), + )) + .limit(1); + + if (!assetRow) { + return res.status(404).json({ message: "Asset not found" }); + } + + // Fetch people detected in this asset + const faces = await db + .select({ + personId: person.id, + personName: person.name, + thumbnailPath: person.thumbnailPath, + }) + .from(assetFaces) + .innerJoin(person, eq(assetFaces.personId, person.id)) + .where(eq(assetFaces.assetId, id)); + + return res.status(200).json({ + ...assetRow, + people: faces, + }); + } catch (error: any) { + return res.status(500).json({ message: error?.message || "Internal server error" }); + } +} diff --git a/src/pages/api/assets/orphan-finder.ts b/src/pages/api/assets/orphan-finder.ts new file mode 100644 index 0000000..55c3e82 --- /dev/null +++ b/src/pages/api/assets/orphan-finder.ts @@ -0,0 +1,99 @@ +import { db } from "@/config/db"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { NextApiRequest, NextApiResponse } from "next"; +import { and, eq, isNull, sql, desc } from "drizzle-orm"; +import { assets } from "@/schema/assets.schema"; +import { exif } from "@/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); + + const conditions: any[] = [ + 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)); + } + + if (notInAlbum === "true") { + conditions.push( + sql`NOT EXISTS (SELECT 1 FROM "album_asset" aa WHERE aa."assetId" = ${assets.id})` + ); + } + + if (noPeople === "true") { + conditions.push( + sql`NOT EXISTS (SELECT 1 FROM "asset_face" af WHERE af."assetId" = ${assets.id} AND af."personId" IS NOT NULL)` + ); + } + + 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); +} diff --git a/src/pages/assets/orphan-finder.tsx b/src/pages/assets/orphan-finder.tsx new file mode 100644 index 0000000..c193a0f --- /dev/null +++ b/src/pages/assets/orphan-finder.tsx @@ -0,0 +1,262 @@ +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, useCallback } 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 = useCallback(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) + }) + }, [filters, hasActiveFilter]) + + useEffect(() => { + fetchOrphans() + }, [fetchOrphans]) + + 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}"` }); + 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 ? ( + + ) : ( + + )} +
+ +
+ + + + + + +
+
+ + )} + + + ) +} From 54eeb73b66777a5b2371a331ada3f548f14ec5dc Mon Sep 17 00:00:00 2001 From: Varun Date: Mon, 6 Apr 2026 20:13:16 +0530 Subject: [PATCH 3/3] feat: hide and edit people option in the asset dialog Signed-off-by: Varun --- src/components/asset-info/AssetInfoPanel.tsx | 195 +++++++++++++++++-- src/components/shared/AssetGrid.tsx | 60 +++++- src/handlers/api/asset.handler.ts | 1 + src/pages/api/assets/[id]/detail.ts | 1 + 4 files changed, 233 insertions(+), 24 deletions(-) diff --git a/src/components/asset-info/AssetInfoPanel.tsx b/src/components/asset-info/AssetInfoPanel.tsx index e8dfaff..4f5b20a 100644 --- a/src/components/asset-info/AssetInfoPanel.tsx +++ b/src/components/asset-info/AssetInfoPanel.tsx @@ -8,9 +8,17 @@ import { 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' @@ -47,7 +55,7 @@ export interface IAssetDetail { rating: number | null fps: number | null projectionType: string | null - people: { personId: string; personName: string; thumbnailPath: string }[] + people: { personId: string; personName: string; thumbnailPath: string; isHidden: boolean }[] } interface AssetInfoPanelProps { @@ -66,6 +74,113 @@ function formatExposureTime(exposure: string): string { 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) @@ -119,12 +234,19 @@ function MiniMap({ latitude, longitude }: { latitude: number; longitude: number 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(setDetail) + .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]) @@ -180,23 +302,62 @@ export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) { {/* People */} {detail.people && detail.people.length > 0 && (
-

People

+
+

People

+
+ {detail.people.some((p) => !p.personName && !hiddenIds.has(p.personId)) && ( + + )} + {detail.people.some((p) => !hiddenIds.has(p.personId)) && ( + + )} +
+
{detail.people.map((p) => ( - - {p.personName} - {p.personName || 'Unknown'} - + { + 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) } + }) + } + }} /> ))}
diff --git a/src/components/shared/AssetGrid.tsx b/src/components/shared/AssetGrid.tsx index 5e8dd18..ceece21 100644 --- a/src/components/shared/AssetGrid.tsx +++ b/src/components/shared/AssetGrid.tsx @@ -11,6 +11,8 @@ 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 }); @@ -33,11 +35,54 @@ interface AssetGridRef { 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(false); + 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, () => ({ @@ -156,16 +201,17 @@ const AssetGrid = forwardRef(({ assets, isInternal const toolbarButtons = useMemo(() => { if (!currentAsset) return []; + const isFav = favoriteIds.has(currentAsset.id); return [ , , @@ -201,7 +247,7 @@ const AssetGrid = forwardRef(({ assets, isInternal , ] : []), ]; - }, [currentAsset, showInfoPanel, onDeleteAsset, onFavoriteAsset, handleOpenInImmich]); + }, [currentAsset, showInfoPanel, onDeleteAsset, onFavoriteAsset, handleOpenInImmich, favoriteIds, handleToggleFavorite]); return (
@@ -210,7 +256,7 @@ const AssetGrid = forwardRef(({ assets, isInternal plugins={[Download, Video]} open={index >= 0} index={index} - close={() => { setIndex(-1); setShowInfoPanel(false); }} + close={() => { setIndex(-1); }} on={{ view: ({ index }) => setIndex(index), }} diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index f6b6945..9cf09c7 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -55,6 +55,7 @@ export interface IUpdateAssetsParams { longitude?: number; dateTimeOriginal?: string; duplicateId?: string | null; + isFavorite?: boolean; } export const updateAssets = async (params: IUpdateAssetsParams) => { diff --git a/src/pages/api/assets/[id]/detail.ts b/src/pages/api/assets/[id]/detail.ts index 0b4859f..6fc8a06 100644 --- a/src/pages/api/assets/[id]/detail.ts +++ b/src/pages/api/assets/[id]/detail.ts @@ -76,6 +76,7 @@ export default async function handler( personId: person.id, personName: person.name, thumbnailPath: person.thumbnailPath, + isHidden: person.isHidden, }) .from(assetFaces) .innerJoin(person, eq(assetFaces.personId, person.id))