Merge branch 'prerelease' into feat/upgrade-nextjs

This commit is contained in:
sai80082 committed 2026-04-07 00:20:50 +05:30
commit 2dd783f949
11 files changed
+1663 -8

No files matched your search

+549
View File
@@ -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<string, string>;
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<IAsset[]> => {
const params: Record<string, string> = {};
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<IAsset[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [filters, setFilters] = useState<IOrphanFilters>({
notInAlbum: true,
noPeople: false,
noLocation: false,
notFavorited: false,
type: 'ALL',
limit: 200,
page: 1,
})
const [contextState, setContextState] = useState<IPhotoSelectionContext>({
selectedIds: [],
assets: [],
config: {
albumId: '',
sort: "fileOriginalDate",
sortOrder: "asc"
},
updateContext: (newConfig: Partial<IPhotoSelectionContext>) => {
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 (
<div className="text-center py-8 text-gray-500">
Select at least one filter to find orphan assets.
</div>
);
}
if (loading) return <Loader />
if (errorMessage) return <div className="text-red-500 p-4">{errorMessage}</div>
return (
<div className="p-4">
{assets.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Search size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No orphan assets found</h3>
<p>All your assets are well-organized with the current filters.</p>
</div>
) : (
<AssetGrid
assets={assets}
selectable={true}
onSelectionChange={(ids) => contextState.updateContext({ selectedIds: ids })}
/>
)}
</div>
)
}
return (
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent="Orphan Finder"
rightComponent={
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<Checkbox
id="notInAlbum"
checked={filters.notInAlbum}
onCheckedChange={() => handleFilterToggle('notInAlbum')}
/>
<Label htmlFor="notInAlbum" className="text-sm cursor-pointer">No Album</Label>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="noPeople"
checked={filters.noPeople}
onCheckedChange={() => handleFilterToggle('noPeople')}
/>
<Label htmlFor="noPeople" className="text-sm cursor-pointer">No People</Label>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="noLocation"
checked={filters.noLocation}
onCheckedChange={() => handleFilterToggle('noLocation')}
/>
<Label htmlFor="noLocation" className="text-sm cursor-pointer">No Location</Label>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="notFavorited"
checked={filters.notFavorited}
onCheckedChange={() => handleFilterToggle('notFavorited')}
/>
<Label htmlFor="notFavorited" className="text-sm cursor-pointer">Not Favorited</Label>
</div>
</div>
<Select
value={filters.type}
onValueChange={(value) => setFilters(prev => ({ ...prev, type: value, page: 1 }))}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Types</SelectItem>
<SelectItem value="IMAGE">Images</SelectItem>
<SelectItem value="VIDEO">Videos</SelectItem>
</SelectContent>
</Select>
{!loading && assets.length > 0 && (
<div className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-500">
{humanizeNumber(assets.length)}
<Camera className="w-4 h-4" />
</div>
)}
</div>
}
/>
<PhotoSelectionContext.Provider value={{ ...contextState, updateContext: contextState.updateContext }}>
{renderContent()}
{selectedAssets.length > 0 && (
<FloatingBar>
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-sm text-muted-foreground">
{contextState.selectedIds.length} Selected
</p>
<div className="flex items-center gap-2">
{contextState.selectedIds.length === contextState.assets.length ? (
<Button variant="outline" size="sm" onClick={() => contextState.updateContext({ selectedIds: [] })}>
Unselect all
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => contextState.updateContext({ selectedIds: contextState.assets.map((a) => a.id) })}>
Select all
</Button>
)}
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600" />
<AlbumSelectorDialog
onSelected={handleAddToAlbum}
onSubmit={handleCreateAlbumWithAssets}
/>
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600" />
<AlertDialog
title="Move selected assets to Trash?"
description="This action will move the selected assets to trash. You can restore them later."
onConfirm={handleTrash}
disabled={contextState.selectedIds.length === 0}
>
<Button variant="outline" size="sm" disabled={contextState.selectedIds.length === 0}>
<Trash className="w-4 h-4 mr-2" />
Trash
</Button>
</AlertDialog>
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}
disabled={contextState.selectedIds.length === 0}
>
<Button variant="destructive" size="sm" disabled={contextState.selectedIds.length === 0}>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialog>
</div>
</div>
</FloatingBar>
)}
</PhotoSelectionContext.Provider>
</PageLayout>
)
}
```
**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: <PackageSearch className="h-4 w-4" />,
},
```
**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"
```
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="flex items-center gap-2 px-2 py-1 rounded-full bg-muted hover:bg-muted/80 transition-colors"
>
<div className="relative h-6 w-6 shrink-0">
<img
src={PERSON_THUBNAIL_PATH(p.personId)}
alt={p.personName}
className={`h-6 w-6 rounded-full object-cover ${hidden ? 'opacity-40' : ''}`}
/>
{hidden && (
<div className="absolute inset-0 flex items-center justify-center">
<EyeOff className="h-3.5 w-3.5 text-foreground" />
</div>
)}
</div>
<span className={`text-xs font-medium ${hidden ? 'line-through text-muted-foreground' : ''}`}>{p.personName || 'Unknown'}</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-3 z-[10000]" side="bottom" align="start">
<div className="space-y-3">
<div className="flex items-center gap-3">
<img
src={PERSON_THUBNAIL_PATH(p.personId)}
alt={p.personName}
className="h-16 w-16 rounded-full object-cover"
/>
<span className="text-sm font-medium truncate">{p.personName || 'Unknown'}</span>
</div>
<div className="flex items-center gap-1">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
className="h-8 text-sm"
onKeyDown={(e) => { if (e.key === 'Enter') handleRename() }}
/>
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 shrink-0" onClick={handleRename} disabled={saving}>
<Check className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-col gap-1">
<button
onClick={handleHide}
disabled={saving}
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-muted transition-colors w-full text-left"
>
<EyeOff className="h-4 w-4" /> Hide person
</button>
<a
href={exImmichUrl + '/people/' + p.personId}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-muted transition-colors w-full"
>
<ExternalLink className="h-4 w-4" /> Open in Immich
</a>
</div>
</div>
</PopoverContent>
</Popover>
)
}
function MiniMap({ latitude, longitude }: { latitude: number; longitude: number }) {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(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 (
<div className="rounded-lg overflow-hidden border h-[160px]">
<div ref={mapContainerRef} className="h-full w-full" />
</div>
)
}
export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) {
const [detail, setDetail] = useState<IAssetDetail | null>(null)
const [loading, setLoading] = useState(true)
const [hiddenIds, setHiddenIds] = useState<Set<string>>(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 (
<div className="w-[360px] min-w-[360px] bg-background border-l overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Info</h2>
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-1/2" />
<div className="h-4 bg-muted rounded w-2/3" />
</div>
</div>
)
}
if (!detail) {
return (
<div className="w-[360px] min-w-[360px] bg-background border-l overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Info</h2>
<p className="text-sm text-muted-foreground">Unable to load asset details.</p>
</div>
)
}
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 (
<div className="w-[360px] min-w-[360px] bg-background border-l overflow-y-auto">
{/* Header */}
<div className="p-4 pb-2">
<h2 className="text-lg font-semibold">Info</h2>
</div>
<div className="px-4 pb-4 space-y-4">
{/* Description */}
{detail.description && (
<p className="text-sm text-muted-foreground">{detail.description}</p>
)}
{/* People */}
{detail.people && detail.people.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-muted-foreground">People</h3>
<div className="flex items-center gap-2">
{detail.people.some((p) => !p.personName && !hiddenIds.has(p.personId)) && (
<button
onClick={async () => {
const unnamed = detail.people.filter((p) => !p.personName && !hiddenIds.has(p.personId))
try {
await Promise.all(unnamed.map((p) => updatePerson(p.personId, { isHidden: true })))
setHiddenIds((prev) => { const next = new Set(prev); unnamed.forEach((p) => next.add(p.personId)); return next })
toast({ title: "Hidden", description: `Hidden ${unnamed.length} unnamed people` })
} catch {
toast({ title: "Error", description: "Failed to hide people", variant: "destructive" })
}
}}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
title="Hide unnamed people"
>
<EyeOff className="h-3.5 w-3.5" />
Hide unnamed
</button>
)}
{detail.people.some((p) => !hiddenIds.has(p.personId)) && (
<button
onClick={async () => {
const unhidden = detail.people.filter((p) => !hiddenIds.has(p.personId))
try {
await Promise.all(unhidden.map((p) => updatePerson(p.personId, { isHidden: true })))
setHiddenIds((prev) => { const next = new Set(prev); unhidden.forEach((p) => next.add(p.personId)); return next })
toast({ title: "Hidden", description: `Hidden ${unhidden.length} people` })
} catch {
toast({ title: "Error", description: "Failed to hide people", variant: "destructive" })
}
}}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
title="Hide all people"
>
<EyeOff className="h-3.5 w-3.5" />
Hide all
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{detail.people.map((p) => (
<PersonChip key={p.personId} person={p} exImmichUrl={exImmichUrl} isHidden={hiddenIds.has(p.personId)} onUpdate={(updated) => {
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) }
})
}
}} />
))}
</div>
</div>
)}
<hr className="border-border" />
{/* Details */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">Details</h3>
<div className="space-y-3">
{/* Date */}
{date && (
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">
{format(date, 'd MMM yyyy')}
</p>
<p className="text-xs text-muted-foreground">
{format(date, 'EEE, HH:mm:ss')}
{detail.timeZone ? ` ${detail.timeZone}` : ''}
</p>
</div>
</div>
)}
{/* File info */}
<div className="flex items-start gap-3">
<ImageIcon className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">{detail.originalFileName}</p>
<p className="text-xs text-muted-foreground">
{[
megapixels,
resolution,
detail.fileSizeInByte ? humanizeBytes(detail.fileSizeInByte) : null,
].filter(Boolean).join(' ')}
</p>
</div>
</div>
{/* Camera */}
{(detail.make || detail.model) && (
<div className="flex items-start gap-3">
<Camera className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">
{[detail.make, detail.model].filter(Boolean).join(' ')}
</p>
<p className="text-xs text-muted-foreground">
{[
detail.exposureTime ? formatExposureTime(detail.exposureTime) : null,
detail.iso ? `ISO ${detail.iso}` : null,
].filter(Boolean).join(' ')}
</p>
</div>
</div>
)}
{/* Lens */}
{detail.lensModel && (
<div className="flex items-start gap-3">
<Aperture className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium truncate max-w-[280px]">{detail.lensModel}</p>
<p className="text-xs text-muted-foreground">
{[
detail.fNumber ? `f/${detail.fNumber}` : null,
detail.focalLength ? `${detail.focalLength} mm` : null,
].filter(Boolean).join(' ')}
</p>
</div>
</div>
)}
{/* Location */}
{(locationParts.length > 0 || hasLocation) && (
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
{locationParts.length > 0 ? (
locationParts.map((part, i) => (
<p key={i} className={i === 0 ? 'text-sm font-medium' : 'text-xs text-muted-foreground'}>
{part}
</p>
))
) : (
<p className="text-xs text-muted-foreground">
{detail.latitude?.toFixed(6)}, {detail.longitude?.toFixed(6)}
</p>
)}
</div>
</div>
)}
{/* Map */}
{hasLocation && (
<MiniMap latitude={detail.latitude!} longitude={detail.longitude!} />
)}
</div>
</div>
</div>
</div>
)
}
+142 -3
View File
@@ -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<AssetGridRef, AssetGridProps>(({ assets, isInternal = true, selectable = false, onSelectionChange }, ref) => {
const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ 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<Set<string>>(() => 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<AssetGridRef, AssetGridProps>(({ 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 [
<button
key="favorite"
type="button"
className="yarl__button"
title={isFav ? "Unfavorite" : "Favorite"}
onClick={() => handleToggleFavorite(currentAsset.id)}
>
<Heart
className={`h-6 w-6 ${isFav ? 'fill-red-500 text-red-500' : 'text-white'}`}
/>
</button>,
<button
key="info"
type="button"
className="yarl__button"
title="Info"
onClick={toggleInfoPanel}
>
<Info className={`h-6 w-6 ${showInfoPanel ? 'text-blue-400' : 'text-white'}`} />
</button>,
<button
key="open-immich"
type="button"
className="yarl__button"
title="Open in Immich"
onClick={handleOpenInImmich}
>
<ExternalLink className="h-6 w-6 text-white" />
</button>,
...(onDeleteAsset ? [
<button
key="delete"
type="button"
className="yarl__button"
title="Delete"
onClick={() => {
onDeleteAsset(currentAsset.id);
setIndex(-1);
}}
>
<Trash2 className="h-6 w-6 text-white" />
</button>,
] : []),
];
}, [currentAsset, showInfoPanel, onDeleteAsset, onFavoriteAsset, handleOpenInImmich, favoriteIds, handleToggleFavorite]);
return (
<div>
<Lightbox
@@ -166,7 +276,36 @@ const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ 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 }) => (
<div className="flex h-full w-full">
<div className="flex-1 flex items-center justify-center overflow-hidden">
{children}
</div>
{showInfoPanel && currentAsset && (
<AssetInfoPanel
assetId={currentAsset.id}
/>
)}
</div>
),
}}
styles={{
container: {
backgroundColor: "rgba(0, 0, 0, 0.95)",
},
}}
/>
<RowsPhotoAlbum
photos={images}
+17 -3
View File
@@ -63,13 +63,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');
}
}
+7 -2
View File
@@ -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: <Video className="h-4 w-4" />,
},
// Bulk Duplicate
@@ -59,6 +59,11 @@ export const sidebarNavs = [
link: "/assets/bulk-duplicate-finder",
icon: <Copy className="h-4 w-4" />,
},
{
title: "Orphan Finder",
link: "/assets/orphan-finder",
icon: <PackageSearch className="h-4 w-4" />,
},
{
title: "Settings",
link: "/settings",
+1
View File
@@ -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";
+4
View File
@@ -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 });
}
+24
View File
@@ -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";
@@ -54,6 +55,7 @@ export interface IUpdateAssetsParams {
longitude?: number;
dateTimeOriginal?: string;
duplicateId?: string | null;
isFavorite?: boolean;
}
export const updateAssets = async (params: IUpdateAssetsParams) => {
@@ -101,3 +103,25 @@ export interface IAssetAlbumInfo {
export const getAlbumsByAssetIds = async (assetIds: string[]): Promise<Record<string, IAssetAlbumInfo[]>> => {
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<IAsset[]> => {
const params: Record<string, string> = {};
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));
}
+92
View File
@@ -0,0 +1,92 @@
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,
isHidden: person.isHidden,
})
.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" });
}
}
+99
View File
@@ -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<string, string>;
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);
}
+262
View File
@@ -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<IAsset[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [filters, setFilters] = useState<IOrphanFilters>({
notInAlbum: true,
noPeople: false,
noLocation: false,
notFavorited: false,
type: 'ALL',
limit: 200,
page: 1,
})
const [contextState, setContextState] = useState<IPhotoSelectionContext>({
selectedIds: [],
assets: [],
config: {
albumId: '',
sort: "fileOriginalDate",
sortOrder: "asc"
},
updateContext: (newConfig: Partial<IPhotoSelectionContext>) => {
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 (
<div className="text-center py-8 text-gray-500">
Select at least one filter to find orphan assets.
</div>
);
}
if (loading) return <div className="flex items-center justify-center py-12"><Loader /></div>
if (errorMessage) return <div className="text-red-500 p-4">{errorMessage}</div>
return (
<div className="p-4">
{assets.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Search size={48} className="mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No orphan assets found</h3>
<p>All your assets are well-organized with the current filters.</p>
</div>
) : (
<AssetGrid
assets={assets}
selectable={true}
onSelectionChange={(ids) => contextState.updateContext({ selectedIds: ids })}
/>
)}
</div>
)
}
return (
<PageLayout className="!p-0 !mb-0 relative pb-20">
<Header
leftComponent="Orphan Finder"
rightComponent={
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<Checkbox
id="notInAlbum"
checked={filters.notInAlbum}
onCheckedChange={() => handleFilterToggle('notInAlbum')}
/>
<Label htmlFor="notInAlbum" className="text-sm cursor-pointer">No Album</Label>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="noPeople"
checked={filters.noPeople}
onCheckedChange={() => handleFilterToggle('noPeople')}
/>
<Label htmlFor="noPeople" className="text-sm cursor-pointer">No People</Label>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="noLocation"
checked={filters.noLocation}
onCheckedChange={() => handleFilterToggle('noLocation')}
/>
<Label htmlFor="noLocation" className="text-sm cursor-pointer">No Location</Label>
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="notFavorited"
checked={filters.notFavorited}
onCheckedChange={() => handleFilterToggle('notFavorited')}
/>
<Label htmlFor="notFavorited" className="text-sm cursor-pointer">Not Favorited</Label>
</div>
</div>
<Select
value={filters.type}
onValueChange={(value) => setFilters(prev => ({ ...prev, type: value, page: 1 }))}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Types</SelectItem>
<SelectItem value="IMAGE">Images</SelectItem>
<SelectItem value="VIDEO">Videos</SelectItem>
</SelectContent>
</Select>
{!loading && assets.length > 0 && (
<div className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-500">
{humanizeNumber(assets.length)}
<Camera className="w-4 h-4" />
</div>
)}
</div>
}
/>
<PhotoSelectionContext.Provider value={{ ...contextState, updateContext: contextState.updateContext }}>
{renderContent()}
{selectedAssets.length > 0 && (
<FloatingBar>
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-sm text-muted-foreground">
{contextState.selectedIds.length} Selected
</p>
<div className="flex items-center gap-2">
{contextState.selectedIds.length === contextState.assets.length ? (
<Button variant="outline" size="sm" onClick={() => contextState.updateContext({ selectedIds: [] })}>
Unselect all
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => contextState.updateContext({ selectedIds: contextState.assets.map((a) => a.id) })}>
Select all
</Button>
)}
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600" />
<AlbumSelectorDialog
onSelected={handleAddToAlbum}
onSubmit={handleCreateAlbumWithAssets}
/>
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600" />
<AlertDialog
title="Move selected assets to Trash?"
description="This action will move the selected assets to trash. You can restore them later."
onConfirm={handleTrash}
disabled={contextState.selectedIds.length === 0}
>
<Button variant="outline" size="sm" disabled={contextState.selectedIds.length === 0}>
<Trash className="w-4 h-4 mr-2" />
Trash
</Button>
</AlertDialog>
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}
disabled={contextState.selectedIds.length === 0}
>
<Button variant="destructive" size="sm" disabled={contextState.selectedIds.length === 0}>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialog>
</div>
</div>
</FloatingBar>
)}
</PhotoSelectionContext.Provider>
</PageLayout>
)
}