fixing selection of photos from multiple sources at once
This commit is contained in:
@@ -33,11 +33,12 @@ interface TripPhoto {
|
|||||||
city?: string | null
|
city?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImmichAsset {
|
interface Asset {
|
||||||
id: string
|
id: string
|
||||||
takenAt: string
|
takenAt: string
|
||||||
city: string | null
|
city: string | null
|
||||||
country: string | null
|
country: string | null
|
||||||
|
provider: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemoriesPanelProps {
|
interface MemoriesPanelProps {
|
||||||
@@ -63,7 +64,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
// Photo picker
|
// Photo picker
|
||||||
const [showPicker, setShowPicker] = useState(false)
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
const [pickerPhotos, setPickerPhotos] = useState<Asset[]>([])
|
||||||
const [pickerLoading, setPickerLoading] = useState(false)
|
const [pickerLoading, setPickerLoading] = useState(false)
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
@@ -238,11 +239,16 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
const loadPickerPhotos = async (useDate: boolean) => {
|
const loadPickerPhotos = async (useDate: boolean) => {
|
||||||
setPickerLoading(true)
|
setPickerLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(`${pickerIntegrationBase}/search`, {
|
const provider = availableProviders.find(p => p.id === selectedProvider)
|
||||||
|
if (!provider) {
|
||||||
|
setPickerPhotos([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await apiClient.post(`/integrations/${provider.id}/search`, {
|
||||||
from: useDate && startDate ? startDate : undefined,
|
from: useDate && startDate ? startDate : undefined,
|
||||||
to: useDate && endDate ? endDate : undefined,
|
to: useDate && endDate ? endDate : undefined,
|
||||||
})
|
})
|
||||||
setPickerPhotos(res.data.assets || [])
|
setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id })))
|
||||||
} catch {
|
} catch {
|
||||||
setPickerPhotos([])
|
setPickerPhotos([])
|
||||||
toast.error(t('memories.error.loadPhotos'))
|
toast.error(t('memories.error.loadPhotos'))
|
||||||
@@ -268,9 +274,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
const executeAddPhotos = async () => {
|
const executeAddPhotos = async () => {
|
||||||
setShowConfirmShare(false)
|
setShowConfirmShare(false)
|
||||||
try {
|
try {
|
||||||
|
const groupedByProvider = new Map<string, string[]>()
|
||||||
|
for (const key of selectedIds) {
|
||||||
|
const [provider, assetId] = key.split('::')
|
||||||
|
if (!provider || !assetId) continue
|
||||||
|
const list = groupedByProvider.get(provider) || []
|
||||||
|
list.push(assetId)
|
||||||
|
groupedByProvider.set(provider, list)
|
||||||
|
}
|
||||||
|
|
||||||
await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, {
|
await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, {
|
||||||
provider: selectedProvider,
|
selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
|
||||||
asset_ids: [...selectedIds],
|
|
||||||
shared: true,
|
shared: true,
|
||||||
})
|
})
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
@@ -312,6 +326,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
const thumbnailBaseUrl = (photo: TripPhoto) =>
|
const thumbnailBaseUrl = (photo: TripPhoto) =>
|
||||||
`/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}`
|
`/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}`
|
||||||
|
|
||||||
|
const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
|
||||||
|
|
||||||
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||||
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
|
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
|
||||||
@@ -461,8 +477,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
if (showPicker) {
|
if (showPicker) {
|
||||||
const alreadyAdded = new Set(
|
const alreadyAdded = new Set(
|
||||||
tripPhotos
|
tripPhotos
|
||||||
.filter(p => p.user_id === currentUser?.id && p.provider === selectedProvider)
|
.filter(p => p.user_id === currentUser?.id)
|
||||||
.map(p => p.asset_id)
|
.map(p => makePickerKey(p.provider, p.asset_id))
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -537,7 +553,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
</div>
|
</div>
|
||||||
) : (() => {
|
) : (() => {
|
||||||
// Group photos by month
|
// Group photos by month
|
||||||
const byMonth: Record<string, ImmichAsset[]> = {}
|
const byMonth: Record<string, Asset[]> = {}
|
||||||
for (const asset of pickerPhotos) {
|
for (const asset of pickerPhotos) {
|
||||||
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
||||||
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
||||||
@@ -555,11 +571,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
||||||
{byMonth[month].map(asset => {
|
{byMonth[month].map(asset => {
|
||||||
const isSelected = selectedIds.has(asset.id)
|
const pickerKey = makePickerKey(asset.provider, asset.id)
|
||||||
const isAlready = alreadyAdded.has(asset.id)
|
const isSelected = selectedIds.has(pickerKey)
|
||||||
|
const isAlready = alreadyAdded.has(pickerKey)
|
||||||
return (
|
return (
|
||||||
<div key={asset.id}
|
<div key={pickerKey}
|
||||||
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
onClick={() => !isAlready && togglePickerSelect(pickerKey)}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||||
cursor: isAlready ? 'default' : 'pointer',
|
cursor: isAlready ? 'default' : 'pointer',
|
||||||
@@ -567,7 +584,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
outlineOffset: -3,
|
outlineOffset: -3,
|
||||||
}}>
|
}}>
|
||||||
<ProviderImg baseUrl={`/api/integrations/${selectedProvider}/assets/${asset.id}/thumbnail?userId=${currentUser!.id}`} provider={selectedProvider} loading="lazy"
|
<ProviderImg baseUrl={`/api/integrations/${asset.provider}/assets/${asset.id}/thumbnail?userId=${currentUser!.id}`} provider={asset.provider} loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -74,20 +74,28 @@ router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request,
|
|||||||
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const provider = String(req.body?.provider || '').toLowerCase();
|
|
||||||
const { shared = true } = req.body;
|
const { shared = true } = req.body;
|
||||||
|
const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null;
|
||||||
|
const provider = String(req.body?.provider || '').toLowerCase();
|
||||||
const assetIdsRaw = req.body?.asset_ids;
|
const assetIdsRaw = req.body?.asset_ids;
|
||||||
|
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
if (!canAccessTrip(tripId, authReq.user.id)) {
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!provider) {
|
const selections = selectionsRaw && selectionsRaw.length > 0
|
||||||
return res.status(400).json({ error: 'provider is required' });
|
? selectionsRaw
|
||||||
}
|
.map((selection: any) => ({
|
||||||
|
provider: String(selection?.provider || '').toLowerCase(),
|
||||||
|
asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [],
|
||||||
|
}))
|
||||||
|
.filter((selection: { provider: string; asset_ids: unknown[] }) => selection.provider && selection.asset_ids.length > 0)
|
||||||
|
: (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0
|
||||||
|
? [{ provider, asset_ids: assetIdsRaw }]
|
||||||
|
: []);
|
||||||
|
|
||||||
if (!Array.isArray(assetIdsRaw) || assetIdsRaw.length === 0) {
|
if (selections.length === 0) {
|
||||||
return res.status(400).json({ error: 'asset_ids required' });
|
return res.status(400).json({ error: 'selections required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = db.prepare(
|
const insert = db.prepare(
|
||||||
@@ -95,11 +103,13 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
|||||||
);
|
);
|
||||||
|
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const raw of assetIdsRaw) {
|
for (const selection of selections) {
|
||||||
const assetId = String(raw || '').trim();
|
for (const raw of selection.asset_ids) {
|
||||||
if (!assetId) continue;
|
const assetId = String(raw || '').trim();
|
||||||
const result = insert.run(tripId, authReq.user.id, assetId, provider, shared ? 1 : 0);
|
if (!assetId) continue;
|
||||||
if (result.changes > 0) added++;
|
const result = insert.run(tripId, authReq.user.id, assetId, selection.provider, shared ? 1 : 0);
|
||||||
|
if (result.changes > 0) added++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, added });
|
res.json({ success: true, added });
|
||||||
|
|||||||
Reference in New Issue
Block a user