Files
RecipesCam/docker/frontend/src/ui/MobileLibrary.tsx
T
3dtours 694de421cf fix(library): the phone's shelf opens on the frame the studio held
Coming back from the studio on a phone left the grid where it was: the
frame the studio was holding was lit by nothing and looked for by hand.
The shelf already stamps the frame it was left on and the wide screen
already opens on it; the phone's grid never read it.

The stamp travels down as a landing, and the grid lights that frame,
walks the list out to it when it sits past the rows drawn so far, and
scrolls it under the eye. Each landing is spent once, so a list that
changes under the reader is not dragged back to it.

probe-mobile-return.mjs, on a phone: the frame the studio held is the one
lit and the one in view, both when it is at the top of the shelf and when
it is buried past the first window.
2026-10-15 20:41:36 +07:00

2572 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number>;
edited: Set<string>;
user: User | null;
admin: boolean;
onOpenStudio: (photo: LibraryPhoto) => Promise<void>;
// 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<void>;
onDisconnectServer: (folder: LibraryFolder) => Promise<void>;
onFindImmich: (query: string) => Promise<void>;
onSetStar?: (photo: LibraryPhoto, star: number) => Promise<void>;
onRotate?: (photo: LibraryPhoto, dir: 'left' | 'right') => Promise<void>;
onSwitchToDesktop?: () => void;
onFetchYear?: (year: number) => Promise<void>;
}
// In-memory cache for local photo blob URLs
const globalMobileThumbUrls = new Map<string, string>();
function useMobileThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<MobileTab>('photos');
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
const [selectedPhoto, setSelectedPhoto] = useState<LibraryPhoto | null>(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<string | null>(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<number>(0);
const [selectedYear, setSelectedYear] = useState<string>('all');
const [selectedMonth, setSelectedMonth] = useState<string>('all');
const [selectedDay, setSelectedDay] = useState<string>('all');
const [timeFrom, setTimeFrom] = useState<string>('any');
const [timeTo, setTimeTo] = useState<string>('any');
const [activeDropdown, setActiveDropdown] = useState<'date' | 'time' | null>(null);
const [syncingServerId, setSyncingServerId] = useState<string | null>(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<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const albumSearchInputRef = useRef<HTMLInputElement>(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<string, number>();
const foldersUnder = new Map<string, Set<string>>();
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<string>();
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<TreeNode[]>(() => {
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<Set<string>>(() => {
const set = new Set<string>();
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<string, LibraryPhoto[]>();
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<number>();
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<number | null>(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<HTMLDivElement>) => {
isDraggingScrollRef.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setScrollVisible(true);
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div className="mob-lib-root">
{/* --- Top Header Bar --- */}
{/* --- Top Header Bar (Website Icon & Title: RecipesCam + Thư viện / Tên Album) --- */}
<header className="mob-lib-header">
<div className="mob-lib-header-left">
<a className="mob-lib-brand" href="/" title="RecipesCam">
<img
src="/assets/RecipesCamIcon.png"
alt="RecipesCam"
width="25"
height="25"
className="mob-lib-brand-img"
/>
<span className="mob-lib-brand-text">
Recipes<em>Cam</em>
</span>
</a>
{/* Nếu đang xem một folder/album cụ thể, hiển thị biểu tượng và tên trên topbar */}
{selectedAlbum ? (
<div className="mob-lib-header-folder-info" title={selectedAlbumLabel || undefined}>
<span className="mob-lib-header-folder-icon">
{isSelectedImmich ? (
<svg className="mob-lib-logo-svg" style={{ width: 14, height: 14, verticalAlign: 'middle' }} viewBox="0 0 100 100" fill="none">
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
) : (
'📁'
)}
</span>
<span className="mob-lib-header-folder-name">
{selectedAlbumLabel}
</span>
</div>
) : (
<span className="mob-lib-header-title">{t('lib.title')}</span>
)}
</div>
<div className="mob-lib-header-right">
{/* User Profile Avatar */}
<div className="mob-lib-avatar" title={user?.email || 'User'}>
{user?.avatar ? (
<img src={user.avatar} alt="User" />
) : (
<span style={{ fontSize: '12px', fontWeight: 700, color: '#f4f4f5' }}>
{(user?.email || 'U').charAt(0).toUpperCase()}
</span>
)}
</div>
{/* Search Button in Header */}
<button
type="button"
className={`mob-lib-status-btn ${showSearch ? 'active' : ''}`}
title="Tìm kiếm & Bộ lọc"
onClick={handleToggleSearch}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
{/* Studio Link */}
<a className="mob-lib-studio-link" href="/app" title="Open Web Studio">
{t('nav.studio')}
</a>
{onSwitchToDesktop ? (
<button
type="button"
className="mob-lib-studio-link"
style={{ background: 'transparent', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer' }}
onClick={onSwitchToDesktop}
title="Chuyển sang giao diện Desktop"
>
🖥️ Desktop
</button>
) : null}
</div>
</header>
{/* --- Sub-bar khi mở Folder hoặc Immich / Cloud --- */}
{tab !== 'library' && (selectedAlbum || cloudPhotos.length > 0 || servers.length > 0) && (
<div className="mob-lib-immich-subbar">
<div className="mob-lib-immich-subbar-left">
{selectedAlbum ? (
<button
type="button"
className="mob-lib-status-btn"
onClick={() => setSelectedAlbum(null)}
aria-label="Back to all photos"
style={{ fontSize: '18px', padding: '0 4px', marginRight: '2px' }}
title="Quay lại ảnh Cloud"
>
‹
</button>
) : null}
{isSelectedImmich || (!selectedAlbum && (cloudPhotos.length > 0 || servers.length > 0)) ? (
/* Immich Brand */
<div className="mob-lib-immich-brand">
<svg className="mob-lib-logo-svg" viewBox="0 0 100 100" fill="none">
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span className="mob-lib-immich-text">immich</span>
<span className="mob-lib-immich-pill">Cloud</span>
</div>
) : (
/* Local Folder Brand */
<div className="mob-lib-folder-brand">
<span style={{ fontSize: '15px' }}>📁</span>
<span className="mob-lib-folder-brand-text">{selectedAlbumLabel}</span>
</div>
)}
{isSelectedImmich && selectedAlbum && selectedAlbum !== '__all__' ? (
<span className="mob-lib-immich-album-name">
/ {selectedAlbumLabel}
</span>
) : null}
</div>
<div className="mob-lib-immich-subbar-right">
<span className="mob-lib-immich-count">
{displayedPhotos.length} {t('lib.mobile.photos').toLowerCase()}
</span>
{/* Search Button in Subbar */}
<button
type="button"
className={`mob-lib-status-btn ${showSearch ? 'active' : ''}`}
title="Tìm kiếm & Bộ lọc"
onClick={handleToggleSearch}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
{/* Cloud Upload / Sync Status Icon (nếu Immich) */}
{cloudFolders.length > 0 && (isSelectedImmich || !selectedAlbum) ? (
<button
type="button"
className={`mob-lib-status-btn ${syncingServerId ? 'spinning' : ''}`}
title="Immich Cloud Status"
disabled={!!syncingServerId}
onClick={() => {
const f = cloudFolders[0];
if (f) void handleSyncServer(f);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
<polyline points="9 12 11.5 14.5 15 10" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
) : null}
</div>
</div>
)}
{/* --- Main Content Area --- */}
<div className="mob-lib-content" ref={scrollContainerRef} onScroll={handleContainerScroll}>
{/* --- Tab 1: "Ảnh" (Photos Timeline / UI Preview) --- */}
{tab === 'photos' && (
<>
{/* Sticky Search & Filter Bar neo ở top của UI Preview */}
{showSearch && (
<div className="mob-lib-sticky-search">
{/* Hàng 1: Input tìm kiếm + Chế độ SINGLE/ALL THUMBS (chỉ biểu tượng) + Nút đóng */}
<div className="mob-lib-search-top-row">
<div className="mob-lib-search-input-wrap">
<svg
className="mob-lib-search-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={searchInputRef}
type="text"
className="mob-lib-search-input"
placeholder={t('lib.mobile.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchQuery.trim()) {
void onFindImmich(searchQuery);
}
}}
/>
{searchQuery ? (
<button
type="button"
className="mob-lib-search-clear"
onClick={() => setSearchQuery('')}
title="Xóa tìm kiếm"
>
✕
</button>
) : null}
</div>
{/* Chế độ SINGLE / ALL THUMBS (chỉ biểu tượng) */}
<div className="mob-lib-view-toggle" role="group" aria-label="View mode">
<button
type="button"
className={`mob-lib-view-btn ${viewMode === 'single' ? 'active' : ''}`}
onClick={() => setViewMode('single')}
title="Single"
aria-label="Single"
>
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5" />
<circle cx="5.4" cy="6.8" r="1.1" fill="currentColor" stroke="none" />
<path d="M2.4 11.6 6 8.5l2.3 1.9 2.5-2.6 2.8 2.6" />
</svg>
</button>
<button
type="button"
className={`mob-lib-view-btn ${viewMode === 'grid' ? 'active' : ''}`}
onClick={() => setViewMode('grid')}
title="All thumbs"
aria-label="All thumbs"
>
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
<rect x="2" y="2" width="5" height="5" rx="1" />
<rect x="9" y="2" width="5" height="5" rx="1" />
<rect x="2" y="9" width="5" height="5" rx="1" />
<rect x="9" y="9" width="5" height="5" rx="1" />
</svg>
</button>
</div>
{/* Nút đóng tìm kiếm */}
<button
type="button"
className="mob-lib-search-close-btn"
onClick={() => {
setShowSearch(false);
setFilterScope('all');
setSearchQuery('');
setStarFilter(0);
setSelectedYear('all');
setSelectedMonth('all');
setSelectedDay('all');
setTimeFrom('any');
setTimeTo('any');
setActiveDropdown(null);
}}
title="Đóng tìm kiếm"
>
✕
</button>
</div>
{/* Hàng chọn phạm vi lọc nếu đang trong một Album/Thư mục cụ thể */}
{selectedAlbum && selectedAlbum !== '__all__' && (
<div className="mob-lib-filter-scope-row">
<span className="mob-lib-filter-scope-label">Lọc trong:</span>
<button
type="button"
className={`mob-lib-scope-pill ${filterScope === 'all' ? 'active' : ''}`}
onClick={() => setFilterScope('all')}
title="Lọc trên toàn bộ ảnh Immich / Cloud"
>
☁️ Tất cả ảnh ({cloudPhotos.length || photos.length})
</button>
<button
type="button"
className={`mob-lib-scope-pill ${filterScope === 'album' ? 'active' : ''}`}
onClick={() => setFilterScope('album')}
title={`Chỉ lọc trong album ${selectedAlbumLabel}`}
>
📁 {selectedAlbumLabel}
</button>
</div>
)}
{/* Hàng 2: Nhóm các nút cùng thuộc tính vào các dropbox */}
<div className="mob-lib-dropbox-row">
{/* Dropbox 1: new first / old first */}
<div className="mob-lib-dropbox">
<select
className="mob-lib-dropbox-select"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'newest' | 'oldest')}
title="Sắp xếp thời gian"
>
<option value="newest">↕ New first</option>
<option value="oldest">↕ Old first</option>
</select>
<span className="mob-lib-dropbox-caret">▾</span>
</div>
{/* Dropbox 2: star: None, 1-5 */}
<div className={`mob-lib-dropbox ${starFilter > 0 ? 'active' : ''}`}>
<select
className="mob-lib-dropbox-select"
value={starFilter}
onChange={(e) => setStarFilter(Number(e.target.value))}
title="Đánh giá sao"
>
<option value={0}>★ Star: None</option>
<option value={1}>★ 1</option>
<option value={2}>★ 2</option>
<option value={3}>★ 3</option>
<option value={4}>★ 4</option>
<option value={5}>★ 5</option>
</select>
<span className="mob-lib-dropbox-caret">▾</span>
</div>
{/* Dropbox 3: Year/Month/Date */}
<button
type="button"
className={`mob-lib-dropbox-btn ${isDateFiltered ? 'active' : ''} ${activeDropdown === 'date' ? 'open' : ''}`}
onClick={() => setActiveDropdown((prev) => (prev === 'date' ? null : 'date'))}
title="Lọc theo Năm / Tháng / Ngày"
>
<span>
{isDateFiltered
? `📅 ${[selectedYear !== 'all' ? selectedYear : null, selectedMonth !== 'all' ? `T${selectedMonth}` : null, selectedDay !== 'all' ? `N${selectedDay}` : null].filter(Boolean).join('/')}`
: '📅 Year/Month/Date'}
</span>
<span className="mob-lib-dropbox-caret">{activeDropdown === 'date' ? '▴' : '▾'}</span>
</button>
{/* Dropbox 4: Time from:... to:.... */}
<button
type="button"
className={`mob-lib-dropbox-btn ${isTimeFiltered ? 'active' : ''} ${activeDropdown === 'time' ? 'open' : ''}`}
onClick={() => setActiveDropdown((prev) => (prev === 'time' ? null : 'time'))}
title="Lọc khung giờ chụp"
>
<span>
{isTimeFiltered
? `🕒 ${timeFrom !== 'any' ? `${String(timeFrom).padStart(2, '0')}:00` : '00:00'}-${timeTo !== 'any' ? `${String(timeTo).padStart(2, '0')}:00` : '23:00'}`
: '🕒 Time from:.. to:..'}
</span>
<span className="mob-lib-dropbox-caret">{activeDropdown === 'time' ? '▴' : '▾'}</span>
</button>
</div>
{/* Popover Card: Year/Month/Date */}
{activeDropdown === 'date' && (
<div className="mob-lib-popover-card">
<div className="mob-lib-popover-header">
<span className="mob-lib-popover-title">📅 Lọc theo Năm / Tháng / Ngày</span>
{isDateFiltered && (
<button
type="button"
className="mob-lib-popover-reset-btn"
onClick={() => {
setSelectedYear('all');
setSelectedMonth('all');
setSelectedDay('all');
}}
>
Xóa lọc
</button>
)}
</div>
<div className="mob-lib-popover-grid-3">
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Năm (Year)</label>
<select
className="mob-lib-popover-select"
value={selectedYear}
onChange={(e) => setSelectedYear(e.target.value)}
>
<option value="all">Tất cả</option>
{availableYears.map((yr) => (
<option key={yr} value={String(yr)}>
{yr}
</option>
))}
</select>
</div>
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Tháng (Month)</label>
<select
className="mob-lib-popover-select"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
>
<option value="all">Tất cả</option>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={String(m)}>
Thg {m}
</option>
))}
</select>
</div>
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Ngày (Date)</label>
<select
className="mob-lib-popover-select"
value={selectedDay}
onChange={(e) => setSelectedDay(e.target.value)}
>
<option value="all">Tất cả</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)}>
Ngày {d}
</option>
))}
</select>
</div>
</div>
<div className="mob-lib-popover-footer">
<button
type="button"
className="mob-lib-popover-done-btn"
onClick={() => setActiveDropdown(null)}
>
Xong
</button>
</div>
</div>
)}
{/* Popover Card: Time from:... to:.... */}
{activeDropdown === 'time' && (
<div className="mob-lib-popover-card">
<div className="mob-lib-popover-header">
<span className="mob-lib-popover-title">🕒 Khung giờ chụp trong ngày</span>
{isTimeFiltered && (
<button
type="button"
className="mob-lib-popover-reset-btn"
onClick={() => {
setTimeFrom('any');
setTimeTo('any');
}}
>
Xóa lọc
</button>
)}
</div>
<div className="mob-lib-popover-grid-2">
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Từ (From)</label>
<select
className="mob-lib-popover-select"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
>
<option value="any">Bất kỳ</option>
{HOURS.map((h) => (
<option key={h} value={String(h)}>
{String(h).padStart(2, '0')}:00
</option>
))}
</select>
</div>
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Đến (To)</label>
<select
className="mob-lib-popover-select"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
>
<option value="any">Bất kỳ</option>
{HOURS.map((h) => (
<option key={h} value={String(h)}>
{String(h).padStart(2, '0')}:00
</option>
))}
</select>
</div>
</div>
<div className="mob-lib-popover-footer">
<button
type="button"
className="mob-lib-popover-done-btn"
onClick={() => setActiveDropdown(null)}
>
Xong
</button>
</div>
</div>
)}
</div>
)}
{displayedPhotos.length === 0 ? (
<div className="mob-lib-empty">
{syncingServerId ? (
<>
<div className="mob-lib-spinner" style={{ margin: '20px auto', width: 32, height: 32 }} />
<div className="mob-lib-empty-title">Đang đồng bộ ảnh từ Immich Cloud...</div>
<div className="mob-lib-empty-desc">Ảnh mới nhất đang được tải về và hiển thị...</div>
</>
) : (
<>
<svg className="mob-lib-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
<div className="mob-lib-empty-title">
{servers.length === 0
? t('lib.mobile.noServers')
: t('lib.mobile.noPhotos')}
</div>
<div className="mob-lib-empty-desc">
{t('lib.mobile.cloudDesc')}
</div>
{servers.length === 0 && (
<button
type="button"
className="mob-lib-btn primary"
onClick={onAddImmich}
style={{ marginTop: '8px', padding: '10px 20px', fontSize: '13px' }}
>
+ {t('lib.mobile.addImmich')}
</button>
)}
</>
)}
</div>
) : viewMode === 'single' ? (
<div className="mob-lib-single-list">
{visiblePhotos.map((photo) => {
const thumbSrc = getThumbUrl(photo);
const previewSrc = photo.remoteId || photo.server ? remoteUrl('preview', photo) : thumbSrc;
return (
<div
key={photo.id}
className="mob-lib-single-card"
onClick={() => setSelectedPhoto(photo)}
>
<div className="mob-lib-single-img-wrap">
<img
className="mob-lib-single-img"
src={previewSrc}
alt={photo.name}
loading="lazy"
decoding="async"
onError={(e) => {
if (thumbSrc && (e.currentTarget as HTMLImageElement).src !== thumbSrc) {
(e.currentTarget as HTMLImageElement).src = thumbSrc;
}
}}
/>
<div className="mob-lib-single-badges">
<svg
className="mob-lib-cloud-badge"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
{photo.star ? (
<div className="mob-lib-star-badge">
<span>★</span>
<span>{photo.star}</span>
</div>
) : null}
{edited.has(photo.id) ? (
<div className="mob-lib-edited-badge" title={t('lib.edited')} />
) : null}
</div>
</div>
<div className="mob-lib-single-info">
<div className="mob-lib-single-name">{photo.name}</div>
<div className="mob-lib-single-date">
{new Date(photo.taken).toLocaleDateString()} · {new Date(photo.taken).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{photo.dir ? ` · ${photo.dir}` : ''}
</div>
</div>
</div>
);
})}
</div>
) : (
<>
{dateGroups.map((group) => (
<div key={group.key} className="mob-lib-date-group">
<div className="mob-lib-date-header">
<span>{group.label}</span>
<button
type="button"
className="mob-lib-date-check"
aria-label="Select date"
>
✓
</button>
</div>
<div className="mob-lib-grid">
{group.photos.map((photo) => {
const thumbSrc = getThumbUrl(photo);
return (
<div
key={photo.id}
className={`mob-lib-thumb-cell ${activeId === photo.id ? 'selected' : ''}`}
onClick={() => setSelectedPhoto(photo)}
>
<img
src={thumbSrc}
alt={photo.name}
loading="lazy"
decoding="async"
onError={(e) => {
// Fallback if thumb failed to load
(e.currentTarget as HTMLElement).style.opacity = '0.3';
}}
/>
{/* Cloud Outline Badge (matches Immich mobile app!) */}
<svg
className="mob-lib-cloud-badge"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
{/* Edited Indicator */}
{edited.has(photo.id) ? (
<div className="mob-lib-edited-badge" title={t('lib.edited')} />
) : null}
{/* Star Rating Badge */}
{photo.star ? (
<div className="mob-lib-star-badge">
<span>★</span>
<span>{photo.star}</span>
</div>
) : null}
</div>
);
})}
</div>
</div>
))}
{/* Progressive load more hint */}
{renderLimit < displayedPhotos.length && (
<div
className="mob-lib-load-more"
style={{
textAlign: 'center',
padding: '16px 0 24px',
color: '#71717a',
fontSize: '12px',
}}
>
Đang hiển thị {visiblePhotos.length} / {displayedPhotos.length} ảnh...
</div>
)}
</>
)}
</>
)}
{/* --- Tab 2: "Tìm kiếm" (Search & Filter) --- */}
{tab === 'search' && (
<div className="mob-lib-search-pane">
<div className="mob-lib-search-top-row">
<div className="mob-lib-search-input-wrap">
<svg
className="mob-lib-search-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
className="mob-lib-search-input"
placeholder={t('lib.mobile.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchQuery.trim()) {
void onFindImmich(searchQuery);
}
}}
/>
{searchQuery ? (
<button
type="button"
className="mob-lib-search-clear"
onClick={() => setSearchQuery('')}
>
✕
</button>
) : null}
</div>
{/* Chế độ SINGLE / ALL THUMBS (chỉ biểu tượng) */}
<div className="mob-lib-view-toggle" role="group" aria-label="View mode">
<button
type="button"
className={`mob-lib-view-btn ${viewMode === 'single' ? 'active' : ''}`}
onClick={() => setViewMode('single')}
title="Single"
aria-label="Single"
>
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5" />
<circle cx="5.4" cy="6.8" r="1.1" fill="currentColor" stroke="none" />
<path d="M2.4 11.6 6 8.5l2.3 1.9 2.5-2.6 2.8 2.6" />
</svg>
</button>
<button
type="button"
className={`mob-lib-view-btn ${viewMode === 'grid' ? 'active' : ''}`}
onClick={() => setViewMode('grid')}
title="All thumbs"
aria-label="All thumbs"
>
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor">
<rect x="2" y="2" width="5" height="5" rx="1" />
<rect x="9" y="2" width="5" height="5" rx="1" />
<rect x="2" y="9" width="5" height="5" rx="1" />
<rect x="9" y="9" width="5" height="5" rx="1" />
</svg>
</button>
</div>
</div>
{/* Dropboxes */}
<div className="mob-lib-dropbox-row">
<div className="mob-lib-dropbox">
<select
className="mob-lib-dropbox-select"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as 'newest' | 'oldest')}
>
<option value="newest">↕ New first</option>
<option value="oldest">↕ Old first</option>
</select>
<span className="mob-lib-dropbox-caret">▾</span>
</div>
<div className={`mob-lib-dropbox ${starFilter > 0 ? 'active' : ''}`}>
<select
className="mob-lib-dropbox-select"
value={starFilter}
onChange={(e) => setStarFilter(Number(e.target.value))}
>
<option value={0}>★ Star: None</option>
<option value={1}>★ 1</option>
<option value={2}>★ 2</option>
<option value={3}>★ 3</option>
<option value={4}>★ 4</option>
<option value={5}>★ 5</option>
</select>
<span className="mob-lib-dropbox-caret">▾</span>
</div>
<button
type="button"
className={`mob-lib-dropbox-btn ${isDateFiltered ? 'active' : ''} ${activeDropdown === 'date' ? 'open' : ''}`}
onClick={() => setActiveDropdown((prev) => (prev === 'date' ? null : 'date'))}
>
<span>
{isDateFiltered
? `📅 ${[selectedYear !== 'all' ? selectedYear : null, selectedMonth !== 'all' ? `T${selectedMonth}` : null, selectedDay !== 'all' ? `N${selectedDay}` : null].filter(Boolean).join('/')}`
: '📅 Year/Month/Date'}
</span>
<span className="mob-lib-dropbox-caret">{activeDropdown === 'date' ? '▴' : '▾'}</span>
</button>
<button
type="button"
className={`mob-lib-dropbox-btn ${isTimeFiltered ? 'active' : ''} ${activeDropdown === 'time' ? 'open' : ''}`}
onClick={() => setActiveDropdown((prev) => (prev === 'time' ? null : 'time'))}
>
<span>
{isTimeFiltered
? `🕒 ${timeFrom !== 'any' ? `${String(timeFrom).padStart(2, '0')}:00` : '00:00'}-${timeTo !== 'any' ? `${String(timeTo).padStart(2, '0')}:00` : '23:00'}`
: '🕒 Time from:.. to:..'}
</span>
<span className="mob-lib-dropbox-caret">{activeDropdown === 'time' ? '▴' : '▾'}</span>
</button>
</div>
{/* Popover Card for Year/Month/Date */}
{activeDropdown === 'date' && (
<div className="mob-lib-popover-card">
<div className="mob-lib-popover-header">
<span className="mob-lib-popover-title">📅 Lọc theo Năm / Tháng / Ngày</span>
{isDateFiltered && (
<button
type="button"
className="mob-lib-popover-reset-btn"
onClick={() => {
setSelectedYear('all');
setSelectedMonth('all');
setSelectedDay('all');
}}
>
Xóa lọc
</button>
)}
</div>
<div className="mob-lib-popover-grid-3">
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Năm</label>
<select
className="mob-lib-popover-select"
value={selectedYear}
onChange={(e) => setSelectedYear(e.target.value)}
>
<option value="all">Tất cả</option>
{availableYears.map((yr) => (
<option key={yr} value={String(yr)}>
{yr}
</option>
))}
</select>
</div>
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Tháng</label>
<select
className="mob-lib-popover-select"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
>
<option value="all">Tất cả</option>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={String(m)}>
Thg {m}
</option>
))}
</select>
</div>
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Ngày</label>
<select
className="mob-lib-popover-select"
value={selectedDay}
onChange={(e) => setSelectedDay(e.target.value)}
>
<option value="all">Tất cả</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)}>
Ngày {d}
</option>
))}
</select>
</div>
</div>
<div className="mob-lib-popover-footer">
<button
type="button"
className="mob-lib-popover-done-btn"
onClick={() => setActiveDropdown(null)}
>
Xong
</button>
</div>
</div>
)}
{/* Popover Card for Time */}
{activeDropdown === 'time' && (
<div className="mob-lib-popover-card">
<div className="mob-lib-popover-header">
<span className="mob-lib-popover-title">🕒 Khung giờ chụp</span>
{isTimeFiltered && (
<button
type="button"
className="mob-lib-popover-reset-btn"
onClick={() => {
setTimeFrom('any');
setTimeTo('any');
}}
>
Xóa lọc
</button>
)}
</div>
<div className="mob-lib-popover-grid-2">
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Từ (From)</label>
<select
className="mob-lib-popover-select"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
>
<option value="any">Bất kỳ</option>
{HOURS.map((h) => (
<option key={h} value={String(h)}>
{String(h).padStart(2, '0')}:00
</option>
))}
</select>
</div>
<div className="mob-lib-popover-col">
<label className="mob-lib-popover-label">Đến (To)</label>
<select
className="mob-lib-popover-select"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
>
<option value="any">Bất kỳ</option>
{HOURS.map((h) => (
<option key={h} value={String(h)}>
{String(h).padStart(2, '0')}:00
</option>
))}
</select>
</div>
</div>
<div className="mob-lib-popover-footer">
<button
type="button"
className="mob-lib-popover-done-btn"
onClick={() => setActiveDropdown(null)}
>
Xong
</button>
</div>
</div>
)}
{/* Search Results Summary */}
<div style={{ fontSize: '13px', color: '#a1a1aa', margin: '4px 0 10px' }}>
{t('lib.count', { n: searchResults.length })}
</div>
{/* Results Grid or Single List */}
{viewMode === 'single' ? (
<div className="mob-lib-single-list">
{searchResults.map((photo) => {
const previewSrc = remoteUrl('preview', photo);
const thumbSrc = remoteUrl('thumbnail', photo);
return (
<div
key={photo.id}
className="mob-lib-single-card"
onClick={() => setSelectedPhoto(photo)}
>
<div className="mob-lib-single-img-wrap">
<img
className="mob-lib-single-img"
src={previewSrc}
alt={photo.name}
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = thumbSrc;
}}
/>
<div className="mob-lib-single-badges">
<svg
className="mob-lib-cloud-badge"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
{photo.star ? (
<div className="mob-lib-star-badge">
<span>★</span>
<span>{photo.star}</span>
</div>
) : null}
{edited.has(photo.id) ? (
<div className="mob-lib-edited-badge" title={t('lib.edited')} />
) : null}
</div>
</div>
<div className="mob-lib-single-info">
<div className="mob-lib-single-name">{photo.name}</div>
<div className="mob-lib-single-date">
{new Date(photo.taken).toLocaleDateString()} · {new Date(photo.taken).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{photo.dir ? ` · ${photo.dir}` : ''}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="mob-lib-grid">
{searchResults.map((photo) => (
<div
key={photo.id}
className={`mob-lib-thumb-cell ${activeId === photo.id ? 'selected' : ''}`}
onClick={() => setSelectedPhoto(photo)}
>
<img src={remoteUrl('thumbnail', photo)} alt={photo.name} loading="lazy" />
<svg
className="mob-lib-cloud-badge"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
{photo.star ? (
<div className="mob-lib-star-badge">
<span>★</span>
<span>{photo.star}</span>
</div>
) : null}
</div>
))}
</div>
)}
</div>
)}
{/* --- Tab 3: "Album" (Cây thư mục tương tự giao diện desktop) --- */}
{tab === 'albums' && (
<div className="mob-lib-tree-pane">
{/* Search Bar sticky khi đang mở tìm kiếm trong Album/Folder */}
{showSearch && (
<div
className="mob-lib-album-search-bar"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
background: 'rgba(24, 24, 27, 0.95)',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
position: 'sticky',
top: 0,
zIndex: 10,
backdropFilter: 'blur(12px)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flex: 1,
background: 'rgba(255, 255, 255, 0.06)',
borderRadius: '8px',
padding: '6px 10px',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ color: '#a1a1aa', marginRight: '8px', flexShrink: 0 }}
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={albumSearchInputRef}
type="text"
placeholder="Tìm album, thư mục..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
background: 'transparent',
border: 'none',
color: '#ffffff',
fontSize: '13px',
width: '100%',
outline: 'none',
}}
/>
{searchQuery ? (
<button
type="button"
onClick={() => setSearchQuery('')}
style={{
background: 'transparent',
border: 'none',
color: '#71717a',
cursor: 'pointer',
padding: '2px',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
}}
title="Xóa tìm kiếm"
>
✕
</button>
) : null}
</div>
<button
type="button"
className="mob-lib-btn"
style={{ padding: '6px 10px', fontSize: '12px' }}
onClick={() => {
setShowSearch(false);
setSearchQuery('');
}}
>
Đóng
</button>
</div>
)}
<div className="mob-lib-tree-toolbar">
<span className="mob-lib-tree-toolbar-title">
📁 {t('lib.mobile.albums')} ({treeNodes.length})
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
className="mob-lib-btn"
style={{ padding: '4px 8px', fontSize: '11px' }}
onClick={() => {
if (expandedFolders.size === 0) {
const all = new Set<string>();
for (const n of treeNodes) all.add(n.key);
setExpandedFolders(all);
} else {
setExpandedFolders(new Set());
}
}}
>
{expandedFolders.size === 0 ? 'Mở rộng' : 'Thu gọn'}
</button>
<button
type="button"
className="mob-lib-btn primary"
style={{ padding: '4px 8px', fontSize: '11px' }}
onClick={onAddImmich}
>
+ Immich
</button>
</div>
</div>
<div className="mob-lib-tree-list">
{showSearch && searchQuery.trim() && treeRows.length === 0 && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: '#71717a', fontSize: '13px' }}>
Không tìm thấy album hoặc thư mục nào khớp với &quot;{searchQuery}&quot;
</div>
)}
{/* Cloud Photos option (khi có cloudPhotos) */}
{(!showSearch || !searchQuery.trim()) && cloudPhotos.length > 0 && (
<div
className={`mob-lib-tree-row ${selectedAlbum === null ? 'selected' : ''}`}
style={{ paddingLeft: '12px' }}
onClick={() => {
setSelectedAlbum(null);
setTab('photos');
}}
>
<span className="mob-lib-tree-caret leaf" />
<span className="mob-lib-tree-icon">☁️</span>
<span className="mob-lib-tree-label">Ảnh Cloud (Immich / Drive)</span>
<span className="mob-lib-tree-count">{cloudPhotos.length}</span>
</div>
)}
{/* Tất cả ảnh option (cả ổ đĩa và cloud) */}
{(!showSearch || !searchQuery.trim()) && (
<div
className={`mob-lib-tree-row ${selectedAlbum === '__all__' || (selectedAlbum === null && cloudPhotos.length === 0) ? 'selected' : ''}`}
style={{ paddingLeft: '12px' }}
onClick={() => {
setSelectedAlbum('__all__');
setTab('photos');
}}
>
<span className="mob-lib-tree-caret leaf" />
<span className="mob-lib-tree-icon">🖼️</span>
<span className="mob-lib-tree-label">{t('lib.mobile.allPhotos')}</span>
<span className="mob-lib-tree-count">{photos.length}</span>
</div>
)}
{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 (
<div
key={n.key}
className={`mob-lib-tree-row ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${12 + n.depth * 16}px` }}
onClick={() => {
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 */}
<span
className={`mob-lib-tree-caret ${under ? (expandedFolders.has(n.key) ? 'open' : '') : 'leaf'}`}
onClick={(e) => {
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 */}
<span className="mob-lib-tree-icon">
{folder?.source === 'immich' ? '☁️' : '📁'}
</span>
{/* Folder Name */}
<span className="mob-lib-tree-label">
{n.label}
</span>
{/* Photo Count */}
<span className="mob-lib-tree-count">
{n.count}
</span>
{/* 3-dots Context Menu Button for touch screens */}
<button
type="button"
className="mob-lib-tree-more-btn"
title="Menu thao tác"
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({
folder,
node: n,
x: Math.max(12, rect.left - 120),
y: Math.min(rect.bottom + 4, window.innerHeight - 250),
});
}}
>
⋮
</button>
</div>
);
})}
</div>
</div>
)}
{/* --- Tab 4: "Thư viện" (Library / Cloud Management) --- */}
{tab === 'library' && (
<div className="mob-lib-manage-pane">
<div className="mob-lib-add-immich-banner">
<svg width="40" height="40" viewBox="0 0 100 100" fill="none">
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<div>
<div style={{ fontSize: '16px', fontWeight: 700, color: '#ffffff' }}>
IMMICH CLOUD
</div>
<div style={{ fontSize: '12px', color: '#a1a1aa', marginTop: '4px', lineHeight: 1.4 }}>
{t('lib.mobile.cloudDesc')}
</div>
</div>
<button
type="button"
className="mob-lib-add-immich-btn"
onClick={onAddImmich}
>
+ {t('lib.mobile.addImmich')}
</button>
</div>
{/* Connected Servers List */}
<div style={{ fontSize: '13px', fontWeight: 700, color: '#a1a1aa', letterSpacing: '0.05em' }}>
{t('lib.mobile.servers')}
</div>
{servers.length === 0 ? (
<div className="mob-lib-card" style={{ textAlign: 'center', color: '#71717a' }}>
{t('lib.mobile.noServers')}
</div>
) : (
servers.map((server) => {
const folder = folders.find((f) => f.server === server.id) ?? null;
const isSyncing = syncingServerId === server.id;
return (
<div key={server.id} className="mob-lib-card">
<div className="mob-lib-card-header">
<div className="mob-lib-card-title">
<svg className="mob-lib-logo-svg" style={{ width: 18, height: 18 }} viewBox="0 0 100 100" fill="none">
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span>{server.name || 'Immich Cloud'}</span>
</div>
<span className="mob-lib-badge-connected">
{t('lib.mobile.connected')}
</span>
</div>
<div className="mob-lib-card-meta">
<div><strong>URL:</strong> {server.url}</div>
{server.version ? <div><strong>Version:</strong> {server.version}</div> : null}
<div>
<strong>Albums:</strong> {server.albums?.length ? server.albums.length : 'All'}
</div>
</div>
<div className="mob-lib-card-actions">
<button
type="button"
className="mob-lib-btn primary"
onClick={() => {
setSelectedAlbum(folder ? normPath(folder.name) : null);
setTab('photos');
}}
>
📸 {t('lib.mobile.photos')}
</button>
<button
type="button"
className="mob-lib-btn"
disabled={isSyncing}
onClick={() => {
if (folder) void handleSyncServer(folder);
}}
>
{isSyncing ? '…' : '🔄'} {t('lib.mobile.rescan')}
</button>
{folder ? (
<button
type="button"
className="mob-lib-btn"
onClick={() => onEditAlbums(folder)}
>
📁 {t('lib.mobile.chooseAlbums')}
</button>
) : null}
{folder ? (
<button
type="button"
className="mob-lib-btn danger"
onClick={() => void onDisconnectServer(folder)}
>
✕ {t('lib.mobile.disconnect')}
</button>
) : null}
</div>
</div>
);
})
)}
</div>
)}
</div>
{/* Floating Scroll Indicator replacing side scrollbar (Auto-hides after idle, no 'scroll' text) */}
<div
className={`mob-lib-scroll-handle ${scrollVisible ? 'visible' : ''}`}
style={{ top: `${handleTop}px` }}
aria-label="Cuộn trang"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const y = e.clientY - rect.top;
if (y < rect.height / 2) {
handleScrollToTop();
} else {
handleScrollToBottom();
}
}}
>
<svg
className="mob-lib-scroll-arrow"
width="12"
height="10"
viewBox="0 0 12 10"
fill="currentColor"
>
<path d="M6 1L1 9h10L6 1z" />
</svg>
<svg
className="mob-lib-scroll-arrow"
width="12"
height="10"
viewBox="0 0 12 10"
fill="currentColor"
>
<path d="M6 9L1 1h10L6 9z" />
</svg>
</div>
{/* --- Fullscreen Photo Preview Modal --- */}
{selectedPhoto && (
<div className="mob-lib-preview-modal">
<div className="mob-lib-preview-topbar">
<button
type="button"
className="mob-lib-status-btn"
onClick={closePhoto}
style={{ fontSize: '18px', padding: '6px' }}
aria-label="Close"
>
✕
</button>
<div className="mob-lib-preview-title">{selectedPhoto.name}</div>
<a
className="mob-lib-studio-link"
href={`/app?lib=${encodeURIComponent(selectedPhoto.id)}`}
onClick={(e) => {
e.preventDefault();
void onOpenStudio(selectedPhoto);
}}
>
{t('nav.studio')}
</a>
</div>
{/* 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. */}
<div
className="mob-lib-preview-body"
data-key="mob-lib-preview-body"
onTouchStart={(e) => {
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);
}}
>
<img
className="mob-lib-preview-img"
src={remoteUrl('preview', selectedPhoto)}
alt={selectedPhoto.name}
/>
</div>
<div className="mob-lib-preview-footer">
<div className="mob-lib-preview-tools">
{/* Star Rating */}
<div className="mob-lib-preview-stars">
{[1, 2, 3, 4, 5].map((st) => (
<button
key={st}
type="button"
className={`mob-lib-star-btn ${(selectedPhoto.star || 0) >= st ? 'active' : ''}`}
onClick={() => {
const nextStar = selectedPhoto.star === st ? 0 : st;
setSelectedPhoto({ ...selectedPhoto, star: nextStar });
if (onSetStar) void onSetStar(selectedPhoto, nextStar);
}}
>
★
</button>
))}
</div>
{/* Rotate Buttons */}
<div className="mob-lib-preview-rot-btns">
<button
type="button"
className="mob-lib-btn"
style={{ padding: '6px 10px', fontSize: '14px' }}
title="Rotate Left"
onClick={() => onRotate && void onRotate(selectedPhoto, 'left')}
>
↺
</button>
<button
type="button"
className="mob-lib-btn"
style={{ padding: '6px 10px', fontSize: '14px' }}
title="Rotate Right"
onClick={() => onRotate && void onRotate(selectedPhoto, 'right')}
>
↻
</button>
</div>
</div>
{/* Photo Metadata Info (tương tự Desktop UI .adm-meta, hiển thị phía trên nút Open In Studio) */}
<div className="mob-lib-preview-meta" data-key="lib-stage-meta">
<b className="mob-lib-meta-name">{selectedPhoto.name}</b>
{selectedPhoto.dir || selectedPhoto.folder ? (
<div className="mob-lib-meta-line">
<span>{selectedPhoto.dir || selectedPhoto.folder}</span>
</div>
) : null}
<div className="mob-lib-meta-line">
<span>
{new Date(selectedPhoto.taken).toLocaleString()} · {t('lib.size', { mb: (selectedPhoto.size / 1048576).toFixed(1) })}
{edited.has(selectedPhoto.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
{shot?.place || coords ? (
<div className="mob-lib-meta-line mob-lib-meta-place" title={coords ?? undefined}>
<span>📍 {shot?.place ?? coords}</span>
</div>
) : null}
{shot?.line ? (
<div className="mob-lib-meta-line mob-lib-meta-specs">
<span>📷 {shot.line}</span>
</div>
) : null}
</div>
{/* Primary Open In Studio Button */}
<button
type="button"
className="mob-lib-open-studio-btn"
onClick={() => void onOpenStudio(selectedPhoto)}
>
<span>🎨</span>
<span>{t('lib.mobile.openStudio')}</span>
</button>
</div>
</div>
)}
{/* --- Context Menu for Folder Tree --- */}
{contextMenu && (
<div className="mob-lib-menu-backdrop" onClick={() => setContextMenu(null)}>
<div
className="mob-lib-context-menu"
style={{
top: Math.min(contextMenu.y, typeof window !== 'undefined' ? window.innerHeight - 250 : 300),
left: Math.max(12, Math.min(contextMenu.x, typeof window !== 'undefined' ? window.innerWidth - 210 : 150)),
}}
onClick={(e) => e.stopPropagation()}
>
<div className="mob-lib-context-header">
{contextMenu.node.label}
</div>
{/* Collapse all */}
{contextMenu.node.depth === 0 ? (
<button
type="button"
className="lib-menu-item"
onClick={() => {
setContextMenu(null);
setExpandedFolders(new Set());
}}
>
<span className="lib-menu-icon">▴</span>
{t('lib.collapseAll')}
</button>
) : null}
{/* Open album */}
<button
type="button"
className="lib-menu-item"
onClick={() => {
setSelectedAlbum(contextMenu.node.key);
setContextMenu(null);
setTab('photos');
}}
>
<span className="lib-menu-icon">📂</span>
{t('lib.mobile.photos')}
</button>
{contextMenu.folder && contextMenu.folder.source === 'immich' ? (
<>
<button
type="button"
className="lib-menu-item"
onClick={() => {
const f = contextMenu.folder!;
setContextMenu(null);
void handleSyncServer(f);
}}
>
<span className="lib-menu-icon">↻</span>
{t('lib.immich.sync')}
</button>
{contextMenu.folder.kind !== 'share' ? (
<button
type="button"
className="lib-menu-item"
onClick={() => {
const f = contextMenu.folder!;
setContextMenu(null);
onEditAlbums(f);
}}
>
<span className="lib-menu-icon">▤</span>
{t('lib.immich.albumsChoose')}
</button>
) : null}
<button
type="button"
className="lib-menu-item"
onClick={() => {
const f = contextMenu.folder!;
setContextMenu(null);
void onDisconnectServer(f);
}}
>
<span className="lib-menu-icon">⚿</span>
{t('lib.immich.disconnect')}
</button>
</>
) : null}
<div style={{ height: '1px', background: 'rgba(255, 255, 255, 0.08)', margin: '4px 0' }} />
<button
type="button"
className="lib-menu-item"
onClick={() => {
setContextMenu(null);
onAddImmich();
}}
>
<span className="lib-menu-icon">+</span>
{t('lib.mobile.addImmich')}
</button>
</div>
</div>
)}
{/* --- Bottom Navigation Bar (matches attached screenshot!) --- */}
<nav className="mob-lib-tab-bar" aria-label="Bottom Navigation">
{/* Tab 1: Ảnh */}
<button
type="button"
className={`mob-lib-tab-item ${tab === 'photos' && !showSearch && !selectedAlbum ? 'active' : ''}`}
onClick={() => {
setSelectedAlbum(null);
setTab('photos');
setShowSearch(false);
handleScrollToTop();
}}
>
<div className="mob-lib-tab-pill">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<span className="mob-lib-tab-label">{t('lib.mobile.photos')}</span>
</button>
{/* Tab 2: Tìm kiếm */}
<button
type="button"
className={`mob-lib-tab-item ${showSearch ? 'active' : ''}`}
onClick={() => {
setFilterScope('all');
handleToggleSearch();
handleScrollToTop();
}}
>
<div className="mob-lib-tab-pill">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</div>
<span className="mob-lib-tab-label">{t('lib.mobile.search')}</span>
</button>
{/* Tab 3: Album */}
<button
type="button"
className={`mob-lib-tab-item ${tab === 'albums' ? 'active' : ''}`}
onClick={() => setTab('albums')}
>
<div className="mob-lib-tab-pill">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
<span className="mob-lib-tab-label">{t('lib.mobile.albums')}</span>
</button>
{/* Tab 4: Thư viện */}
<button
type="button"
className={`mob-lib-tab-item ${tab === 'library' ? 'active' : ''}`}
onClick={() => setTab('library')}
>
<div className="mob-lib-tab-pill">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
<span className="mob-lib-tab-label">{t('lib.mobile.library')}</span>
</button>
</nav>
</div>
);
}