evOo-local/apps/web/src/pages/media/LibraryView.tsx

295 lines
10 KiB
TypeScript

import { Plural, Trans, useLingui } from "@lingui/react/macro";
import type { MediaAsset, MediaCollection, MediaSource } from "@rakazo/contracts";
import { Button } from "@rakazo/ui-web";
import { Plus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { LoadingState } from "../../components/ai/primitives";
import { AssetGrid, useAssetSelection } from "../../components/media/library/AssetGrid";
import { ConnectSourceDialog } from "../../components/media/library/ConnectSourceDialog";
import { CreateProjectDialog } from "../../components/media/library/CreateProjectDialog";
import {
CollectionCard,
FilterChips,
IndexingProgress,
LibraryModeToggle,
LibrarySearch,
} from "../../components/media/library/LibraryChrome";
import { usePhotosSync } from "../../components/media/library/usePhotosSync";
import { VirtualGrid } from "../../components/media/library/VirtualGrid";
import type { LibraryFilter } from "../../lib/media-library";
import { assetQueryFor, hasAssetQuery, INGEST_PENDING_STATES } from "../../lib/media-library";
import { usePolling } from "../../lib/media-poll";
import { rpcErrorMessage, useMediaStudio } from "../../lib/media-studio";
import { rpc } from "../../lib/rpc";
import type { MediaViewProps } from "./nav";
const PAGE = 200;
type Paged<T> = { items: T[]; cursor: string | null; done: boolean; loading: boolean };
const emptyPage = <T,>(): Paged<T> => ({ items: [], cursor: null, done: false, loading: false });
/**
* Cursor-paged list that appends on demand. A new `key` starts over and drops
* pages still in flight for the previous key.
*/
function usePagedList<T>(
key: string,
load: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }>,
enabled: boolean,
) {
const [state, setState] = useState<Paged<T>>(emptyPage);
const [error, setError] = useState<unknown>(null);
const generation = useRef(0);
const busy = useRef(false);
const loadRef = useRef(load);
loadRef.current = load;
const stateRef = useRef(state);
stateRef.current = state;
const more = useCallback(async () => {
const current = stateRef.current;
if (busy.current || current.done) return;
busy.current = true;
const mine = generation.current;
setState((s) => ({ ...s, loading: true }));
try {
const page = await loadRef.current(current.cursor);
if (mine !== generation.current) return;
setError(null);
setState((s) => ({
items: [...s.items, ...page.items],
cursor: page.nextCursor,
done: page.nextCursor === null,
loading: false,
}));
} catch (cause) {
if (mine !== generation.current) return;
setError(cause);
setState((s) => ({ ...s, loading: false, done: true }));
} finally {
if (mine === generation.current) busy.current = false;
}
}, []);
const reset = useCallback(() => {
generation.current += 1;
busy.current = false;
stateRef.current = emptyPage();
setState(emptyPage());
setError(null);
}, []);
useEffect(() => {
void key;
reset();
if (enabled) void more();
}, [key, enabled, reset, more]);
const restart = useCallback(() => {
reset();
void more();
}, [reset, more]);
return { ...state, error, more, reset: restart };
}
export function LibraryView({
navigate,
mode = "sessions",
}: MediaViewProps & { mode?: "sessions" | "themes" }) {
const { t } = useLingui();
const { canLinkFolder } = useMediaStudio();
const [filters, setFilters] = useState<ReadonlySet<LibraryFilter>>(() => new Set());
const [text, setText] = useState("");
const [debouncedText, setDebouncedText] = useState("");
const [sources, setSources] = useState<MediaSource[] | null>(null);
const [pending, setPending] = useState(false);
const [connectOpen, setConnectOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const selection = useAssetSelection();
useEffect(() => {
const timer = setTimeout(() => setDebouncedText(text), 250);
return () => clearTimeout(timer);
}, [text]);
const searching = hasAssetQuery(filters, debouncedText);
const query = assetQueryFor(filters, debouncedText);
const queryKey = JSON.stringify(query);
const kind = mode === "themes" ? "theme" : "session";
const collections = usePagedList<MediaCollection>(
kind,
(cursor) => rpc.media.collections.list({ kind, cursor, limit: PAGE }),
!searching,
);
const assets = usePagedList<MediaAsset>(
queryKey,
(cursor) => rpc.media.assets.search({ ...query, cursor, limit: PAGE }),
searching,
);
const refreshStatus = useCallback(async () => {
const [list, work] = await Promise.all([
rpc.media.sources.list({}),
rpc.media.assets.search({ ingestStates: [...INGEST_PENDING_STATES], cursor: null, limit: 1 }),
]);
setSources(list);
setPending(work.items.length > 0);
}, []);
const photos = usePhotosSync(
useCallback(() => {
void refreshStatus();
collections.reset();
}, [refreshStatus, collections.reset]),
);
useEffect(() => {
void refreshStatus().catch(() => setSources([]));
}, [refreshStatus]);
const indexing =
photos.syncing || pending || (sources ?? []).some((source) => source.status === "syncing");
usePolling(refreshStatus, indexing);
// Indexing just finished: the grouping changed underneath us.
const wasIndexing = useRef(indexing);
useEffect(() => {
if (wasIndexing.current && !indexing) {
if (searching) assets.reset();
else collections.reset();
}
wasIndexing.current = indexing;
}, [indexing, searching, assets.reset, collections.reset]);
const noSources = sources !== null && sources.length === 0 && !photos.syncing;
const selectedIds = [...selection.selected];
return (
<div data-testid="media-library" className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2 px-4 pt-3 pb-2">
<LibraryModeToggle
mode={mode}
onChange={(next) =>
navigate(next === "people" ? { name: "people" } : { name: "library", mode: next })
}
/>
<LibrarySearch value={text} onChange={setText} />
<FilterChips value={filters} onChange={setFilters} />
<div className="ml-auto flex items-center gap-2">
{selectedIds.length > 0 ? (
<>
<Button variant="ghost" size="sm" onClick={selection.clear}>
<Plural value={selectedIds.length} one="# selected" other="# selected" />
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Trans>Create project</Trans>
</Button>
</>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Sources`}
onClick={() => setConnectOpen(true)}
>
<Plus />
</Button>
</div>
</div>
{indexing ? (
<IndexingProgress
done={photos.progress?.uploadsDone}
total={photos.progress?.uploadsTotal}
/>
) : null}
{sources === null ? (
<div className="grid flex-1 place-items-center">
<LoadingState label={t`Loading`} />
</div>
) : noSources ? (
<div className="grid flex-1 place-items-center px-8">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-ui-body text-muted-foreground">
<Trans>Connect your photos and videos.</Trans>
</p>
<Button data-testid="media-connect-source-open" onClick={() => setConnectOpen(true)}>
<Trans>Connect a source</Trans>
</Button>
</div>
</div>
) : searching ? (
assets.items.length === 0 && !assets.loading ? (
<EmptyMessage error={assets.error}>
<Trans>Nothing matches.</Trans>
</EmptyMessage>
) : (
<AssetGrid
assets={assets.items}
selection={selection}
scrub
onNearEnd={() => void assets.more()}
/>
)
) : collections.items.length === 0 && !collections.loading ? (
indexing ? null : (
<EmptyMessage error={collections.error}>
{mode === "themes" ? <Trans>No themes yet.</Trans> : <Trans>No sessions yet.</Trans>}
</EmptyMessage>
)
) : (
<VirtualGrid
testId="media-collection-grid"
label={mode === "themes" ? t`Themes` : t`Sessions`}
count={collections.items.length}
getKey={(index) => collections.items[index]?.id ?? index}
minTileWidth={220}
gap={16}
tileAspect={10 / 16}
extraHeight={44}
onNearEnd={() => void collections.more()}
renderItem={(index) => {
const collection = collections.items[index];
if (!collection) return null;
return (
<CollectionCard
collection={collection}
onOpen={() => navigate({ name: "collection", collectionId: collection.id })}
/>
);
}}
/>
)}
<ConnectSourceDialog
open={connectOpen}
onOpenChange={setConnectOpen}
sources={sources ?? []}
canLinkFolder={canLinkFolder}
photos={photos}
onChanged={() => void refreshStatus()}
/>
<CreateProjectDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultTitle={debouncedText.trim() || t`New project`}
collectionIds={[]}
assetIds={selectedIds}
onCreated={(projectId) => navigate({ name: "project", projectId })}
/>
</div>
);
}
function EmptyMessage({ error, children }: { error: unknown; children: React.ReactNode }) {
const { t } = useLingui();
return (
<div className="grid flex-1 place-items-center px-8 text-center text-ui-body text-muted-foreground">
{error ? rpcErrorMessage(error, t`Could not load the library.`) : children}
</div>
);
}