import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { dayKey, dayLabel } from './dateGroups'; import { go } from '../nav'; import { getPhotoThumbs, readPhotoFile, remoteUrl, type LibraryDir, type LibraryFolder, type LibraryPhoto } from '../engine/library'; import { readGps, readSpecs, specsLine } from '../engine/imageOps'; import { api, type User } from '../api'; import type { ImmichServer } from '../engine/immich'; export type MobileTab = 'photos' | 'search' | 'albums' | 'library'; interface MobileLibraryProps { folders: LibraryFolder[]; servers: ImmichServer[]; serverYears?: number[]; photos: LibraryPhoto[]; dirs: LibraryDir[]; walked: Record; edited: Set; user: User | null; admin: boolean; onOpenStudio: (photo: LibraryPhoto) => Promise; // The frame the shelf is to open on when the reader comes back from the // studio, stamped so that a second visit to the same frame lands again. focus?: { id: string; at: number } | null; onAddImmich: () => void; onEditAlbums: (folder: LibraryFolder) => void; onSyncServer: (folder: LibraryFolder) => Promise; onDisconnectServer: (folder: LibraryFolder) => Promise; onFindImmich: (query: string) => Promise; onSetStar?: (photo: LibraryPhoto, star: number) => Promise; onRotate?: (photo: LibraryPhoto, dir: 'left' | 'right') => Promise; onSwitchToDesktop?: () => void; onFetchYear?: (year: number) => Promise; } // In-memory cache for local photo blob URLs const globalMobileThumbUrls = new Map(); function useMobileThumbUrls(photos: LibraryPhoto[]): Record { const [urls, setUrls] = useState>(() => { const init: Record = {}; for (const p of photos) { if (!p.remoteId && !p.server) { const cached = globalMobileThumbUrls.get(p.id); if (cached) init[p.id] = cached; } } return init; }); useEffect(() => { let alive = true; const localPhotos = photos.filter((p) => !p.remoteId && !p.server); if (!localPhotos.length) return; const idsToFetch: string[] = []; const sync: Record = {}; for (const p of localPhotos) { const u = globalMobileThumbUrls.get(p.id); if (u) { sync[p.id] = u; } else { idsToFetch.push(p.id); } } if (Object.keys(sync).length > 0) { setUrls((prev) => ({ ...prev, ...sync })); } if (idsToFetch.length === 0) return; void getPhotoThumbs(idsToFetch).then((map) => { if (!alive) return; const loaded: Record = {}; for (const [id, blob] of map.entries()) { const u = URL.createObjectURL(blob); globalMobileThumbUrls.set(id, u); loaded[id] = u; } if (Object.keys(loaded).length > 0) { setUrls((prev) => ({ ...prev, ...loaded })); } }); return () => { alive = false; }; }, [photos]); return urls; } export function MobileLibrary({ folders, servers, serverYears = [], photos, dirs, walked, edited, user, admin, onOpenStudio, focus, onAddImmich, onEditAlbums, onSyncServer, onDisconnectServer, onFindImmich, onSetStar, onRotate, onSwitchToDesktop, onFetchYear, }: MobileLibraryProps) { const { t, lang } = useI18n(); const [tab, setTab] = useState('photos'); const [selectedAlbum, setSelectedAlbum] = useState(null); const [selectedPhoto, setSelectedPhoto] = useState(null); // The frame the reader stopped at: it outlives the preview, so closing the // picture lights that frame up in the thumb grid. const [activeId, setActiveId] = useState(null); const [shot, setShot] = useState<{ line: string; lat: number | null; lng: number | null; place: string | null } | null>(null); // Read photo EXIF specifications and GPS (similar to Desktop UI) useEffect(() => { if (!selectedPhoto) { setShot(null); return; } let live = true; (async () => { try { let head: Uint8Array | null = null; if (selectedPhoto.remoteId) { // Fast path: try cached preview image bytes first const prevRes = await fetch(remoteUrl('preview', selectedPhoto)).catch(() => null); if (prevRes && prevRes.ok) { const buf = await prevRes.arrayBuffer(); const prevHead = new Uint8Array(buf.slice(0, 131072)); const specs = await readSpecs(prevHead); if (specs && (specs.camera || specs.aperture || specs.exposure || specs.iso)) { head = prevHead; } } if (!head) { // Range request for original file if preview had stripped EXIF const origRes = await fetch(remoteUrl('original', selectedPhoto), { headers: { Range: 'bytes=0-131071' }, }).catch(() => null); if (origRes && origRes.ok) { const buf = await origRes.arrayBuffer(); head = new Uint8Array(buf.slice(0, 131072)); } } } else { const file = await readPhotoFile(selectedPhoto).catch(() => null); if (file) { head = new Uint8Array(await file.slice(0, 131072).arrayBuffer()); } } if (head) { const [specs, gps] = await Promise.all([readSpecs(head), readGps(head)]); const curShot = { line: specsLine(specs), lat: gps?.latitude ?? null, lng: gps?.longitude ?? null, place: null as string | null, }; if (live) setShot(curShot); if (curShot.lat !== null && curShot.lng !== null) { void api .place(curShot.lat, curShot.lng) .then(({ place }) => { if (!place) return; curShot.place = place; if (live) setShot({ ...curShot }); }) .catch(() => {}); } } else { if (live) setShot(null); } } catch { if (live) setShot(null); } })(); return () => { live = false; }; }, [selectedPhoto]); const coords = useMemo(() => { return shot?.lat !== null && shot?.lat !== undefined && shot?.lng !== null && shot?.lng !== undefined ? `${shot.lat.toFixed(4)}, ${shot.lng.toFixed(4)}` : null; }, [shot]); const [searchQuery, setSearchQuery] = useState(''); const [viewMode, setViewMode] = useState<'grid' | 'single'>('grid'); const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); const [starFilter, setStarFilter] = useState(0); const [selectedYear, setSelectedYear] = useState('all'); const [selectedMonth, setSelectedMonth] = useState('all'); const [selectedDay, setSelectedDay] = useState('all'); const [timeFrom, setTimeFrom] = useState('any'); const [timeTo, setTimeTo] = useState('any'); const [activeDropdown, setActiveDropdown] = useState<'date' | 'time' | null>(null); const [syncingServerId, setSyncingServerId] = useState(null); const [showSearch, setShowSearch] = useState(false); const [filterScope, setFilterScope] = useState<'all' | 'album'>('all'); // On-demand fetch for selected year if photos for that year are missing useEffect(() => { if (selectedYear === 'all' || !onFetchYear) return; const yr = Number(selectedYear); if (!Number.isFinite(yr) || yr < 1900) return; const hasPhotosForYear = photos.some((p) => new Date(p.taken || 0).getFullYear() === yr); if (!hasPhotosForYear) { void onFetchYear(yr); } }, [selectedYear, photos, onFetchYear]); const HOURS = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []); const isDateFiltered = selectedYear !== 'all' || selectedMonth !== 'all' || selectedDay !== 'all'; const isTimeFiltered = timeFrom !== 'any' || timeTo !== 'any'; const scrollContainerRef = useRef(null); const searchInputRef = useRef(null); const albumSearchInputRef = useRef(null); const handleToggleSearch = useCallback(() => { setShowSearch((prev) => { const next = !prev; if (next) { setTimeout(() => { if (tab === 'albums') albumSearchInputRef.current?.focus(); else searchInputRef.current?.focus(); }, 120); } return next; }); }, [tab]); // Helper to normalize directory paths const normPath = useCallback((p: string) => { return (p || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''); }, []); // Cloud photos: filter photos that belong to Immich, Google Drive, or Remote Cloud const isCloudPhoto = useCallback((p: LibraryPhoto) => { if (p.server || p.remoteId) return true; const lowerFolder = (p.folder || '').toLowerCase(); return ( lowerFolder.includes('immich') || lowerFolder.includes('drive') || lowerFolder.includes('cloud') || lowerFolder.includes('google') ); }, []); const cloudPhotos = useMemo(() => { return photos.filter(isCloudPhoto); }, [photos, isCloudPhoto]); // Available cloud folders const cloudFolders = useMemo(() => { return folders.filter( (f) => f.source === 'immich' || f.server || f.name.toLowerCase().includes('immich') || f.name.toLowerCase().includes('drive') || f.name.toLowerCase().includes('cloud') || f.name.toLowerCase().includes('google') ); }, [folders]); // Displayed photos in Photos tab (filtered by album, search query, stars, year/month/day, time, sort) const displayedPhotos = useMemo(() => { let list = photos; if (showSearch && filterScope === 'all') { // Khi đang bật bộ lọc & chọn phạm vi Tất cả ảnh: Lọc trên toàn bộ ảnh Cloud của Immich (hoặc toàn bộ photos) list = selectedAlbum === '__all__' ? photos : (cloudPhotos.length > 0 ? cloudPhotos : photos); } else if (selectedAlbum === '__all__') { list = photos; } else if (selectedAlbum) { const lowerSel = normPath(selectedAlbum).toLowerCase(); list = list.filter((p) => { const pFolder = normPath(p.folder || '').toLowerCase(); const pDir = normPath(p.dir || '').toLowerCase(); const pFull = pDir ? `${pFolder}/${pDir}` : pFolder; const pId = normPath(p.id || '').toLowerCase(); return ( pFull === lowerSel || pFull.startsWith(`${lowerSel}/`) || pFolder === lowerSel || pDir === lowerSel || pId.startsWith(`${lowerSel}/`) ); }); } else { // Mặc định mở LIBRARY trên mobile: load ảnh từ CLOUD được thêm vào LIBRARY của người dùng (IMMICH, GOOGLE DRIVE...) if (cloudPhotos.length > 0) { list = cloudPhotos; } } if (showSearch) { if (searchQuery.trim()) { const q = searchQuery.toLowerCase().trim(); list = list.filter((p) => p.name.toLowerCase().includes(q) || (p.dir && p.dir.toLowerCase().includes(q))); } if (starFilter > 0) { list = list.filter((p) => (p.star || 0) >= starFilter); } if (selectedYear !== 'all') { const yr = Number(selectedYear); list = list.filter((p) => new Date(p.taken || 0).getFullYear() === yr); } if (selectedMonth !== 'all') { const mo = Number(selectedMonth); list = list.filter((p) => new Date(p.taken || 0).getMonth() + 1 === mo); } if (selectedDay !== 'all') { const dy = Number(selectedDay); list = list.filter((p) => new Date(p.taken || 0).getDate() === dy); } if (timeFrom !== 'any' && timeTo !== 'any') { const f = Number(timeFrom); const tNum = Number(timeTo); list = list.filter((p) => { const h = new Date(p.taken || 0).getHours(); return f <= tNum ? (h >= f && h <= tNum) : (h >= f || h <= tNum); }); } else if (timeFrom !== 'any') { const f = Number(timeFrom); list = list.filter((p) => new Date(p.taken || 0).getHours() >= f); } else if (timeTo !== 'any') { const tNum = Number(timeTo); list = list.filter((p) => new Date(p.taken || 0).getHours() <= tNum); } } if (sortOrder === 'oldest') { return [...list].sort((a, b) => (a.taken || 0) - (b.taken || 0)); } return [...list].sort((a, b) => (b.taken || 0) - (a.taken || 0)); }, [ photos, cloudPhotos, selectedAlbum, showSearch, filterScope, searchQuery, starFilter, selectedYear, selectedMonth, selectedDay, timeFrom, timeTo, sortOrder, normPath, ]); // --- Folder Tree Column (Desktop style for Album tab) --- interface TreeNode { key: string; label: string; depth: number; count: number; } const baseTreeInfo = useMemo(() => { const hits = new Map(); const foldersUnder = new Map>(); const add = (folder: string, rel: string, counted: boolean) => { const normF = normPath(folder); const normRel = normPath(rel); const lowerF = normF.toLowerCase(); const set = foldersUnder.get(lowerF) ?? new Set(); foldersUnder.set(lowerF, set); let path = ''; for (const part of normRel.split('/')) { if (!part) continue; path = path ? `${path}/${part}` : part; set.add(path); if (counted) { const key = `${lowerF}/${path.toLowerCase()}`; hits.set(key, (hits.get(key) ?? 0) + 1); } } }; for (const photo of photos) { const normF = (photo.folder || '').replace(/\\/g, '/'); const normDir = (photo.dir || '').replace(/\\/g, '/'); hits.set(normF.toLowerCase(), (hits.get(normF.toLowerCase()) ?? 0) + 1); if (normDir) add(normF, normDir, true); } for (const dir of dirs) add(dir.folder, dir.rel, false); return { hits, foldersUnder }; }, [photos, dirs, normPath]); const treeNodes = useMemo(() => { const { hits, foldersUnder } = baseTreeInfo; const counted = (key: string) => { const lowerKey = normPath(key).toLowerCase(); const walkVal = walked[lowerKey]; if (walkVal !== undefined) return walkVal; return hits.get(lowerKey) ?? 0; }; const out: TreeNode[] = []; for (const folder of folders) { const normF = normPath(folder.name); const lowerF = normF.toLowerCase(); out.push({ key: normF, label: folder.label || folder.name, depth: 0, count: counted(normF), }); const subdirs = [...(foldersUnder.get(lowerF) ?? [])].sort((a, b) => a.localeCompare(b)); for (const dir of subdirs) { const parts = dir.split('/'); const key = `${normF}/${dir}`; out.push({ key, label: parts[parts.length - 1], depth: parts.length, count: counted(key), }); } } return out; }, [baseTreeInfo, folders, walked, normPath]); const treeParents = useMemo( () => new Set(treeNodes.filter((n) => n.depth > 0).map((n) => n.key.slice(0, n.key.lastIndexOf('/')))), [treeNodes] ); const [expandedFolders, setExpandedFolders] = useState>(() => { const set = new Set(); for (const f of folders) set.add(normPath(f.name)); return set; }); const treeRows = useMemo(() => { if (tab === 'albums' && showSearch && searchQuery.trim()) { const q = searchQuery.toLowerCase().trim(); return treeNodes.filter((n) => n.label.toLowerCase().includes(q) || n.key.toLowerCase().includes(q)); } return treeNodes.filter((n) => { const parts = n.key.split('/'); for (let i = 1; i < parts.length; i++) { if (!expandedFolders.has(parts.slice(0, i).join('/'))) return false; } return true; }); }, [treeNodes, expandedFolders, tab, showSearch, searchQuery]); const [contextMenu, setContextMenu] = useState<{ folder: LibraryFolder | null; node: TreeNode; x: number; y: number; } | null>(null); // Selected folder entity const selectedFolder = useMemo(() => { if (!selectedAlbum) return null; const lowerSel = normPath(selectedAlbum).toLowerCase(); return ( folders.find((f) => { const fNorm = normPath(f.name).toLowerCase(); return lowerSel === fNorm || lowerSel.startsWith(`${fNorm}/`); }) ?? null ); }, [selectedAlbum, folders, normPath]); // Whether the selected album/folder belongs to Immich Cloud / Remote Cloud const isSelectedImmich = Boolean( (selectedAlbum === null && cloudPhotos.length > 0) || selectedFolder?.source === 'immich' || selectedFolder?.server || (selectedAlbum && selectedAlbum !== '__all__' && ( selectedAlbum.toLowerCase().includes('immich') || selectedAlbum.toLowerCase().includes('drive') || selectedAlbum.toLowerCase().includes('cloud') || selectedAlbum.toLowerCase().includes('google') )) ); // Label of the selected album/folder const selectedAlbumLabel = useMemo(() => { if (selectedAlbum === '__all__') return t('lib.mobile.allPhotos'); if (!selectedAlbum) { if (cloudPhotos.length > 0) return 'Cloud Library'; return null; } const lowerSel = normPath(selectedAlbum).toLowerCase(); const node = treeNodes.find((n) => normPath(n.key).toLowerCase() === lowerSel); if (node) return node.label; if (selectedFolder?.label) return selectedFolder.label; const lastPart = selectedAlbum.split('/').pop(); return lastPart || selectedAlbum; }, [selectedAlbum, cloudPhotos.length, treeNodes, selectedFolder, normPath, t]); // Progressive Render Limit (prevents DOM freeze with thousands of photos) const [renderLimit, setRenderLimit] = useState(80); // Reset limit when filter, album, or view criteria change useEffect(() => { setRenderLimit(80); }, [ selectedAlbum, tab, searchQuery, starFilter, sortOrder, selectedYear, selectedMonth, selectedDay, timeFrom, timeTo, viewMode, ]); const visiblePhotos = useMemo(() => { return displayedPhotos.slice(0, renderLimit); }, [displayedPhotos, renderLimit]); const localThumbUrls = useMobileThumbUrls(visiblePhotos); const getThumbUrl = useCallback( (photo: LibraryPhoto) => { if (photo.remoteId || photo.server) { return remoteUrl('thumbnail', photo); } return localThumbUrls[photo.id] || ''; }, [localThumbUrls] ); // Group photos by date based on visiblePhotos for progressive rendering const dateGroups = useMemo(() => { const groups: { key: string; label: string; photos: LibraryPhoto[] }[] = []; const map = new Map(); for (const photo of visiblePhotos) { const key = dayKey(photo.taken || photo.addedAt || Date.now()); let arr = map.get(key); if (!arr) { arr = []; map.set(key, arr); } arr.push(photo); } for (const [key, items] of map.entries()) { const firstTimestamp = items[0]?.taken || items[0]?.addedAt || Date.now(); groups.push({ key, label: dayLabel(firstTimestamp, lang), photos: items, }); } return groups; }, [visiblePhotos, lang]); // The frame a swipe lands on: the list the reader is looking at is the shelf, // so the step goes the way that list is read and stops at its ends. const stepPhoto = (d: number) => { if (!selectedPhoto) return; const i = searchResults.findIndex((p) => p.id === selectedPhoto.id); const next = i < 0 ? undefined : searchResults[i + d]; if (next) setSelectedPhoto(next); }; // Every frame the preview shows — opened or swiped to — is the one the reader // stopped at. useEffect(() => { if (selectedPhoto) setActiveId(selectedPhoto.id); }, [selectedPhoto]); // Closing the frame in the one-at-a-time list hands the reader to the thumb // grid, which opens on the frame they stopped at. const closePhoto = () => { setSelectedPhoto(null); if (viewMode === 'single') setViewMode('grid'); }; // Where the finger went down: only the distance it travelled matters, and only // once it has lifted. const swipeFrom = useRef<{ x: number; y: number } | null>(null); // Filtered photos in Search tab const searchResults = useMemo(() => { let list = photos; if (searchQuery.trim()) { const q = searchQuery.toLowerCase().trim(); list = list.filter((p) => p.name.toLowerCase().includes(q) || (p.dir && p.dir.toLowerCase().includes(q))); } if (starFilter > 0) { list = list.filter((p) => (p.star || 0) >= starFilter); } if (selectedYear !== 'all') { const yr = Number(selectedYear); list = list.filter((p) => new Date(p.taken || 0).getFullYear() === yr); } if (selectedMonth !== 'all') { const mo = Number(selectedMonth); list = list.filter((p) => new Date(p.taken || 0).getMonth() + 1 === mo); } if (selectedDay !== 'all') { const dy = Number(selectedDay); list = list.filter((p) => new Date(p.taken || 0).getDate() === dy); } if (timeFrom !== 'any' && timeTo !== 'any') { const f = Number(timeFrom); const tNum = Number(timeTo); list = list.filter((p) => { const h = new Date(p.taken || 0).getHours(); return f <= tNum ? (h >= f && h <= tNum) : (h >= f || h <= tNum); }); } else if (timeFrom !== 'any') { const f = Number(timeFrom); list = list.filter((p) => new Date(p.taken || 0).getHours() >= f); } else if (timeTo !== 'any') { const tNum = Number(timeTo); list = list.filter((p) => new Date(p.taken || 0).getHours() <= tNum); } if (sortOrder === 'oldest') { return [...list].sort((a, b) => (a.taken || 0) - (b.taken || 0)); } return [...list].sort((a, b) => (b.taken || 0) - (a.taken || 0)); }, [ cloudPhotos, photos, searchQuery, starFilter, selectedYear, selectedMonth, selectedDay, timeFrom, timeTo, sortOrder, ]); // Landing on the frame the studio was holding: the grid is sent to it rather // than left where it was. The frame may sit past the rows drawn so far, and it // is only in the DOM on the render that walks them out — which is why the // scroll below runs again when `renderLimit` moves. Each landing is spent // once, so a list that changes under the reader is not dragged back to it. const landed = useRef(0); useEffect(() => { if (!focus || landed.current === focus.at) return; landed.current = focus.at; setActiveId(focus.id); const at = displayedPhotos.findIndex((p) => p.id === focus.id); if (at >= 0) setRenderLimit((prev) => Math.max(prev, at + 1)); }, [focus, displayedPhotos]); // Landing on that frame: the grid brings it under the eye rather than leaving // the reader to hunt for it. useEffect(() => { if (viewMode !== 'grid' || !activeId) return; scrollContainerRef.current?.querySelector('.mob-lib-thumb-cell.selected')?.scrollIntoView({ block: 'center' }); }, [viewMode, activeId, renderLimit]); // Available years from photos & server timeline buckets const availableYears = useMemo(() => { const years = new Set(); const pool = cloudPhotos.length ? cloudPhotos : photos; for (const p of pool) { if (p.taken) years.add(new Date(p.taken).getFullYear()); } for (const yr of serverYears) { years.add(yr); } return Array.from(years).sort((a, b) => b - a); }, [cloudPhotos, photos, serverYears]); // Floating fast-scroll handle states replacing native scrollbar const [scrollVisible, setScrollVisible] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); const hideTimerRef = useRef(null); const isDraggingScrollRef = useRef(false); const handleContainerScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; const maxScroll = el.scrollHeight - el.clientHeight; if (maxScroll > 10) { const ratio = Math.max(0, Math.min(1, el.scrollTop / maxScroll)); setScrollProgress(ratio); setScrollVisible(true); } if (hideTimerRef.current) { window.clearTimeout(hideTimerRef.current); } hideTimerRef.current = window.setTimeout(() => { if (!isDraggingScrollRef.current) { setScrollVisible(false); } }, 1800); // Kích hoạt nạp thêm ảnh khi cuộn gần tới đáy (< 500px) if (el.scrollHeight - el.scrollTop - el.clientHeight < 500) { setRenderLimit((prev) => (prev < displayedPhotos.length ? Math.min(displayedPhotos.length, prev + 60) : prev)); } }, [displayedPhotos.length]); useEffect(() => { return () => { if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); }; }, []); const handlePointerDown = (e: React.PointerEvent) => { isDraggingScrollRef.current = true; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); setScrollVisible(true); if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); }; const handlePointerMove = (e: React.PointerEvent) => { if (!isDraggingScrollRef.current) return; const el = scrollContainerRef.current; if (!el) return; const minY = 65; const maxY = Math.max(minY + 40, window.innerHeight - 120); const currentY = Math.max(minY, Math.min(maxY, e.clientY)); const ratio = (currentY - minY) / (maxY - minY); const maxScroll = el.scrollHeight - el.clientHeight; el.scrollTop = ratio * maxScroll; setScrollProgress(ratio); setScrollVisible(true); // Sync renderLimit with fast-scroll handle position const needed = Math.floor(ratio * displayedPhotos.length) + 60; setRenderLimit((prev) => Math.max(prev, Math.min(displayedPhotos.length, needed))); }; const handlePointerUp = (e: React.PointerEvent) => { if (!isDraggingScrollRef.current) return; isDraggingScrollRef.current = false; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch {} if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); hideTimerRef.current = window.setTimeout(() => { setScrollVisible(false); }, 1800); }; // Calculate top offset for scroll handle based on scrollProgress const handleTop = useMemo(() => { if (typeof window === 'undefined') return 80; const minY = 65; const maxY = Math.max(minY + 40, window.innerHeight - 120); return Math.round(minY + scrollProgress * (maxY - minY)); }, [scrollProgress]); // Handle fast scroll const handleScrollToTop = () => { scrollContainerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleScrollToBottom = () => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: 'smooth', }); } }; // Sync server handler const handleSyncServer = async (folder: LibraryFolder) => { if (!folder.server) return; setSyncingServerId(folder.server); try { await onSyncServer(folder); } finally { setSyncingServerId(null); } }; return (
{/* --- Top Header Bar --- */} {/* --- Top Header Bar (Website Icon & Title: RecipesCam + Thư viện / Tên Album) --- */}
RecipesCam RecipesCam {/* Nếu đang xem một folder/album cụ thể, hiển thị biểu tượng và tên trên topbar */} {selectedAlbum ? (
{isSelectedImmich ? ( ) : ( '📁' )} {selectedAlbumLabel}
) : ( {t('lib.title')} )}
{/* User Profile Avatar */}
{user?.avatar ? ( User ) : ( {(user?.email || 'U').charAt(0).toUpperCase()} )}
{/* Search Button in Header */} {/* Studio Link */} {t('nav.studio')} {onSwitchToDesktop ? ( ) : null}
{/* --- Sub-bar khi mở Folder hoặc Immich / Cloud --- */} {tab !== 'library' && (selectedAlbum || cloudPhotos.length > 0 || servers.length > 0) && (
{selectedAlbum ? ( ) : null} {isSelectedImmich || (!selectedAlbum && (cloudPhotos.length > 0 || servers.length > 0)) ? ( /* Immich Brand */
immich Cloud
) : ( /* Local Folder Brand */
📁 {selectedAlbumLabel}
)} {isSelectedImmich && selectedAlbum && selectedAlbum !== '__all__' ? ( / {selectedAlbumLabel} ) : null}
{displayedPhotos.length} {t('lib.mobile.photos').toLowerCase()} {/* Search Button in Subbar */} {/* Cloud Upload / Sync Status Icon (nếu Immich) */} {cloudFolders.length > 0 && (isSelectedImmich || !selectedAlbum) ? ( ) : null}
)} {/* --- Main Content Area --- */}
{/* --- Tab 1: "Ảnh" (Photos Timeline / UI Preview) --- */} {tab === 'photos' && ( <> {/* Sticky Search & Filter Bar neo ở top của UI Preview */} {showSearch && (
{/* Hàng 1: Input tìm kiếm + Chế độ SINGLE/ALL THUMBS (chỉ biểu tượng) + Nút đóng */}
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && searchQuery.trim()) { void onFindImmich(searchQuery); } }} /> {searchQuery ? ( ) : null}
{/* Chế độ SINGLE / ALL THUMBS (chỉ biểu tượng) */}
{/* Nút đóng tìm kiếm */}
{/* Hàng chọn phạm vi lọc nếu đang trong một Album/Thư mục cụ thể */} {selectedAlbum && selectedAlbum !== '__all__' && (
Lọc trong:
)} {/* Hàng 2: Nhóm các nút cùng thuộc tính vào các dropbox */}
{/* Dropbox 1: new first / old first */}
▾
{/* Dropbox 2: star: None, 1-5 */}
0 ? 'active' : ''}`}> ▾
{/* Dropbox 3: Year/Month/Date */} {/* Dropbox 4: Time from:... to:.... */}
{/* Popover Card: Year/Month/Date */} {activeDropdown === 'date' && (
📅 Lọc theo Năm / Tháng / Ngày {isDateFiltered && ( )}
)} {/* Popover Card: Time from:... to:.... */} {activeDropdown === 'time' && (
🕒 Khung giờ chụp trong ngày {isTimeFiltered && ( )}
)}
)} {displayedPhotos.length === 0 ? (
{syncingServerId ? ( <>
Đang đồng bộ ảnh từ Immich Cloud...
Ảnh mới nhất đang được tải về và hiển thị...
) : ( <>
{servers.length === 0 ? t('lib.mobile.noServers') : t('lib.mobile.noPhotos')}
{t('lib.mobile.cloudDesc')}
{servers.length === 0 && ( )} )}
) : viewMode === 'single' ? (
{visiblePhotos.map((photo) => { const thumbSrc = getThumbUrl(photo); const previewSrc = photo.remoteId || photo.server ? remoteUrl('preview', photo) : thumbSrc; return (
setSelectedPhoto(photo)} >
{photo.name} { if (thumbSrc && (e.currentTarget as HTMLImageElement).src !== thumbSrc) { (e.currentTarget as HTMLImageElement).src = thumbSrc; } }} />
{photo.star ? (
★ {photo.star}
) : null} {edited.has(photo.id) ? (
) : null}
{photo.name}
{new Date(photo.taken).toLocaleDateString()} · {new Date(photo.taken).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {photo.dir ? ` · ${photo.dir}` : ''}
); })}
) : ( <> {dateGroups.map((group) => (
{group.label}
{group.photos.map((photo) => { const thumbSrc = getThumbUrl(photo); return (
setSelectedPhoto(photo)} > {photo.name} { // Fallback if thumb failed to load (e.currentTarget as HTMLElement).style.opacity = '0.3'; }} /> {/* Cloud Outline Badge (matches Immich mobile app!) */} {/* Edited Indicator */} {edited.has(photo.id) ? (
) : null} {/* Star Rating Badge */} {photo.star ? (
★ {photo.star}
) : null}
); })}
))} {/* Progressive load more hint */} {renderLimit < displayedPhotos.length && (
Đang hiển thị {visiblePhotos.length} / {displayedPhotos.length} ảnh...
)} )} )} {/* --- Tab 2: "Tìm kiếm" (Search & Filter) --- */} {tab === 'search' && (
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && searchQuery.trim()) { void onFindImmich(searchQuery); } }} /> {searchQuery ? ( ) : null}
{/* Chế độ SINGLE / ALL THUMBS (chỉ biểu tượng) */}
{/* Dropboxes */}
▾
0 ? 'active' : ''}`}> ▾
{/* Popover Card for Year/Month/Date */} {activeDropdown === 'date' && (
📅 Lọc theo Năm / Tháng / Ngày {isDateFiltered && ( )}
)} {/* Popover Card for Time */} {activeDropdown === 'time' && (
🕒 Khung giờ chụp {isTimeFiltered && ( )}
)} {/* Search Results Summary */}
{t('lib.count', { n: searchResults.length })}
{/* Results Grid or Single List */} {viewMode === 'single' ? (
{searchResults.map((photo) => { const previewSrc = remoteUrl('preview', photo); const thumbSrc = remoteUrl('thumbnail', photo); return (
setSelectedPhoto(photo)} >
{photo.name} { (e.currentTarget as HTMLImageElement).src = thumbSrc; }} />
{photo.star ? (
★ {photo.star}
) : null} {edited.has(photo.id) ? (
) : null}
{photo.name}
{new Date(photo.taken).toLocaleDateString()} · {new Date(photo.taken).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {photo.dir ? ` · ${photo.dir}` : ''}
); })}
) : (
{searchResults.map((photo) => (
setSelectedPhoto(photo)} > {photo.name} {photo.star ? (
★ {photo.star}
) : null}
))}
)}
)} {/* --- Tab 3: "Album" (Cây thư mục tương tự giao diện desktop) --- */} {tab === 'albums' && (
{/* Search Bar sticky khi đang mở tìm kiếm trong Album/Folder */} {showSearch && (
setSearchQuery(e.target.value)} style={{ background: 'transparent', border: 'none', color: '#ffffff', fontSize: '13px', width: '100%', outline: 'none', }} /> {searchQuery ? ( ) : null}
)}
📁 {t('lib.mobile.albums')} ({treeNodes.length})
{showSearch && searchQuery.trim() && treeRows.length === 0 && (
Không tìm thấy album hoặc thư mục nào khớp với "{searchQuery}"
)} {/* Cloud Photos option (khi có cloudPhotos) */} {(!showSearch || !searchQuery.trim()) && cloudPhotos.length > 0 && (
{ setSelectedAlbum(null); setTab('photos'); }} > ☁️ Ảnh Cloud (Immich / Drive) {cloudPhotos.length}
)} {/* Tất cả ảnh option (cả ổ đĩa và cloud) */} {(!showSearch || !searchQuery.trim()) && (
{ setSelectedAlbum('__all__'); setTab('photos'); }} > 🖼️ {t('lib.mobile.allPhotos')} {photos.length}
)} {treeRows.map((n) => { const folder = folders.find((f) => { const fNorm = normPath(f.name); return n.key === fNorm || n.key.startsWith(`${fNorm}/`); }) ?? folders[0] ?? null; const under = treeParents.has(n.key); const isSelected = selectedAlbum === n.key; return (
{ if (under && (!showSearch || !searchQuery.trim())) { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(n.key)) next.delete(n.key); else next.add(n.key); return next; }); } if (showSearch) { setShowSearch(false); setSearchQuery(''); } setSelectedAlbum(n.key); setTab('photos'); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ folder, node: n, x: Math.min(e.clientX, window.innerWidth - 210), y: Math.min(e.clientY, window.innerHeight - 250), }); }} > {/* Expand/Collapse Caret */} { e.stopPropagation(); if (under) { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(n.key)) next.delete(n.key); else next.add(n.key); return next; }); } }} /> {/* Folder Icon */} {folder?.source === 'immich' ? '☁️' : '📁'} {/* Folder Name */} {n.label} {/* Photo Count */} {n.count} {/* 3-dots Context Menu Button for touch screens */}
); })}
)} {/* --- Tab 4: "Thư viện" (Library / Cloud Management) --- */} {tab === 'library' && (
IMMICH CLOUD
{t('lib.mobile.cloudDesc')}
{/* Connected Servers List */}
{t('lib.mobile.servers')}
{servers.length === 0 ? (
{t('lib.mobile.noServers')}
) : ( servers.map((server) => { const folder = folders.find((f) => f.server === server.id) ?? null; const isSyncing = syncingServerId === server.id; return (
{server.name || 'Immich Cloud'}
{t('lib.mobile.connected')}
URL: {server.url}
{server.version ?
Version: {server.version}
: null}
Albums: {server.albums?.length ? server.albums.length : 'All'}
{folder ? ( ) : null} {folder ? ( ) : null}
); }) )}
)}
{/* Floating Scroll Indicator replacing side scrollbar (Auto-hides after idle, no 'scroll' text) */}
{ const rect = e.currentTarget.getBoundingClientRect(); const y = e.clientY - rect.top; if (y < rect.height / 2) { handleScrollToTop(); } else { handleScrollToBottom(); } }} >
{/* --- Fullscreen Photo Preview Modal --- */} {selectedPhoto && (
{/* A finger across the picture is the shelf's own step: left goes the way the list is read, right goes back. A drag that wandered more up and down than sideways is a scroll of a page that cannot scroll, and is left alone — and so is a tap, which is a press and a lift in place. */}
{ const t = e.touches[0]; swipeFrom.current = { x: t.clientX, y: t.clientY }; }} onTouchEnd={(e) => { const start = swipeFrom.current; swipeFrom.current = null; if (!start) return; const t = e.changedTouches[0]; const dx = t.clientX - start.x; const dy = t.clientY - start.y; if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy)) return; stepPhoto(dx < 0 ? 1 : -1); }} > {selectedPhoto.name}
{/* Star Rating */}
{[1, 2, 3, 4, 5].map((st) => ( ))}
{/* Rotate Buttons */}
{/* Photo Metadata Info (tương tự Desktop UI .adm-meta, hiển thị phía trên nút Open In Studio) */}
{selectedPhoto.name} {selectedPhoto.dir || selectedPhoto.folder ? (
{selectedPhoto.dir || selectedPhoto.folder}
) : null}
{new Date(selectedPhoto.taken).toLocaleString()} · {t('lib.size', { mb: (selectedPhoto.size / 1048576).toFixed(1) })} {edited.has(selectedPhoto.id) ? ` · ${t('lib.edited')}` : ''}
{shot?.place || coords ? (
📍 {shot?.place ?? coords}
) : null} {shot?.line ? (
📷 {shot.line}
) : null}
{/* Primary Open In Studio Button */}
)} {/* --- Context Menu for Folder Tree --- */} {contextMenu && (
setContextMenu(null)}>
e.stopPropagation()} >
{contextMenu.node.label}
{/* Collapse all */} {contextMenu.node.depth === 0 ? ( ) : null} {/* Open album */} {contextMenu.folder && contextMenu.folder.source === 'immich' ? ( <> {contextMenu.folder.kind !== 'share' ? ( ) : null} ) : null}
)} {/* --- Bottom Navigation Bar (matches attached screenshot!) --- */}
); }