feat: Immich photo integration — Photos addon with sharing, filters, lightbox
- Immich connection per user (Settings → Immich URL + API Key) - Photos addon (admin-toggleable, trip tab) - Manual photo selection from Immich library (date filter + all photos) - Photo sharing with consent popup, per-photo privacy toggle - Lightbox with liquid glass EXIF info panel (camera, lens, location, settings) - Location filter + date sort in gallery - WebSocket live sync when photos are added/removed/shared - Proxy endpoints for thumbnails and originals with token auth
This commit is contained in:
@@ -3,10 +3,10 @@ import { adminApi } from '../../api/client'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addon {
|
interface Addon {
|
||||||
|
|||||||
692
client/src/components/Memories/MemoriesPanel.tsx
Normal file
692
client/src/components/Memories/MemoriesPanel.tsx
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TripPhoto {
|
||||||
|
immich_asset_id: string
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
shared: number
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImmichAsset {
|
||||||
|
id: string
|
||||||
|
takenAt: string
|
||||||
|
city: string | null
|
||||||
|
country: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoriesPanelProps {
|
||||||
|
tripId: number
|
||||||
|
startDate: string | null
|
||||||
|
endDate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Trip photos (saved selections)
|
||||||
|
const [tripPhotos, setTripPhotos] = useState<TripPhoto[]>([])
|
||||||
|
|
||||||
|
// Photo picker
|
||||||
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
|
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
||||||
|
const [pickerLoading, setPickerLoading] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Confirm share popup
|
||||||
|
const [showConfirmShare, setShowConfirmShare] = useState(false)
|
||||||
|
|
||||||
|
// Filters & sort
|
||||||
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
|
const [locationFilter, setLocationFilter] = useState('')
|
||||||
|
|
||||||
|
// Lightbox
|
||||||
|
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||||
|
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||||
|
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||||
|
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitial()
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
// WebSocket: reload photos when another user adds/removes/shares
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => loadPhotos()
|
||||||
|
window.addEventListener('memories:updated', handler)
|
||||||
|
return () => window.removeEventListener('memories:updated', handler)
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const loadPhotos = async () => {
|
||||||
|
try {
|
||||||
|
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
|
||||||
|
setTripPhotos(photosRes.data.photos || [])
|
||||||
|
} catch {
|
||||||
|
setTripPhotos([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInitial = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const statusRes = await apiClient.get('/integrations/immich/status')
|
||||||
|
setConnected(statusRes.data.connected)
|
||||||
|
} catch {
|
||||||
|
setConnected(false)
|
||||||
|
}
|
||||||
|
await loadPhotos()
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [pickerDateFilter, setPickerDateFilter] = useState(true)
|
||||||
|
|
||||||
|
const openPicker = async () => {
|
||||||
|
setShowPicker(true)
|
||||||
|
setPickerLoading(true)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setPickerDateFilter(!!(startDate && endDate))
|
||||||
|
await loadPickerPhotos(!!(startDate && endDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPickerPhotos = async (useDate: boolean) => {
|
||||||
|
setPickerLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('/integrations/immich/search', {
|
||||||
|
from: useDate && startDate ? startDate : undefined,
|
||||||
|
to: useDate && endDate ? endDate : undefined,
|
||||||
|
})
|
||||||
|
setPickerPhotos(res.data.assets || [])
|
||||||
|
} catch {
|
||||||
|
setPickerPhotos([])
|
||||||
|
} finally {
|
||||||
|
setPickerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePickerSelect = (id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
setShowConfirmShare(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeAddPhotos = async () => {
|
||||||
|
setShowConfirmShare(false)
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
|
||||||
|
asset_ids: [...selectedIds],
|
||||||
|
shared: true,
|
||||||
|
})
|
||||||
|
setShowPicker(false)
|
||||||
|
loadInitial()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const removePhoto = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||||
|
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleSharing = async (assetId: string, shared: boolean) => {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
|
||||||
|
setTripPhotos(prev => prev.map(p =>
|
||||||
|
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||||
|
))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
|
||||||
|
const thumbnailUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
||||||
|
|
||||||
|
const originalUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
||||||
|
|
||||||
|
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||||
|
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||||
|
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
|
||||||
|
|
||||||
|
// Unique locations for filter
|
||||||
|
const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
|
||||||
|
|
||||||
|
// Apply filter + sort
|
||||||
|
const allVisible = allVisibleRaw
|
||||||
|
.filter(p => !locationFilter || p.city === locationFilter)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = new Date(a.added_at || 0).getTime()
|
||||||
|
const db = new Date(b.added_at || 0).getTime()
|
||||||
|
return sortAsc ? da - db : db - da
|
||||||
|
})
|
||||||
|
|
||||||
|
const font: React.CSSProperties = {
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', ...font }}>
|
||||||
|
<div className="w-8 h-8 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not connected ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!connected && allVisible.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 40, textAlign: 'center', ...font }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.notConnected')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
|
||||||
|
{t('memories.notConnectedHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (showPicker) {
|
||||||
|
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
{/* Picker header */}
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.selectPhotos')}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowPicker(false)}
|
||||||
|
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={confirmSelection} disabled={selectedIds.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600,
|
||||||
|
cursor: selectedIds.size > 0 ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||||
|
background: selectedIds.size > 0 ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: selectedIds.size > 0 ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||||
|
}}>
|
||||||
|
{selectedIds.size > 0 ? t('memories.addSelected', { count: selectedIds.size }) : t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{startDate && endDate && (
|
||||||
|
<button onClick={() => { if (!pickerDateFilter) { setPickerDateFilter(true); loadPickerPhotos(true) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.tripDates')} ({startDate ? new Date(startDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : ''} — {endDate ? new Date(endDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : ''})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { if (pickerDateFilter || !startDate) { setPickerDateFilter(false); loadPickerPhotos(false) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: !pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.allPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{selectedIds.size} {t('memories.selected')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picker grid */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{pickerLoading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 60 }}>
|
||||||
|
<div className="w-7 h-7 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
) : pickerPhotos.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={36} style={{ color: 'var(--text-faint)', margin: '0 auto 10px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>{t('memories.noPhotos')}</p>
|
||||||
|
</div>
|
||||||
|
) : (() => {
|
||||||
|
// Group photos by month
|
||||||
|
const byMonth: Record<string, ImmichAsset[]> = {}
|
||||||
|
for (const asset of pickerPhotos) {
|
||||||
|
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
||||||
|
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
||||||
|
if (!byMonth[key]) byMonth[key] = []
|
||||||
|
byMonth[key].push(asset)
|
||||||
|
}
|
||||||
|
const sortedMonths = Object.keys(byMonth).sort().reverse()
|
||||||
|
|
||||||
|
return sortedMonths.map(month => (
|
||||||
|
<div key={month} style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>
|
||||||
|
{month !== 'unknown'
|
||||||
|
? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
||||||
|
{byMonth[month].map(asset => {
|
||||||
|
const isSelected = selectedIds.has(asset.id)
|
||||||
|
const isAlready = alreadyAdded.has(asset.id)
|
||||||
|
return (
|
||||||
|
<div key={asset.id}
|
||||||
|
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
||||||
|
style={{
|
||||||
|
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||||
|
cursor: isAlready ? 'default' : 'pointer',
|
||||||
|
opacity: isAlready ? 0.3 : 1,
|
||||||
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
|
outlineOffset: -3,
|
||||||
|
}}>
|
||||||
|
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 4, right: 4, width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Check size={13} color="var(--bg-primary)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAlready && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', fontSize: 10, color: 'white', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{t('memories.alreadyAdded')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm share popup (inside picker) */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Gallery ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.title')}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||||
|
{allVisible.length} {t('memories.photosFound')}
|
||||||
|
{othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{connected && (
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={14} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Sort bar */}
|
||||||
|
{allVisibleRaw.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => setSortAsc(v => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
|
||||||
|
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<ArrowUpDown size={11} /> {sortAsc ? t('memories.oldest') : t('memories.newest')}
|
||||||
|
</button>
|
||||||
|
{locations.length > 1 && (
|
||||||
|
<select value={locationFilter} onChange={e => setLocationFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-card)', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
cursor: 'pointer', outline: 'none',
|
||||||
|
}}>
|
||||||
|
<option value="">{t('memories.allLocations')}</option>
|
||||||
|
{locations.map(loc => <option key={loc} value={loc}>{loc}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{allVisible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', margin: '0 auto 12px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>
|
||||||
|
{t('memories.noPhotos')}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
|
||||||
|
{t('memories.noPhotosHint')}
|
||||||
|
</p>
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '9px 18px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={15} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 6 }}>
|
||||||
|
{allVisible.map(photo => {
|
||||||
|
const isOwn = photo.user_id === currentUser?.id
|
||||||
|
return (
|
||||||
|
<div key={photo.immich_asset_id} className="group"
|
||||||
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||||
|
setLightboxInfoLoading(true)
|
||||||
|
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||||
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||||
|
|
||||||
|
{/* Other user's avatar */}
|
||||||
|
{!isOwn && (
|
||||||
|
<div className="memories-avatar" style={{ position: 'absolute', bottom: 6, left: 6, zIndex: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: `hsl(${photo.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||||
|
border: '2px solid white', boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{photo.username[0]}
|
||||||
|
</div>
|
||||||
|
<div className="memories-avatar-tooltip" style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginBottom: 6, padding: '3px 8px', borderRadius: 6,
|
||||||
|
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||||
|
}}>
|
||||||
|
{photo.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Own photo actions (hover) */}
|
||||||
|
{isOwn && (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
|
||||||
|
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
|
||||||
|
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={12} color="white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not shared indicator */}
|
||||||
|
{isOwn && !photo.shared && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 6, right: 6, padding: '2px 6px', borderRadius: 6,
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
fontSize: 9, color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<EyeOff size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||||
|
{t('memories.private')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.memories-avatar:hover .memories-avatar-tooltip { opacity: 1 !important; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Confirm share popup */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightboxId && lightboxUserId && (
|
||||||
|
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
|
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={20} color="white" />
|
||||||
|
</button>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||||
|
<img
|
||||||
|
src={originalUrl(lightboxId, lightboxUserId)}
|
||||||
|
alt=""
|
||||||
|
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info panel — liquid glass */}
|
||||||
|
{lightboxInfo && (
|
||||||
|
<div style={{
|
||||||
|
width: 240, flexShrink: 0, borderRadius: 16, padding: 18,
|
||||||
|
background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', color: 'white',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '100%', overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* Date */}
|
||||||
|
{lightboxInfo.takenAt && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Date</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{(lightboxInfo.city || lightboxInfo.country) && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>
|
||||||
|
<MapPin size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />Location
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Camera */}
|
||||||
|
{lightboxInfo.camera && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Camera</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500 }}>{lightboxInfo.camera}</div>
|
||||||
|
{lightboxInfo.lens && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 2 }}>{lightboxInfo.lens}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
{lightboxInfo.focalLength && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Focal</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.focalLength}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.aperture && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Aperture</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.aperture}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.shutter && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shutter</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.shutter}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.iso && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>ISO</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.iso}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolution & File */}
|
||||||
|
{(lightboxInfo.width || lightboxInfo.fileName) && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 10 }}>
|
||||||
|
{lightboxInfo.width && lightboxInfo.height && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>{lightboxInfo.width} × {lightboxInfo.height}</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.fileSize && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxInfoLoading && (
|
||||||
|
<div style={{ width: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(255,255,255,0.2)', borderTopColor: 'white' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -380,8 +380,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// Addons
|
// Addons
|
||||||
'admin.addons.title': 'الإضافات',
|
'admin.addons.title': 'الإضافات',
|
||||||
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
|
'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.',
|
||||||
'admin.addons.catalog.memories.name': 'الذكريات',
|
'admin.addons.catalog.memories.name': 'صور (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'ألبومات صور مشتركة لكل رحلة',
|
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
|
||||||
'admin.addons.catalog.packing.name': 'التعبئة',
|
'admin.addons.catalog.packing.name': 'التعبئة',
|
||||||
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
|
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
|
||||||
'admin.addons.catalog.budget.name': 'الميزانية',
|
'admin.addons.catalog.budget.name': 'الميزانية',
|
||||||
@@ -1118,6 +1118,32 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'day.editAccommodation': 'تعديل الإقامة',
|
'day.editAccommodation': 'تعديل الإقامة',
|
||||||
'day.reservations': 'الحجوزات',
|
'day.reservations': 'الحجوزات',
|
||||||
|
|
||||||
|
// Memories / Immich
|
||||||
|
'memories.title': 'صور',
|
||||||
|
'memories.notConnected': 'Immich غير متصل',
|
||||||
|
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
|
||||||
|
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
|
||||||
|
'memories.noPhotos': 'لم يتم العثور على صور',
|
||||||
|
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
|
||||||
|
'memories.photosFound': 'صور',
|
||||||
|
'memories.fromOthers': 'من آخرين',
|
||||||
|
'memories.sharePhotos': 'مشاركة الصور',
|
||||||
|
'memories.sharing': 'مشترك',
|
||||||
|
'memories.reviewTitle': 'مراجعة صورك',
|
||||||
|
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
|
||||||
|
'memories.shareCount': 'مشاركة {count} صور',
|
||||||
|
'memories.immichUrl': 'عنوان خادم Immich',
|
||||||
|
'memories.immichApiKey': 'مفتاح API',
|
||||||
|
'memories.testConnection': 'اختبار الاتصال',
|
||||||
|
'memories.connected': 'متصل',
|
||||||
|
'memories.disconnected': 'غير متصل',
|
||||||
|
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
||||||
|
'memories.connectionError': 'تعذر الاتصال بـ Immich',
|
||||||
|
'memories.saved': 'تم حفظ إعدادات Immich',
|
||||||
|
'memories.oldest': 'الأقدم أولاً',
|
||||||
|
'memories.newest': 'الأحدث أولاً',
|
||||||
|
'memories.allLocations': 'جميع المواقع',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'الدردشة',
|
'collab.tabs.chat': 'الدردشة',
|
||||||
'collab.tabs.notes': 'الملاحظات',
|
'collab.tabs.notes': 'الملاحظات',
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
|
'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken',
|
||||||
'admin.addons.catalog.collab.name': 'Collab',
|
'admin.addons.catalog.collab.name': 'Collab',
|
||||||
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung',
|
||||||
|
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||||
|
'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen',
|
||||||
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ',
|
||||||
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
|
||||||
'admin.addons.enabled': 'Aktiviert',
|
'admin.addons.enabled': 'Aktiviert',
|
||||||
@@ -475,7 +477,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'vacay.remaining': 'Rest',
|
'vacay.remaining': 'Rest',
|
||||||
'vacay.carriedOver': 'aus {year}',
|
'vacay.carriedOver': 'aus {year}',
|
||||||
'vacay.blockWeekends': 'Wochenenden sperren',
|
'vacay.blockWeekends': 'Wochenenden sperren',
|
||||||
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen',
|
'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Wochenendtagen',
|
||||||
|
'vacay.weekendDays': 'Wochenendtage',
|
||||||
|
'vacay.mon': 'Mo',
|
||||||
|
'vacay.tue': 'Di',
|
||||||
|
'vacay.wed': 'Mi',
|
||||||
|
'vacay.thu': 'Do',
|
||||||
|
'vacay.fri': 'Fr',
|
||||||
|
'vacay.sat': 'Sa',
|
||||||
|
'vacay.sun': 'So',
|
||||||
'vacay.publicHolidays': 'Feiertage',
|
'vacay.publicHolidays': 'Feiertage',
|
||||||
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren',
|
||||||
'vacay.selectCountry': 'Land wählen',
|
'vacay.selectCountry': 'Land wählen',
|
||||||
@@ -1112,6 +1122,45 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||||
'day.reservations': 'Reservierungen',
|
'day.reservations': 'Reservierungen',
|
||||||
|
|
||||||
|
// Photos / Immich
|
||||||
|
'memories.title': 'Fotos',
|
||||||
|
'memories.notConnected': 'Immich nicht verbunden',
|
||||||
|
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
|
||||||
|
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
|
||||||
|
'memories.noPhotos': 'Keine Fotos gefunden',
|
||||||
|
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
|
||||||
|
'memories.photosFound': 'Fotos',
|
||||||
|
'memories.fromOthers': 'von anderen',
|
||||||
|
'memories.sharePhotos': 'Fotos teilen',
|
||||||
|
'memories.sharing': 'Wird geteilt',
|
||||||
|
'memories.reviewTitle': 'Deine Fotos prüfen',
|
||||||
|
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
|
||||||
|
'memories.shareCount': '{count} Fotos teilen',
|
||||||
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
|
'memories.immichApiKey': 'API-Schlüssel',
|
||||||
|
'memories.testConnection': 'Verbindung testen',
|
||||||
|
'memories.connected': 'Verbunden',
|
||||||
|
'memories.disconnected': 'Nicht verbunden',
|
||||||
|
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
||||||
|
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
||||||
|
'memories.saved': 'Immich-Einstellungen gespeichert',
|
||||||
|
'memories.addPhotos': 'Fotos hinzufügen',
|
||||||
|
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
||||||
|
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
||||||
|
'memories.selected': 'ausgewählt',
|
||||||
|
'memories.addSelected': '{count} Fotos hinzufügen',
|
||||||
|
'memories.alreadyAdded': 'Hinzugefügt',
|
||||||
|
'memories.private': 'Privat',
|
||||||
|
'memories.stopSharing': 'Nicht mehr teilen',
|
||||||
|
'memories.oldest': 'Älteste zuerst',
|
||||||
|
'memories.newest': 'Neueste zuerst',
|
||||||
|
'memories.allLocations': 'Alle Orte',
|
||||||
|
'memories.tripDates': 'Trip-Zeitraum',
|
||||||
|
'memories.allPhotos': 'Alle Fotos',
|
||||||
|
'memories.confirmShareTitle': 'Mit Reisebegleitern teilen?',
|
||||||
|
'memories.confirmShareHint': '{count} Fotos werden für alle Mitglieder dieses Trips sichtbar. Du kannst einzelne Fotos nachträglich auf privat setzen.',
|
||||||
|
'memories.confirmShareButton': 'Fotos teilen',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notizen',
|
'collab.tabs.notes': 'Notizen',
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
|
'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats',
|
||||||
'admin.addons.catalog.collab.name': 'Collab',
|
'admin.addons.catalog.collab.name': 'Collab',
|
||||||
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning',
|
||||||
|
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||||
|
'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance',
|
||||||
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
'admin.addons.subtitleBefore': 'Enable or disable features to customize your ',
|
||||||
'admin.addons.subtitleAfter': ' experience.',
|
'admin.addons.subtitleAfter': ' experience.',
|
||||||
'admin.addons.enabled': 'Enabled',
|
'admin.addons.enabled': 'Enabled',
|
||||||
@@ -475,7 +477,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'vacay.remaining': 'Left',
|
'vacay.remaining': 'Left',
|
||||||
'vacay.carriedOver': 'from {year}',
|
'vacay.carriedOver': 'from {year}',
|
||||||
'vacay.blockWeekends': 'Block Weekends',
|
'vacay.blockWeekends': 'Block Weekends',
|
||||||
'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays',
|
'vacay.blockWeekendsHint': 'Prevent vacation entries on weekend days',
|
||||||
|
'vacay.weekendDays': 'Weekend days',
|
||||||
|
'vacay.mon': 'Mon',
|
||||||
|
'vacay.tue': 'Tue',
|
||||||
|
'vacay.wed': 'Wed',
|
||||||
|
'vacay.thu': 'Thu',
|
||||||
|
'vacay.fri': 'Fri',
|
||||||
|
'vacay.sat': 'Sat',
|
||||||
|
'vacay.sun': 'Sun',
|
||||||
'vacay.publicHolidays': 'Public Holidays',
|
'vacay.publicHolidays': 'Public Holidays',
|
||||||
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
'vacay.publicHolidaysHint': 'Mark public holidays in the calendar',
|
||||||
'vacay.selectCountry': 'Select country',
|
'vacay.selectCountry': 'Select country',
|
||||||
@@ -1112,6 +1122,45 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'day.editAccommodation': 'Edit accommodation',
|
'day.editAccommodation': 'Edit accommodation',
|
||||||
'day.reservations': 'Reservations',
|
'day.reservations': 'Reservations',
|
||||||
|
|
||||||
|
// Photos / Immich
|
||||||
|
'memories.title': 'Photos',
|
||||||
|
'memories.notConnected': 'Immich not connected',
|
||||||
|
'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.',
|
||||||
|
'memories.noDates': 'Add dates to your trip to load photos.',
|
||||||
|
'memories.noPhotos': 'No photos found',
|
||||||
|
'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.',
|
||||||
|
'memories.photosFound': 'photos',
|
||||||
|
'memories.fromOthers': 'from others',
|
||||||
|
'memories.sharePhotos': 'Share photos',
|
||||||
|
'memories.sharing': 'Sharing',
|
||||||
|
'memories.reviewTitle': 'Review your photos',
|
||||||
|
'memories.reviewHint': 'Click photos to exclude them from sharing.',
|
||||||
|
'memories.shareCount': 'Share {count} photos',
|
||||||
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
|
'memories.immichApiKey': 'API Key',
|
||||||
|
'memories.testConnection': 'Test connection',
|
||||||
|
'memories.connected': 'Connected',
|
||||||
|
'memories.disconnected': 'Not connected',
|
||||||
|
'memories.connectionSuccess': 'Connected to Immich',
|
||||||
|
'memories.connectionError': 'Could not connect to Immich',
|
||||||
|
'memories.saved': 'Immich settings saved',
|
||||||
|
'memories.addPhotos': 'Add photos',
|
||||||
|
'memories.selectPhotos': 'Select photos from Immich',
|
||||||
|
'memories.selectHint': 'Tap photos to select them.',
|
||||||
|
'memories.selected': 'selected',
|
||||||
|
'memories.addSelected': 'Add {count} photos',
|
||||||
|
'memories.alreadyAdded': 'Added',
|
||||||
|
'memories.private': 'Private',
|
||||||
|
'memories.stopSharing': 'Stop sharing',
|
||||||
|
'memories.oldest': 'Oldest first',
|
||||||
|
'memories.newest': 'Newest first',
|
||||||
|
'memories.allLocations': 'All locations',
|
||||||
|
'memories.tripDates': 'Trip dates',
|
||||||
|
'memories.allPhotos': 'All photos',
|
||||||
|
'memories.confirmShareTitle': 'Share with trip members?',
|
||||||
|
'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
|
||||||
|
'memories.confirmShareButton': 'Share photos',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notes',
|
'collab.tabs.notes': 'Notes',
|
||||||
|
|||||||
@@ -945,8 +945,8 @@ const es: Record<string, string> = {
|
|||||||
'photos.linkPlace': 'Vincular lugar',
|
'photos.linkPlace': 'Vincular lugar',
|
||||||
'photos.noPlace': 'Sin lugar',
|
'photos.noPlace': 'Sin lugar',
|
||||||
'photos.uploadN': 'Subida de {n} foto(s)',
|
'photos.uploadN': 'Subida de {n} foto(s)',
|
||||||
'admin.addons.catalog.memories.name': 'Recuerdos',
|
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje',
|
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
|
||||||
'admin.addons.catalog.packing.name': 'Equipaje',
|
'admin.addons.catalog.packing.name': 'Equipaje',
|
||||||
'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
|
'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
|
||||||
'admin.addons.catalog.budget.name': 'Presupuesto',
|
'admin.addons.catalog.budget.name': 'Presupuesto',
|
||||||
@@ -1065,6 +1065,32 @@ const es: Record<string, string> = {
|
|||||||
'day.editAccommodation': 'Editar alojamiento',
|
'day.editAccommodation': 'Editar alojamiento',
|
||||||
'day.reservations': 'Reservas',
|
'day.reservations': 'Reservas',
|
||||||
|
|
||||||
|
// Memories / Immich
|
||||||
|
'memories.title': 'Fotos',
|
||||||
|
'memories.notConnected': 'Immich no conectado',
|
||||||
|
'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
|
||||||
|
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
|
||||||
|
'memories.noPhotos': 'No se encontraron fotos',
|
||||||
|
'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
|
||||||
|
'memories.photosFound': 'fotos',
|
||||||
|
'memories.fromOthers': 'de otros',
|
||||||
|
'memories.sharePhotos': 'Compartir fotos',
|
||||||
|
'memories.sharing': 'Compartiendo',
|
||||||
|
'memories.reviewTitle': 'Revisar tus fotos',
|
||||||
|
'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.',
|
||||||
|
'memories.shareCount': 'Compartir {count} fotos',
|
||||||
|
'memories.immichUrl': 'URL del servidor Immich',
|
||||||
|
'memories.immichApiKey': 'Clave API',
|
||||||
|
'memories.testConnection': 'Probar conexión',
|
||||||
|
'memories.connected': 'Conectado',
|
||||||
|
'memories.disconnected': 'No conectado',
|
||||||
|
'memories.connectionSuccess': 'Conectado a Immich',
|
||||||
|
'memories.connectionError': 'No se pudo conectar a Immich',
|
||||||
|
'memories.saved': 'Configuración de Immich guardada',
|
||||||
|
'memories.oldest': 'Más antiguas',
|
||||||
|
'memories.newest': 'Más recientes',
|
||||||
|
'memories.allLocations': 'Todas las ubicaciones',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Mensajes',
|
'collab.tabs.chat': 'Mensajes',
|
||||||
'collab.tabs.notes': 'Notas',
|
'collab.tabs.notes': 'Notas',
|
||||||
|
|||||||
@@ -373,8 +373,8 @@ const fr: Record<string, string> = {
|
|||||||
'admin.tabs.addons': 'Extensions',
|
'admin.tabs.addons': 'Extensions',
|
||||||
'admin.addons.title': 'Extensions',
|
'admin.addons.title': 'Extensions',
|
||||||
'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.',
|
'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.',
|
||||||
'admin.addons.catalog.memories.name': 'Souvenirs',
|
'admin.addons.catalog.memories.name': 'Photos (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Albums photo partagés pour chaque voyage',
|
'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich',
|
||||||
'admin.addons.catalog.packing.name': 'Bagages',
|
'admin.addons.catalog.packing.name': 'Bagages',
|
||||||
'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
|
'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
|
||||||
'admin.addons.catalog.budget.name': 'Budget',
|
'admin.addons.catalog.budget.name': 'Budget',
|
||||||
@@ -1111,6 +1111,32 @@ const fr: Record<string, string> = {
|
|||||||
'day.editAccommodation': 'Modifier l\'hébergement',
|
'day.editAccommodation': 'Modifier l\'hébergement',
|
||||||
'day.reservations': 'Réservations',
|
'day.reservations': 'Réservations',
|
||||||
|
|
||||||
|
// Memories / Immich
|
||||||
|
'memories.title': 'Photos',
|
||||||
|
'memories.notConnected': 'Immich non connecté',
|
||||||
|
'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.',
|
||||||
|
'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
|
||||||
|
'memories.noPhotos': 'Aucune photo trouvée',
|
||||||
|
'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.',
|
||||||
|
'memories.photosFound': 'photos',
|
||||||
|
'memories.fromOthers': 'd\'autres',
|
||||||
|
'memories.sharePhotos': 'Partager les photos',
|
||||||
|
'memories.sharing': 'Partagé',
|
||||||
|
'memories.reviewTitle': 'Vérifier vos photos',
|
||||||
|
'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.',
|
||||||
|
'memories.shareCount': 'Partager {count} photos',
|
||||||
|
'memories.immichUrl': 'URL du serveur Immich',
|
||||||
|
'memories.immichApiKey': 'Clé API',
|
||||||
|
'memories.testConnection': 'Tester la connexion',
|
||||||
|
'memories.connected': 'Connecté',
|
||||||
|
'memories.disconnected': 'Non connecté',
|
||||||
|
'memories.connectionSuccess': 'Connecté à Immich',
|
||||||
|
'memories.connectionError': 'Impossible de se connecter à Immich',
|
||||||
|
'memories.saved': 'Paramètres Immich enregistrés',
|
||||||
|
'memories.oldest': 'Plus anciennes',
|
||||||
|
'memories.newest': 'Plus récentes',
|
||||||
|
'memories.allLocations': 'Tous les lieux',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notes',
|
'collab.tabs.notes': 'Notes',
|
||||||
|
|||||||
@@ -373,8 +373,8 @@ const nl: Record<string, string> = {
|
|||||||
'admin.tabs.addons': 'Add-ons',
|
'admin.tabs.addons': 'Add-ons',
|
||||||
'admin.addons.title': 'Add-ons',
|
'admin.addons.title': 'Add-ons',
|
||||||
'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.',
|
'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.',
|
||||||
'admin.addons.catalog.memories.name': 'Herinneringen',
|
'admin.addons.catalog.memories.name': 'Foto\'s (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Gedeelde fotoalbums voor elke reis',
|
'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie',
|
||||||
'admin.addons.catalog.packing.name': 'Inpakken',
|
'admin.addons.catalog.packing.name': 'Inpakken',
|
||||||
'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
|
'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
|
||||||
'admin.addons.catalog.budget.name': 'Budget',
|
'admin.addons.catalog.budget.name': 'Budget',
|
||||||
@@ -1111,6 +1111,32 @@ const nl: Record<string, string> = {
|
|||||||
'day.editAccommodation': 'Accommodatie bewerken',
|
'day.editAccommodation': 'Accommodatie bewerken',
|
||||||
'day.reservations': 'Reserveringen',
|
'day.reservations': 'Reserveringen',
|
||||||
|
|
||||||
|
// Memories / Immich
|
||||||
|
'memories.title': 'Foto\'s',
|
||||||
|
'memories.notConnected': 'Immich niet verbonden',
|
||||||
|
'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.',
|
||||||
|
'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.',
|
||||||
|
'memories.noPhotos': 'Geen foto\'s gevonden',
|
||||||
|
'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.',
|
||||||
|
'memories.photosFound': 'foto\'s',
|
||||||
|
'memories.fromOthers': 'van anderen',
|
||||||
|
'memories.sharePhotos': 'Foto\'s delen',
|
||||||
|
'memories.sharing': 'Wordt gedeeld',
|
||||||
|
'memories.reviewTitle': 'Je foto\'s bekijken',
|
||||||
|
'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.',
|
||||||
|
'memories.shareCount': '{count} foto\'s delen',
|
||||||
|
'memories.immichUrl': 'Immich Server URL',
|
||||||
|
'memories.immichApiKey': 'API-sleutel',
|
||||||
|
'memories.testConnection': 'Verbinding testen',
|
||||||
|
'memories.connected': 'Verbonden',
|
||||||
|
'memories.disconnected': 'Niet verbonden',
|
||||||
|
'memories.connectionSuccess': 'Verbonden met Immich',
|
||||||
|
'memories.connectionError': 'Kon niet verbinden met Immich',
|
||||||
|
'memories.saved': 'Immich-instellingen opgeslagen',
|
||||||
|
'memories.oldest': 'Oudste eerst',
|
||||||
|
'memories.newest': 'Nieuwste eerst',
|
||||||
|
'memories.allLocations': 'Alle locaties',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
'collab.tabs.notes': 'Notities',
|
'collab.tabs.notes': 'Notities',
|
||||||
|
|||||||
@@ -373,8 +373,8 @@ const ru: Record<string, string> = {
|
|||||||
'admin.tabs.addons': 'Дополнения',
|
'admin.tabs.addons': 'Дополнения',
|
||||||
'admin.addons.title': 'Дополнения',
|
'admin.addons.title': 'Дополнения',
|
||||||
'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.',
|
'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.',
|
||||||
'admin.addons.catalog.memories.name': 'Воспоминания',
|
'admin.addons.catalog.memories.name': 'Фото (Immich)',
|
||||||
'admin.addons.catalog.memories.description': 'Общие фотоальбомы для каждой поездки',
|
'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich',
|
||||||
'admin.addons.catalog.packing.name': 'Сборы',
|
'admin.addons.catalog.packing.name': 'Сборы',
|
||||||
'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
|
'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
|
||||||
'admin.addons.catalog.budget.name': 'Бюджет',
|
'admin.addons.catalog.budget.name': 'Бюджет',
|
||||||
@@ -1111,6 +1111,32 @@ const ru: Record<string, string> = {
|
|||||||
'day.editAccommodation': 'Редактировать жильё',
|
'day.editAccommodation': 'Редактировать жильё',
|
||||||
'day.reservations': 'Бронирования',
|
'day.reservations': 'Бронирования',
|
||||||
|
|
||||||
|
// Memories / Immich
|
||||||
|
'memories.title': 'Фото',
|
||||||
|
'memories.notConnected': 'Immich не подключён',
|
||||||
|
'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
|
||||||
|
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
|
||||||
|
'memories.noPhotos': 'Фотографии не найдены',
|
||||||
|
'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
|
||||||
|
'memories.photosFound': 'фото',
|
||||||
|
'memories.fromOthers': 'от других',
|
||||||
|
'memories.sharePhotos': 'Поделиться фото',
|
||||||
|
'memories.sharing': 'Общий доступ',
|
||||||
|
'memories.reviewTitle': 'Проверьте ваши фото',
|
||||||
|
'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.',
|
||||||
|
'memories.shareCount': 'Поделиться ({count} фото)',
|
||||||
|
'memories.immichUrl': 'URL сервера Immich',
|
||||||
|
'memories.immichApiKey': 'API-ключ',
|
||||||
|
'memories.testConnection': 'Проверить подключение',
|
||||||
|
'memories.connected': 'Подключено',
|
||||||
|
'memories.disconnected': 'Не подключено',
|
||||||
|
'memories.connectionSuccess': 'Подключение к Immich установлено',
|
||||||
|
'memories.connectionError': 'Не удалось подключиться к Immich',
|
||||||
|
'memories.saved': 'Настройки Immich сохранены',
|
||||||
|
'memories.oldest': 'Сначала старые',
|
||||||
|
'memories.newest': 'Сначала новые',
|
||||||
|
'memories.allLocations': 'Все места',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': 'Чат',
|
'collab.tabs.chat': 'Чат',
|
||||||
'collab.tabs.notes': 'Заметки',
|
'collab.tabs.notes': 'Заметки',
|
||||||
|
|||||||
@@ -373,8 +373,8 @@ const zh: Record<string, string> = {
|
|||||||
'admin.tabs.addons': '扩展',
|
'admin.tabs.addons': '扩展',
|
||||||
'admin.addons.title': '扩展',
|
'admin.addons.title': '扩展',
|
||||||
'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。',
|
'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。',
|
||||||
'admin.addons.catalog.memories.name': '回忆',
|
'admin.addons.catalog.memories.name': '照片 (Immich)',
|
||||||
'admin.addons.catalog.memories.description': '每次旅行的共享相册',
|
'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片',
|
||||||
'admin.addons.catalog.packing.name': '行李',
|
'admin.addons.catalog.packing.name': '行李',
|
||||||
'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
|
'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
|
||||||
'admin.addons.catalog.budget.name': '预算',
|
'admin.addons.catalog.budget.name': '预算',
|
||||||
@@ -1111,6 +1111,32 @@ const zh: Record<string, string> = {
|
|||||||
'day.editAccommodation': '编辑住宿',
|
'day.editAccommodation': '编辑住宿',
|
||||||
'day.reservations': '预订',
|
'day.reservations': '预订',
|
||||||
|
|
||||||
|
// Memories / Immich
|
||||||
|
'memories.title': '照片',
|
||||||
|
'memories.notConnected': 'Immich 未连接',
|
||||||
|
'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
|
||||||
|
'memories.noDates': '为旅行添加日期以加载照片。',
|
||||||
|
'memories.noPhotos': '未找到照片',
|
||||||
|
'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
|
||||||
|
'memories.photosFound': '张照片',
|
||||||
|
'memories.fromOthers': '来自他人',
|
||||||
|
'memories.sharePhotos': '分享照片',
|
||||||
|
'memories.sharing': '分享中',
|
||||||
|
'memories.reviewTitle': '审查您的照片',
|
||||||
|
'memories.reviewHint': '点击照片以将其从分享中排除。',
|
||||||
|
'memories.shareCount': '分享 {count} 张照片',
|
||||||
|
'memories.immichUrl': 'Immich 服务器地址',
|
||||||
|
'memories.immichApiKey': 'API 密钥',
|
||||||
|
'memories.testConnection': '测试连接',
|
||||||
|
'memories.connected': '已连接',
|
||||||
|
'memories.disconnected': '未连接',
|
||||||
|
'memories.connectionSuccess': '已连接到 Immich',
|
||||||
|
'memories.connectionError': '无法连接到 Immich',
|
||||||
|
'memories.saved': 'Immich 设置已保存',
|
||||||
|
'memories.oldest': '最早优先',
|
||||||
|
'memories.newest': '最新优先',
|
||||||
|
'memories.allLocations': '所有地点',
|
||||||
|
|
||||||
// Collab Addon
|
// Collab Addon
|
||||||
'collab.tabs.chat': '聊天',
|
'collab.tabs.chat': '聊天',
|
||||||
'collab.tabs.notes': '笔记',
|
'collab.tabs.notes': '笔记',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import CustomSelect from '../components/shared/CustomSelect'
|
|||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
|
||||||
import { authApi, adminApi } from '../api/client'
|
import { authApi, adminApi } from '../api/client'
|
||||||
|
import apiClient from '../api/client'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type { UserWithOidc } from '../types'
|
import type { UserWithOidc } from '../types'
|
||||||
import { getApiErrorMessage } from '../types'
|
import { getApiErrorMessage } from '../types'
|
||||||
@@ -56,6 +57,59 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
|
|
||||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// Immich
|
||||||
|
const [memoriesEnabled, setMemoriesEnabled] = useState(false)
|
||||||
|
const [immichUrl, setImmichUrl] = useState('')
|
||||||
|
const [immichApiKey, setImmichApiKey] = useState('')
|
||||||
|
const [immichConnected, setImmichConnected] = useState(false)
|
||||||
|
const [immichTesting, setImmichTesting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/addons').then(r => {
|
||||||
|
const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled)
|
||||||
|
setMemoriesEnabled(!!mem)
|
||||||
|
if (mem) {
|
||||||
|
apiClient.get('/integrations/immich/settings').then(r2 => {
|
||||||
|
setImmichUrl(r2.data.immich_url || '')
|
||||||
|
setImmichConnected(r2.data.connected)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveImmich = async () => {
|
||||||
|
setSaving(s => ({ ...s, immich: true }))
|
||||||
|
try {
|
||||||
|
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||||
|
toast.success(t('memories.saved'))
|
||||||
|
// Test connection
|
||||||
|
const res = await apiClient.get('/integrations/immich/status')
|
||||||
|
setImmichConnected(res.data.connected)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('memories.connectionError'))
|
||||||
|
} finally {
|
||||||
|
setSaving(s => ({ ...s, immich: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestImmich = async () => {
|
||||||
|
setImmichTesting(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/integrations/immich/status')
|
||||||
|
if (res.data.connected) {
|
||||||
|
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||||
|
setImmichConnected(true)
|
||||||
|
} else {
|
||||||
|
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||||
|
setImmichConnected(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('memories.connectionError'))
|
||||||
|
} finally {
|
||||||
|
setImmichTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map settings
|
// Map settings
|
||||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||||
@@ -387,6 +441,45 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Immich — only when Memories addon is enabled */}
|
||||||
|
{memoriesEnabled && (
|
||||||
|
<Section title="Immich" icon={Camera}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||||
|
<input type="url" value={immichUrl} onChange={e => setImmichUrl(e.target.value)}
|
||||||
|
placeholder="https://immich.example.com"
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||||
|
<input type="password" value={immichApiKey} onChange={e => setImmichApiKey(e.target.value)}
|
||||||
|
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={handleSaveImmich} disabled={saving.immich}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400">
|
||||||
|
<Save className="w-4 h-4" /> {t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
|
||||||
|
{immichTesting
|
||||||
|
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||||
|
: <Camera className="w-4 h-4" />}
|
||||||
|
{t('memories.testConnection')}
|
||||||
|
</button>
|
||||||
|
{immichConnected && (
|
||||||
|
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
|
{t('memories.connected')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Section title={t('settings.account')} icon={User}>
|
<Section title={t('settings.account')} icon={User}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
|||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||||
|
import MemoriesPanel from '../components/Memories/MemoriesPanel'
|
||||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
@@ -54,7 +55,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
addonsApi.enabled().then(data => {
|
addonsApi.enabled().then(data => {
|
||||||
const map = {}
|
const map = {}
|
||||||
data.addons.forEach(a => { map[a.id] = true })
|
data.addons.forEach(a => { map[a.id] = true })
|
||||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories })
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
authApi.getAppConfig().then(config => {
|
authApi.getAppConfig().then(config => {
|
||||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
@@ -67,6 +68,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||||
|
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []),
|
||||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
|
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -656,6 +658,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'memories' && (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||||
|
<MemoriesPanel tripId={Number(tripId)} startDate={trip?.start_date || null} endDate={trip?.end_date || null} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'collab' && (
|
{activeTab === 'collab' && (
|
||||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
||||||
|
|||||||
@@ -232,6 +232,11 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
|||||||
files: state.files.filter(f => f.id !== payload.fileId),
|
files: state.files.filter(f => f.id !== payload.fileId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memories / Photos
|
||||||
|
case 'memories:updated':
|
||||||
|
window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload }))
|
||||||
|
return {}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,28 @@ function runMigrations(db: Database.Database): void {
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`);
|
)`);
|
||||||
},
|
},
|
||||||
|
() => {
|
||||||
|
// Configurable weekend days
|
||||||
|
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch {}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Immich integration
|
||||||
|
try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch {}
|
||||||
|
try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch {}
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS trip_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
immich_asset_id TEXT NOT NULL,
|
||||||
|
shared INTEGER NOT NULL DEFAULT 1,
|
||||||
|
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(trip_id, user_id, immich_asset_id)
|
||||||
|
)`);
|
||||||
|
// Add memories addon
|
||||||
|
try {
|
||||||
|
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7);
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ import vacayRoutes from './routes/vacay';
|
|||||||
app.use('/api/addons/vacay', vacayRoutes);
|
app.use('/api/addons/vacay', vacayRoutes);
|
||||||
import atlasRoutes from './routes/atlas';
|
import atlasRoutes from './routes/atlas';
|
||||||
app.use('/api/addons/atlas', atlasRoutes);
|
app.use('/api/addons/atlas', atlasRoutes);
|
||||||
|
import immichRoutes from './routes/immich';
|
||||||
|
app.use('/api/integrations/immich', immichRoutes);
|
||||||
|
|
||||||
app.use('/api/maps', mapsRoutes);
|
app.use('/api/maps', mapsRoutes);
|
||||||
app.use('/api/weather', weatherRoutes);
|
app.use('/api/weather', weatherRoutes);
|
||||||
|
|||||||
268
server/src/routes/immich.ts
Normal file
268
server/src/routes/immich.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { broadcast } from '../websocket';
|
||||||
|
import { AuthRequest } from '../types';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ── Immich Connection Settings ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
res.json({
|
||||||
|
immich_url: user?.immich_url || '',
|
||||||
|
connected: !!(user?.immich_url && user?.immich_api_key),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/settings', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { immich_url, immich_api_key } = req.body;
|
||||||
|
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
|
||||||
|
immich_url?.trim() || null,
|
||||||
|
immich_api_key?.trim() || null,
|
||||||
|
authReq.user.id
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) {
|
||||||
|
return res.json({ connected: false, error: 'Not configured' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/users/me`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` });
|
||||||
|
const data = await resp.json() as { name?: string; email?: string };
|
||||||
|
res.json({ connected: true, user: { name: data.name, email: data.email } });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Browse Immich Library (for photo picker) ────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { page = '1', size = '50' } = req.query;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' });
|
||||||
|
const buckets = await resp.json();
|
||||||
|
res.json({ buckets });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
res.status(502).json({ error: 'Could not reach Immich' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search photos by date range (for the date-filter in picker)
|
||||||
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { from, to } = req.body;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||||
|
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||||
|
type: 'IMAGE',
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
|
||||||
|
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||||
|
const assets = (data.assets?.items || []).map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
takenAt: a.fileCreatedAt || a.createdAt,
|
||||||
|
city: a.exifInfo?.city || null,
|
||||||
|
country: a.exifInfo?.country || null,
|
||||||
|
}));
|
||||||
|
res.json({ assets });
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Could not reach Immich' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Trip Photos (selected by user) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// Get all photos for a trip (own + shared by others)
|
||||||
|
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
|
||||||
|
const photos = db.prepare(`
|
||||||
|
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||||
|
u.username, u.avatar, u.immich_url
|
||||||
|
FROM trip_photos tp
|
||||||
|
JOIN users u ON tp.user_id = u.id
|
||||||
|
WHERE tp.trip_id = ?
|
||||||
|
AND (tp.user_id = ? OR tp.shared = 1)
|
||||||
|
ORDER BY tp.added_at ASC
|
||||||
|
`).all(tripId, authReq.user.id);
|
||||||
|
|
||||||
|
res.json({ photos });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add photos to a trip
|
||||||
|
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { asset_ids, shared = true } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'asset_ids required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
let added = 0;
|
||||||
|
for (const assetId of asset_ids) {
|
||||||
|
const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0);
|
||||||
|
if (result.changes > 0) added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, added });
|
||||||
|
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a photo from a trip (own photos only)
|
||||||
|
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||||
|
.run(req.params.tripId, authReq.user.id, req.params.assetId);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle sharing for a specific photo
|
||||||
|
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { shared } = req.body;
|
||||||
|
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||||
|
.run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Asset Details ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { userId } = req.query;
|
||||||
|
|
||||||
|
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' });
|
||||||
|
const asset = await resp.json() as any;
|
||||||
|
res.json({
|
||||||
|
id: asset.id,
|
||||||
|
takenAt: asset.fileCreatedAt || asset.createdAt,
|
||||||
|
width: asset.exifInfo?.exifImageWidth || null,
|
||||||
|
height: asset.exifInfo?.exifImageHeight || null,
|
||||||
|
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
|
||||||
|
lens: asset.exifInfo?.lensModel || null,
|
||||||
|
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
|
||||||
|
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
|
||||||
|
shutter: asset.exifInfo?.exposureTime || null,
|
||||||
|
iso: asset.exifInfo?.iso || null,
|
||||||
|
city: asset.exifInfo?.city || null,
|
||||||
|
state: asset.exifInfo?.state || null,
|
||||||
|
country: asset.exifInfo?.country || null,
|
||||||
|
lat: asset.exifInfo?.latitude || null,
|
||||||
|
lng: asset.exifInfo?.longitude || null,
|
||||||
|
fileSize: asset.exifInfo?.fileSizeInByte || null,
|
||||||
|
fileName: asset.originalFileName || null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Proxy error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Asset proxy routes accept token via query param (for <img> src usage)
|
||||||
|
function authFromQuery(req: Request, res: Response, next: Function) {
|
||||||
|
const token = req.query.token as string;
|
||||||
|
if (token && !req.headers.authorization) {
|
||||||
|
req.headers.authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return (authenticate as any)(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { userId } = req.query;
|
||||||
|
|
||||||
|
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).send('Failed');
|
||||||
|
res.set('Content-Type', resp.headers.get('content-type') || 'image/webp');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
res.send(buffer);
|
||||||
|
} catch {
|
||||||
|
res.status(502).send('Proxy error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { assetId } = req.params;
|
||||||
|
const { userId } = req.query;
|
||||||
|
|
||||||
|
const targetUserId = userId ? Number(userId) : authReq.user.id;
|
||||||
|
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||||
|
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, {
|
||||||
|
headers: { 'x-api-key': user.immich_api_key },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return res.status(resp.status).send('Failed');
|
||||||
|
res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
res.send(buffer);
|
||||||
|
} catch {
|
||||||
|
res.status(502).send('Proxy error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
Reference in New Issue
Block a user