feat(media): library view with virtualized grid, filters, sources and studio chrome
This commit is contained in:
parent
159fafdb55
commit
9a3423e458
80
apps/web/src/components/media/library/AssetGrid.tsx
Normal file
80
apps/web/src/components/media/library/AssetGrid.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { MediaAsset } from "@rakazo/contracts";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { AssetTile } from "./AssetTile";
|
||||
import { VirtualGrid } from "./VirtualGrid";
|
||||
|
||||
/** Selected asset ids; shift-click selects the range since the last click. */
|
||||
export function useAssetSelection() {
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const anchor = useRef<number | null>(null);
|
||||
|
||||
const toggle = useCallback((assets: readonly MediaAsset[], index: number, range: boolean) => {
|
||||
const target = assets[index];
|
||||
if (!target) return;
|
||||
const start = anchor.current;
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (range && start !== null) {
|
||||
const [from, to] = [Math.min(start, index), Math.max(start, index)];
|
||||
for (const asset of assets.slice(from, to + 1)) next.add(asset.id);
|
||||
} else if (next.has(target.id)) {
|
||||
next.delete(target.id);
|
||||
} else {
|
||||
next.add(target.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
anchor.current = index;
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
anchor.current = null;
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
return { selected, toggle, clear };
|
||||
}
|
||||
|
||||
export function AssetGrid({
|
||||
assets,
|
||||
selection,
|
||||
scrub = false,
|
||||
onNearEnd,
|
||||
footer,
|
||||
}: {
|
||||
assets: readonly MediaAsset[];
|
||||
selection: ReturnType<typeof useAssetSelection>;
|
||||
scrub?: boolean;
|
||||
onNearEnd?: () => void;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const selecting = selection.selected.size > 0;
|
||||
return (
|
||||
<VirtualGrid
|
||||
testId="media-asset-grid"
|
||||
label={t`Clips`}
|
||||
count={assets.length}
|
||||
getKey={(index) => assets[index]?.id ?? index}
|
||||
minTileWidth={150}
|
||||
tileAspect={9 / 16}
|
||||
onNearEnd={onNearEnd}
|
||||
footer={footer}
|
||||
renderItem={(index) => {
|
||||
const asset = assets[index];
|
||||
if (!asset) return null;
|
||||
return (
|
||||
<AssetTile
|
||||
asset={asset}
|
||||
scrub={scrub}
|
||||
selecting={selecting}
|
||||
selected={selection.selected.has(asset.id)}
|
||||
onToggle={(event) => selection.toggle(assets, index, event.shiftKey)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
155
apps/web/src/components/media/library/AssetTile.tsx
Normal file
155
apps/web/src/components/media/library/AssetTile.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { MediaAsset } from "@rakazo/contracts";
|
||||
import { Checkbox, cn } from "@rakazo/ui-web";
|
||||
import { Image as ImageIcon, Star, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { mediaAssetPath, useMediaObjectUrl } from "../../../lib/media-bytes";
|
||||
import { formatDuration, scrubFraction } from "../../../lib/media-library";
|
||||
|
||||
/** Poster of an asset, or a quiet placeholder until the proxy job made one. */
|
||||
export function AssetThumb({
|
||||
assetId,
|
||||
hasThumbnail,
|
||||
kind,
|
||||
className,
|
||||
}: {
|
||||
assetId: string | null;
|
||||
hasThumbnail: boolean;
|
||||
kind?: MediaAsset["kind"];
|
||||
className?: string;
|
||||
}) {
|
||||
const thumb = useMediaObjectUrl(
|
||||
assetId && hasThumbnail ? mediaAssetPath(assetId, "thumb") : null,
|
||||
{ keep: true },
|
||||
);
|
||||
if (thumb.url) {
|
||||
return (
|
||||
<img
|
||||
src={thumb.url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className={cn("size-full object-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Icon = kind === "photo" || kind === "live_photo" ? ImageIcon : Video;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid size-full place-items-center bg-muted text-muted-foreground/60",
|
||||
thumb.status === "loading" && "animate-pulse",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon strokeWidth={1.6} className="size-icon-lg" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plays the proxy under the pointer: left edge is the first frame, right edge the last. */
|
||||
function Scrubber({ asset, fraction }: { asset: MediaAsset; fraction: number }) {
|
||||
const proxy = useMediaObjectUrl(mediaAssetPath(asset.id, "proxy"));
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const duration = (asset.durationMs ?? 0) / 1000;
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!proxy.url || !video || duration <= 0) return;
|
||||
const target = fraction * duration;
|
||||
if (Math.abs(video.currentTime - target) > 0.04) video.currentTime = target;
|
||||
}, [proxy.url, fraction, duration]);
|
||||
return (
|
||||
<>
|
||||
{proxy.url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={proxy.url}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
onLoadedMetadata={(event) => {
|
||||
event.currentTarget.currentTime = fraction * (event.currentTarget.duration || 0);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-primary-foreground"
|
||||
style={{ width: `${fraction * 100}%` }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetTile({
|
||||
asset,
|
||||
selected,
|
||||
selecting,
|
||||
onToggle,
|
||||
scrub = false,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
selected: boolean;
|
||||
/** Show checkboxes on every tile, not only on hover. */
|
||||
selecting: boolean;
|
||||
onToggle: (event: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
scrub?: boolean;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const [fraction, setFraction] = useState<number | null>(null);
|
||||
const canScrub = scrub && asset.kind !== "photo" && asset.hasProxy && (asset.durationMs ?? 0) > 0;
|
||||
const duration = asset.kind === "photo" ? "" : formatDuration(asset.durationMs);
|
||||
const captured = asset.capturedAt ? new Date(asset.capturedAt).toLocaleString() : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="media-asset-tile"
|
||||
data-asset-id={asset.id}
|
||||
aria-pressed={selected}
|
||||
aria-label={captured || t`Clip`}
|
||||
onClick={onToggle}
|
||||
onPointerMove={
|
||||
canScrub
|
||||
? (event) => {
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
setFraction(scrubFraction(event.clientX, box.left, box.width));
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerLeave={canScrub ? () => setFraction(null) : undefined}
|
||||
className={cn(
|
||||
"group/tile relative size-full overflow-hidden rounded-lg bg-muted outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected && "ring-2 ring-primary",
|
||||
)}
|
||||
>
|
||||
<AssetThumb assetId={asset.id} hasThumbnail={asset.hasThumbnail} kind={asset.kind} />
|
||||
{canScrub && fraction !== null ? <Scrubber asset={asset} fraction={fraction} /> : null}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-1.5 top-1.5 transition-opacity",
|
||||
selecting || selected ? "opacity-100" : "opacity-0 group-hover/tile:opacity-100",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none bg-background/80"
|
||||
/>
|
||||
</span>
|
||||
{asset.isFavorite ? (
|
||||
<Star
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
className="absolute right-1.5 top-1.5 size-icon-sm text-primary-foreground drop-shadow"
|
||||
/>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-foreground/70 px-1 font-mono text-ui-caption tabular-nums text-background">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/components/media/library/ConfirmDialog.tsx
Normal file
57
apps/web/src/components/media/library/ConfirmDialog.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@rakazo/ui-web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
action: ReactNode;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onCancel();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans>Cancel</Trans>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
onClick={() => onConfirm()}
|
||||
>
|
||||
{action}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
248
apps/web/src/components/media/library/ConnectSourceDialog.tsx
Normal file
248
apps/web/src/components/media/library/ConnectSourceDialog.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { MediaSource } from "@rakazo/contracts";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Field,
|
||||
FieldLabel,
|
||||
Input,
|
||||
} from "@rakazo/ui-web";
|
||||
import { FolderOpen, Images, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { desktopBridge } from "../../../lib/desktop";
|
||||
import { rpcErrorMessage } from "../../../lib/media-studio";
|
||||
import { rpc } from "../../../lib/rpc";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import type { usePhotosSync } from "./usePhotosSync";
|
||||
|
||||
const SERVER_SYNCED = new Set<MediaSource["kind"]>(["folder", "emulator"]);
|
||||
|
||||
export function ConnectSourceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sources,
|
||||
canLinkFolder,
|
||||
photos,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sources: readonly MediaSource[];
|
||||
canLinkFolder: boolean;
|
||||
photos: ReturnType<typeof usePhotosSync>;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const labelId = useId();
|
||||
const pathId = useId();
|
||||
const [label, setLabel] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<MediaSource | null>(null);
|
||||
const picker = desktopBridge()?.files?.pickDirectory;
|
||||
const nothingToConnect = !canLinkFolder && !photos.available;
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
onChanged();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
setError(rpcErrorMessage(cause, t`That did not work. Try again.`));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const connectFolder = async () => {
|
||||
const ok = await run(() =>
|
||||
rpc.media.sources.createFolder({
|
||||
label: label.trim() || folderName(path) || t`Folder`,
|
||||
path: path.trim(),
|
||||
}),
|
||||
);
|
||||
if (ok) {
|
||||
setLabel("");
|
||||
setPath("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="media-connect-source" className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sources</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{sources.length > 0 ? (
|
||||
<ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
|
||||
{sources.map((source) => (
|
||||
<li key={source.id} className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-ui-body-sm font-medium" dir="auto">
|
||||
{source.label}
|
||||
</div>
|
||||
<div className="truncate text-ui-caption text-muted-foreground">
|
||||
{source.status === "error" && source.error ? (
|
||||
<span className="text-destructive">{source.error}</span>
|
||||
) : (
|
||||
<Trans>{source.assetCount} items</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{SERVER_SYNCED.has(source.kind) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Sync`}
|
||||
disabled={busy || source.status === "syncing"}
|
||||
onClick={() => void run(() => rpc.media.sources.sync({ sourceId: source.id }))}
|
||||
>
|
||||
<RefreshCw className={source.status === "syncing" ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Remove ${source.label}`}
|
||||
disabled={busy}
|
||||
onClick={() => setRemoving(source)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{photos.available ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
disabled={photos.syncing || photos.denied}
|
||||
onClick={() => {
|
||||
void photos.sync();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Images aria-hidden="true" />
|
||||
<Trans>Apple Photos</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
{photos.denied ? (
|
||||
<p className="text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Allow evOo to access Photos in System Settings.</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canLinkFolder ? (
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void connectFolder();
|
||||
}}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={pathId}>
|
||||
<Trans>Folder</Trans>
|
||||
</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={pathId}
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
placeholder={t`/Users/you/Movies`}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{picker ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label={t`Choose folder`}
|
||||
onClick={() => {
|
||||
void picker()
|
||||
.then((picked) => {
|
||||
if (picked) setPath(picked);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<FolderOpen aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
{path.trim() ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={labelId}>
|
||||
<Trans>Name</Trans>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={labelId}
|
||||
value={label}
|
||||
maxLength={60}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={folderName(path)}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={busy || !path.trim()}>
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{nothingToConnect && sources.length === 0 ? (
|
||||
<p className="text-ui-body-sm text-muted-foreground">
|
||||
<Trans>Only the owner of this evOo can connect a folder.</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{error || photos.error ? (
|
||||
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||
{error ?? photos.error}
|
||||
</p>
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
open={removing !== null}
|
||||
destructive
|
||||
title={<Trans>Remove {removing?.label ?? ""}?</Trans>}
|
||||
description={
|
||||
<Trans>Its clips leave the library. Your original files stay untouched.</Trans>
|
||||
}
|
||||
action={<Trans>Remove</Trans>}
|
||||
onCancel={() => setRemoving(null)}
|
||||
onConfirm={() => {
|
||||
const target = removing;
|
||||
setRemoving(null);
|
||||
if (target) void run(() => rpc.media.sources.remove({ sourceId: target.id }));
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function folderName(path: string): string {
|
||||
return (
|
||||
path
|
||||
.trim()
|
||||
.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.at(-1) ?? ""
|
||||
);
|
||||
}
|
||||
203
apps/web/src/components/media/library/CreateProjectDialog.tsx
Normal file
203
apps/web/src/components/media/library/CreateProjectDialog.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { MediaProjectGoal, PlatformProfileSummary } from "@rakazo/contracts";
|
||||
import { MediaProjectGoalSchema } from "@rakazo/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
Input,
|
||||
NativeSelect,
|
||||
NativeSelectOption,
|
||||
} from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { rpcErrorMessage } from "../../../lib/media-studio";
|
||||
import { rpc } from "../../../lib/rpc";
|
||||
|
||||
export function useGoalLabels(): Record<MediaProjectGoal, string> {
|
||||
const { t } = useLingui();
|
||||
return {
|
||||
teaser: t`Teaser`,
|
||||
full_release: t`Full release`,
|
||||
trailer: t`Trailer`,
|
||||
highlight: t`Highlight`,
|
||||
story: t`Story`,
|
||||
custom: t`Custom`,
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultTitle,
|
||||
collectionIds,
|
||||
assetIds,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultTitle: string;
|
||||
collectionIds: string[];
|
||||
assetIds: string[];
|
||||
onCreated: (projectId: string) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const goals = useGoalLabels();
|
||||
const titleId = useId();
|
||||
const goalId = useId();
|
||||
const [title, setTitle] = useState(defaultTitle);
|
||||
const [goal, setGoal] = useState<MediaProjectGoal>("teaser");
|
||||
const [profiles, setProfiles] = useState<PlatformProfileSummary[] | null>(null);
|
||||
const [chosen, setChosen] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle(defaultTitle);
|
||||
setError(null);
|
||||
let active = true;
|
||||
rpc.media.profiles
|
||||
.list({})
|
||||
.then((list) => {
|
||||
if (!active) return;
|
||||
setProfiles(list);
|
||||
setChosen((current) =>
|
||||
current.size > 0 ? current : new Set(list.slice(0, 1).map((p) => p.id)),
|
||||
);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (active) setError(rpcErrorMessage(cause, t`Could not load platforms.`));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, defaultTitle, t]);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const project = await rpc.media.projects.create({
|
||||
title: title.trim(),
|
||||
goal,
|
||||
collectionIds,
|
||||
assetIds,
|
||||
profileIds: [...chosen],
|
||||
brandKitId: null,
|
||||
});
|
||||
onOpenChange(false);
|
||||
onCreated(project.id);
|
||||
} catch (cause) {
|
||||
setError(rpcErrorMessage(cause, t`Could not create the project.`));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="media-create-project" className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>New project</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void create();
|
||||
}}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={titleId}>
|
||||
<Trans>Title</Trans>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={titleId}
|
||||
value={title}
|
||||
maxLength={120}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={goalId}>
|
||||
<Trans>Goal</Trans>
|
||||
</FieldLabel>
|
||||
<NativeSelect
|
||||
id={goalId}
|
||||
className="w-full"
|
||||
value={goal}
|
||||
onChange={(event) => setGoal(event.target.value as MediaProjectGoal)}
|
||||
>
|
||||
{MediaProjectGoalSchema.options.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{goals[option]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">
|
||||
<Trans>Platforms</Trans>
|
||||
</FieldLegend>
|
||||
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
||||
{(profiles ?? []).map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center gap-2.5 rounded-md px-1 py-1.5 text-ui-body-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${goalId}-${profile.id}`}
|
||||
checked={chosen.has(profile.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setChosen((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(profile.id);
|
||||
else next.delete(profile.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`${goalId}-${profile.id}`}
|
||||
className="min-w-0 flex-1 cursor-pointer truncate"
|
||||
>
|
||||
{profile.name}
|
||||
</label>
|
||||
<span className="text-ui-caption text-muted-foreground">
|
||||
{profile.aspectRatios.join(" · ")}
|
||||
</span>
|
||||
{profile.unverified || profile.stale ? (
|
||||
<Badge variant="outline">
|
||||
<Trans>Unverified</Trans>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
{error ? (
|
||||
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={busy || !title.trim() || chosen.size === 0}>
|
||||
<Trans>Create project</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
172
apps/web/src/components/media/library/LibraryChrome.tsx
Normal file
172
apps/web/src/components/media/library/LibraryChrome.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { MediaCollection } from "@rakazo/contracts";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Toggle,
|
||||
} from "@rakazo/ui-web";
|
||||
import { Search } from "lucide-react";
|
||||
import type { LibraryFilter } from "../../../lib/media-library";
|
||||
import { formatDuration, LIBRARY_FILTERS } from "../../../lib/media-library";
|
||||
import { LoadingState } from "../../ai/primitives";
|
||||
import { AssetThumb } from "./AssetTile";
|
||||
|
||||
export type LibraryMode = "sessions" | "themes" | "people";
|
||||
|
||||
/** Sessions | Themes | People, shared by the library and people views. */
|
||||
export function LibraryModeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: LibraryMode;
|
||||
onChange: (mode: LibraryMode) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<Tabs value={mode} onValueChange={(value) => onChange(value as LibraryMode)}>
|
||||
<TabsList aria-label={t`Group by`}>
|
||||
<TabsTrigger value="sessions">
|
||||
<Trans>Sessions</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="themes">
|
||||
<Trans>Themes</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="people">
|
||||
<Trans>People</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function LibrarySearch({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<InputGroup className="w-full max-w-64">
|
||||
<InputGroupAddon>
|
||||
<Search strokeWidth={1.8} aria-hidden="true" className="size-icon-md" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={t`Search`}
|
||||
aria-label={t`Search media`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterChips({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ReadonlySet<LibraryFilter>;
|
||||
onChange: (value: ReadonlySet<LibraryFilter>) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const labels: Record<LibraryFilter, string> = {
|
||||
videos: t`Videos`,
|
||||
photos: t`Photos`,
|
||||
favorites: t`Favorites`,
|
||||
best: t`Best`,
|
||||
};
|
||||
return (
|
||||
<fieldset aria-label={t`Filters`} className="flex flex-wrap items-center gap-1.5">
|
||||
{LIBRARY_FILTERS.map((filter) => (
|
||||
<Toggle
|
||||
key={filter}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full"
|
||||
pressed={value.has(filter)}
|
||||
onPressedChange={(pressed) => {
|
||||
const next = new Set(value);
|
||||
if (pressed) next.add(filter);
|
||||
else next.delete(filter);
|
||||
onChange(next);
|
||||
}}
|
||||
>
|
||||
{labels[filter]}
|
||||
</Toggle>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Only rendered while indexing runs; disappears when the work is done. */
|
||||
export function IndexingProgress({ done, total }: { done?: number; total?: number }) {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<div data-testid="media-indexing" className="flex items-center gap-3 px-4 py-2">
|
||||
<LoadingState label={t`Indexing`} />
|
||||
{total ? (
|
||||
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">
|
||||
{done ?? 0}/{total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectionCard({
|
||||
collection,
|
||||
onOpen,
|
||||
}: {
|
||||
collection: MediaCollection;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const range = collectionDateRange(collection);
|
||||
const count = collection.assetCount;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="media-collection-card"
|
||||
onClick={onOpen}
|
||||
className="group/card flex size-full min-w-0 flex-col gap-1.5 rounded-lg text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg bg-muted">
|
||||
<AssetThumb
|
||||
assetId={collection.coverAssetId}
|
||||
hasThumbnail={collection.coverAssetId !== null}
|
||||
className="transition-transform group-hover/card:scale-[1.02]"
|
||||
/>
|
||||
{collection.totalDurationMs > 0 ? (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-foreground/70 px-1 font-mono text-ui-caption tabular-nums text-background">
|
||||
{formatDuration(collection.totalDurationMs)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 px-0.5">
|
||||
<div className="truncate text-ui-body-sm font-medium text-foreground" dir="auto">
|
||||
{collection.title}
|
||||
</div>
|
||||
<div className="truncate text-ui-caption text-muted-foreground">
|
||||
{range ? `${range} · ` : ""}
|
||||
<Plural value={count} one="# item" other="# items" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function collectionDateRange(
|
||||
collection: Pick<MediaCollection, "startAt" | "endAt">,
|
||||
): string {
|
||||
if (!collection.startAt) return "";
|
||||
const start = new Date(collection.startAt);
|
||||
const end = collection.endAt ? new Date(collection.endAt) : start;
|
||||
const day = (date: Date) => date.toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
return day(start) === day(end) ? day(start) : `${day(start)} – ${day(end)}`;
|
||||
}
|
||||
128
apps/web/src/components/media/library/VirtualGrid.tsx
Normal file
128
apps/web/src/components/media/library/VirtualGrid.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { cn } from "@rakazo/ui-web";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { gridColumns, gridRowCount } from "../../../lib/media-library";
|
||||
|
||||
/**
|
||||
* Row-virtualized grid: only the rows in view (plus overscan) are mounted, so
|
||||
* 50k items cost the same DOM as fifty. Tiles share one size derived from the
|
||||
* container width; `onNearEnd` asks for the next page before the end shows.
|
||||
*/
|
||||
export function VirtualGrid({
|
||||
count,
|
||||
renderItem,
|
||||
getKey,
|
||||
minTileWidth = 168,
|
||||
gap = 8,
|
||||
tileAspect = 9 / 16,
|
||||
extraHeight = 0,
|
||||
overscan = 4,
|
||||
onNearEnd,
|
||||
footer,
|
||||
className,
|
||||
label,
|
||||
testId,
|
||||
}: {
|
||||
count: number;
|
||||
renderItem: (index: number) => ReactNode;
|
||||
getKey?: (index: number) => string | number;
|
||||
minTileWidth?: number;
|
||||
gap?: number;
|
||||
/** Media height / width. */
|
||||
tileAspect?: number;
|
||||
/** Fixed height under the media (title lines). */
|
||||
extraHeight?: number;
|
||||
overscan?: number;
|
||||
onNearEnd?: () => void;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const measure = () => setWidth(element.clientWidth);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const padding = 16;
|
||||
const inner = Math.max(0, width - padding * 2);
|
||||
const columns = gridColumns(inner, minTileWidth, gap);
|
||||
const tileWidth = columns > 0 ? (inner - gap * (columns - 1)) / columns : minTileWidth;
|
||||
const rowHeight = Math.round(tileWidth * tileAspect) + extraHeight + gap;
|
||||
const rows = gridRowCount(count, columns);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan,
|
||||
paddingStart: padding,
|
||||
paddingEnd: padding,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [virtualizer, rowHeight]);
|
||||
|
||||
const items = virtualizer.getVirtualItems();
|
||||
const lastRow = items.at(-1)?.index ?? -1;
|
||||
const nearEnd = rows > 0 && lastRow >= rows - overscan - 1;
|
||||
const latestNearEnd = useRef(onNearEnd);
|
||||
latestNearEnd.current = onNearEnd;
|
||||
useEffect(() => {
|
||||
if (nearEnd) latestNearEnd.current?.();
|
||||
}, [nearEnd, count]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={scrollRef}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
data-count={count}
|
||||
className={cn("rk-scroll relative min-h-0 flex-1 overflow-y-auto", className)}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{width > 0
|
||||
? items.map((row) => {
|
||||
const start = row.index * columns;
|
||||
const end = Math.min(count, start + columns);
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="absolute inset-x-4 grid"
|
||||
style={{
|
||||
top: row.start,
|
||||
gap,
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: end - start }, (_, offset) => {
|
||||
const index = start + offset;
|
||||
return (
|
||||
<div
|
||||
key={getKey ? getKey(index) : index}
|
||||
style={{ height: rowHeight - gap }}
|
||||
className="min-w-0"
|
||||
>
|
||||
{renderItem(index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
{footer}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/media/library/usePhotosSync.ts
Normal file
72
apps/web/src/components/media/library/usePhotosSync.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { PhotosSourceStatus, PhotosSyncOutcome, PhotosSyncProgress } from "@rakazo/contracts";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { desktopBridge } from "../../../lib/desktop";
|
||||
import { selectedSpaceId } from "../../../lib/rpc";
|
||||
|
||||
/**
|
||||
* Apple Photos runs in the desktop main process; the renderer only asks for a
|
||||
* sync and shows progress. Absent outside the macOS desktop app.
|
||||
*/
|
||||
export function usePhotosSync(onFinished?: (outcome: PhotosSyncOutcome) => void) {
|
||||
const photos = desktopBridge()?.media?.photos;
|
||||
const [status, setStatus] = useState<PhotosSourceStatus>({ available: false });
|
||||
const [progress, setProgress] = useState<PhotosSyncProgress | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!photos) return;
|
||||
let active = true;
|
||||
photos
|
||||
.status()
|
||||
.then((value) => {
|
||||
if (!active) return;
|
||||
setStatus(value);
|
||||
if (value.available && value.syncing) setSyncing(true);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const off = photos.onProgress((value) => {
|
||||
if (active) setProgress(value);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
off();
|
||||
};
|
||||
}, [photos]);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
if (!photos) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await photos.sync({ spaceId: selectedSpaceId() });
|
||||
if (!outcome.ok) setError(outcome.message);
|
||||
onFinished?.(outcome);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setProgress(null);
|
||||
photos
|
||||
.status()
|
||||
.then(setStatus)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, [photos, onFinished]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
void photos?.cancel().catch(() => undefined);
|
||||
}, [photos]);
|
||||
|
||||
return {
|
||||
available: Boolean(photos) && status.available,
|
||||
denied:
|
||||
status.available &&
|
||||
(status.authorization === "denied" || status.authorization === "restricted"),
|
||||
syncing,
|
||||
progress,
|
||||
error,
|
||||
sync,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
91
apps/web/src/lib/media-bytes.test.ts
Normal file
91
apps/web/src/lib/media-bytes.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMediaObjectUrlCache,
|
||||
mediaAssetPath,
|
||||
mediaBundlePath,
|
||||
mediaRenderFilePath,
|
||||
mediaUploadChunkPath,
|
||||
} from "./media-bytes";
|
||||
|
||||
function setup(idleLimit = 2) {
|
||||
let next = 0;
|
||||
const revoked: string[] = [];
|
||||
const loads: string[] = [];
|
||||
const pending = new Map<string, (blob: Blob) => void>();
|
||||
const cache = createMediaObjectUrlCache({
|
||||
load: (path, signal) => {
|
||||
loads.push(path);
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
pending.set(path, resolve);
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||
});
|
||||
},
|
||||
createUrl: () => {
|
||||
next += 1;
|
||||
return `blob:${next}`;
|
||||
},
|
||||
revokeUrl: (url) => revoked.push(url),
|
||||
spaceId: () => "space-1",
|
||||
idleLimit,
|
||||
});
|
||||
const finish = (path: string) => pending.get(path)?.(new Blob(["x"]));
|
||||
return { cache, revoked, loads, finish };
|
||||
}
|
||||
|
||||
describe("media byte paths", () => {
|
||||
it("encodes ids into the byte routes", () => {
|
||||
expect(mediaAssetPath("a/b", "thumb")).toBe("/api/media/assets/a%2Fb/thumb");
|
||||
expect(mediaRenderFilePath("r1", "video.mp4", { download: true })).toBe(
|
||||
"/api/media/renders/r1/files/video.mp4?download=1",
|
||||
);
|
||||
expect(mediaBundlePath("p1")).toBe("/api/media/bundles/p1");
|
||||
expect(mediaUploadChunkPath("u1", 3)).toBe("/api/media/uploads/u1/chunks/3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("media object URL cache", () => {
|
||||
it("shares one load between holders", async () => {
|
||||
const { cache, loads, finish } = setup();
|
||||
const a = cache.acquire("/t/1", { keep: true });
|
||||
const b = cache.acquire("/t/1", { keep: true });
|
||||
finish("/t/1");
|
||||
await expect(a.promise).resolves.toBe("blob:1");
|
||||
await expect(b.promise).resolves.toBe("blob:1");
|
||||
expect(loads).toEqual(["/t/1"]);
|
||||
});
|
||||
|
||||
it("keeps thumbnails idle up to the limit, evicting the oldest", async () => {
|
||||
const { cache, revoked, finish } = setup(2);
|
||||
for (const id of ["1", "2", "3"]) {
|
||||
const handle = cache.acquire(`/t/${id}`, { keep: true });
|
||||
finish(`/t/${id}`);
|
||||
await handle.promise;
|
||||
handle.release();
|
||||
}
|
||||
expect(cache.size()).toBe(2);
|
||||
expect(revoked).toEqual(["blob:1"]);
|
||||
});
|
||||
|
||||
it("drops large files as soon as the last holder releases", async () => {
|
||||
const { cache, revoked, finish } = setup();
|
||||
const handle = cache.acquire("/p/1");
|
||||
finish("/p/1");
|
||||
await handle.promise;
|
||||
handle.release();
|
||||
handle.release();
|
||||
expect(cache.size()).toBe(0);
|
||||
expect(revoked).toEqual(["blob:1"]);
|
||||
});
|
||||
|
||||
it("aborts a load nobody waits for anymore", async () => {
|
||||
const { cache } = setup();
|
||||
const handle = cache.acquire("/p/2");
|
||||
const settled = vi.fn();
|
||||
handle.promise.catch(settled);
|
||||
handle.release();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(settled).toHaveBeenCalled();
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
182
apps/web/src/lib/media-library.test.ts
Normal file
182
apps/web/src/lib/media-library.test.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assetQueryFor,
|
||||
BEST_QUALITY,
|
||||
consentInput,
|
||||
consentState,
|
||||
csvHeaders,
|
||||
dayToIso,
|
||||
formatDuration,
|
||||
funnelTotals,
|
||||
funnelWidths,
|
||||
gridColumns,
|
||||
gridRowCount,
|
||||
guessColumns,
|
||||
guessDelimiter,
|
||||
guessPublicationKey,
|
||||
hasAssetQuery,
|
||||
type LibraryFilter,
|
||||
scrubFraction,
|
||||
splitCsvLine,
|
||||
} from "./media-library";
|
||||
|
||||
describe("library filters", () => {
|
||||
it("maps chips to an asset query", () => {
|
||||
const filters = new Set<LibraryFilter>(["videos", "favorites", "best"]);
|
||||
expect(assetQueryFor(filters, " beach ")).toEqual({
|
||||
kinds: ["video"],
|
||||
favoritesOnly: true,
|
||||
minQuality: BEST_QUALITY,
|
||||
text: "beach",
|
||||
});
|
||||
expect(assetQueryFor(new Set(["photos"]), "")).toEqual({ kinds: ["photo", "live_photo"] });
|
||||
expect(assetQueryFor(new Set(), "")).toEqual({});
|
||||
});
|
||||
|
||||
it("knows when a query is active", () => {
|
||||
expect(hasAssetQuery(new Set(), " ")).toBe(false);
|
||||
expect(hasAssetQuery(new Set(), "x")).toBe(true);
|
||||
expect(hasAssetQuery(new Set(["videos"]), "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grid geometry", () => {
|
||||
it("fits columns to the width", () => {
|
||||
expect(gridColumns(0, 160, 8)).toBe(1);
|
||||
expect(gridColumns(159, 160, 8)).toBe(1);
|
||||
expect(gridColumns(328, 160, 8)).toBe(2);
|
||||
expect(gridColumns(1000, 160, 8)).toBe(6);
|
||||
});
|
||||
|
||||
it("counts rows for 50k assets without materializing them", () => {
|
||||
expect(gridRowCount(50_000, 6)).toBe(8334);
|
||||
expect(gridRowCount(0, 6)).toBe(0);
|
||||
expect(gridRowCount(5, 0)).toBe(5);
|
||||
});
|
||||
|
||||
it("clamps the scrub position", () => {
|
||||
expect(scrubFraction(50, 0, 100)).toBe(0.5);
|
||||
expect(scrubFraction(-10, 0, 100)).toBe(0);
|
||||
expect(scrubFraction(200, 0, 100)).toBe(1);
|
||||
expect(scrubFraction(5, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats clip lengths", () => {
|
||||
expect(formatDuration(4000)).toBe("0:04");
|
||||
expect(formatDuration(62_400)).toBe("1:02");
|
||||
expect(formatDuration(3_723_000)).toBe("1:02:03");
|
||||
expect(formatDuration(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consent", () => {
|
||||
const now = new Date("2026-10-03T10:00:00Z");
|
||||
|
||||
it("records platform verification with its date", () => {
|
||||
expect(
|
||||
consentInput(
|
||||
"p1",
|
||||
{ consentDate: "2026-10-01", method: "platform_verified", verifiedDate: "2026-09-30" },
|
||||
now,
|
||||
),
|
||||
).toEqual({
|
||||
personId: "p1",
|
||||
consentArtifactId: null,
|
||||
consentRecordedAt: "2026-10-01T12:00:00.000Z",
|
||||
ageVerificationMethod: "platform_verified",
|
||||
ageVerifiedAt: "2026-09-30T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the verification date when there is no age proof", () => {
|
||||
expect(
|
||||
consentInput("p1", { consentDate: "2026-10-01", method: "none", verifiedDate: "x" }, now)
|
||||
?.ageVerifiedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects incomplete or future dates", () => {
|
||||
expect(
|
||||
consentInput("p1", { consentDate: "", method: "none", verifiedDate: "" }, now),
|
||||
).toBeNull();
|
||||
expect(
|
||||
consentInput(
|
||||
"p1",
|
||||
{ consentDate: "2026-10-01", method: "document_on_file", verifiedDate: "" },
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
consentInput("p1", { consentDate: "2026-12-01", method: "none", verifiedDate: "" }, now),
|
||||
).toBeNull();
|
||||
expect(dayToIso("2026-13-45")).toBeNull();
|
||||
});
|
||||
|
||||
it("summarizes a person's state", () => {
|
||||
const base = { consentRecordedAt: null, ageVerificationMethod: "none", ageVerifiedAt: null };
|
||||
expect(consentState({ ...base, ageVerificationMethod: "none" })).toBe("missing");
|
||||
expect(consentState({ ...base, consentRecordedAt: "2026-10-01T12:00:00.000Z" } as never)).toBe(
|
||||
"consent_only",
|
||||
);
|
||||
expect(
|
||||
consentState({
|
||||
consentRecordedAt: "2026-10-01T12:00:00.000Z",
|
||||
ageVerificationMethod: "platform_verified",
|
||||
ageVerifiedAt: "2026-10-01T12:00:00.000Z",
|
||||
}),
|
||||
).toBe("complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("csv mapping", () => {
|
||||
it("splits quoted cells", () => {
|
||||
expect(splitCsvLine('a,"b, c","d ""e"""', ",")).toEqual(["a", "b, c", 'd "e"']);
|
||||
});
|
||||
|
||||
it("detects the delimiter and headers", () => {
|
||||
const csv = "Datum;utm_content;Aufrufe;Klicks;Käufe;Umsatz\n2026-10-01;a;1;2;3;4";
|
||||
const delimiter = guessDelimiter(csv.split("\n")[0] ?? "");
|
||||
expect(delimiter).toBe(";");
|
||||
const headers = csvHeaders(csv, delimiter);
|
||||
expect(headers).toEqual(["Datum", "utm_content", "Aufrufe", "Klicks", "Käufe", "Umsatz"]);
|
||||
expect(guessColumns(headers)).toEqual({
|
||||
publication: "utm_content",
|
||||
observedAt: "Datum",
|
||||
views: "Aufrufe",
|
||||
clicks: "Klicks",
|
||||
conversions: "Käufe",
|
||||
revenue: "Umsatz",
|
||||
});
|
||||
});
|
||||
|
||||
it("guesses how publications are identified", () => {
|
||||
expect(guessPublicationKey("utm_content")).toBe("utm_content");
|
||||
expect(guessPublicationKey("tracking_url")).toBe("tracking_url");
|
||||
expect(guessPublicationKey("Post URL")).toBe("external_url");
|
||||
expect(guessPublicationKey("publication_id")).toBe("id");
|
||||
expect(guessPublicationKey(undefined)).toBe("utm_content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("funnel", () => {
|
||||
it("sums steps and keeps currencies apart", () => {
|
||||
const totals = funnelTotals([
|
||||
{ views: 1000, clicks: 100, conversions: 10, revenueCents: 9900, currency: "EUR" },
|
||||
{ views: 500, clicks: 20, conversions: 1, revenueCents: 1500, currency: "USD" },
|
||||
{ views: 10, clicks: 0, conversions: 0, revenueCents: 0, currency: "EUR" },
|
||||
]);
|
||||
expect(totals).toEqual({
|
||||
views: 1510,
|
||||
clicks: 120,
|
||||
conversions: 11,
|
||||
revenue: [
|
||||
{ currency: "EUR", cents: 9900 },
|
||||
{ currency: "USD", cents: 1500 },
|
||||
],
|
||||
});
|
||||
expect(funnelWidths(totals).views).toBe(1);
|
||||
expect(funnelWidths({ views: 0, clicks: 0, conversions: 0 }).views).toBe(0);
|
||||
});
|
||||
});
|
||||
312
apps/web/src/lib/media-library.ts
Normal file
312
apps/web/src/lib/media-library.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import type {
|
||||
AgeVerificationMethod,
|
||||
ConsentInput,
|
||||
FunnelRow,
|
||||
MediaAssetKind,
|
||||
MediaAssetQuery,
|
||||
MediaPerson,
|
||||
MetricCsvMapping,
|
||||
} from "@rakazo/contracts";
|
||||
|
||||
/* Pure helpers behind the Media Studio library, people and results views. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LIBRARY_FILTERS = ["videos", "photos", "favorites", "best"] as const;
|
||||
export type LibraryFilter = (typeof LIBRARY_FILTERS)[number];
|
||||
|
||||
/** Quality floor for the "best" chip. */
|
||||
export const BEST_QUALITY = 0.6;
|
||||
|
||||
export function assetQueryFor(
|
||||
filters: ReadonlySet<LibraryFilter>,
|
||||
text: string,
|
||||
): Partial<MediaAssetQuery> {
|
||||
const query: Partial<MediaAssetQuery> = {};
|
||||
const kinds: MediaAssetKind[] = [];
|
||||
if (filters.has("videos")) kinds.push("video");
|
||||
if (filters.has("photos")) kinds.push("photo", "live_photo");
|
||||
if (kinds.length > 0) query.kinds = kinds;
|
||||
if (filters.has("favorites")) query.favoritesOnly = true;
|
||||
if (filters.has("best")) query.minQuality = BEST_QUALITY;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed) query.text = trimmed.slice(0, 200);
|
||||
return query;
|
||||
}
|
||||
|
||||
export function hasAssetQuery(filters: ReadonlySet<LibraryFilter>, text: string): boolean {
|
||||
return filters.size > 0 || text.trim().length > 0;
|
||||
}
|
||||
|
||||
/** Ingest states that mean the server still has work queued for an asset. */
|
||||
export const INGEST_PENDING_STATES = ["indexed", "proxying", "proxied", "analyzing"] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Columns that fit `width` with tiles at least `minTile` wide. */
|
||||
export function gridColumns(width: number, minTile: number, gap: number): number {
|
||||
if (!Number.isFinite(width) || width <= 0) return 1;
|
||||
return Math.max(1, Math.floor((width + gap) / (minTile + gap)));
|
||||
}
|
||||
|
||||
export function gridRowCount(items: number, columns: number): number {
|
||||
return Math.ceil(items / Math.max(1, columns));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatDuration(ms: number | null | undefined): string {
|
||||
if (ms == null || !Number.isFinite(ms) || ms < 0) return "";
|
||||
const total = Math.round(ms / 1000);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = String(total % 60).padStart(2, "0");
|
||||
if (hours > 0) return `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`;
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function formatMoney(cents: number, currency: string, locale?: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(cents / 100);
|
||||
} catch {
|
||||
return `${(cents / 100).toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRate(numerator: number, denominator: number, locale?: string): string {
|
||||
if (denominator <= 0) return "–";
|
||||
return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(
|
||||
numerator / denominator,
|
||||
);
|
||||
}
|
||||
|
||||
/** Position of the pointer across an element as 0..1, for hover scrubbing. */
|
||||
export function scrubFraction(clientX: number, left: number, width: number): number {
|
||||
if (width <= 0) return 0;
|
||||
return Math.min(1, Math.max(0, (clientX - left) / width));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConsentState = "complete" | "consent_only" | "missing";
|
||||
|
||||
export function consentState(
|
||||
person: Pick<MediaPerson, "consentRecordedAt" | "ageVerificationMethod" | "ageVerifiedAt">,
|
||||
): ConsentState {
|
||||
if (!person.consentRecordedAt) return "missing";
|
||||
if (person.ageVerificationMethod !== "none" && person.ageVerifiedAt) return "complete";
|
||||
return "consent_only";
|
||||
}
|
||||
|
||||
export interface ConsentForm {
|
||||
/** yyyy-mm-dd from a date input. */
|
||||
consentDate: string;
|
||||
method: AgeVerificationMethod;
|
||||
/** yyyy-mm-dd; ignored when method is "none". */
|
||||
verifiedDate: string;
|
||||
}
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** yyyy-mm-dd → ISO timestamp at UTC noon, so the calendar day survives any time zone. */
|
||||
export function dayToIso(day: string): string | null {
|
||||
if (!DAY.test(day)) return null;
|
||||
const date = new Date(`${day}T12:00:00.000Z`);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
export function isoToDay(iso: string | null | undefined): string {
|
||||
return iso ? iso.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export function todayDay(now: Date = new Date()): string {
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Build the recordConsent input, or null when the form is incomplete. */
|
||||
export function consentInput(
|
||||
personId: string,
|
||||
form: ConsentForm,
|
||||
now: Date = new Date(),
|
||||
): ConsentInput | null {
|
||||
const consentRecordedAt = dayToIso(form.consentDate);
|
||||
if (!consentRecordedAt) return null;
|
||||
const today = todayDay(now);
|
||||
if (form.consentDate > today) return null;
|
||||
if (form.method === "none") {
|
||||
return {
|
||||
personId,
|
||||
consentArtifactId: null,
|
||||
consentRecordedAt,
|
||||
ageVerificationMethod: "none",
|
||||
ageVerifiedAt: null,
|
||||
};
|
||||
}
|
||||
const ageVerifiedAt = dayToIso(form.verifiedDate);
|
||||
if (!ageVerifiedAt || form.verifiedDate > today) return null;
|
||||
return {
|
||||
personId,
|
||||
consentArtifactId: null,
|
||||
consentRecordedAt,
|
||||
ageVerificationMethod: form.method,
|
||||
ageVerifiedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV metric import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CsvDelimiter = MetricCsvMapping["delimiter"];
|
||||
|
||||
/** Split one CSV line, honoring double-quoted fields. */
|
||||
export function splitCsvLine(line: string, delimiter: CsvDelimiter): string[] {
|
||||
const cells: string[] = [];
|
||||
let cell = "";
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if (quoted) {
|
||||
if (char === '"' && line[index + 1] === '"') {
|
||||
cell += '"';
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = false;
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
quoted = true;
|
||||
} else if (char === delimiter) {
|
||||
cells.push(cell.trim());
|
||||
cell = "";
|
||||
} else {
|
||||
cell += char;
|
||||
}
|
||||
}
|
||||
cells.push(cell.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function firstCsvLine(csv: string): string {
|
||||
const withoutBom = csv.startsWith("") ? csv.slice(1) : csv;
|
||||
return withoutBom.split(/\r?\n/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
export function guessDelimiter(headerLine: string): CsvDelimiter {
|
||||
const counts: Array<[CsvDelimiter, number]> = [
|
||||
[",", headerLine.split(",").length],
|
||||
[";", headerLine.split(";").length],
|
||||
["\t", headerLine.split("\t").length],
|
||||
];
|
||||
counts.sort((a, b) => b[1] - a[1]);
|
||||
return counts[0]?.[0] ?? ",";
|
||||
}
|
||||
|
||||
export function csvHeaders(csv: string, delimiter: CsvDelimiter): string[] {
|
||||
return splitCsvLine(firstCsvLine(csv), delimiter).filter((header) => header.length > 0);
|
||||
}
|
||||
|
||||
export type MappingColumn = keyof MetricCsvMapping["columns"];
|
||||
export const MAPPING_COLUMNS: MappingColumn[] = [
|
||||
"publication",
|
||||
"observedAt",
|
||||
"views",
|
||||
"clicks",
|
||||
"conversions",
|
||||
"revenue",
|
||||
"currency",
|
||||
];
|
||||
|
||||
const COLUMN_HINTS: Record<MappingColumn, RegExp> = {
|
||||
publication: /(utm.?content|tracking|publication|post|video|url|link|^id$)/i,
|
||||
observedAt: /(date|day|time|observed|datum)/i,
|
||||
views: /(views?|impressions?|plays?|aufrufe)/i,
|
||||
clicks: /(clicks?|taps?|klicks?)/i,
|
||||
conversions: /(conversions?|purchases?|sales|orders?|käufe|kaeufe)/i,
|
||||
revenue: /(revenue|earnings|amount|umsatz|income)/i,
|
||||
currency: /(currency|währung|waehrung)/i,
|
||||
};
|
||||
|
||||
/** Best guess of which header feeds which metric; every header is used at most once. */
|
||||
export function guessColumns(headers: string[]): Partial<Record<MappingColumn, string>> {
|
||||
const used = new Set<string>();
|
||||
const guess: Partial<Record<MappingColumn, string>> = {};
|
||||
for (const column of MAPPING_COLUMNS) {
|
||||
const match = headers.find((header) => !used.has(header) && COLUMN_HINTS[column].test(header));
|
||||
if (match) {
|
||||
guess[column] = match;
|
||||
used.add(match);
|
||||
}
|
||||
}
|
||||
return guess;
|
||||
}
|
||||
|
||||
export function guessPublicationKey(
|
||||
header: string | undefined,
|
||||
): MetricCsvMapping["publicationKey"] {
|
||||
if (!header) return "utm_content";
|
||||
if (/utm/i.test(header)) return "utm_content";
|
||||
if (/track/i.test(header)) return "tracking_url";
|
||||
if (/(url|link)/i.test(header)) return "external_url";
|
||||
if (/id$/i.test(header)) return "id";
|
||||
return "utm_content";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Funnel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FunnelTotals {
|
||||
views: number;
|
||||
clicks: number;
|
||||
conversions: number;
|
||||
/** One amount per currency; revenue never mixes currencies. */
|
||||
revenue: Array<{ currency: string; cents: number }>;
|
||||
}
|
||||
|
||||
export function funnelTotals(
|
||||
rows: ReadonlyArray<
|
||||
Pick<FunnelRow, "views" | "clicks" | "conversions" | "revenueCents" | "currency">
|
||||
>,
|
||||
): FunnelTotals {
|
||||
const revenue = new Map<string, number>();
|
||||
let views = 0;
|
||||
let clicks = 0;
|
||||
let conversions = 0;
|
||||
for (const row of rows) {
|
||||
views += row.views;
|
||||
clicks += row.clicks;
|
||||
conversions += row.conversions;
|
||||
if (row.revenueCents > 0 || !revenue.has(row.currency)) {
|
||||
revenue.set(row.currency, (revenue.get(row.currency) ?? 0) + row.revenueCents);
|
||||
}
|
||||
}
|
||||
return {
|
||||
views,
|
||||
clicks,
|
||||
conversions,
|
||||
revenue: [...revenue.entries()]
|
||||
.map(([currency, cents]) => ({ currency, cents }))
|
||||
.sort((a, b) => b.cents - a.cents || a.currency.localeCompare(b.currency)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Bar widths for the funnel steps, each relative to views (0..1). */
|
||||
export function funnelWidths(totals: Pick<FunnelTotals, "views" | "clicks" | "conversions">) {
|
||||
const base = Math.max(totals.views, totals.clicks, totals.conversions, 1);
|
||||
return {
|
||||
views: totals.views / base,
|
||||
clicks: totals.clicks / base,
|
||||
conversions: totals.conversions / base,
|
||||
};
|
||||
}
|
||||
@ -1,6 +1,294 @@
|
||||
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";
|
||||
|
||||
/** Placeholder; the W3-web-library slice replaces this view. */
|
||||
export function LibraryView(_props: MediaViewProps & Record<string, unknown>) {
|
||||
return null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,21 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { MediaProject } from "@rakazo/contracts";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@rakazo/ui-web";
|
||||
import { ChevronDown, ChevronLeft, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MediaStudioContext, useMediaCapabilities } from "../../lib/media-studio";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { BrandKitView } from "./BrandKitView";
|
||||
import { CollectionView } from "./CollectionView";
|
||||
import { EditorView } from "./EditorView";
|
||||
import { LibraryView } from "./LibraryView";
|
||||
import type { MediaView } from "./nav";
|
||||
import type { MediaSection, MediaView } from "./nav";
|
||||
import { pushView, sectionOf } from "./nav";
|
||||
import { PeopleView } from "./PeopleView";
|
||||
import { ProjectView } from "./ProjectView";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
import { ReviewView } from "./ReviewView";
|
||||
|
||||
/** Media Studio surface. Chrome (header, close, tabs) belongs to the W3-web-library slice. */
|
||||
export function MediaStudio({ onClose }: { onClose: () => void }) {
|
||||
const [view, setView] = useState<MediaView>({ name: "library" });
|
||||
const props = { navigate: setView, onClose };
|
||||
function ViewSwitch({
|
||||
view,
|
||||
navigate,
|
||||
onClose,
|
||||
}: {
|
||||
view: MediaView;
|
||||
navigate: (view: MediaView) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const props = { navigate, onClose };
|
||||
switch (view.name) {
|
||||
case "library":
|
||||
return <LibraryView {...props} />;
|
||||
return <LibraryView {...props} mode={view.mode} />;
|
||||
case "collection":
|
||||
return <CollectionView {...props} collectionId={view.collectionId} />;
|
||||
case "people":
|
||||
@ -32,3 +58,135 @@ export function MediaStudio({ onClose }: { onClose: () => void }) {
|
||||
return <BrandKitView {...props} brandKitId={view.brandKitId} />;
|
||||
}
|
||||
}
|
||||
|
||||
function ProjectsMenu({
|
||||
navigate,
|
||||
refreshKey,
|
||||
}: {
|
||||
navigate: (view: MediaView) => void;
|
||||
refreshKey: unknown;
|
||||
}) {
|
||||
const [projects, setProjects] = useState<MediaProject[]>([]);
|
||||
useEffect(() => {
|
||||
void refreshKey;
|
||||
let active = true;
|
||||
rpc.media.projects
|
||||
.list({ cursor: null, limit: 20 })
|
||||
.then((page) => {
|
||||
if (active) setProjects(page.items.filter((project) => project.status !== "archived"));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [refreshKey]);
|
||||
if (projects.length === 0) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="sm" data-testid="media-projects-menu" />}
|
||||
>
|
||||
<Trans>Projects</Trans>
|
||||
<ChevronDown data-icon="inline-end" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 w-64 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => navigate({ name: "project", projectId: project.id })}
|
||||
>
|
||||
<span className="truncate" dir="auto">
|
||||
{project.title}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** Media Studio surface: a full-window layer with its own back stack. */
|
||||
export function MediaStudio({
|
||||
onClose,
|
||||
canLinkFolder = false,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** Deployment owner only: folder sources live on the host. */
|
||||
canLinkFolder?: boolean;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const capabilities = useMediaCapabilities();
|
||||
const [stack, setStack] = useState<MediaView[]>([{ name: "library" }]);
|
||||
const view = stack.at(-1) ?? { name: "library" };
|
||||
const section = sectionOf(view);
|
||||
const navigate = useCallback(
|
||||
(next: MediaView) => setStack((current) => pushView(current, next)),
|
||||
[],
|
||||
);
|
||||
const back = useCallback(
|
||||
() => setStack((current) => (current.length > 1 ? current.slice(0, -1) : current)),
|
||||
[],
|
||||
);
|
||||
const env = useMemo(() => ({ capabilities, canLinkFolder }), [capabilities, canLinkFolder]);
|
||||
const notConfigured = capabilities !== null && !capabilities.configured;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
data-testid="media-studio"
|
||||
data-view={view.name}
|
||||
showCloseButton={false}
|
||||
className="inset-0 top-0 left-0 flex h-full w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none bg-background p-0 text-foreground ring-0 sm:max-w-none"
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-sidebar-border px-3 py-2.5 md:px-[18px]">
|
||||
{stack.length > 1 ? (
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t`Back`} onClick={back}>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogTitle className="text-ui-body font-medium text-foreground">
|
||||
<Trans>Media</Trans>
|
||||
</DialogTitle>
|
||||
<div className="flex min-w-0 flex-1 justify-center">
|
||||
<Tabs
|
||||
value={section ?? ""}
|
||||
onValueChange={(value) => navigate({ name: value as MediaSection })}
|
||||
>
|
||||
<TabsList variant="line" aria-label={t`Media Studio`}>
|
||||
<TabsTrigger value="library">
|
||||
<Trans>Library</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="results">
|
||||
<Trans>Results</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="brandKit">
|
||||
<Trans>Brand kit</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ProjectsMenu navigate={navigate} refreshKey={view} />
|
||||
<DialogClose aria-label={t`Close`} render={<Button variant="ghost" size="icon-sm" />}>
|
||||
<X />
|
||||
</DialogClose>
|
||||
</header>
|
||||
<MediaStudioContext.Provider value={env}>
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
{notConfigured ? (
|
||||
<div className="grid flex-1 place-items-center px-8 text-center text-ui-body text-muted-foreground">
|
||||
<Trans>Media Studio is not set up on this server.</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<ViewSwitch view={view} navigate={navigate} onClose={onClose} />
|
||||
)}
|
||||
</main>
|
||||
</MediaStudioContext.Provider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/** Screens inside Media Studio. Every view receives `navigate` to move between them. */
|
||||
export type MediaView =
|
||||
| { name: "library" }
|
||||
| { name: "library"; mode?: "sessions" | "themes" }
|
||||
| { name: "collection"; collectionId: string }
|
||||
| { name: "people" }
|
||||
| { name: "results" }
|
||||
@ -12,3 +12,36 @@ export type MediaView =
|
||||
export interface MediaViewProps {
|
||||
navigate: (view: MediaView) => void;
|
||||
}
|
||||
|
||||
/** Top-level sections: opening one starts a fresh back stack. */
|
||||
export type MediaSection = "library" | "results" | "brandKit";
|
||||
|
||||
export function sectionOf(view: MediaView): MediaSection | null {
|
||||
switch (view.name) {
|
||||
case "library":
|
||||
case "people":
|
||||
case "collection":
|
||||
return "library";
|
||||
case "results":
|
||||
return "results";
|
||||
case "brandKit":
|
||||
return "brandKit";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRootView(view: MediaView): boolean {
|
||||
return (
|
||||
view.name === "library" ||
|
||||
view.name === "people" ||
|
||||
view.name === "results" ||
|
||||
(view.name === "brandKit" && !view.brandKitId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Push `next` onto the back stack; root views replace it. */
|
||||
export function pushView(stack: readonly MediaView[], next: MediaView): MediaView[] {
|
||||
if (isRootView(next)) return [next];
|
||||
return [...stack, next];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user