Merge branch 'prerelease' into feat/upgrade-nextjs
This commit is contained in:
commit
2dd783f949
11 files changed
+1663
-8
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user