295 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|