improve: Immich cloud filter and optimize
This commit is contained in:
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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 @@
|
||||
681fea0885d24f5933dcd069764416987f1a3b5ee6359b30c3590b4abac43a2f recipescam-images.tar.gz
|
||||
ae8bc8ad046e269bf7b81bde7fe42ec5dd4848e1380b8667fbc9c357ed91dfc9 recipescam-images.tar.gz
|
||||
|
||||
Reference in New Issue
Block a user