694de421cf
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.
2572 lines
102 KiB
TypeScript
2572 lines
102 KiB
TypeScript
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 "{searchQuery}"
|
||
</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>
|
||
);
|
||
}
|