improve: Immich cloud filter and optimize

This commit is contained in:
2026-10-11 20:18:04 +07:00
parent 87c68ea7bb
commit 6039f2aa4e
16 changed files with 4699 additions and 87 deletions
+34 -7
View File
@@ -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<any>(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),
});
});
+1 -1
View File
@@ -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
+22 -10
View File
@@ -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() {
</div>
) : null}
<div className={`workspace${stripOpen ? ' strip-open' : ''}`}>
<div className={`workspace${isMobile ? ' is-mobile' : ''}${stripOpen ? ' strip-open' : ''}`}>
{/* 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() {
<div className="chips">
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
<div
className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}${
className={`col col-main${tab === 'create' ? ' col-create' : ''}${
tab === 'light' ? ' col-dev' : ''
}`}
data-col="main"
@@ -3773,39 +3779,43 @@ export function Workspace() {
light, FX and frame — as a recipe of this account's own. */}
<button
type="button"
className="btn primary"
className="chip btn-save-action"
data-key="save-recent"
onClick={() => void saveRecipe()}
>
{t('save.recent')}
+ {t('save.recent')}
</button>
<p className="hint">{t('save.recentHint')}</p>
<p className="hint save-hint">{t('save.recentHint')}</p>
{saved.length === 0 ? (
<p className="hint">{t('save.recentEmpty')}</p>
<p className="hint save-hint">{t('save.recentEmpty')}</p>
) : (
<div className="saved-list">
{saved.map((entry) => (
<div className="saved-row" key={entry.id}>
<button
type="button"
className="chip"
className={`chip link-chip${recipe.name === entry.name ? ' on' : ''}`}
data-key={`saved-${entry.id}`}
aria-pressed={recipe.name === entry.name}
onClick={() => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name })}
>
<span className="chip-glyph" aria-hidden="true">◉</span>
<span className="chip-label">{entry.name}</span>
</button>
{/* Only this account can delete its own file — the API
scopes the delete by user, the button mirrors it. */}
<button
type="button"
className="chip"
className="saved-del-btn"
data-key={`saved-del-${entry.id}`}
title={t('save.delete')}
aria-label={t('save.delete')}
onClick={() => void deleteRecipeEntry(entry)}
onClick={(e) => {
e.stopPropagation();
void deleteRecipeEntry(entry);
}}
>
<span className="chip-label" aria-hidden="true">✕</span>
<span aria-hidden="true">✕</span>
</button>
</div>
))}
@@ -4037,6 +4047,8 @@ export function Workspace() {
},
})),
]}
list
glyph="◉"
/>
) : (
<p className="hint">{t('myp.historyEmpty')}</p>
+139 -3
View File
@@ -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 <p className="hint adm-note">{t('auth.busy')}</p>;
return <Catalogue admin={Boolean(user?.admin)} />;
return <Catalogue admin={Boolean(user?.admin)} user={user} />;
}
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<LibraryFolder[]>(() => cachedFolders());
const [ready, setReady] = useState<Set<string>>(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 }) {
</div>
);
if (isMobile) {
return (
<>
<MobileLibrary
folders={folders}
servers={servers}
photos={photos}
dirs={dirs}
walked={walked}
edited={edited}
user={user}
admin={admin}
onOpenStudio={open}
onAddImmich={() => 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 ? (
<ImmichDialog
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setAddImmich(false)}
/>
) : null}
{editAlbums ? (
<ImmichDialog
server={servers.find((s) => s.id === editAlbums.server) ?? null}
onSaved={(saved, isNew) => void serverSaved(saved, isNew)}
onClose={() => setEditAlbums(null)}
/>
) : null}
</>
);
}
return (
<div className="adm lib-root">
<header className="adm-bar">
@@ -2205,6 +2332,15 @@ function Catalogue({ admin }: { admin: boolean }) {
</a>
<b className="adm-title">{t('lib.title')}</b>
<span className="adm-spacer" />
<button
type="button"
className="btn"
title="Xem giao diện Mobile Immich Cloud"
onClick={() => setForceView('mobile')}
style={{ gap: '4px' }}
>
📱 Mobile Cloud
</button>
<span className="hint" data-key="lib-count">
{t('lib.count', { n: photos.length })}
</span>
+88 -56
View File
@@ -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<ImmichSyncResult> {
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<string, number> = {};
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<string>();
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<string>();
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<string>();
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<ImmichSyncResul
})
);
return { total: rows.length, albums: wanted.length, dirs };
if (backgroundBatch.length) {
void (async () => {
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
+51 -3
View File
@@ -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<LibraryFolder[]> {
@@ -425,6 +436,15 @@ export async function listFolders(): Promise<LibraryFolder[]> {
const rows = await ask<LibraryFolder[]>(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<LibraryPhoto[] | null
if (chunk.length < READ_CHUNK) break;
}
rows.sort((a, b) => 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;
+24
View File
@@ -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',
+24
View File
@@ -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',
+1
View File
@@ -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';
+223 -6
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -146,6 +146,11 @@ export function ColGrip({ col, min = 110, max = 560 }: { col: string; min?: numb
const ref = useRef<HTMLDivElement>(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;
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
export function useIsMobile(breakpoint = 860): boolean {
const [isMobile, setIsMobile] = useState<boolean>(() => {
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;
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
681fea0885d24f5933dcd069764416987f1a3b5ee6359b30c3590b4abac43a2f recipescam-images.tar.gz
ae8bc8ad046e269bf7b81bde7fe42ec5dd4848e1380b8667fbc9c357ed91dfc9 recipescam-images.tar.gz