diff --git a/docker/backend/src/immich.ts b/docker/backend/src/immich.ts index 9395f8f..50c37de 100644 --- a/docker/backend/src/immich.ts +++ b/docker/backend/src/immich.ts @@ -428,6 +428,26 @@ export function immichRoutes( }); } + if (album) { + // First try GET /albums/:id (supported across many Immich versions) + const albRes = await call(server, { path: `/albums/${album}` }); + if (albRes.ok) { + const albData = await jsonOf<{ assets?: RawAsset[] }>(albRes); + if (Array.isArray(albData?.assets) && albData.assets.length > 0) { + const allAssets = albData.assets.filter((a) => a.type === 'IMAGE' || a.type === undefined); + const start = (page - 1) * size; + return reply.send({ + ok: true, + page, + size, + total: allAssets.length, + hasMore: start + size < allAssets.length, + items: allAssets.slice(start, start + size).map(assetRow), + }); + } + } + } + // One query, one answer. Immich reads every field it is handed as one more // thing an asset must be — `originalFileName` with `tagIds` is a file called // this *and* tagged that — so the box is read as a tag when it names one, and @@ -448,17 +468,24 @@ export function immichRoutes( }, }); if (!res.ok) return reply.status(502).send(refusal(res)); - const found = await jsonOf<{ assets?: { items?: RawAsset[]; total?: number } }>(res); - const items = found?.assets?.items ?? []; + const found = await jsonOf(res); + // Robust parsing across all Immich versions: + // Immich v1: found.assets.items + // Immich v2+: found.items + // Raw array: Array.isArray(found) + const rawItems: RawAsset[] = found?.assets?.items ?? found?.items ?? (Array.isArray(found) ? found : []); + const total: number | null = found?.assets?.total ?? found?.total ?? found?.count ?? null; + const hasMore: boolean = + rawItems.length === size || + (typeof found?.nextCursor === 'string' && !!found.nextCursor) || + (total !== null && page * size < total); return reply.send({ ok: true, page, size, - total: found?.assets?.total ?? null, - // The next page is only asked for while a full one came back: that is the - // one shape every Immich version agrees on (no `nextPage`, no `nextCursor`). - hasMore: items.length === size, - items: items.map(assetRow), + total, + hasMore, + items: rawItems.map(assetRow), }); }); diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index 4f6cbc3..00bf637 100644 --- a/docker/frontend/public/sw.js +++ b/docker/frontend/public/sw.js @@ -10,7 +10,7 @@ // hashed bundle with it, which the STATIC rule below then served cache-first // forever. Every navigation here now goes past the browser's own cache, so the // build a visitor gets is the one the server has. -const VERSION = 'recipescam-v12'; +const VERSION = 'recipescam-v25'; // nginx answers all three routes with the same index.html (SPA fallback), so they // are one document under three keys: an offline navigation finds it whichever key diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 562da07..80104ac 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { ExportModal } from './ui/ExportModal'; import { CameraView } from './ui/CameraView'; import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; +import { useIsMobile } from './ui/useIsMobile'; import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps'; @@ -347,6 +348,11 @@ interface Look { export function Workspace() { const { t } = useI18n(); + const [forceView] = useState<'auto' | 'mobile' | 'desktop'>(() => { + return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto'; + }); + const detectedMobile = useIsMobile(860); + const isMobile = forceView === 'mobile' || (forceView === 'auto' && detectedMobile); // Session restore: read once, synchronously, so the first paint already holds // the user's own settings instead of a flash of the defaults. The photo comes @@ -3728,7 +3734,7 @@ export function Workspace() { ) : null} -
+
{/* The tab opens its own strip in the bottom bar's place; the bar holds one strip at a time, so a pick both switches the tab and hands the bar over to it (see .strip-open in app.css). */} @@ -3747,7 +3753,7 @@ export function Workspace() {
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
void saveRecipe()} > - {t('save.recent')} + + {t('save.recent')} -

{t('save.recentHint')}

+

{t('save.recentHint')}

{saved.length === 0 ? ( -

{t('save.recentEmpty')}

+

{t('save.recentEmpty')}

) : (
{saved.map((entry) => (
{/* Only this account can delete its own file — the API scopes the delete by user, the button mirrors it. */}
))} @@ -4037,6 +4047,8 @@ export function Workspace() { }, })), ]} + list + glyph="◉" /> ) : (

{t('myp.historyEmpty')}

diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 9e59cc8..de5acfd 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react'; import { useI18n } from './i18n/I18nProvider'; import { go } from './nav'; -import { api } from './api'; +import { api, type User } from './api'; import { refreshAuth, useAuth } from './auth'; import { keep, recall } from './remember'; import { readGps, readSpecs, specsLine } from './engine/imageOps'; @@ -71,6 +71,7 @@ import { type ImmichServer, } from './engine/immich'; import { ImmichDialog } from './ui/ImmichDialog'; +import { MobileLibrary } from './ui/MobileLibrary'; // The catalogue screen, laid out like the admin's picture manager: the tree of // folders and subfolders down the left, the frame that is up in the middle, and @@ -349,11 +350,23 @@ export function Library() { if (away) return null; if (!known) return

{t('auth.busy')}

; - return ; + return ; } -function Catalogue({ admin }: { admin: boolean }) { +import { useIsMobile } from './ui/useIsMobile'; + +function Catalogue({ admin, user }: { admin: boolean; user: User | null }) { const { t } = useI18n(); + const [forceView, setForceViewState] = useState<'auto' | 'mobile' | 'desktop'>(() => { + return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto'; + }); + const setForceView = (v: 'auto' | 'mobile' | 'desktop') => { + setForceViewState(v); + localStorage.setItem('rc_force_view', v); + }; + const detectedMobile = useIsMobile(860); + const isMobile = forceView === 'mobile' || (forceView === 'auto' && detectedMobile); + const [folders, setFolders] = useState(() => cachedFolders()); const [ready, setReady] = useState>(new Set()); // The Immich servers this account has saved, and the two dialogs that write @@ -936,6 +949,74 @@ function Catalogue({ admin }: { admin: boolean }) { [reload, t] ); + // Automatically restore and reconcile cloud folders (Immich Cloud) for registered users. + // When a user adds an Immich server, it is saved on their account. When logging in on any + // device, this automatically restores the cloud folder and syncs its albums and photos. + useEffect(() => { + if (!loaded) return; + let alive = true; + + void (async () => { + if (!servers.length) return; + + const current = await listFolders(); + let nextFolders = [...current]; + let changed = false; + const taken = new Set(nextFolders.map((f) => f.name)); + + for (const server of servers) { + let folder = nextFolders.find( + (f) => f.source === 'immich' && f.server === server.id && f.kind !== 'search' + ); + if (!folder) { + folder = await addImmichFolder(server, taken); + taken.add(folder.name); + nextFolders.push(folder); + changed = true; + void syncServer(folder); + } else if (JSON.stringify(folder.albums ?? []) !== JSON.stringify(server.albums ?? [])) { + folder = { ...folder, albums: server.albums }; + await putFolder(folder); + nextFolders = nextFolders.map((f) => (f.name === folder!.name ? folder! : f)); + changed = true; + void syncServer(folder); + } + } + + // Remove any local immich folders whose server is no longer in servers + const serverIds = new Set(servers.map((s) => s.id)); + const removed = nextFolders.filter( + (f) => f.source === 'immich' && f.server && !serverIds.has(f.server) + ); + if (removed.length > 0) { + for (const r of removed) { + await removeFolder(r.name); + } + nextFolders = nextFolders.filter((f) => !removed.some((rem) => rem.name === f.name)); + changed = true; + } + + if (changed && alive) { + nextFolders.sort(bySourceThenName); + setFolders(nextFolders); + setReady((prev) => { + const ok = new Set(prev); + for (const f of nextFolders) { + if (f.source === 'immich') ok.add(f.name); + } + return ok; + }); + if (!node && nextFolders[0]) { + setNode(nextFolders[0].name); + } + } + })(); + + return () => { + alive = false; + }; + }, [loaded, servers, syncServer, node]); + // A reading asked for here is a request, not a reading: one walk runs at a time, // and a folder asked for while another is being read waits its turn — which is // what makes a right click on four folders mean four readings rather than three @@ -2196,6 +2277,52 @@ function Catalogue({ admin }: { admin: boolean }) {
); + if (isMobile) { + return ( + <> + setAddImmich(true)} + onEditAlbums={(folder) => setEditAlbums(folder)} + onSyncServer={syncServer} + onDisconnectServer={disconnect} + onFindImmich={findImmich} + onSetStar={async (photo, star) => { + await setStar(photo.id, star); + setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, star } : p))); + }} + onRotate={async (photo, dir) => { + const rot = (((photo.rot || 0) + (dir === 'left' ? 270 : 90)) % 360) as 0 | 90 | 180 | 270; + await setRotation(photo.id, rot); + setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, rot } : p))); + }} + onSwitchToDesktop={() => setForceView('desktop')} + /> + {addImmich ? ( + void serverSaved(saved, isNew)} + onClose={() => setAddImmich(false)} + /> + ) : null} + {editAlbums ? ( + s.id === editAlbums.server) ?? null} + onSaved={(saved, isNew) => void serverSaved(saved, isNew)} + onClose={() => setEditAlbums(null)} + /> + ) : null} + + ); + } + return (
@@ -2205,6 +2332,15 @@ function Catalogue({ admin }: { admin: boolean }) { {t('lib.title')} + {t('lib.count', { n: photos.length })} diff --git a/docker/frontend/src/engine/immich.ts b/docker/frontend/src/engine/immich.ts index ec14c7f..27f074f 100644 --- a/docker/frontend/src/engine/immich.ts +++ b/docker/frontend/src/engine/immich.ts @@ -238,84 +238,103 @@ export interface ImmichSyncResult { // fresh while the others are a week old is a column nobody can read a number off. export async function syncImmich(folder: LibraryFolder): Promise { const server = folder.server ?? ''; - // A search node is one query and no albums: the backend reads it as a name or a - // tag, and what it answers with is filed under the node itself — a label of `''` - // is the node's own wall — so the album list is not even asked for. const search = folder.kind === 'search' ? (folder.query ?? '').trim() : ''; - const albums = search ? [] : await listImmichAlbums(server); - const labels = albumLabels(albums); const chosen = folder.albums ?? []; - // An empty tick list is a decision and not a missing value: it means every album - // this key can see (see the plan, the album-ID field). - const wanted: { id: string | null; label: string }[] = search - ? [{ id: null, label: '' }] - : albums - .filter((a) => chosen.length === 0 || chosen.includes(a.id)) - .map((a) => ({ id: a.id, label: labels.get(a.id) ?? a.id.slice(0, 8) })); + const root = normPath(folder.name); const rows: LibraryPhoto[] = []; const dirs: LibraryDir[] = []; const counts: Record = {}; - const root = normPath(folder.name); - for (const album of wanted) { - const label = album.label; - // The names met in this one album. Two files of one name are two files, and - // the id is the name, so the second of them moves rather than disappears. - const taken = new Set(); - let page = 1; - let count = 0; - for (;;) { - const { items, hasMore } = await listAssets(server, album.id, page, search); - for (const item of items) { - if (!item.id || !item.name) continue; - const name = frameName(item.name, item.id, taken); - taken.add(item.name); - rows.push({ - // A search node's frames hang off the node itself, so their path is one - // segment shorter than an album's. - id: [root, label, name].filter(Boolean).join('/'), - folder: folder.name, - dir: label, - name, - server, - remoteId: item.id, - taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), - size: item.size ?? 0, - addedAt: Date.now(), - }); - count++; - } - if (!hasMore || !items.length) break; - page++; + // 1. ALWAYS read ALL photos of the Immich API (filed under root of folder: dir = '') + // This guarantees that all photos (timeline, camera roll, uploads) are fully loaded into the Library. + const rootTaken = new Set(); + let page = 1; + let rootCount = 0; + for (;;) { + const { items, hasMore } = await listAssets(server, null, page, search); + for (const item of items) { + if (!item.id || !item.name) continue; + const name = frameName(item.name, item.id, rootTaken); + rootTaken.add(item.name); + rootTaken.add(name); + rows.push({ + id: [root, name].join('/'), + folder: folder.name, + dir: '', + name, + server, + remoteId: item.id, + taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), + size: item.size ?? 0, + addedAt: Date.now(), + }); + rootCount++; } - // A search node has no row under it: what it answers with is the node's own - // frames, and the row for them is the node. - if (label) { + if (!hasMore || !items.length) break; + page++; + } + counts[root] = rootCount; + + // 2. ALBUMS of Immich ONLY display in the ALBUM section if the user chooses specific albums (chosen.length > 0). + // When chosen is empty, dirs remains empty ([]) so no album subfolders clutter the ALBUM tree. + // When chosen has album IDs, only those specific chosen albums are fetched and populated into dirs. + if (!search && chosen.length > 0) { + const albums = await listImmichAlbums(server); + const labels = albumLabels(albums); + const chosenAlbums = albums.filter((a) => chosen.includes(a.id)); + + for (const album of chosenAlbums) { + const label = labels.get(album.id) ?? album.id.slice(0, 8); + const albumTaken = new Set(); + let albPage = 1; + let albCount = 0; + for (;;) { + const { items, hasMore } = await listAssets(server, album.id, albPage, search); + for (const item of items) { + if (!item.id || !item.name) continue; + const name = frameName(item.name, item.id, albumTaken); + albumTaken.add(item.name); + albumTaken.add(name); + rows.push({ + id: [root, label, name].join('/'), + folder: folder.name, + dir: label, + name, + server, + remoteId: item.id, + taken: item.takenAt ? Date.parse(item.takenAt) || Date.now() : Date.now(), + size: item.size ?? 0, + addedAt: Date.now(), + }); + albCount++; + } + if (!hasMore || !items.length) break; + albPage++; + } dirs.push({ id: `${root}/${label}`, folder: folder.name, rel: label }); - counts[`${root}/${label}`] = count; + counts[`${root}/${label}`] = albCount; } } - counts[root] = rows.length; await saveWalkedDirs(root, dirs, counts, true); // A frame the server no longer lists is not a frame to keep drawing: the row, // its tile and its marks go, exactly as a frame taken off the disk does. This // read saw the whole node, so it is the one allowed to say it. - // Spelled the way `prunePhotos` reads a row back: no leading slash for a frame - // that hangs off the node itself. const met = new Set(rows.map((r) => (r.dir ? `${normPath(r.dir)}/${r.name}` : r.name).toLowerCase())); await prunePhotos(root, '', met); await putPhotos(rows); - // And the tiles, for the frames that have none. A frame whose tile is already - // kept is not asked for again, so the second sync of a roll costs the rows. + // Pre-cache thumbnails for initial display without stalling large libraries const missing = new Set(await missingThumbs(rows.map((r) => r.id))); const queue = rows.filter((r) => missing.has(r.id)); + const initialBatch = queue.slice(0, 80); + const backgroundBatch = queue.slice(80); + await Promise.all( - Array.from({ length: Math.min(TILE_LANES, queue.length) }, async () => { - while (queue.length) { - const row = queue.shift()!; + Array.from({ length: Math.min(TILE_LANES, initialBatch.length) }, async () => { + while (initialBatch.length) { + const row = initialBatch.shift()!; try { const res = await fetch(remoteUrl('thumbnail', row)); if (res.ok) await putPhotoThumb(row.id, await res.blob()); @@ -328,7 +347,20 @@ export async function syncImmich(folder: LibraryFolder): Promise { + while (backgroundBatch.length) { + const row = backgroundBatch.shift()!; + try { + const res = await fetch(remoteUrl('thumbnail', row)); + if (res.ok) await putPhotoThumb(row.id, await res.blob()); + } catch {} + await breathe(); + } + })(); + } + + return { total: rootCount, albums: dirs.length, dirs }; } // The name a server's node takes: the first one is `Immich`, and a second is diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index dd64622..0481ef6 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -417,7 +417,18 @@ export function bySourceThenName(a: LibraryFolder, b: LibraryFolder): number { } export function cachedFolders(): LibraryFolder[] { - return cachedFoldersList ?? []; + if (cachedFoldersList) return cachedFoldersList; + try { + const raw = sessionStorage.getItem('rc_cached_folders'); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + cachedFoldersList = parsed; + return cachedFoldersList; + } + } + } catch {} + return []; } export async function listFolders(): Promise { @@ -425,6 +436,15 @@ export async function listFolders(): Promise { const rows = await ask(FOLDERS, 'readonly', (s) => s.getAll()); const sorted = rows.sort(bySourceThenName); cachedFoldersList = sorted; + try { + const slim = sorted.map((f) => ({ + name: f.name, + label: f.label, + source: f.source, + server: f.server, + })); + sessionStorage.setItem('rc_cached_folders', JSON.stringify(slim)); + } catch {} return sorted; } catch { return cachedFoldersList ?? []; @@ -2027,7 +2047,18 @@ function pump(): void { let cached: LibraryPhoto[] | null = null; export function cachedPhotos(): LibraryPhoto[] { - return cached ?? []; + if (cached) return cached; + try { + const raw = sessionStorage.getItem('rc_cached_photos'); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + cached = parsed; + return cached; + } + } + } catch {} + return []; } export function clearLibraryCache(): void { @@ -2418,7 +2449,24 @@ export async function readPhotos(folder?: string): Promise b.taken - a.taken); - if (!folder) cached = rows; + if (!folder) { + cached = rows; + try { + const slim = rows.slice(0, 300).map((p) => ({ + id: p.id, + folder: p.folder, + name: p.name, + taken: p.taken, + addedAt: p.addedAt, + dir: p.dir, + star: p.star, + server: p.server, + remoteId: p.remoteId, + rot: p.rot, + })); + sessionStorage.setItem('rc_cached_photos', JSON.stringify(slim)); + } catch {} + } return rows; } catch { return null; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 4c2ffa1..c7613a0 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -378,6 +378,30 @@ export const en: Dict = { 'lib.immich.added': 'Read {n} frames from {folder}.', 'lib.immich.hint': 'Right-click to read again, choose albums, disconnect or remove it from the library.', + // Mobile Cloud Library + 'lib.mobile.photos': 'Photos', + 'lib.mobile.search': 'Search', + 'lib.mobile.albums': 'Albums', + 'lib.mobile.library': 'Library', + 'lib.mobile.allAlbums': 'All albums', + 'lib.mobile.allPhotos': 'All photos', + 'lib.mobile.addImmich': 'ADD IMMICH', + 'lib.mobile.addImmichCloud': 'ADD IMMICH CLOUD SERVER', + 'lib.mobile.cloudDesc': 'Mobile browsers do not support local file system management. Cloud library allows you to browse and edit photos directly from your Immich Cloud.', + 'lib.mobile.servers': 'CLOUD SERVERS', + 'lib.mobile.noServers': 'No Immich Cloud servers connected yet.', + 'lib.mobile.noPhotos': 'No photos in this cloud library yet.', + 'lib.mobile.noAlbums': 'No albums found.', + 'lib.mobile.connected': 'Connected', + 'lib.mobile.scroll': 'scroll', + 'lib.mobile.openStudio': 'OPEN IN STUDIO', + 'lib.mobile.preview': 'Preview', + 'lib.mobile.details': 'Details', + 'lib.mobile.rescan': 'RELOAD', + 'lib.mobile.chooseAlbums': 'CHOOSE ALBUMS', + 'lib.mobile.disconnect': 'DISCONNECT', + 'lib.mobile.searchPlaceholder': 'Search on Immich (filename, tag)...', + 'nav.admin': 'Admin', 'nav.photos': 'My photos', 'nav.profile': 'Profile', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 38ecb31..dc33c08 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -390,6 +390,30 @@ export const vi = { 'lib.immich.added': 'Đã đọc {n} ảnh từ {folder}.', 'lib.immich.hint': 'Chuột phải để đọc lại, chọn album, ngắt kết nối hoặc xoá khỏi thư viện.', + // Mobile Cloud Library + 'lib.mobile.photos': 'Ảnh', + 'lib.mobile.search': 'Tìm kiếm', + 'lib.mobile.albums': 'Album', + 'lib.mobile.library': 'Thư viện', + 'lib.mobile.allAlbums': 'Tất cả album', + 'lib.mobile.allPhotos': 'Tất cả ảnh', + 'lib.mobile.addImmich': 'THÊM IMMICH', + 'lib.mobile.addImmichCloud': 'THÊM MÁY CHỦ IMMICH CLOUD', + 'lib.mobile.cloudDesc': 'Trên mobile không hỗ trợ quản lý thư mục máy tính cục bộ. Thư viện cloud giúp bạn mở và chỉnh sửa trực tiếp các kho ảnh Immich Cloud.', + 'lib.mobile.servers': 'MÁY CHỦ CLOUD', + 'lib.mobile.noServers': 'Chưa có máy chủ Immich Cloud nào được kết nối.', + 'lib.mobile.noPhotos': 'Chưa có ảnh nào trong thư mục cloud.', + 'lib.mobile.noAlbums': 'Chưa có album nào.', + 'lib.mobile.connected': 'Đã kết nối', + 'lib.mobile.scroll': 'scroll', + 'lib.mobile.openStudio': 'MỞ TRONG STUDIO', + 'lib.mobile.preview': 'Xem ảnh', + 'lib.mobile.details': 'Chi tiết', + 'lib.mobile.rescan': 'ĐỌC LẠI', + 'lib.mobile.chooseAlbums': 'CHỌN ALBUM', + 'lib.mobile.disconnect': 'NGẮT KẾT NỐI', + 'lib.mobile.searchPlaceholder': 'Tìm kiếm trên Immich (tên ảnh, tag)...', + 'adm.title': 'Quản trị dải phim', 'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.', 'adm.upload': 'THÊM ẢNH', diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index 6a8277c..ef91794 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -4,6 +4,7 @@ import './styles/fonts.css'; import './styles/tokens.css'; import './styles/app.css'; import './styles/landing.css'; +import './styles/mobile-library.css'; import { ThemeProvider } from './theme/ThemeProvider'; import { I18nProvider } from './i18n/I18nProvider'; import { Landing } from './Landing'; diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 36b3a16..b883277 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -272,8 +272,44 @@ beside it — the account's recipe list is the one place a file can be thrown away from the studio. */ .saved-list { display: flex; flex-direction: column; gap: 6px; } -.saved-row { display: flex; gap: 6px; align-items: stretch; } -.saved-row .chip:first-child { flex: 1; justify-content: space-between; text-align: left; } +.saved-row { display: flex; gap: 6px; align-items: center; justify-content: space-between; width: 100%; } +.saved-row .link-chip { + flex: 1; + border: none; + background: none; + border-radius: 0; + padding: 3px 0; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + text-align: left; + font-size: 12px; + color: var(--text); +} +.saved-row .link-chip:hover .chip-label { text-decoration: underline; } +.saved-row .link-chip.on .chip-label, +.saved-row .link-chip[aria-pressed="true"] .chip-label { color: var(--accent); } +.saved-row .link-chip .chip-glyph { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + color: var(--text-dim); +} +.saved-del-btn { + border: none; + background: none; + color: var(--text-dim); + cursor: pointer; + font-size: 11px; + line-height: 1; + padding: 3px 6px; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0.7; +} +.saved-del-btn:hover { color: var(--text); opacity: 1; } .chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } /* A chip painted with the value it picks (a WB preset's cast, the same swatch the ruler paints). The fill and the ink are inline — they ARE the value, not @@ -1826,8 +1862,18 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- .rail button .glyph { display: none; } .rail button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent-line); } + .col, + .col-main, + .col-sub, + .col[data-col="slider"], + .rail { + width: 100% !important; + max-width: 100vw !important; + min-width: 100% !important; + box-sizing: border-box !important; + } + .col-grip { display: none !important; } .col { - width: auto; padding: 8px; gap: 6px; overflow: visible; @@ -1836,7 +1882,7 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- /* One chip per line is the desktop column's shape; a phone wants the row. */ .col .chip-row { flex-direction: row; align-items: center; } .col .chip { text-align: center; } - .col-slider, .col-create { width: auto; } + .col-slider, .col-create { width: 100% !important; } /* --- the tab's own chips: the bottom bar -------------------------------- */ /* A strip reads across, never down: what does not fit scrolls sideways, so @@ -1858,7 +1904,12 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- allowed to shrink would squeeze under its own contents and the chips would paint over the ones beside them. */ .col-main > *, .col-sub:not(.wm-extra) > * { flex: 0 0 auto; } - .col-main:not(.col-create) .chip-row { flex-wrap: nowrap; } + .col-main:not(.col-create) .chip-row, + .col-sub:not(.wm-extra) .chip-row { + flex-wrap: nowrap !important; + min-width: 100% !important; + width: max-content !important; + } .col-main .chip { flex: 0 0 auto; } /* The rule a strip is read across runs the other way here: in a row of chips it divides the line, it does not break it. */ @@ -1949,7 +2000,9 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- .col-sub[data-col="wm"] { border-top: 1px solid var(--border-soft); overflow-y: auto; } .workspace:has(.chips > .col-sub[data-col="options"]) .col-main, .workspace:has(.chips > .col-sub[data-col="wm"]) .col-main, - .workspace:has(.chips > .col-sub[data-col="recipes"]) .col-main { display: none; } + .workspace:has(.chips > .col-sub[data-col="recipes"]) .col-main, + .workspace:has(.chips > .col-sub[data-col="history"]) .col-main, + .workspace:has(.chips > .col-sub[data-col="mask"]) .col-main { display: none; } /* One strip at a time holds here too: the ruler a knob opens is the last strip of the walk, so it takes the place of the strip that opened it. The @@ -2250,3 +2303,167 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- color: #fff; font-size: 12px; } + +/* --- Mobile Studio & Force Mobile View Overrides ------------------------- */ +@media (max-width: 860px) { + .save-hint { display: none !important; } + .btn-save-action { flex: 0 0 auto !important; white-space: nowrap !important; } + .col-main:has(.saved-list) { + flex-direction: row !important; + align-items: center !important; + gap: 10px !important; + width: 100% !important; + max-width: 100vw !important; + overflow-x: auto !important; + overflow-y: hidden !important; + flex-wrap: nowrap !important; + } + .saved-list { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 12px !important; + overflow-x: auto !important; + overflow-y: hidden !important; + flex-wrap: nowrap !important; + flex: 0 0 auto !important; + } + .saved-row { + display: inline-flex !important; + align-items: center !important; + gap: 4px !important; + flex: 0 0 auto !important; + white-space: nowrap !important; + width: auto !important; + } + .saved-row .link-chip { + padding: 6px 4px !important; + white-space: nowrap !important; + } +} + +.workspace.is-mobile { + flex-direction: column !important; +} +.workspace.is-mobile:not(.strip-open) .chips { + display: none !important; +} +.workspace.is-mobile.strip-open .rail { + display: none !important; +} +.workspace.is-mobile .stage-slot { + order: 0 !important; + min-height: 0 !important; +} +.workspace.is-mobile .col[data-col="slider"] { + order: 1 !important; +} +.workspace.is-mobile .col-sub { + order: 2 !important; +} +.workspace.is-mobile .col-main { + order: 3 !important; +} +.workspace.is-mobile .rail { + order: 4 !important; + width: 100% !important; + max-width: 100vw !important; + flex-direction: row !important; + overflow-x: auto !important; + overflow-y: hidden !important; +} +.workspace.is-mobile .chips { + display: contents !important; +} +.workspace.is-mobile .col, +.workspace.is-mobile .col-main, +.workspace.is-mobile .col-sub, +.workspace.is-mobile .col[data-col="slider"], +.workspace.is-mobile .col-create, +.workspace.is-mobile .col-slider { + width: 100% !important; + max-width: 100vw !important; + min-width: 100% !important; + box-sizing: border-box !important; + border-right: none !important; +} +.workspace.is-mobile .col-main { + flex-direction: row !important; + align-items: center !important; + gap: 6px !important; + max-height: 46vh !important; + overflow-x: auto !important; + overflow-y: hidden !important; + background: var(--bg) !important; + border-top: 1px solid var(--border-soft) !important; + padding: 6px 8px !important; +} +.workspace.is-mobile .col-main > *, +.workspace.is-mobile .col-sub:not(.wm-extra) > * { + flex: 0 0 auto !important; +} +.workspace.is-mobile .col-main:not(.col-create) .chip-row, +.workspace.is-mobile .col-sub:not(.wm-extra) .chip-row { + min-width: 100% !important; + width: max-content !important; + flex-wrap: nowrap !important; + flex-direction: row !important; + align-items: center !important; +} +.workspace.is-mobile .col-grip { + display: none !important; +} +.workspace.is-mobile .save-hint { + display: none !important; +} +.workspace.is-mobile .btn-save-action { + flex: 0 0 auto !important; + white-space: nowrap !important; +} +.workspace.is-mobile .col-main:has(.saved-list) { + flex-direction: row !important; + align-items: center !important; + gap: 10px !important; + width: 100% !important; + max-width: 100vw !important; + overflow-x: auto !important; + overflow-y: hidden !important; + flex-wrap: nowrap !important; +} +.workspace.is-mobile .saved-list { + display: flex !important; + flex-direction: row !important; + align-items: center !important; + gap: 12px !important; + overflow-x: auto !important; + overflow-y: hidden !important; + flex-wrap: nowrap !important; + flex: 0 0 auto !important; +} +.workspace.is-mobile .saved-row { + display: inline-flex !important; + align-items: center !important; + gap: 4px !important; + flex: 0 0 auto !important; + white-space: nowrap !important; + width: auto !important; +} +.workspace.is-mobile .saved-row .link-chip { + padding: 6px 4px !important; + white-space: nowrap !important; +} +.workspace.is-mobile .col-sub[data-col="options"], +.workspace.is-mobile .col-sub[data-col="wm"], +.workspace.is-mobile .col-sub[data-col="recipes"] { + order: 3 !important; + background: var(--bg) !important; + max-height: 46vh !important; +} +.workspace.is-mobile:has(.chips > .col-sub[data-col="options"]) .col-main, +.workspace.is-mobile:has(.chips > .col-sub[data-col="wm"]) .col-main, +.workspace.is-mobile:has(.chips > .col-sub[data-col="recipes"]) .col-main, +.workspace.is-mobile:has(.chips > .col-sub[data-col="history"]) .col-main, +.workspace.is-mobile:has(.chips > .col-sub[data-col="mask"]) .col-main { + display: none !important; +} + diff --git a/docker/frontend/src/styles/mobile-library.css b/docker/frontend/src/styles/mobile-library.css new file mode 100644 index 0000000..384b064 --- /dev/null +++ b/docker/frontend/src/styles/mobile-library.css @@ -0,0 +1,1554 @@ +/* Mobile Cloud Library (Immich Cloud) Styles */ + +.mob-lib-root { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: #101014; + color: #f4f4f5; + font-family: var(--sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif); + overflow: hidden; + position: relative; + user-select: none; + -webkit-user-select: none; +} + +/* --- Top Header Bar --- */ +.mob-lib-header { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + padding: max(10px, env(safe-area-inset-top)) 16px 10px; + background: rgba(16, 16, 20, 0.88); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.mob-lib-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.mob-lib-brand { + display: flex; + align-items: center; + gap: 7px; + text-decoration: none; + color: #ffffff; +} + +.mob-lib-brand-img { + width: 25px; + height: 25px; + border-radius: 6px; + flex-shrink: 0; + display: block; +} + +.mob-lib-logo-svg { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.mob-lib-brand-text { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.02em; + color: #ffffff; + line-height: 1; +} + +.mob-lib-brand-text em { + font-style: normal; + font-weight: 500; + color: #ff5a36; +} + +.mob-lib-header-title { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.55); + padding-left: 8px; + border-left: 1px solid rgba(255, 255, 255, 0.15); + line-height: 1.2; +} + +.mob-lib-header-folder-info { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 170px; + padding-left: 8px; + border-left: 1px solid rgba(255, 255, 255, 0.15); +} + +.mob-lib-header-folder-icon { + font-size: 14px; + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +.mob-lib-header-folder-name { + font-size: 13px; + font-weight: 600; + color: #f4f4f5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mob-lib-folder-brand { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + overflow: hidden; +} + +.mob-lib-folder-brand-text { + font-size: 14px; + font-weight: 700; + color: #f4f4f5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Subbar when Immich Cloud or Folder is open --- */ +.mob-lib-immich-subbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: rgba(22, 22, 28, 0.94); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; + z-index: 25; +} + +.mob-lib-immich-subbar-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; +} + +.mob-lib-immich-brand { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.mob-lib-immich-brand .mob-lib-logo-svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.mob-lib-immich-text { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.02em; + color: #ffffff; + line-height: 1; +} + +.mob-lib-immich-pill { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 6px; + border-radius: 999px; + background: rgba(66, 133, 244, 0.18); + color: #60a5fa; + border: 1px solid rgba(66, 133, 244, 0.35); + line-height: 1.4; +} + +.mob-lib-immich-album-name { + font-size: 13px; + font-weight: 600; + color: #e4e4e7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mob-lib-immich-subbar-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.mob-lib-immich-count { + font-size: 11px; + color: #a1a1aa; +} + +.mob-lib-header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.mob-lib-status-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: #d4d4d8; + cursor: pointer; + padding: 4px; + border-radius: 50%; + transition: opacity 0.2s; +} + +.mob-lib-status-btn:active { + opacity: 0.6; +} + +.mob-lib-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + overflow: hidden; + border: 1.5px solid rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + background: #27272a; +} + +.mob-lib-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.mob-lib-studio-link { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: #f4f4f5; + font-size: 11px; + font-weight: 600; + text-decoration: none; + letter-spacing: 0.02em; + transition: background 0.2s; +} + +.mob-lib-studio-link:active { + background: rgba(255, 255, 255, 0.2); +} + +/* --- Main Content Area --- */ +.mob-lib-content { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + padding-bottom: calc(72px + env(safe-area-inset-bottom)); + position: relative; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.mob-lib-content::-webkit-scrollbar { + display: none; +} + +/* --- Timeline / Date Grouping --- */ +.mob-lib-date-group { + margin-bottom: 12px; +} + +.mob-lib-date-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 12px 8px; + font-size: 16px; + font-weight: 600; + color: #f4f4f5; + letter-spacing: -0.01em; +} + +.mob-lib-date-check { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1.5px solid rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + color: transparent; + cursor: pointer; + background: transparent; + padding: 0; + transition: all 0.2s; +} + +.mob-lib-date-check.checked { + background: var(--accent, #f59e0b); + border-color: var(--accent, #f59e0b); + color: #000; +} + +/* --- 3-Column Photo Grid --- */ +.mob-lib-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2px; +} + +.mob-lib-thumb-cell { + position: relative; + aspect-ratio: 1; + background: #1c1c22; + overflow: hidden; + cursor: pointer; + touch-action: manipulation; + content-visibility: auto; + contain-intrinsic-size: 110px 110px; +} + +.mob-lib-thumb-cell img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: opacity 0.2s; +} + +.mob-lib-thumb-cell:active img { + opacity: 0.8; +} + +.mob-lib-cloud-badge { + position: absolute; + bottom: 4px; + right: 4px; + width: 16px; + height: 16px; + color: #ffffff; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.85)); + pointer-events: none; + opacity: 0.95; +} + +.mob-lib-edited-badge { + position: absolute; + top: 4px; + left: 4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent, #f59e0b); + box-shadow: 0 0 4px rgba(0, 0, 0, 0.8); + pointer-events: none; +} + +.mob-lib-star-badge { + position: absolute; + top: 4px; + right: 4px; + display: flex; + align-items: center; + gap: 1px; + background: rgba(0, 0, 0, 0.6); + padding: 1px 4px; + border-radius: 4px; + font-size: 10px; + color: #fbbf24; + backdrop-filter: blur(4px); + pointer-events: none; +} + +/* --- Floating Fast Scroll Indicator replacing side scrollbar --- */ +.mob-lib-scroll-handle { + position: fixed; + right: 6px; + z-index: 45; + width: 28px; + height: 44px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: rgba(28, 30, 38, 0.92); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); + color: #ffffff; + cursor: grab; + user-select: none; + -webkit-user-select: none; + touch-action: none; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease, transform 0.15s ease; +} + +.mob-lib-scroll-handle.visible { + opacity: 1; + pointer-events: auto; +} + +.mob-lib-scroll-handle:active { + cursor: grabbing; + transform: scale(1.08); + background: rgba(45, 48, 62, 0.98); + border-color: rgba(255, 255, 255, 0.4); +} + +.mob-lib-scroll-arrow { + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + color: #f4f4f5; + opacity: 0.9; +} + +/* --- Search View --- */ +.mob-lib-search-pane { + padding: 12px; +} + +.mob-lib-search-input-wrap { + position: relative; + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.mob-lib-search-icon { + position: absolute; + left: 12px; + color: #71717a; + pointer-events: none; +} + +.mob-lib-search-input { + width: 100%; + padding: 10px 36px 10px 38px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: #1c1c22; + color: #ffffff; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.mob-lib-search-input:focus { + border-color: var(--accent, #f59e0b); +} + +.mob-lib-search-clear { + position: absolute; + right: 12px; + background: transparent; + border: none; + color: #71717a; + cursor: pointer; + padding: 4px; + display: flex; +} + +.mob-lib-chips-row { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 12px; + scrollbar-width: none; +} + +.mob-lib-chips-row::-webkit-scrollbar { + display: none; +} + +.mob-lib-chip { + flex: 0 0 auto; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.12); + background: #1c1c22; + color: #d4d4d8; + cursor: pointer; +} + +.mob-lib-chip.active { + background: var(--accent, #f59e0b); + color: #000000; + border-color: var(--accent, #f59e0b); + font-weight: 600; +} + +/* --- Sticky Search & Filter Bar neo ở top của UI Preview --- */ +.mob-lib-sticky-search { + position: sticky; + top: 0; + z-index: 30; + background: rgba(16, 16, 20, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.mob-lib-search-close-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #e4e4e7; + font-size: 13px; + font-weight: 600; + width: 34px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + white-space: nowrap; + transition: background 0.2s; + flex-shrink: 0; + padding: 0; +} + +.mob-lib-search-close-btn:active { + background: rgba(255, 255, 255, 0.2); +} + +/* --- Search Top Row with Input, View Mode Toggle, and Close --- */ +.mob-lib-search-top-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.mob-lib-view-toggle { + display: inline-flex; + align-items: center; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 2px; + flex-shrink: 0; + gap: 2px; +} + +.mob-lib-view-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 6px; + border: none; + background: transparent; + color: #a1a1aa; + cursor: pointer; + transition: all 0.15s ease; + padding: 0; +} + +.mob-lib-view-btn:active { + transform: scale(0.95); +} + +.mob-lib-view-btn.active { + background: rgba(255, 255, 255, 0.22); + color: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); +} + +/* --- Filter Scope Row (Toggle between All Immich Photos and Current Album) --- */ +.mob-lib-filter-scope-row { + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; + padding: 2px 0; +} + +.mob-lib-filter-scope-row::-webkit-scrollbar { + display: none; +} + +.mob-lib-filter-scope-label { + font-size: 11px; + font-weight: 600; + color: #a1a1aa; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; + margin-right: 2px; +} + +.mob-lib-scope-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 500; + color: #a1a1aa; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.mob-lib-scope-pill:active { + transform: scale(0.96); +} + +.mob-lib-scope-pill.active { + background: rgba(59, 130, 246, 0.2); + border-color: rgba(59, 130, 246, 0.6); + color: #60a5fa; + font-weight: 600; + box-shadow: 0 1px 4px rgba(59, 130, 246, 0.2); +} + +/* --- Dropbox Row --- */ +.mob-lib-dropbox-row { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.mob-lib-dropbox-row::-webkit-scrollbar { + display: none; +} + +.mob-lib-dropbox { + position: relative; + display: inline-flex; + align-items: center; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 0 8px; + height: 32px; + font-size: 12px; + color: #e4e4e7; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.mob-lib-dropbox.active, +.mob-lib-dropbox-btn.active { + background: rgba(245, 158, 11, 0.18); + border-color: rgba(245, 158, 11, 0.6); + color: #fbbf24; + font-weight: 600; +} + +.mob-lib-dropbox-btn { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 0 10px; + height: 32px; + font-size: 12px; + color: #e4e4e7; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.mob-lib-dropbox-btn.open { + border-color: rgba(96, 165, 250, 0.7); + background: rgba(59, 130, 246, 0.18); + color: #93c5fd; +} + +.mob-lib-dropbox-select { + appearance: none; + -webkit-appearance: none; + background: transparent; + border: none; + color: inherit; + font-size: 12px; + font-weight: inherit; + padding: 0 12px 0 0; + cursor: pointer; + outline: none; + height: 100%; +} + +.mob-lib-dropbox-select option { + background: #1c1c24; + color: #ffffff; +} + +.mob-lib-dropbox-caret { + font-size: 10px; + opacity: 0.7; + margin-left: 2px; + pointer-events: none; +} + +/* --- Popover Card for Year/Month/Date & Time --- */ +.mob-lib-popover-card { + margin-top: 4px; + background: #18181f; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + animation: mobLibFadeDown 0.18s ease-out; +} + +@keyframes mobLibFadeDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.mob-lib-popover-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mob-lib-popover-title { + font-size: 12px; + font-weight: 700; + color: #d4d4d8; + letter-spacing: 0.02em; +} + +.mob-lib-popover-reset-btn { + background: transparent; + border: none; + color: #ef4444; + font-size: 11px; + font-weight: 600; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; +} + +.mob-lib-popover-reset-btn:active { + background: rgba(239, 68, 68, 0.15); +} + +.mob-lib-popover-grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.mob-lib-popover-grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.mob-lib-popover-col { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mob-lib-popover-label { + font-size: 10px; + font-weight: 600; + color: #a1a1aa; + text-transform: uppercase; +} + +.mob-lib-popover-select { + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.14); + color: #ffffff; + border-radius: 8px; + padding: 6px 8px; + font-size: 12px; + outline: none; +} + +.mob-lib-popover-select option { + background: #1c1c24; + color: #ffffff; +} + +.mob-lib-popover-footer { + display: flex; + justify-content: flex-end; +} + +.mob-lib-popover-done-btn { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #ffffff; + font-size: 11px; + font-weight: 600; + padding: 5px 14px; + border-radius: 6px; + cursor: pointer; +} + +.mob-lib-popover-done-btn:active { + background: rgba(255, 255, 255, 0.24); +} + +/* --- Single Photo Mode (1 large card per row) --- */ +.mob-lib-single-list { + display: flex; + flex-direction: column; + gap: 16px; + padding: 12px 14px; +} + +.mob-lib-single-card { + background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease; + content-visibility: auto; + contain-intrinsic-size: 320px 280px; +} + +.mob-lib-single-card:active { + transform: scale(0.99); + border-color: rgba(255, 255, 255, 0.2); +} + +.mob-lib-single-img-wrap { + position: relative; + width: 100%; + aspect-ratio: 4 / 3; + background: #09090b; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.mob-lib-single-img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.mob-lib-single-badges { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 6px; + z-index: 2; +} + +.mob-lib-single-info { + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 4px; + background: rgba(18, 18, 22, 0.7); +} + +.mob-lib-single-name { + font-size: 13px; + font-weight: 600; + color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mob-lib-single-date { + font-size: 11px; + color: #a1a1aa; +} + +/* --- Album Folder Tree Column (Desktop style on Mobile) --- */ +.mob-lib-tree-pane { + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 100%; +} + +.mob-lib-tree-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; +} + +.mob-lib-tree-toolbar-title { + font-size: 13px; + font-weight: 700; + color: #f4f4f5; +} + +.mob-lib-tree-list { + display: flex; + flex-direction: column; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; + overflow: hidden; +} + +.mob-lib-tree-row { + display: flex; + align-items: center; + gap: 8px; + padding: 11px 12px; + color: #d4d4d8; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background 0.15s, color 0.15s; +} + +.mob-lib-tree-row:last-child { + border-bottom: none; +} + +.mob-lib-tree-row:active { + background: rgba(255, 255, 255, 0.08); +} + +.mob-lib-tree-row.selected { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.mob-lib-tree-caret { + display: inline-block; + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid rgba(255, 255, 255, 0.6); + transition: transform 0.15s; + flex-shrink: 0; + cursor: pointer; + padding: 4px; + background-clip: content-box; +} + +.mob-lib-tree-caret.open { + transform: rotate(90deg); +} + +.mob-lib-tree-caret.leaf { + border-left-color: transparent; + cursor: default; +} + +.mob-lib-tree-icon { + font-size: 15px; + flex-shrink: 0; +} + +.mob-lib-tree-label { + flex: 1 1 auto; + font-size: 13.5px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.mob-lib-tree-count { + font-size: 11px; + font-family: var(--mono, ui-monospace, monospace); + color: #71717a; + background: rgba(255, 255, 255, 0.06); + padding: 2px 7px; + border-radius: 999px; + flex-shrink: 0; +} + +.mob-lib-tree-more-btn { + background: transparent; + border: none; + color: #a1a1aa; + font-size: 18px; + padding: 4px 8px; + cursor: pointer; + border-radius: 6px; + flex-shrink: 0; + line-height: 1; +} + +.mob-lib-tree-more-btn:active { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} + +/* --- Context Menu Backdrop & Panel --- */ +.mob-lib-menu-backdrop { + position: fixed; + inset: 0; + z-index: 60; + background: rgba(0, 0, 0, 0.45); +} + +.mob-lib-context-menu { + position: fixed; + z-index: 61; + min-width: 200px; + padding: 6px; + background: #1c1c24; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8); + display: flex; + flex-direction: column; + gap: 2px; + animation: mobMenuPop 0.15s ease-out; +} + +@keyframes mobMenuPop { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.mob-lib-context-header { + padding: 6px 10px 6px; + font-size: 11px; + font-weight: 700; + color: #a1a1aa; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Albums View --- */ +.mob-lib-albums-pane { + padding: 12px; +} + +.mob-lib-albums-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.mob-lib-album-card { + display: flex; + flex-direction: column; + background: #18181e; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: transform 0.15s, border-color 0.2s; +} + +.mob-lib-album-card:active { + transform: scale(0.98); + border-color: var(--accent, #f59e0b); +} + +.mob-lib-album-cover { + width: 100%; + aspect-ratio: 1; + background: #23232b; + position: relative; + overflow: hidden; +} + +.mob-lib-album-cover img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.mob-lib-album-cover-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #71717a; +} + +.mob-lib-album-info { + padding: 10px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.mob-lib-album-title { + font-size: 14px; + font-weight: 600; + color: #f4f4f5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mob-lib-album-count { + font-size: 12px; + color: #a1a1aa; +} + +/* --- Library / Cloud Management View --- */ +.mob-lib-manage-pane { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.mob-lib-card { + background: #18181e; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.mob-lib-card-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mob-lib-card-title { + font-size: 15px; + font-weight: 600; + color: #f4f4f5; + display: flex; + align-items: center; + gap: 8px; +} + +.mob-lib-badge-connected { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + color: #34d399; + background: rgba(52, 211, 153, 0.12); + padding: 2px 8px; + border-radius: 999px; +} + +.mob-lib-badge-connected::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: #34d399; +} + +.mob-lib-card-meta { + font-size: 12px; + color: #a1a1aa; + display: flex; + flex-direction: column; + gap: 4px; +} + +.mob-lib-card-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; +} + +.mob-lib-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + border: 1px solid rgba(255, 255, 255, 0.12); + background: #23232b; + color: #f4f4f5; + cursor: pointer; + transition: all 0.2s; +} + +.mob-lib-btn:active { + transform: scale(0.97); +} + +.mob-lib-btn.primary { + background: var(--accent, #f59e0b); + color: #000000; + border-color: var(--accent, #f59e0b); +} + +.mob-lib-btn.danger { + background: rgba(239, 68, 68, 0.15); + color: #f87171; + border-color: rgba(239, 68, 68, 0.3); +} + +.mob-lib-add-immich-banner { + background: linear-gradient(135deg, rgba(66, 133, 244, 0.15), rgba(52, 211, 153, 0.15)); + border: 1px solid rgba(66, 133, 244, 0.3); + border-radius: 14px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + text-align: center; +} + +.mob-lib-add-immich-btn { + width: 100%; + padding: 12px 18px; + border-radius: 10px; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.02em; + background: #2563eb; + color: #ffffff; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4); +} + +.mob-lib-add-immich-btn:active { + transform: scale(0.98); +} + +/* --- Empty States --- */ +.mob-lib-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + gap: 14px; + color: #a1a1aa; +} + +.mob-lib-empty-icon { + width: 48px; + height: 48px; + color: #52525b; +} + +.mob-lib-empty-title { + font-size: 16px; + font-weight: 600; + color: #e4e4e7; +} + +.mob-lib-empty-desc { + font-size: 13px; + line-height: 1.5; + max-width: 320px; +} + +/* --- Bottom Navigation Bar --- */ +.mob-lib-tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 40; + height: calc(60px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: #18181b; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: space-around; + user-select: none; +} + +.mob-lib-tab-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.55); + cursor: pointer; + padding: 6px 0; + text-decoration: none; + transition: color 0.15s; +} + +.mob-lib-tab-pill { + padding: 4px 18px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.mob-lib-tab-item.active .mob-lib-tab-pill { + background: rgba(255, 255, 255, 0.14); +} + +.mob-lib-tab-item.active { + color: #ffffff; +} + +.mob-lib-tab-label { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.01em; + line-height: 1; +} + +.mob-lib-tab-item.active .mob-lib-tab-label { + font-weight: 600; + color: #ffffff; +} + +/* --- Fullscreen Photo Preview Modal --- */ +.mob-lib-preview-modal { + position: fixed; + inset: 0; + z-index: 60; + background: #09090b; + display: flex; + flex-direction: column; + overflow: hidden; + animation: mobLibFadeIn 0.2s ease-out; +} + +@keyframes mobLibFadeIn { + from { opacity: 0; transform: scale(0.98); } + to { opacity: 1; transform: scale(1); } +} + +.mob-lib-preview-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: max(10px, env(safe-area-inset-top)) 16px 10px; + background: rgba(9, 9, 11, 0.85); + backdrop-filter: blur(16px); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; + z-index: 10; +} + +.mob-lib-preview-title { + font-size: 14px; + font-weight: 600; + color: #f4f4f5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 60vw; + text-align: center; +} + +.mob-lib-preview-body { + flex: 1 1 auto; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: #000000; +} + +.mob-lib-preview-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + user-select: none; +} + +.mob-lib-preview-footer { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 16px max(14px, env(safe-area-inset-bottom)); + background: rgba(9, 9, 11, 0.9); + backdrop-filter: blur(16px); + border-top: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; + z-index: 10; +} + +.mob-lib-preview-tools { + display: flex; + align-items: center; + justify-content: space-between; +} + +.mob-lib-preview-stars { + display: flex; + gap: 4px; +} + +.mob-lib-star-btn { + background: transparent; + border: none; + font-size: 20px; + color: rgba(255, 255, 255, 0.25); + cursor: pointer; + padding: 4px; + line-height: 1; +} + +.mob-lib-star-btn.active { + color: #fbbf24; +} + +.mob-lib-preview-rot-btns { + display: flex; + gap: 6px; +} + +.mob-lib-open-studio-btn { + width: 100%; + padding: 12px; + border-radius: 999px; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.03em; + background: var(--accent, #f59e0b); + color: #000000; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + box-shadow: 0 4px 16px rgba(245, 158, 11, 0.35); +} + +.mob-lib-open-studio-btn:active { + transform: scale(0.98); +} + +/* --- Photo Metadata (Desktop style, displayed above Open in Studio) --- */ +.mob-lib-preview-meta { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11.5px; + color: #a1a1aa; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + padding: 9px 12px; + min-width: 0; + max-height: 140px; + overflow-y: auto; + line-height: 1.4; +} + +.mob-lib-meta-name { + color: #f4f4f5; + font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace); + font-size: 12.5px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mob-lib-meta-line { + display: flex; + align-items: center; + gap: 6px; + overflow-wrap: anywhere; +} + +.mob-lib-meta-place { + color: #60a5fa; +} + +.mob-lib-meta-specs { + color: #e4e4e7; + font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace); + font-size: 11px; +} diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 02350ec..12fe6ea 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -146,6 +146,11 @@ export function ColGrip({ col, min = 110, max = 560 }: { col: string; min?: numb const ref = useRef(null); const key = `studio-col-${col}`; useEffect(() => { + if (window.innerWidth <= 860 || localStorage.getItem('rc_force_view') === 'mobile') { + const box = ref.current?.parentElement; + if (box && box.style.width) box.style.width = ''; + return; + } const box = ref.current?.parentElement; const kept = recall(key); if (box && kept) box.style.width = kept; diff --git a/docker/frontend/src/ui/MobileLibrary.tsx b/docker/frontend/src/ui/MobileLibrary.tsx new file mode 100644 index 0000000..37c7d9b --- /dev/null +++ b/docker/frontend/src/ui/MobileLibrary.tsx @@ -0,0 +1,2486 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useI18n } from '../i18n/I18nProvider'; +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[]; + photos: LibraryPhoto[]; + dirs: LibraryDir[]; + walked: Record; + edited: Set; + user: User | null; + admin: boolean; + onOpenStudio: (photo: LibraryPhoto) => Promise; + onAddImmich: () => void; + onEditAlbums: (folder: LibraryFolder) => void; + onSyncServer: (folder: LibraryFolder) => Promise; + onDisconnectServer: (folder: LibraryFolder) => Promise; + onFindImmich: (query: string) => Promise; + onSetStar?: (photo: LibraryPhoto, star: number) => Promise; + onRotate?: (photo: LibraryPhoto, dir: 'left' | 'right') => Promise; + onSwitchToDesktop?: () => void; +} + +// Format timestamp into Immich style: "CN, 4 thg 10, 2026" +function formatTimelineDate(timestamp: number, lang: string): string { + const d = new Date(timestamp); + if (lang === 'vi') { + const days = ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7']; + return `${days[d.getDay()]}, ${d.getDate()} thg ${d.getMonth() + 1}, ${d.getFullYear()}`; + } + return d.toLocaleDateString(undefined, { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} + +// In-memory cache for local photo blob URLs +const globalMobileThumbUrls = new Map(); + +function useMobileThumbUrls(photos: LibraryPhoto[]): Record { + const [urls, setUrls] = useState>(() => { + const init: Record = {}; + for (const p of photos) { + if (!p.remoteId && !p.server) { + const cached = globalMobileThumbUrls.get(p.id); + if (cached) init[p.id] = cached; + } + } + return init; + }); + + useEffect(() => { + let alive = true; + const localPhotos = photos.filter((p) => !p.remoteId && !p.server); + if (!localPhotos.length) return; + + const idsToFetch: string[] = []; + const sync: Record = {}; + + for (const p of localPhotos) { + const u = globalMobileThumbUrls.get(p.id); + if (u) { + sync[p.id] = u; + } else { + idsToFetch.push(p.id); + } + } + + if (Object.keys(sync).length > 0) { + setUrls((prev) => ({ ...prev, ...sync })); + } + + if (idsToFetch.length === 0) return; + + void getPhotoThumbs(idsToFetch).then((map) => { + if (!alive) return; + const loaded: Record = {}; + for (const [id, blob] of map.entries()) { + const u = URL.createObjectURL(blob); + globalMobileThumbUrls.set(id, u); + loaded[id] = u; + } + if (Object.keys(loaded).length > 0) { + setUrls((prev) => ({ ...prev, ...loaded })); + } + }); + + return () => { + alive = false; + }; + }, [photos]); + + return urls; +} + +function dateKey(timestamp: number): string { + const d = new Date(timestamp); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export function MobileLibrary({ + folders, + servers, + photos, + dirs, + walked, + edited, + user, + admin, + onOpenStudio, + onAddImmich, + onEditAlbums, + onSyncServer, + onDisconnectServer, + onFindImmich, + onSetStar, + onRotate, + onSwitchToDesktop, +}: MobileLibraryProps) { + const { t, lang } = useI18n(); + const [tab, setTab] = useState('photos'); + const [selectedAlbum, setSelectedAlbum] = useState(null); + const [selectedPhoto, setSelectedPhoto] = useState(null); + const [shot, setShot] = useState<{ line: string; lat: number | null; lng: number | null; place: string | null } | null>(null); + + // Read photo EXIF specifications and GPS (similar to Desktop UI) + useEffect(() => { + if (!selectedPhoto) { + setShot(null); + return; + } + let live = true; + (async () => { + try { + let head: Uint8Array | null = null; + if (selectedPhoto.remoteId) { + // Fast path: try cached preview image bytes first + const prevRes = await fetch(remoteUrl('preview', selectedPhoto)).catch(() => null); + if (prevRes && prevRes.ok) { + const buf = await prevRes.arrayBuffer(); + const prevHead = new Uint8Array(buf.slice(0, 131072)); + const specs = await readSpecs(prevHead); + if (specs && (specs.camera || specs.aperture || specs.exposure || specs.iso)) { + head = prevHead; + } + } + if (!head) { + // Range request for original file if preview had stripped EXIF + const origRes = await fetch(remoteUrl('original', selectedPhoto), { + headers: { Range: 'bytes=0-131071' }, + }).catch(() => null); + if (origRes && origRes.ok) { + const buf = await origRes.arrayBuffer(); + head = new Uint8Array(buf.slice(0, 131072)); + } + } + } else { + const file = await readPhotoFile(selectedPhoto).catch(() => null); + if (file) { + head = new Uint8Array(await file.slice(0, 131072).arrayBuffer()); + } + } + + if (head) { + const [specs, gps] = await Promise.all([readSpecs(head), readGps(head)]); + const curShot = { + line: specsLine(specs), + lat: gps?.latitude ?? null, + lng: gps?.longitude ?? null, + place: null as string | null, + }; + if (live) setShot(curShot); + + if (curShot.lat !== null && curShot.lng !== null) { + void api + .place(curShot.lat, curShot.lng) + .then(({ place }) => { + if (!place) return; + curShot.place = place; + if (live) setShot({ ...curShot }); + }) + .catch(() => {}); + } + } else { + if (live) setShot(null); + } + } catch { + if (live) setShot(null); + } + })(); + return () => { + live = false; + }; + }, [selectedPhoto]); + + const coords = useMemo(() => { + return shot?.lat !== null && shot?.lat !== undefined && shot?.lng !== null && shot?.lng !== undefined + ? `${shot.lat.toFixed(4)}, ${shot.lng.toFixed(4)}` + : null; + }, [shot]); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState<'grid' | 'single'>('grid'); + const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest'); + const [starFilter, setStarFilter] = useState(0); + const [selectedYear, setSelectedYear] = useState('all'); + const [selectedMonth, setSelectedMonth] = useState('all'); + const [selectedDay, setSelectedDay] = useState('all'); + const [timeFrom, setTimeFrom] = useState('any'); + const [timeTo, setTimeTo] = useState('any'); + const [activeDropdown, setActiveDropdown] = useState<'date' | 'time' | null>(null); + const [syncingServerId, setSyncingServerId] = useState(null); + const [showSearch, setShowSearch] = useState(false); + const [filterScope, setFilterScope] = useState<'all' | 'album'>('all'); + + const HOURS = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []); + const isDateFiltered = selectedYear !== 'all' || selectedMonth !== 'all' || selectedDay !== 'all'; + const isTimeFiltered = timeFrom !== 'any' || timeTo !== 'any'; + + const scrollContainerRef = useRef(null); + const searchInputRef = useRef(null); + const albumSearchInputRef = useRef(null); + + const handleToggleSearch = useCallback(() => { + setShowSearch((prev) => { + const next = !prev; + if (next) { + setTimeout(() => { + if (tab === 'albums') albumSearchInputRef.current?.focus(); + else searchInputRef.current?.focus(); + }, 120); + } + return next; + }); + }, [tab]); + + // Helper to normalize directory paths + const normPath = useCallback((p: string) => { + return (p || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''); + }, []); + + // Cloud photos: filter photos that belong to Immich, Google Drive, or Remote Cloud + const isCloudPhoto = useCallback((p: LibraryPhoto) => { + if (p.server || p.remoteId) return true; + const lowerFolder = (p.folder || '').toLowerCase(); + return ( + lowerFolder.includes('immich') || + lowerFolder.includes('drive') || + lowerFolder.includes('cloud') || + lowerFolder.includes('google') + ); + }, []); + + const cloudPhotos = useMemo(() => { + return photos.filter(isCloudPhoto); + }, [photos, isCloudPhoto]); + + // Available cloud folders + const cloudFolders = useMemo(() => { + return folders.filter( + (f) => + f.source === 'immich' || + f.server || + f.name.toLowerCase().includes('immich') || + f.name.toLowerCase().includes('drive') || + f.name.toLowerCase().includes('cloud') || + f.name.toLowerCase().includes('google') + ); + }, [folders]); + + // Displayed photos in Photos tab (filtered by album, search query, stars, year/month/day, time, sort) + const displayedPhotos = useMemo(() => { + let list = photos; + if (showSearch && filterScope === 'all') { + // Khi đang bật bộ lọc & chọn phạm vi Tất cả ảnh: Lọc trên toàn bộ ảnh Cloud của Immich (hoặc toàn bộ photos) + list = selectedAlbum === '__all__' ? photos : (cloudPhotos.length > 0 ? cloudPhotos : photos); + } else if (selectedAlbum === '__all__') { + list = photos; + } else if (selectedAlbum) { + const lowerSel = normPath(selectedAlbum).toLowerCase(); + list = list.filter((p) => { + const pFolder = normPath(p.folder || '').toLowerCase(); + const pDir = normPath(p.dir || '').toLowerCase(); + const pFull = pDir ? `${pFolder}/${pDir}` : pFolder; + const pId = normPath(p.id || '').toLowerCase(); + return ( + pFull === lowerSel || + pFull.startsWith(`${lowerSel}/`) || + pFolder === lowerSel || + pDir === lowerSel || + pId.startsWith(`${lowerSel}/`) + ); + }); + } else { + // Mặc định mở LIBRARY trên mobile: load ảnh từ CLOUD được thêm vào LIBRARY của người dùng (IMMICH, GOOGLE DRIVE...) + if (cloudPhotos.length > 0) { + list = cloudPhotos; + } + } + + if (showSearch) { + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase().trim(); + list = list.filter((p) => p.name.toLowerCase().includes(q) || (p.dir && p.dir.toLowerCase().includes(q))); + } + if (starFilter > 0) { + list = list.filter((p) => (p.star || 0) >= starFilter); + } + if (selectedYear !== 'all') { + const yr = Number(selectedYear); + list = list.filter((p) => new Date(p.taken || 0).getFullYear() === yr); + } + if (selectedMonth !== 'all') { + const mo = Number(selectedMonth); + list = list.filter((p) => new Date(p.taken || 0).getMonth() + 1 === mo); + } + if (selectedDay !== 'all') { + const dy = Number(selectedDay); + list = list.filter((p) => new Date(p.taken || 0).getDate() === dy); + } + if (timeFrom !== 'any' && timeTo !== 'any') { + const f = Number(timeFrom); + const tNum = Number(timeTo); + list = list.filter((p) => { + const h = new Date(p.taken || 0).getHours(); + return f <= tNum ? (h >= f && h <= tNum) : (h >= f || h <= tNum); + }); + } else if (timeFrom !== 'any') { + const f = Number(timeFrom); + list = list.filter((p) => new Date(p.taken || 0).getHours() >= f); + } else if (timeTo !== 'any') { + const tNum = Number(timeTo); + list = list.filter((p) => new Date(p.taken || 0).getHours() <= tNum); + } + } + + if (sortOrder === 'oldest') { + return [...list].sort((a, b) => (a.taken || 0) - (b.taken || 0)); + } + return [...list].sort((a, b) => (b.taken || 0) - (a.taken || 0)); + }, [ + photos, + cloudPhotos, + selectedAlbum, + showSearch, + filterScope, + searchQuery, + starFilter, + selectedYear, + selectedMonth, + selectedDay, + timeFrom, + timeTo, + sortOrder, + normPath, + ]); + + // --- Folder Tree Column (Desktop style for Album tab) --- + interface TreeNode { + key: string; + label: string; + depth: number; + count: number; + } + + const baseTreeInfo = useMemo(() => { + const hits = new Map(); + const foldersUnder = new Map>(); + const add = (folder: string, rel: string, counted: boolean) => { + const normF = normPath(folder); + const normRel = normPath(rel); + const lowerF = normF.toLowerCase(); + const set = foldersUnder.get(lowerF) ?? new Set(); + foldersUnder.set(lowerF, set); + let path = ''; + for (const part of normRel.split('/')) { + if (!part) continue; + path = path ? `${path}/${part}` : part; + set.add(path); + if (counted) { + const key = `${lowerF}/${path.toLowerCase()}`; + hits.set(key, (hits.get(key) ?? 0) + 1); + } + } + }; + for (const photo of photos) { + const normF = (photo.folder || '').replace(/\\/g, '/'); + const normDir = (photo.dir || '').replace(/\\/g, '/'); + hits.set(normF.toLowerCase(), (hits.get(normF.toLowerCase()) ?? 0) + 1); + if (normDir) add(normF, normDir, true); + } + for (const dir of dirs) add(dir.folder, dir.rel, false); + return { hits, foldersUnder }; + }, [photos, dirs, normPath]); + + const treeNodes = useMemo(() => { + const { hits, foldersUnder } = baseTreeInfo; + const counted = (key: string) => { + const lowerKey = normPath(key).toLowerCase(); + const walkVal = walked[lowerKey]; + if (walkVal !== undefined) return walkVal; + return hits.get(lowerKey) ?? 0; + }; + const out: TreeNode[] = []; + for (const folder of folders) { + const normF = normPath(folder.name); + const lowerF = normF.toLowerCase(); + out.push({ + key: normF, + label: folder.label || folder.name, + depth: 0, + count: counted(normF), + }); + const subdirs = [...(foldersUnder.get(lowerF) ?? [])].sort((a, b) => a.localeCompare(b)); + for (const dir of subdirs) { + const parts = dir.split('/'); + const key = `${normF}/${dir}`; + out.push({ + key, + label: parts[parts.length - 1], + depth: parts.length, + count: counted(key), + }); + } + } + return out; + }, [baseTreeInfo, folders, walked, normPath]); + + const treeParents = useMemo( + () => new Set(treeNodes.filter((n) => n.depth > 0).map((n) => n.key.slice(0, n.key.lastIndexOf('/')))), + [treeNodes] + ); + + const [expandedFolders, setExpandedFolders] = useState>(() => { + const set = new Set(); + for (const f of folders) set.add(normPath(f.name)); + return set; + }); + + const treeRows = useMemo(() => { + if (tab === 'albums' && showSearch && searchQuery.trim()) { + const q = searchQuery.toLowerCase().trim(); + return treeNodes.filter((n) => n.label.toLowerCase().includes(q) || n.key.toLowerCase().includes(q)); + } + return treeNodes.filter((n) => { + const parts = n.key.split('/'); + for (let i = 1; i < parts.length; i++) { + if (!expandedFolders.has(parts.slice(0, i).join('/'))) return false; + } + return true; + }); + }, [treeNodes, expandedFolders, tab, showSearch, searchQuery]); + + const [contextMenu, setContextMenu] = useState<{ + folder: LibraryFolder | null; + node: TreeNode; + x: number; + y: number; + } | null>(null); + + // Selected folder entity + const selectedFolder = useMemo(() => { + if (!selectedAlbum) return null; + const lowerSel = normPath(selectedAlbum).toLowerCase(); + return ( + folders.find((f) => { + const fNorm = normPath(f.name).toLowerCase(); + return lowerSel === fNorm || lowerSel.startsWith(`${fNorm}/`); + }) ?? null + ); + }, [selectedAlbum, folders, normPath]); + + // Whether the selected album/folder belongs to Immich Cloud / Remote Cloud + const isSelectedImmich = Boolean( + (selectedAlbum === null && cloudPhotos.length > 0) || + selectedFolder?.source === 'immich' || + selectedFolder?.server || + (selectedAlbum && selectedAlbum !== '__all__' && ( + selectedAlbum.toLowerCase().includes('immich') || + selectedAlbum.toLowerCase().includes('drive') || + selectedAlbum.toLowerCase().includes('cloud') || + selectedAlbum.toLowerCase().includes('google') + )) + ); + + // Label of the selected album/folder + const selectedAlbumLabel = useMemo(() => { + if (selectedAlbum === '__all__') return t('lib.mobile.allPhotos'); + if (!selectedAlbum) { + if (cloudPhotos.length > 0) return 'Cloud Library'; + return null; + } + const lowerSel = normPath(selectedAlbum).toLowerCase(); + const node = treeNodes.find((n) => normPath(n.key).toLowerCase() === lowerSel); + if (node) return node.label; + if (selectedFolder?.label) return selectedFolder.label; + const lastPart = selectedAlbum.split('/').pop(); + return lastPart || selectedAlbum; + }, [selectedAlbum, cloudPhotos.length, treeNodes, selectedFolder, normPath, t]); + + // Progressive Render Limit (prevents DOM freeze with thousands of photos) + const [renderLimit, setRenderLimit] = useState(80); + + // Reset limit when filter, album, or view criteria change + useEffect(() => { + setRenderLimit(80); + }, [ + selectedAlbum, + tab, + searchQuery, + starFilter, + sortOrder, + selectedYear, + selectedMonth, + selectedDay, + timeFrom, + timeTo, + viewMode, + ]); + + const visiblePhotos = useMemo(() => { + return displayedPhotos.slice(0, renderLimit); + }, [displayedPhotos, renderLimit]); + + const localThumbUrls = useMobileThumbUrls(visiblePhotos); + + const getThumbUrl = useCallback( + (photo: LibraryPhoto) => { + if (photo.remoteId || photo.server) { + return remoteUrl('thumbnail', photo); + } + return localThumbUrls[photo.id] || ''; + }, + [localThumbUrls] + ); + + // Group photos by date based on visiblePhotos for progressive rendering + const dateGroups = useMemo(() => { + const groups: { key: string; label: string; photos: LibraryPhoto[] }[] = []; + const map = new Map(); + + for (const photo of visiblePhotos) { + const key = dateKey(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: formatTimelineDate(firstTimestamp, lang), + photos: items, + }); + } + + return groups; + }, [visiblePhotos, lang]); + + // 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, + ]); + + // Available years from photos + const availableYears = useMemo(() => { + const years = new Set(); + const pool = cloudPhotos.length ? cloudPhotos : photos; + for (const p of pool) { + if (p.taken) years.add(new Date(p.taken).getFullYear()); + } + return Array.from(years).sort((a, b) => b - a); + }, [cloudPhotos, photos]); + + // Floating fast-scroll handle states replacing native scrollbar + const [scrollVisible, setScrollVisible] = useState(false); + const [scrollProgress, setScrollProgress] = useState(0); + const hideTimerRef = useRef(null); + const isDraggingScrollRef = useRef(false); + + const handleContainerScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + const maxScroll = el.scrollHeight - el.clientHeight; + if (maxScroll > 10) { + const ratio = Math.max(0, Math.min(1, el.scrollTop / maxScroll)); + setScrollProgress(ratio); + setScrollVisible(true); + } + + if (hideTimerRef.current) { + window.clearTimeout(hideTimerRef.current); + } + hideTimerRef.current = window.setTimeout(() => { + if (!isDraggingScrollRef.current) { + setScrollVisible(false); + } + }, 1800); + + // Kích hoạt nạp thêm ảnh khi cuộn gần tới đáy (< 500px) + if (el.scrollHeight - el.scrollTop - el.clientHeight < 500) { + setRenderLimit((prev) => (prev < displayedPhotos.length ? Math.min(displayedPhotos.length, prev + 60) : prev)); + } + }, [displayedPhotos.length]); + + useEffect(() => { + return () => { + if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); + }; + }, []); + + const handlePointerDown = (e: React.PointerEvent) => { + isDraggingScrollRef.current = true; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + setScrollVisible(true); + if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDraggingScrollRef.current) return; + const el = scrollContainerRef.current; + if (!el) return; + const minY = 65; + const maxY = Math.max(minY + 40, window.innerHeight - 120); + const currentY = Math.max(minY, Math.min(maxY, e.clientY)); + const ratio = (currentY - minY) / (maxY - minY); + const maxScroll = el.scrollHeight - el.clientHeight; + el.scrollTop = ratio * maxScroll; + setScrollProgress(ratio); + setScrollVisible(true); + + // Sync renderLimit with fast-scroll handle position + const needed = Math.floor(ratio * displayedPhotos.length) + 60; + setRenderLimit((prev) => Math.max(prev, Math.min(displayedPhotos.length, needed))); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!isDraggingScrollRef.current) return; + isDraggingScrollRef.current = false; + try { + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + } catch {} + if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = window.setTimeout(() => { + setScrollVisible(false); + }, 1800); + }; + + // Calculate top offset for scroll handle based on scrollProgress + const handleTop = useMemo(() => { + if (typeof window === 'undefined') return 80; + const minY = 65; + const maxY = Math.max(minY + 40, window.innerHeight - 120); + return Math.round(minY + scrollProgress * (maxY - minY)); + }, [scrollProgress]); + + // Handle fast scroll + const handleScrollToTop = () => { + scrollContainerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handleScrollToBottom = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + top: scrollContainerRef.current.scrollHeight, + behavior: 'smooth', + }); + } + }; + + // Sync server handler + const handleSyncServer = async (folder: LibraryFolder) => { + if (!folder.server) return; + setSyncingServerId(folder.server); + try { + await onSyncServer(folder); + } finally { + setSyncingServerId(null); + } + }; + + return ( +
+ {/* --- Top Header Bar --- */} + {/* --- Top Header Bar (Website Icon & Title: RecipesCam + Thư viện / Tên Album) --- */} +
+
+ + RecipesCam + + RecipesCam + + + + {/* Nếu đang xem một folder/album cụ thể, hiển thị biểu tượng và tên trên topbar */} + {selectedAlbum ? ( +
+ + {isSelectedImmich ? ( + + + + + + + ) : ( + '📁' + )} + + + {selectedAlbumLabel} + +
+ ) : ( + {t('lib.title')} + )} +
+ +
+ {/* User Profile Avatar */} +
+ {user?.avatar ? ( + User + ) : ( + + {(user?.email || 'U').charAt(0).toUpperCase()} + + )} +
+ + {/* Search Button in Header */} + + + {/* Studio Link */} + + {t('nav.studio')} + + + {onSwitchToDesktop ? ( + + ) : null} +
+
+ + {/* --- Sub-bar khi mở Folder hoặc Immich / Cloud --- */} + {tab !== 'library' && (selectedAlbum || cloudPhotos.length > 0 || servers.length > 0) && ( +
+
+ {selectedAlbum ? ( + + ) : null} + + {isSelectedImmich || (!selectedAlbum && (cloudPhotos.length > 0 || servers.length > 0)) ? ( + /* Immich Brand */ +
+ + + + + + + immich + Cloud +
+ ) : ( + /* Local Folder Brand */ +
+ 📁 + {selectedAlbumLabel} +
+ )} + + {isSelectedImmich && selectedAlbum && selectedAlbum !== '__all__' ? ( + + / {selectedAlbumLabel} + + ) : null} +
+ +
+ + {displayedPhotos.length} {t('lib.mobile.photos').toLowerCase()} + + + {/* Search Button in Subbar */} + + + {/* Cloud Upload / Sync Status Icon (nếu Immich) */} + {cloudFolders.length > 0 && (isSelectedImmich || !selectedAlbum) ? ( + + ) : null} +
+
+ )} + + {/* --- Main Content Area --- */} +
+ {/* --- Tab 1: "Ảnh" (Photos Timeline / UI Preview) --- */} + {tab === 'photos' && ( + <> + {/* Sticky Search & Filter Bar neo ở top của UI Preview */} + {showSearch && ( +
+ {/* Hàng 1: Input tìm kiếm + Chế độ SINGLE/ALL THUMBS (chỉ biểu tượng) + Nút đóng */} +
+
+ + + + + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && searchQuery.trim()) { + void onFindImmich(searchQuery); + } + }} + /> + {searchQuery ? ( + + ) : null} +
+ + {/* Chế độ SINGLE / ALL THUMBS (chỉ biểu tượng) */} +
+ + +
+ + {/* Nút đóng tìm kiếm */} + +
+ + {/* Hàng chọn phạm vi lọc nếu đang trong một Album/Thư mục cụ thể */} + {selectedAlbum && selectedAlbum !== '__all__' && ( +
+ Lọc trong: + + +
+ )} + + {/* Hàng 2: Nhóm các nút cùng thuộc tính vào các dropbox */} +
+ {/* Dropbox 1: new first / old first */} +
+ + ▾ +
+ + {/* Dropbox 2: star: None, 1-5 */} +
0 ? 'active' : ''}`}> + + ▾ +
+ + {/* Dropbox 3: Year/Month/Date */} + + + {/* Dropbox 4: Time from:... to:.... */} + +
+ + {/* Popover Card: Year/Month/Date */} + {activeDropdown === 'date' && ( +
+
+ 📅 Lọc theo Năm / Tháng / Ngày + {isDateFiltered && ( + + )} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ )} + + {/* Popover Card: Time from:... to:.... */} + {activeDropdown === 'time' && ( +
+
+ 🕒 Khung giờ chụp trong ngày + {isTimeFiltered && ( + + )} +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ )} +
+ )} + + + {displayedPhotos.length === 0 ? ( +
+ + + +
+ {servers.length === 0 + ? t('lib.mobile.noServers') + : t('lib.mobile.noPhotos')} +
+
+ {t('lib.mobile.cloudDesc')} +
+ +
+ ) : viewMode === 'single' ? ( +
+ {visiblePhotos.map((photo) => { + const thumbSrc = getThumbUrl(photo); + const previewSrc = photo.remoteId || photo.server ? remoteUrl('preview', photo) : thumbSrc; + return ( +
setSelectedPhoto(photo)} + > +
+ {photo.name} { + if (thumbSrc && (e.currentTarget as HTMLImageElement).src !== thumbSrc) { + (e.currentTarget as HTMLImageElement).src = thumbSrc; + } + }} + /> +
+ + + + {photo.star ? ( +
+ ★ + {photo.star} +
+ ) : null} + {edited.has(photo.id) ? ( +
+ ) : null} +
+
+
+
{photo.name}
+
+ {new Date(photo.taken).toLocaleDateString()} · {new Date(photo.taken).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {photo.dir ? ` · ${photo.dir}` : ''} +
+
+
+ ); + })} +
+ ) : ( + <> + {dateGroups.map((group) => ( +
+
+ {group.label} + +
+ +
+ {group.photos.map((photo) => { + const thumbSrc = getThumbUrl(photo); + return ( +
setSelectedPhoto(photo)} + > + {photo.name} { + // Fallback if thumb failed to load + (e.currentTarget as HTMLElement).style.opacity = '0.3'; + }} + /> + + {/* Cloud Outline Badge (matches Immich mobile app!) */} + + + + + {/* Edited Indicator */} + {edited.has(photo.id) ? ( +
+ ) : null} + + {/* Star Rating Badge */} + {photo.star ? ( +
+ ★ + {photo.star} +
+ ) : null} +
+ ); + })} +
+
+ ))} + + {/* Progressive load more hint */} + {renderLimit < displayedPhotos.length && ( +
+ Đang hiển thị {visiblePhotos.length} / {displayedPhotos.length} ảnh... +
+ )} + + )} + + )} + + {/* --- Tab 2: "Tìm kiếm" (Search & Filter) --- */} + {tab === 'search' && ( +
+
+
+ + + + + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && searchQuery.trim()) { + void onFindImmich(searchQuery); + } + }} + /> + {searchQuery ? ( + + ) : null} +
+ + {/* Chế độ SINGLE / ALL THUMBS (chỉ biểu tượng) */} +
+ + +
+
+ + {/* Dropboxes */} +
+
+ + ▾ +
+ +
0 ? 'active' : ''}`}> + + ▾ +
+ + + + +
+ + {/* Popover Card for Year/Month/Date */} + {activeDropdown === 'date' && ( +
+
+ 📅 Lọc theo Năm / Tháng / Ngày + {isDateFiltered && ( + + )} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ )} + + {/* Popover Card for Time */} + {activeDropdown === 'time' && ( +
+
+ 🕒 Khung giờ chụp + {isTimeFiltered && ( + + )} +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ )} + + {/* Search Results Summary */} +
+ {t('lib.count', { n: searchResults.length })} +
+ + {/* Results Grid or Single List */} + {viewMode === 'single' ? ( +
+ {searchResults.map((photo) => { + const previewSrc = remoteUrl('preview', photo); + const thumbSrc = remoteUrl('thumbnail', photo); + return ( +
setSelectedPhoto(photo)} + > +
+ {photo.name} { + (e.currentTarget as HTMLImageElement).src = thumbSrc; + }} + /> +
+ + + + {photo.star ? ( +
+ ★ + {photo.star} +
+ ) : null} + {edited.has(photo.id) ? ( +
+ ) : null} +
+
+
+
{photo.name}
+
+ {new Date(photo.taken).toLocaleDateString()} · {new Date(photo.taken).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {photo.dir ? ` · ${photo.dir}` : ''} +
+
+
+ ); + })} +
+ ) : ( +
+ {searchResults.map((photo) => ( +
setSelectedPhoto(photo)} + > + {photo.name} + + + + {photo.star ? ( +
+ ★ + {photo.star} +
+ ) : null} +
+ ))} +
+ )} +
+ )} + + {/* --- Tab 3: "Album" (Cây thư mục tương tự giao diện desktop) --- */} + {tab === 'albums' && ( +
+ {/* Search Bar sticky khi đang mở tìm kiếm trong Album/Folder */} + {showSearch && ( +
+
+ + + + + setSearchQuery(e.target.value)} + style={{ + background: 'transparent', + border: 'none', + color: '#ffffff', + fontSize: '13px', + width: '100%', + outline: 'none', + }} + /> + {searchQuery ? ( + + ) : null} +
+ +
+ )} + +
+ + 📁 {t('lib.mobile.albums')} ({treeNodes.length}) + +
+ + +
+
+ +
+ {showSearch && searchQuery.trim() && treeRows.length === 0 && ( +
+ Không tìm thấy album hoặc thư mục nào khớp với "{searchQuery}" +
+ )} + + {/* Cloud Photos option (khi có cloudPhotos) */} + {(!showSearch || !searchQuery.trim()) && cloudPhotos.length > 0 && ( +
{ + setSelectedAlbum(null); + setTab('photos'); + }} + > + + ☁️ + Ảnh Cloud (Immich / Drive) + {cloudPhotos.length} +
+ )} + + {/* Tất cả ảnh option (cả ổ đĩa và cloud) */} + {(!showSearch || !searchQuery.trim()) && ( +
{ + setSelectedAlbum('__all__'); + setTab('photos'); + }} + > + + 🖼️ + {t('lib.mobile.allPhotos')} + {photos.length} +
+ )} + + {treeRows.map((n) => { + const folder = folders.find((f) => { + const fNorm = normPath(f.name); + return n.key === fNorm || n.key.startsWith(`${fNorm}/`); + }) ?? folders[0] ?? null; + const under = treeParents.has(n.key); + const isSelected = selectedAlbum === n.key; + + return ( +
{ + if (under && (!showSearch || !searchQuery.trim())) { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(n.key)) next.delete(n.key); + else next.add(n.key); + return next; + }); + } + if (showSearch) { + setShowSearch(false); + setSearchQuery(''); + } + setSelectedAlbum(n.key); + setTab('photos'); + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + folder, + node: n, + x: Math.min(e.clientX, window.innerWidth - 210), + y: Math.min(e.clientY, window.innerHeight - 250), + }); + }} + > + {/* Expand/Collapse Caret */} + { + e.stopPropagation(); + if (under) { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(n.key)) next.delete(n.key); + else next.add(n.key); + return next; + }); + } + }} + /> + + {/* Folder Icon */} + + {folder?.source === 'immich' ? '☁️' : '📁'} + + + {/* Folder Name */} + + {n.label} + + + {/* Photo Count */} + + {n.count} + + + {/* 3-dots Context Menu Button for touch screens */} + +
+ ); + })} +
+
+ )} + + {/* --- Tab 4: "Thư viện" (Library / Cloud Management) --- */} + {tab === 'library' && ( +
+
+ + + + + + +
+
+ IMMICH CLOUD +
+
+ {t('lib.mobile.cloudDesc')} +
+
+ +
+ + {/* Connected Servers List */} +
+ {t('lib.mobile.servers')} +
+ + {servers.length === 0 ? ( +
+ {t('lib.mobile.noServers')} +
+ ) : ( + servers.map((server) => { + const folder = folders.find((f) => f.server === server.id) ?? null; + const isSyncing = syncingServerId === server.id; + + return ( +
+
+
+ + + + + + + {server.name || 'Immich Cloud'} +
+ + {t('lib.mobile.connected')} + +
+ +
+
URL: {server.url}
+ {server.version ?
Version: {server.version}
: null} +
+ Albums: {server.albums?.length ? server.albums.length : 'All'} +
+
+ +
+ + + + + {folder ? ( + + ) : null} + + {folder ? ( + + ) : null} +
+
+ ); + }) + )} +
+ )} +
+ + {/* Floating Scroll Indicator replacing side scrollbar (Auto-hides after idle, no 'scroll' text) */} +
{ + const rect = e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + if (y < rect.height / 2) { + handleScrollToTop(); + } else { + handleScrollToBottom(); + } + }} + > + + + + + + +
+ + {/* --- Fullscreen Photo Preview Modal --- */} + {selectedPhoto && ( +
+
+ +
{selectedPhoto.name}
+ { + e.preventDefault(); + void onOpenStudio(selectedPhoto); + }} + > + {t('nav.studio')} + +
+ +
+ {selectedPhoto.name} +
+ +
+
+ {/* Star Rating */} +
+ {[1, 2, 3, 4, 5].map((st) => ( + + ))} +
+ + {/* Rotate Buttons */} +
+ + +
+
+ + {/* Photo Metadata Info (tương tự Desktop UI .adm-meta, hiển thị phía trên nút Open In Studio) */} +
+ {selectedPhoto.name} + {selectedPhoto.dir || selectedPhoto.folder ? ( +
+ {selectedPhoto.dir || selectedPhoto.folder} +
+ ) : null} +
+ + {new Date(selectedPhoto.taken).toLocaleString()} · {t('lib.size', { mb: (selectedPhoto.size / 1048576).toFixed(1) })} + {edited.has(selectedPhoto.id) ? ` · ${t('lib.edited')}` : ''} + +
+ {shot?.place || coords ? ( +
+ 📍 {shot?.place ?? coords} +
+ ) : null} + {shot?.line ? ( +
+ 📷 {shot.line} +
+ ) : null} +
+ + {/* Primary Open In Studio Button */} + +
+
+ )} + + {/* --- Context Menu for Folder Tree --- */} + {contextMenu && ( +
setContextMenu(null)}> +
e.stopPropagation()} + > +
+ {contextMenu.node.label} +
+ + {/* Collapse all */} + {contextMenu.node.depth === 0 ? ( + + ) : null} + + {/* Open album */} + + + {contextMenu.folder && contextMenu.folder.source === 'immich' ? ( + <> + + {contextMenu.folder.kind !== 'share' ? ( + + ) : null} + + + ) : null} + +
+ + +
+
+ )} + + {/* --- Bottom Navigation Bar (matches attached screenshot!) --- */} + +
+ ); +} diff --git a/docker/frontend/src/ui/useIsMobile.ts b/docker/frontend/src/ui/useIsMobile.ts new file mode 100644 index 0000000..349a9b2 --- /dev/null +++ b/docker/frontend/src/ui/useIsMobile.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +export function useIsMobile(breakpoint = 860): boolean { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false; + return ( + window.innerWidth <= breakpoint || + (typeof window.matchMedia === 'function' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches) || + (typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) + ); + }); + + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = typeof window.matchMedia === 'function' ? window.matchMedia(`(max-width: ${breakpoint}px)`) : null; + const check = () => { + setIsMobile( + window.innerWidth <= breakpoint || + (mq ? mq.matches : false) || + (typeof navigator !== 'undefined' && /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) + ); + }; + + check(); + window.addEventListener('resize', check); + if (mq) { + if (typeof mq.addEventListener === 'function') { + mq.addEventListener('change', check); + } else if (typeof mq.addListener === 'function') { + mq.addListener(check); + } + } + return () => { + window.removeEventListener('resize', check); + if (mq) { + if (typeof mq.removeEventListener === 'function') { + mq.removeEventListener('change', check); + } else if (typeof mq.removeListener === 'function') { + mq.removeListener(check); + } + } + }; + }, [breakpoint]); + + return isMobile; +} diff --git a/docker/recipescam-images.tar.gz b/docker/recipescam-images.tar.gz index 75bbd94..26196d5 100644 Binary files a/docker/recipescam-images.tar.gz and b/docker/recipescam-images.tar.gz differ diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 7212932..4851cbd 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -681fea0885d24f5933dcd069764416987f1a3b5ee6359b30c3590b4abac43a2f recipescam-images.tar.gz +ae8bc8ad046e269bf7b81bde7fe42ec5dd4848e1380b8667fbc9c357ed91dfc9 recipescam-images.tar.gz